このコミットが含まれているのは:
2026-07-06 21:12:49 +09:00
コミット f66532a55d
3個のファイルの変更329行の追加192行の削除
+3 -1
ファイルの表示
@@ -38,6 +38,7 @@
--top-nav-root-bg-desktop: #fefce8;
--top-nav-active-bg: #fef08a;
--top-nav-submenu-bg: #fef08a;
--top-nav-mobile-menu-bg: #fef08a;
--top-nav-mobile-active-bg: #fefce8;
--top-nav-brand-link: #db2777;
--top-nav-menu-link: #1d4ed8;
@@ -89,6 +90,7 @@
--top-nav-root-bg-desktop: #230505;
--top-nav-active-bg: #450a0a;
--top-nav-submenu-bg: #450a0a;
--top-nav-mobile-menu-bg: #230505;
--top-nav-mobile-active-bg: #450a0a;
--top-nav-brand-link: #f9a8d4;
--top-nav-menu-link: #93c5fd;
@@ -237,7 +239,7 @@ body
.top-nav-mobile-menu
{
background: var(--top-nav-root-bg-mobile);
background: var(--top-nav-mobile-menu-bg);
}
.top-nav-mobile-active
+201 -78
ファイルの表示
@@ -52,6 +52,20 @@ export type ThemeTokens = {
topNavBrandLink: string
topNavMenuLink: string
tagColours: ThemeTagColours }
type EditableTopNavTokens = {
rootBackgroundMobile: string
rootBackgroundDesktop: string
activeBackground: string
brandLink: string }
type ResolvedTopNavTokens = {
rootBackgroundMobile: string
rootBackgroundDesktop: string
activeBackground: string
submenuBackground: string
mobileMenuBackground: string
mobileActiveBackground: string
brandLink: string
menuLink: string }
export type UserThemeSlot = {
baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo
@@ -908,9 +922,100 @@ const normaliseTopNavColourValue = (
}
const normaliseThemeTokens = (
rawTokens: unknown,
baseTheme: BuiltinThemeId,
const clamp = (value: number, min: number, max: number): number =>
Math.min (Math.max (value, min), max)
const parseHslComponents = (
value: string,
): { h: number
s: number
l: number } | null => {
const raw = value.startsWith ('hsl(') && value.endsWith (')')
? value.slice (4, -1)
: value
const match =
raw.match (
/^(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/,
)
if (match == null)
return null
return {
h: Number (match[1]),
s: Number (match[2]),
l: Number (match[3]),
}
}
const formatHslToken = (
{ h, s, l }: { h: number
s: number
l: number },
): string =>
`${ h.toFixed (1).replace (/\.0$/, '') }`
+ ` ${ s.toFixed (1).replace (/\.0$/, '') }%`
+ ` ${ l.toFixed (1).replace (/\.0$/, '') }%`
const hslLightness = (value: string): number | null =>
parseHslComponents (value)?.l ?? null
const readableForegroundFor = (value: string): string =>
(hslLightness (value) ?? 50) < 56
? LIGHT_THEME_TOKENS.primaryForeground
: DARK_THEME_TOKENS.primaryForeground
const deriveMutedForeground = (
foreground: string,
background: string,
fallback: string,
): string => {
const foregroundHsl = parseHslComponents (foreground)
const backgroundHsl = parseHslComponents (background)
if (foregroundHsl == null || backgroundHsl == null)
return fallback
return formatHslToken ({
h: foregroundHsl.h,
s: clamp (foregroundHsl.s * .6, 0, 100),
l: clamp (
foregroundHsl.l + (backgroundHsl.l - foregroundHsl.l) * .35,
0,
100,
),
})
}
const deriveTopNavTokens = (
baseTheme: BuiltinThemeId,
editable: EditableTopNavTokens,
link: string,
): ResolvedTopNavTokens => ({
rootBackgroundMobile: editable.rootBackgroundMobile,
rootBackgroundDesktop: editable.rootBackgroundDesktop,
activeBackground: editable.activeBackground,
submenuBackground: editable.activeBackground,
mobileMenuBackground: editable.rootBackgroundMobile,
mobileActiveBackground: (
baseTheme === 'dark'
? editable.activeBackground
: editable.rootBackgroundDesktop
),
brandLink: editable.brandLink,
menuLink: link,
})
export const buildThemeTokens = (
baseTheme: BuiltinThemeId,
rawTokens: unknown,
): ThemeTokens => {
const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS
const tokens =
@@ -922,89 +1027,103 @@ const normaliseThemeTokens = (
? rawTokens as Record<string, unknown>
: { }
const background =
normaliseThemeTokenValue (tokens.background, fallback.background)
const foreground =
normaliseThemeTokenValue (tokens.foreground, fallback.foreground)
const card =
normaliseThemeTokenValue (tokens.card, fallback.card)
const muted =
normaliseThemeTokenValue (tokens.muted, fallback.muted)
const primary =
normaliseThemeTokenValue (tokens.primary, fallback.primary)
const accent =
normaliseThemeTokenValue (tokens.accent, fallback.accent)
const border =
normaliseThemeTokenValue (tokens.border, fallback.border)
const link =
normaliseThemeTokenValue (tokens.link, fallback.link)
const topNavRootBackgroundMobile = normaliseTopNavColourValue (
tokens.topNavRootBackgroundMobile
?? legacyTopNavTokens.topNavRootBackgroundMobile
?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundMobile,
)
const topNavRootBackgroundDesktop = normaliseTopNavColourValue (
tokens.topNavRootBackgroundDesktop
?? legacyTopNavTokens.topNavRootBackgroundDesktop
?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundDesktop,
)
const topNavActiveBackground = normaliseTopNavColourValue (
tokens.topNavActiveBackground
?? legacyTopNavTokens.topNavActiveBackground
?? legacyTopNavTokens.topNavHighlightBackground
?? legacyTopNavTokens.topNavHighlightBackground,
fallback.topNavActiveBackground,
)
const topNavBrandLink = normaliseTopNavColourValue (
tokens.topNavBrandLink
?? legacyTopNavTokens.topNavBrandLink
?? legacyTopNavTokens.topNavLink,
fallback.topNavBrandLink,
)
const resolvedTopNav = deriveTopNavTokens (
baseTheme,
{
rootBackgroundMobile: topNavRootBackgroundMobile,
rootBackgroundDesktop: topNavRootBackgroundDesktop,
activeBackground: topNavActiveBackground,
brandLink: topNavBrandLink,
},
link,
)
return {
background: normaliseThemeTokenValue (tokens.background, fallback.background),
foreground: normaliseThemeTokenValue (tokens.foreground, fallback.foreground),
card: normaliseThemeTokenValue (tokens.card, fallback.card),
cardForeground: normaliseThemeTokenValue (
tokens.cardForeground,
fallback.cardForeground,
),
popover: normaliseThemeTokenValue (tokens.popover, fallback.popover),
popoverForeground: normaliseThemeTokenValue (
tokens.popoverForeground,
fallback.popoverForeground,
),
primary: normaliseThemeTokenValue (tokens.primary, fallback.primary),
primaryForeground: normaliseThemeTokenValue (
tokens.primaryForeground,
fallback.primaryForeground,
),
secondary: normaliseThemeTokenValue (tokens.secondary, fallback.secondary),
secondaryForeground: normaliseThemeTokenValue (
tokens.secondaryForeground,
fallback.secondaryForeground,
),
muted: normaliseThemeTokenValue (tokens.muted, fallback.muted),
mutedForeground: normaliseThemeTokenValue (
tokens.mutedForeground,
background,
foreground,
card,
cardForeground: foreground,
popover: card,
popoverForeground: foreground,
primary,
primaryForeground: readableForegroundFor (primary),
secondary: muted,
secondaryForeground: foreground,
muted,
mutedForeground: deriveMutedForeground (
foreground,
background,
fallback.mutedForeground,
),
accent: normaliseThemeTokenValue (tokens.accent, fallback.accent),
accentForeground: normaliseThemeTokenValue (
tokens.accentForeground,
fallback.accentForeground,
),
destructive: normaliseThemeTokenValue (
tokens.destructive,
fallback.destructive,
),
destructiveForeground: normaliseThemeTokenValue (
tokens.destructiveForeground,
fallback.destructiveForeground,
),
border: normaliseThemeTokenValue (tokens.border, fallback.border),
input: normaliseThemeTokenValue (tokens.input, fallback.input),
ring: normaliseThemeTokenValue (tokens.ring, fallback.ring),
link: normaliseThemeTokenValue (tokens.link, fallback.link),
topNavRootBackgroundMobile: normaliseTopNavColourValue (
tokens.topNavRootBackgroundMobile ?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavRootBackgroundMobile,
),
topNavRootBackgroundDesktop: normaliseTopNavColourValue (
tokens.topNavRootBackgroundDesktop ?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundDesktop,
),
topNavActiveBackground: normaliseTopNavColourValue (
tokens.topNavActiveBackground
?? legacyTopNavTokens.topNavHighlightBackground
?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavActiveBackground,
),
topNavSubmenuBackground: normaliseTopNavColourValue (
tokens.topNavSubmenuBackground,
fallback.topNavSubmenuBackground,
),
topNavMobileActiveBackground: normaliseTopNavColourValue (
tokens.topNavMobileActiveBackground ?? legacyTopNavTokens.topNavBackground,
fallback.topNavMobileActiveBackground,
),
topNavBrandLink: normaliseTopNavColourValue (
tokens.topNavBrandLink
?? legacyTopNavTokens.topNavLink,
fallback.topNavBrandLink,
),
topNavMenuLink: normaliseTopNavColourValue (
tokens.topNavMenuLink
?? tokens.link
?? fallback.topNavMenuLink,
fallback.topNavMenuLink,
),
accent,
accentForeground: readableForegroundFor (accent),
destructive: fallback.destructive,
destructiveForeground: fallback.destructiveForeground,
border,
input: border,
ring: primary,
link,
topNavRootBackgroundMobile: resolvedTopNav.rootBackgroundMobile,
topNavRootBackgroundDesktop: resolvedTopNav.rootBackgroundDesktop,
topNavActiveBackground: resolvedTopNav.activeBackground,
topNavSubmenuBackground: resolvedTopNav.submenuBackground,
topNavMobileActiveBackground: resolvedTopNav.mobileActiveBackground,
topNavBrandLink: resolvedTopNav.brandLink,
topNavMenuLink: resolvedTopNav.menuLink,
tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours),
}
}
const normaliseThemeTokens = (
rawTokens: unknown,
baseTheme: BuiltinThemeId,
): ThemeTokens => {
return buildThemeTokens (baseTheme, rawTokens)
}
export const getUserThemeSlotSelection = (
baseTheme: BuiltinThemeId,
slotNo: UserThemeSlotNo,
@@ -1504,6 +1623,10 @@ export const applyThemeTokens = (tokens: ThemeTokens): void => {
'--top-nav-submenu-bg',
tokens.topNavSubmenuBackground,
)
root.style.setProperty (
'--top-nav-mobile-menu-bg',
tokens.topNavRootBackgroundMobile,
)
root.style.setProperty (
'--top-nav-mobile-active-bg',
tokens.topNavMobileActiveBackground,
+125 -113
ファイルの表示
@@ -21,6 +21,7 @@ import { apiPut } from '@/lib/api'
import {
applyClientAppearance,
applyThemeAppearanceState,
buildThemeTokens,
fetchUserSettings,
fetchUserThemeSlots,
getClientActiveDarkThemeSlotNo,
@@ -163,33 +164,26 @@ const themeSelections: ThemeSelectionItem[] = [
]
const themeTokenGroups: ThemeTokenGroupSpec[] = [
{ title: '基本',
{ title: '基本',
fields: [
{ key: 'background', label: '背景' },
{ key: 'foreground', label: '文字' },
{ key: 'primary', label: '主色' },
{ key: 'accent', label: 'アクセント' },
{ key: 'link', label: 'リンク' },
] },
{ title: 'パネル',
{ title: '',
fields: [
{ key: 'card', label: 'カード背景' },
{ key: 'card', label: 'カード' },
{ key: 'muted', label: '薄い背景' },
{ key: 'border', label: '境界線' },
{ key: 'input', label: '入力欄' },
] },
{ title: 'リンク',
fields: [
{ key: 'link', label: 'リンク' },
] },
{ title: '上部ナビ',
fields: [
{ key: 'topNavRootBackgroundMobile', label: 'スマホ背景' },
{ key: 'topNavRootBackgroundDesktop', label: 'PC 背景' },
{ key: 'topNavActiveBackground', label: '選択中背景' },
{ key: 'topNavSubmenuBackground', label: 'サブメニュー背景' },
{ key: 'topNavMobileActiveBackground', label: 'スマホ選択中背景' },
{ key: 'topNavBrandLink', label: 'ロゴ色' },
{ key: 'topNavMenuLink', label: 'メニュー文字色' },
] },
]
@@ -199,7 +193,7 @@ const ThemeSegmentedControl = <T extends string | number,> (
) => (
<div
role="radiogroup"
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
className="inline-flex w-fit flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
{options.map (option => (
<button
key={String (option.value)}
@@ -207,7 +201,7 @@ const ThemeSegmentedControl = <T extends string | number,> (
role="radio"
aria-checked={value === option.value}
className={cn (
'min-w-0 rounded-lg px-3 py-2 text-sm font-medium',
'min-w-0 rounded-lg px-3 py-1.5 text-sm font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value
@@ -307,21 +301,11 @@ const comparableThemeTokensForSelection = (
}
}
const serialiseThemeDraft = (
themeMode: ClientThemeMode,
activeLightThemeSlotNo: UserThemeSlotNo,
activeDarkThemeSlotNo: UserThemeSlotNo,
themeSlots: UserThemeSlotMap,
const serialiseThemeTokens = (
themeSlots: UserThemeSlotMap,
selection: UserThemeSlotSelection,
): string =>
JSON.stringify ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
themeSlots: USER_THEME_SLOT_SELECTIONS.map (selection => ({
selection,
tokens: comparableThemeTokensForSelection (themeSlots, selection),
})),
})
JSON.stringify (comparableThemeTokensForSelection (themeSlots, selection))
const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
@@ -570,8 +554,10 @@ const ThemeSection: FC<ThemeSectionProps> = (
<FieldError messages={settingsFieldErrors.theme ?? []}/>
<div className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<div className="space-y-2">
<Label></Label>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="shrink-0">
<Label></Label>
</div>
<ThemeSegmentedControl
value={draftThemeMode}
options={themeModeSelections.map (selection => ({
@@ -580,32 +566,50 @@ const ThemeSection: FC<ThemeSectionProps> = (
onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
</div>
<div className="space-y-2">
<Label></Label>
<select
className={cn (inputClass (false), 'w-full')}
value={draftActiveLightThemeSlotNo}
onChange={ev =>
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{selection.label}
</option>))}
</select>
</div>
<div className="space-y-3">
<div className="space-y-1">
<h3 className="text-sm font-semibold">使</h3>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<div className="space-y-2">
<Label></Label>
<select
className={cn (inputClass (false), 'w-full')}
value={draftActiveDarkThemeSlotNo}
onChange={ev =>
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{selection.label}
</option>))}
</select>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<select
className={cn (inputClass (false), 'w-full')}
value={draftActiveLightThemeSlotNo}
onChange={ev =>
onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{`ライト ${ selection.value }`}
</option>))}
</select>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<div className="space-y-2">
<Label></Label>
<select
className={cn (inputClass (false), 'w-full')}
value={draftActiveDarkThemeSlotNo}
onChange={ev =>
onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
{themeSlotNoSelections.map (selection => (
<option key={selection.value} value={selection.value}>
{`ダーク ${ selection.value }`}
</option>))}
</select>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
@@ -617,10 +621,6 @@ const ThemeSection: FC<ThemeSectionProps> = (
</Button>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p></p>
</div>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
tag={previewTags.general}
@@ -636,22 +636,20 @@ const ThemeSection: FC<ThemeSectionProps> = (
key={group.title}
className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">
<h3 className="text-sm font-semibold">{group.title}</h3>
<div className="space-y-3">
<div className="space-y-1">
{group.fields.map (field => (
<div
key={field.key}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{field.label}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
className="flex items-center justify-between gap-4 border-b border-border/60 py-2
last:border-b-0">
<p className="text-sm font-medium">{field.label}</p>
<div className="flex items-center gap-3">
<input
type="color"
value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
onChange={ev =>
onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
className="h-10 w-16 cursor-pointer rounded border border-border
className="h-9 w-14 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/>
</div>
@@ -666,13 +664,13 @@ const ThemeSection: FC<ThemeSectionProps> = (
</div>
</div>
<div className="space-y-3">
<div className="space-y-1">
{CATEGORIES.map (category => (
<div
key={category}
className="flex flex-col gap-3 rounded-lg border border-border/70 px-3 py-3
sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
className="flex items-center justify-between gap-4 border-b border-border/60 py-2
last:border-b-0">
<div className="flex min-w-0 items-center gap-3">
<p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
<div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
<TagLink
@@ -683,12 +681,12 @@ const ThemeSection: FC<ThemeSectionProps> = (
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-3">
<input
type="color"
value={selectedTheme.tokens.tagColours[category]}
onChange={ev => onTagColourChange (category, ev.target.value)}
className="h-10 w-16 cursor-pointer rounded border border-border
className="h-9 w-14 cursor-pointer rounded border border-border
bg-transparent p-1 disabled:cursor-not-allowed
disabled:opacity-50"/>
</div>
@@ -770,22 +768,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
[displayedThemeSelection.selection, draftThemeSlots])
const hasUnsavedThemeChanges = useMemo (
() =>
serialiseThemeDraft (draftThemeMode,
draftActiveLightThemeSlotNo,
draftActiveDarkThemeSlotNo,
draftThemeSlots)
!== serialiseThemeDraft (savedThemeMode,
savedActiveLightThemeSlotNo,
savedActiveDarkThemeSlotNo,
savedThemeSlots),
[draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots,
savedActiveDarkThemeSlotNo,
savedActiveLightThemeSlotNo,
savedThemeMode,
savedThemeSlots])
serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection)
!== serialiseThemeTokens (savedThemeSlots, displayedThemeSelection.selection),
[displayedThemeSelection.selection, draftThemeSlots, savedThemeSlots])
const savedName = user?.name ?? ''
const hasUnsavedAccountChanges = name !== savedName
const hasPageUnsavedChanges = Object.values (dirtyStates).some (state => state.dirty)
@@ -862,7 +847,7 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
clearUserValidationErrors ()
}
const currentThemeDraftState = (): ThemeDraftState => ({
const currentThemeDraftState = (): ThemeDraftState => ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
@@ -875,16 +860,24 @@ const currentThemeDraftState = (): ThemeDraftState => ({
themeSlots: savedThemeSlots })
const discardThemeChanges = () => {
setDraftThemeMode (savedThemeMode)
setDraftActiveLightThemeSlotNo (savedActiveLightThemeSlotNo)
setDraftActiveDarkThemeSlotNo (savedActiveDarkThemeSlotNo)
setDraftThemeSlots (savedThemeSlots)
setCachedUserThemeSlots (savedThemeSlots)
setClientThemeAppearance ({
themeMode: savedThemeMode,
activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo })
applyClientAppearance (savedThemeSlots)
const selection = displayedThemeSelection.selection
const [_, baseTheme, slotNo] = selection.split (':')
const nextThemeSlots = {
...draftThemeSlots,
[selection]: savedThemeSlots[selection]
?? getDefaultUserThemeSlot (
baseTheme as BuiltinThemeId,
Number (slotNo) as UserThemeSlotNo,
),
}
setDraftThemeSlots (nextThemeSlots)
setCachedUserThemeSlots (nextThemeSlots)
applyThemeAppearanceState ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
}, nextThemeSlots)
}
const discardAllDirtyChanges = async (): Promise<void> => {
@@ -939,6 +932,7 @@ const currentThemeDraftState = (): ThemeDraftState => ({
confirmText = '変更を破棄して切り替える',
): Promise<void> => {
const baseState = currentThemeDraftState ()
let themeSlots = baseState.themeSlots
if (hasUnsavedThemeChanges)
{
@@ -949,14 +943,31 @@ const currentThemeDraftState = (): ThemeDraftState => ({
if (!(confirmed))
return
discardThemeChanges ()
const selection = displayedThemeSelection.selection
const [_, baseTheme, slotNo] = selection.split (':')
themeSlots = {
...baseState.themeSlots,
[selection]: savedThemeSlots[selection]
?? getDefaultUserThemeSlot (
baseTheme as BuiltinThemeId,
Number (slotNo) as UserThemeSlotNo,
),
}
}
const nextState = buildNextState (
hasUnsavedThemeChanges
? savedThemeDraftState ()
: baseState)
? { ...savedThemeDraftState (), themeSlots }
: { ...baseState, themeSlots })
setSavedThemeMode (nextState.themeMode)
setSavedActiveLightThemeSlotNo (nextState.activeLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (nextState.activeDarkThemeSlotNo)
setClientThemeAppearance ({
themeMode: nextState.themeMode,
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
})
applyDraftThemeState ({
themeMode: nextState.themeMode,
activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
@@ -1007,12 +1018,21 @@ const currentThemeDraftState = (): ThemeDraftState => ({
updater: (tokens: ThemeTokens) => ThemeTokens,
) => {
const selection = displayedThemeSelection.selection
const fallbackThemeSlot =
getDefaultUserThemeSlot (
displayedThemeSelection.baseTheme,
displayedThemeSelection.slotNo,
)
const currentThemeSlot =
draftThemeSlots[selection]
?? ensureCompleteThemeSlots ({ })[selection]
?? fallbackThemeSlot
const nextTokens = buildThemeTokens (
currentThemeSlot.baseTheme,
updater (currentThemeSlot.tokens),
)
const nextThemeSlots = {
...draftThemeSlots,
[selection]: { ...currentThemeSlot, tokens: updater (currentThemeSlot.tokens) } }
[selection]: { ...currentThemeSlot, tokens: nextTokens } }
applyDraftThemeState ({ themeSlots: nextThemeSlots })
}
@@ -1094,16 +1114,8 @@ const currentThemeDraftState = (): ThemeDraftState => ({
const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)
setSavedThemeMode (draftThemeMode)
setSavedActiveLightThemeSlotNo (draftActiveLightThemeSlotNo)
setSavedActiveDarkThemeSlotNo (draftActiveDarkThemeSlotNo)
setSavedThemeSlots (completeThemeSlots)
setDraftThemeSlots (completeThemeSlots)
// Display mode and active slot numbers are browser-local state.
setClientThemeAppearance ({
themeMode: draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo })
setCachedUserThemeSlots (completeThemeSlots)
applyClientAppearance (completeThemeSlots)
toast ({ title: 'テーマ設定を保存しました.' })