diff --git a/frontend/src/index.css b/frontend/src/index.css index bed2346..f82ccec 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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 diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index adaa01b..c860406 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -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 : { } + 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, diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 1b432fe..12b796d 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -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 = ( ) => (
+ className="inline-flex w-fit flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1"> {options.map (option => (
-
-

保存するまで反映は確定しません。

-
-
= ( key={group.title} className="space-y-3 rounded-xl border border-border/70 bg-background/70 p-4">

{group.title}

-
+
{group.fields.map (field => (
-
-

{field.label}

-
-
- +

{field.label}

+
+ 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"/>
@@ -666,13 +664,13 @@ const ThemeSection: FC = (
-
+
{CATEGORIES.map (category => (
-
+ className="flex items-center justify-between gap-4 border-b border-border/60 py-2 + last:border-b-0"> +

{CATEGORY_NAMES[category]}

= (
-
+
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"/>
@@ -770,22 +768,9 @@ const SettingPage: FC = ({ 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 = ({ 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 => { @@ -939,6 +932,7 @@ const currentThemeDraftState = (): ThemeDraftState => ({ confirmText = '変更を破棄して切り替える', ): Promise => { 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: 'テーマ設定を保存しました.' })