import { CATEGORIES } from '@/consts' import { apiGet, apiPatch, apiPut } from '@/lib/api' import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts' import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts' export type UserSettings = { theme: 'system' | 'light' | 'dark' autoFetchTitle: 'auto' | 'manual' | 'off' autoFetchThumbnail: 'auto' | 'manual' | 'off' wikiEditorMode: 'split' | 'write' | 'preview' } export type ThemeTagColours = Record export type BuiltinThemeId = 'light' | 'dark' export type UserThemeSlotNo = 1 | 2 | 3 export type UserThemeSlotSelection = `user:${ BuiltinThemeId }:${ UserThemeSlotNo }` export type ClientThemeMode = 'system' | 'light' | 'dark' export type ThemeSlotSelection = | 'system' | `builtin:${ BuiltinThemeId }` | UserThemeSlotSelection export type ThemeId = BuiltinThemeId | `custom:${ string }` export type ActiveThemeId = ThemeId | 'system' export type ResolvedTheme = 'light' | 'dark' export type ThemeTokens = { background: string foreground: string card: string cardForeground: string popover: string popoverForeground: string primary: string primaryForeground: string secondary: string secondaryForeground: string muted: string mutedForeground: string accent: string accentForeground: string destructive: string destructiveForeground: string border: string input: string ring: string link: string topNavRootBackgroundMobile: string topNavRootBackgroundDesktop: string topNavActiveBackground: string topNavSubmenuBackground: string topNavMobileActiveBackground: string 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 tokens: ThemeTokens createdAt?: string updatedAt?: string } export type UserThemeSlotMap = Partial> export type ResolvedThemeSelection = { selection: ThemeSlotSelection baseTheme: BuiltinThemeId builtin: boolean tokens: ThemeTokens } export type ResolvedDisplayedTheme = { themeMode: ClientThemeMode baseTheme: BuiltinThemeId selection: UserThemeSlotSelection slotNo: UserThemeSlotNo tokens: ThemeTokens } export type ClientTheme = { id: ThemeId name: string builtin: boolean baseTheme: BuiltinThemeId tokens: ThemeTokens } export type CustomClientTheme = ClientTheme & { builtin: false } export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' export type TheatreTagFlow = 'vertical' | 'horizontal' export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off' export type ClientAnimationMode = 'normal' | 'reduced' | 'off' export type ClientEmbedAutoLoadMode = 'auto' | 'manual' | 'off' export type ClientLinkPreloadMode = 'off' | 'intent' export type ClientTagRelationDisplayMode = 'flat' | 'grouped' export type ClientThumbnailMode = 'normal' | 'light' | 'off' export type ClientPaneBreakpoint = 'desktop' | 'tablet' export type ClientListKey = 'postList' | 'postSearch' | 'tagList' export type ClientListLimit = 20 | 50 | 100 type ClientPaneSettings = { widthPxByBreakpoint?: Partial> collapsed?: boolean } type ClientListSettings = { limit?: ClientListLimit order?: string } export type ClientKeyboardSettings = { enabled?: boolean bindings?: Partial> } export type ClientAppearanceSettings = { themeMode?: ClientThemeMode activeLightThemeSlotNo?: UserThemeSlotNo activeDarkThemeSlotNo?: UserThemeSlotNo activeThemeSlot?: ThemeSlotSelection activeThemeId?: ActiveThemeId customThemes?: Record // Legacy fields kept for migration from the earlier appearance model. theme?: UserSettings['theme'] tagColours?: Partial } export type ClientBehaviorSettings = { animation?: ClientAnimationMode linkPreload?: ClientLinkPreloadMode tagRelationDisplay?: ClientTagRelationDisplayMode embedAutoLoad?: ClientEmbedAutoLoadMode thumbnailMode?: ClientThumbnailMode } // DB-backed settings. At present only `theme` is surfaced in `/users/settings`. // The remaining fields are retained for existing backend work and are not wired // to the common settings UI yet. export const DEFAULT_USER_SETTINGS: UserSettings = { theme: 'system', autoFetchTitle: 'manual', autoFetchThumbnail: 'manual', wikiEditorMode: 'split' } // Browser-local settings only. These are device or screen specific. export type ClientSettings = { appearance?: ClientAppearanceSettings behavior?: ClientBehaviorSettings keyboard?: ClientKeyboardSettings panes?: Record lists?: Partial> theatre?: { layoutMode?: TheatreLayoutMode tagFlow?: TheatreTagFlow } gekanator?: { backgroundMotion?: GekanatorBackgroundMotionMode } reducedMotion?: string embedAutoLoad?: string thumbnailMode?: string } export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings' export const CLIENT_SETTINGS_EVENT = 'btrc-hub:client-settings-change' export const USER_THEME_SLOT_NOS: UserThemeSlotNo[] = [1, 2, 3] export const USER_THEME_SLOT_SELECTIONS: UserThemeSlotSelection[] = [ 'user:light:1', 'user:light:2', 'user:light:3', 'user:dark:1', 'user:dark:2', 'user:dark:3'] export const THEME_OPTIONS = ['system', 'light', 'dark'] as const export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const export const POST_LIST_ORDER_OPTIONS = [ 'title:asc', 'title:desc', 'url:asc', 'url:desc', 'original_created_at:asc', 'original_created_at:desc', 'created_at:asc', 'created_at:desc', 'updated_at:asc', 'updated_at:desc'] as const satisfies readonly FetchPostsOrder[] export const TAG_LIST_ORDER_OPTIONS = [ 'name:asc', 'name:desc', 'category:asc', 'category:desc', 'post_count:asc', 'post_count:desc', 'created_at:asc', 'created_at:desc', 'updated_at:asc', 'updated_at:desc'] as const satisfies readonly FetchTagsOrder[] export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20 export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc' export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc' export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = { deerjikist: '#9f1239', meme: '#6b21a8', character: '#4d7c0f', general: '#155e75', material: '#c2410c', meta: '#a16207', nico: '#4b5563' } export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = { deerjikist: '#fb7185', meme: '#d8b4fe', character: '#bef264', general: '#67e8f9', material: '#fdba74', meta: '#fde047', nico: '#e5e7eb' } export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS export const LIGHT_THEME_TOKENS: ThemeTokens = { background: '0 0% 100%', foreground: '222.2 84% 4.9%', card: '0 0% 100%', cardForeground: '222.2 84% 4.9%', popover: '0 0% 100%', popoverForeground: '222.2 84% 4.9%', primary: '222.2 47.4% 11.2%', primaryForeground: '210 40% 98%', secondary: '210 40% 96.1%', secondaryForeground: '222.2 47.4% 11.2%', muted: '210 40% 96.1%', mutedForeground: '215.4 16.3% 46.9%', accent: '210 40% 96.1%', accentForeground: '222.2 47.4% 11.2%', destructive: '0 72.2% 50.6%', destructiveForeground: '210 40% 98%', border: '214.3 31.8% 91.4%', input: '214.3 31.8% 91.4%', ring: '222.2 84% 4.9%', link: '221.2 83.2% 53.3%', topNavRootBackgroundMobile: '#fef08a', topNavRootBackgroundDesktop: '#fefce8', topNavActiveBackground: '#fef08a', topNavSubmenuBackground: '#fef08a', topNavMobileActiveBackground: '#fefce8', topNavBrandLink: '#db2777', topNavMenuLink: '#1d4ed8', tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS } export const DARK_THEME_TOKENS: ThemeTokens = { background: '222.2 84% 4.9%', foreground: '210 40% 98%', card: '222.2 84% 4.9%', cardForeground: '210 40% 98%', popover: '222.2 84% 4.9%', popoverForeground: '210 40% 98%', primary: '210 40% 98%', primaryForeground: '222.2 47.4% 11.2%', secondary: '217.2 32.6% 17.5%', secondaryForeground: '210 40% 98%', muted: '217.2 32.6% 17.5%', mutedForeground: '215 20.2% 65.1%', accent: '217.2 32.6% 17.5%', accentForeground: '210 40% 98%', destructive: '0 62.8% 45%', destructiveForeground: '210 40% 98%', border: '217.2 32.6% 17.5%', input: '217.2 32.6% 17.5%', ring: '212.7 26.8% 83.9%', link: '213.1 93.9% 67.8%', topNavRootBackgroundMobile: '#230505', topNavRootBackgroundDesktop: '#230505', topNavActiveBackground: '#450a0a', topNavSubmenuBackground: '#450a0a', topNavMobileActiveBackground: '#450a0a', topNavBrandLink: '#f9a8d4', topNavMenuLink: '#93c5fd', tagColours: DEFAULT_DARK_THEME_TAG_COLOURS } export const BUILTIN_THEMES: Record = { light: { id: 'light', name: 'ライト・モード', builtin: true, baseTheme: 'light', tokens: LIGHT_THEME_TOKENS }, dark: { id: 'dark', name: 'ダーク・モード', builtin: true, baseTheme: 'dark', tokens: DARK_THEME_TOKENS } } const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY = 'gekanator:background-motion:v1' const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated' const createCustomThemeId = (): `custom:${ string }` => { const randomPart = globalThis.crypto?.randomUUID?.() ?? `${ Date.now ().toString (36) }-${ Math.random ().toString (36).slice (2) }` return `custom:${ randomPart }` } export const fetchUserSettings = async (): Promise => { const raw = await apiGet<{ theme: UserSettings['theme'] auto_fetch_title: UserSettings['autoFetchTitle'] auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] wiki_editor_mode: UserSettings['wikiEditorMode'] }> ('/users/settings') return { theme: raw.theme, autoFetchTitle: raw.auto_fetch_title, autoFetchThumbnail: raw.auto_fetch_thumbnail, wikiEditorMode: raw.wiki_editor_mode } } export const updateUserSettings = async ( settings: Partial<{ theme: UserSettings['theme'] }>, ): Promise => { const raw = await apiPatch<{ theme: UserSettings['theme'] auto_fetch_title: UserSettings['autoFetchTitle'] auto_fetch_thumbnail: UserSettings['autoFetchThumbnail'] wiki_editor_mode: UserSettings['wikiEditorMode'] }> ('/users/settings', settings) return { theme: raw.theme, autoFetchTitle: raw.auto_fetch_title, autoFetchThumbnail: raw.auto_fetch_thumbnail, wikiEditorMode: raw.wiki_editor_mode } } const safeParseClientSettings = (raw: string | null): ClientSettings => { if (!(raw)) return { } try { const parsed = JSON.parse (raw) return parsed && typeof parsed === 'object' ? parsed as ClientSettings : { } } catch { return { } } } export const loadClientSettings = (): ClientSettings => safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY)) export const saveClientSettings = (settings: ClientSettings): void => { localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings)) if (typeof window !== 'undefined') { window.dispatchEvent (new CustomEvent (CLIENT_SETTINGS_EVENT, { detail: settings })) } } export const updateClientSettings = ( updater: (settings: ClientSettings) => ClientSettings, ): ClientSettings => { const next = updater (loadClientSettings ()) saveClientSettings (next) return next } const validListLimit = (value: unknown): ClientListLimit | null => value === 20 || value === 50 || value === 100 ? value : null const normaliseThemeMode = (value: unknown): ClientThemeMode | null => value === 'system' || value === 'light' || value === 'dark' ? value : null const normaliseThemeSlotNo = (value: unknown): UserThemeSlotNo | null => value === 1 || value === 2 || value === 3 ? value : null const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => { if (value === 'normal' || value === 'reduced' || value === 'off') return value if (value === 'true') return 'reduced' if (value === 'false') return 'normal' return null } const normaliseEmbedAutoLoadMode = (value: unknown): ClientEmbedAutoLoadMode | null => value === 'auto' || value === 'manual' || value === 'off' ? value : null const normaliseLinkPreloadMode = ( value: unknown, ): ClientLinkPreloadMode | null => value === 'off' || value === 'intent' ? value : null const normaliseTagRelationDisplayMode = ( value: unknown, ): ClientTagRelationDisplayMode | null => value === 'flat' || value === 'grouped' ? value : null const normaliseThumbnailMode = (value: unknown): ClientThumbnailMode | null => value === 'normal' || value === 'light' || value === 'off' ? value : null const normaliseKeyboardBindings = ( bindings: unknown, ): Partial> => { if (!(bindings) || typeof bindings !== 'object') return { } return ( Object.keys (DEFAULT_KEY_BINDINGS) .reduce>> ((normalised, actionId) => { const value = (bindings as Record)[actionId] if (value === null) { normalised[actionId as ShortcutActionId] = null return normalised } const binding = normaliseKeyBinding (value) if (binding != null) normalised[actionId as ShortcutActionId] = binding return normalised }, { })) } export const getClientKeyboardSettings = (): ClientKeyboardSettings => { const keyboard = loadClientSettings ().keyboard return { enabled: keyboard?.enabled !== false, bindings: normaliseKeyboardBindings (keyboard?.bindings) } } const legacyAnimationMode = (): ClientAnimationMode | null => normaliseAnimationMode (loadClientSettings ().reducedMotion) const legacyEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode | null => normaliseEmbedAutoLoadMode (loadClientSettings ().embedAutoLoad) const legacyThumbnailMode = (): ClientThumbnailMode | null => normaliseThumbnailMode (loadClientSettings ().thumbnailMode) export const getClientAnimationMode = (): ClientAnimationMode => loadClientSettings ().behavior?.animation ?? legacyAnimationMode () ?? 'normal' export const applyClientAnimationMode = (mode: ClientAnimationMode): void => { if (typeof document === 'undefined') return if (mode === 'normal') { delete document.documentElement.dataset.animation return } document.documentElement.dataset.animation = mode } export const setClientAnimationMode = ( animation: ClientAnimationMode, ): ClientBehaviorSettings => setClientBehaviorSettings ({ ...getClientBehaviorSettings (), animation }) export const getClientLinkPreloadMode = (): ClientLinkPreloadMode => normaliseLinkPreloadMode (loadClientSettings ().behavior?.linkPreload) ?? 'intent' export const setClientLinkPreloadMode = ( linkPreload: ClientLinkPreloadMode, ): ClientBehaviorSettings => setClientBehaviorSettings ({ ...getClientBehaviorSettings (), linkPreload }) export const getClientTagRelationDisplayMode = (): ClientTagRelationDisplayMode => normaliseTagRelationDisplayMode (loadClientSettings ().behavior?.tagRelationDisplay) ?? 'grouped' export const setClientTagRelationDisplayMode = ( tagRelationDisplay: ClientTagRelationDisplayMode, ): ClientBehaviorSettings => setClientBehaviorSettings ({ ...getClientBehaviorSettings (), tagRelationDisplay }) export const getClientEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode => loadClientSettings ().behavior?.embedAutoLoad ?? legacyEmbedAutoLoadMode () ?? 'auto' export const setClientEmbedAutoLoadMode = ( embedAutoLoad: ClientEmbedAutoLoadMode, ): ClientBehaviorSettings => setClientBehaviorSettings ({ ...getClientBehaviorSettings (), embedAutoLoad }) export const getClientThumbnailMode = (): ClientThumbnailMode => loadClientSettings ().behavior?.thumbnailMode ?? legacyThumbnailMode () ?? 'normal' export const setClientThumbnailMode = ( thumbnailMode: ClientThumbnailMode, ): ClientBehaviorSettings => setClientBehaviorSettings ({ ...getClientBehaviorSettings (), thumbnailMode }) export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({ animation: getClientAnimationMode (), linkPreload: getClientLinkPreloadMode (), tagRelationDisplay: getClientTagRelationDisplayMode (), embedAutoLoad: getClientEmbedAutoLoadMode (), thumbnailMode: getClientThumbnailMode () }) export const setClientBehaviorSettings = ( behavior: ClientBehaviorSettings, ): ClientBehaviorSettings => { const next: ClientBehaviorSettings = { animation: ( normaliseAnimationMode (behavior.animation) ?? getClientAnimationMode ()), linkPreload: ( normaliseLinkPreloadMode (behavior.linkPreload) ?? getClientLinkPreloadMode ()), tagRelationDisplay: ( normaliseTagRelationDisplayMode (behavior.tagRelationDisplay) ?? getClientTagRelationDisplayMode ()), embedAutoLoad: ( normaliseEmbedAutoLoadMode (behavior.embedAutoLoad) ?? getClientEmbedAutoLoadMode ()), thumbnailMode: ( normaliseThumbnailMode (behavior.thumbnailMode) ?? getClientThumbnailMode ()) } updateClientSettings (settings => ({ ...settings, behavior: next })) applyClientAnimationMode (next.animation ?? 'normal') return next } export const setClientKeyboardSettings = ( keyboard: ClientKeyboardSettings, ): ClientKeyboardSettings => { const next: ClientKeyboardSettings = { enabled: keyboard.enabled !== false, bindings: normaliseKeyboardBindings (keyboard.bindings) } updateClientSettings (settings => ({ ...settings, keyboard: next })) return next } export const getEffectiveKeyBindings = ( bindings: Partial> = getClientKeyboardSettings ().bindings ?? { }, ): Record => Object.keys (DEFAULT_KEY_BINDINGS).reduce> ( (effectiveBindings, actionId) => { const key = actionId as ShortcutActionId effectiveBindings[key] = Object.prototype.hasOwnProperty.call (bindings, key) ? bindings[key] ?? null : DEFAULT_KEY_BINDINGS[key] return effectiveBindings }, { ...DEFAULT_KEY_BINDINGS }) export const setClientKeyBinding = ( actionId: ShortcutActionId, binding: KeyBinding | null, ): ClientKeyboardSettings => { const current = getClientKeyboardSettings () return setClientKeyboardSettings ({ ...current, bindings: { ...(current.bindings ?? { }), [actionId]: binding } }) } export const resetClientKeyBinding = ( actionId: ShortcutActionId, ): ClientKeyboardSettings => { const current = getClientKeyboardSettings () const nextBindings = { ...(current.bindings ?? { }) } delete nextBindings[actionId] return setClientKeyboardSettings ({ ...current, bindings: nextBindings }) } export const resetAllClientKeyBindings = (): ClientKeyboardSettings => { const current = getClientKeyboardSettings () return setClientKeyboardSettings ({ ...current, bindings: { } }) } const normaliseHexColour = (value: string): string | null => /^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null const isBuiltinThemeId = (value: string): value is BuiltinThemeId => value === 'light' || value === 'dark' const isCustomThemeId = (value: string): value is `custom:${ string }` => value.startsWith ('custom:') const themeCopyName = (baseTheme: BuiltinThemeId): string => `${ BUILTIN_THEMES[baseTheme].name }のコピー` const appearanceFromSettings = (): ClientAppearanceSettings => loadClientSettings ().appearance ?? { } let cachedUserThemeSlots: UserThemeSlotMap = { } export const hasStoredClientThemeSelection = (): boolean => { const appearance = appearanceFromSettings () return (appearance.themeMode != null || appearance.activeLightThemeSlotNo != null || appearance.activeDarkThemeSlotNo != null || appearance.activeThemeSlot != null || appearance.activeThemeId != null || appearance.customThemes != null || appearance.theme != null || appearance.tagColours != null) } const mixHexColour = ( source: string, target: string, ratio: number, ): string => { const clampedRatio = Math.max (0, Math.min (1, ratio)) const src = source.slice (1) const dst = target.slice (1) const channels = [0, 2, 4].map (offset => { const srcValue = parseInt (src.slice (offset, offset + 2), 16) const dstValue = parseInt (dst.slice (offset, offset + 2), 16) const mixed = Math.round (srcValue + (dstValue - srcValue) * clampedRatio) return mixed.toString (16).padStart (2, '0') }) return `#${ channels.join ('') }` } // Legacy appearance-model compatibility. // The current settings UI uses `themeMode` plus the light/dark slot numbers. // The helpers below remain only to read older localStorage shapes. const legacyThemeSlotSelection = (): ThemeSlotSelection => { const appearance = appearanceFromSettings () const customThemes = getClientCustomThemes () if (appearance.activeThemeId === 'system') return 'system' if (appearance.activeThemeId === 'light') return 'builtin:light' if (appearance.activeThemeId === 'dark') return 'builtin:dark' if (typeof appearance.activeThemeId === 'string' && customThemes[appearance.activeThemeId] != null) return `builtin:${ customThemes[appearance.activeThemeId].baseTheme }` if (appearance.theme === 'light') return 'builtin:light' if (appearance.theme === 'dark') return 'builtin:dark' return 'system' } const legacyThemeMode = (): ClientThemeMode => normaliseThemeMode (appearanceFromSettings ().theme) ?? (legacyThemeSlotSelection () === 'system' ? 'system' : (baseThemeOfSelection (legacyThemeSlotSelection ()) ?? 'system')) const legacyThemeSlotNoFor = ( baseTheme: BuiltinThemeId, ): UserThemeSlotNo => { const legacySelection = legacyThemeSlotSelection () if (isUserThemeSlotSelection (legacySelection)) { const [, selectionBaseTheme, slotNo] = legacySelection.split (':') if (selectionBaseTheme === baseTheme) return Number (slotNo) as UserThemeSlotNo } return 1 } export const getClientActiveThemeSlot = (): ThemeSlotSelection => normaliseThemeSlotSelection (appearanceFromSettings ().activeThemeSlot) ?? legacyThemeSlotSelection () // Legacy write path. New code should use `setClientThemeAppearance`. export const setClientActiveThemeSlot = ( activeThemeSlot: ThemeSlotSelection, ): void => { updateClientSettings (settings => ({ ...settings, appearance: { ...(settings.appearance ?? { }), activeThemeSlot } })) } export const getClientThemeMode = (): UserSettings['theme'] => normaliseThemeMode (appearanceFromSettings ().themeMode) ?? legacyThemeMode () export const getClientActiveLightThemeSlotNo = (): UserThemeSlotNo => normaliseThemeSlotNo (appearanceFromSettings ().activeLightThemeSlotNo) ?? legacyThemeSlotNoFor ('light') export const getClientActiveDarkThemeSlotNo = (): UserThemeSlotNo => normaliseThemeSlotNo (appearanceFromSettings ().activeDarkThemeSlotNo) ?? legacyThemeSlotNoFor ('dark') export const setClientThemeAppearance = ( { themeMode, activeLightThemeSlotNo, activeDarkThemeSlotNo }: { themeMode: ClientThemeMode activeLightThemeSlotNo: UserThemeSlotNo activeDarkThemeSlotNo: UserThemeSlotNo }, ): void => { updateClientSettings (settings => ({ ...settings, appearance: { ...(settings.appearance ?? { }), themeMode, activeLightThemeSlotNo, activeDarkThemeSlotNo } })) } export const setClientThemeMode = (theme: UserSettings['theme']): void => { setClientThemeAppearance ({ themeMode: theme, activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (), activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo () }) } export const seedClientThemeMode = (theme: UserSettings['theme']): void => { if (hasStoredClientThemeSelection ()) return setClientThemeMode (theme) } const prefersDarkTheme = (): boolean => window.matchMedia ('(prefers-color-scheme: dark)').matches export const resolveThemeMode = ( theme: UserSettings['theme'], ): ResolvedTheme => theme === 'system' ? (prefersDarkTheme () ? 'dark' : 'light') : theme export const defaultThemeTagColoursFor = ( theme: ResolvedTheme, ): ThemeTagColours => theme === 'dark' ? DEFAULT_DARK_THEME_TAG_COLOURS : DEFAULT_LIGHT_THEME_TAG_COLOURS const normaliseThemeTagColours = ( input: Partial | null | undefined, fallback: ThemeTagColours, ): ThemeTagColours => CATEGORIES.reduce ( (colours, category) => { const candidate = input?.[category] colours[category] = typeof candidate === 'string' && normaliseHexColour (candidate) != null ? candidate : fallback[category] return colours }, { ...fallback }) const normaliseThemeTokenValue = ( value: unknown, fallback: string, ): string => typeof value === 'string' && value !== '' ? value : fallback const cssColourFromThemeToken = ( value: unknown, fallback: string, ): string => { if (typeof value !== 'string' || value === '') return fallback const normalisedHex = normaliseHexColour (value) if (normalisedHex != null) return normalisedHex return (/^-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/.test (value) ? `hsl(${ value })` : fallback) } 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 = rawTokens && typeof rawTokens === 'object' ? rawTokens as Partial : { } const legacyTopNavTokens = rawTokens && typeof rawTokens === 'object' ? 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 topNavMenuLink = cssColourFromThemeToken ( link, fallback.topNavMenuLink) const resolvedTopNav = deriveTopNavTokens ( baseTheme, { rootBackgroundMobile: topNavRootBackgroundMobile, rootBackgroundDesktop: topNavRootBackgroundDesktop, activeBackground: topNavActiveBackground, brandLink: topNavBrandLink }, topNavMenuLink) return { 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, 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 normaliseTopNavColourValue = ( value: unknown, fallback: string, ): string => cssColourFromThemeToken (value, fallback) const normaliseThemeTokens = ( rawTokens: unknown, baseTheme: BuiltinThemeId, ): ThemeTokens => { const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS const tokens = rawTokens && typeof rawTokens === 'object' ? rawTokens as Partial : { } const legacyTopNavTokens = rawTokens && typeof rawTokens === 'object' ? 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) return buildThemeTokens ( baseTheme, { ...tokens, background, foreground, card, muted, primary, accent, border, link, topNavRootBackgroundMobile: tokens.topNavRootBackgroundMobile ?? legacyTopNavTokens.topNavRootBackgroundMobile ?? legacyTopNavTokens.topNavBackground, topNavRootBackgroundDesktop: tokens.topNavRootBackgroundDesktop ?? legacyTopNavTokens.topNavRootBackgroundDesktop ?? legacyTopNavTokens.topNavBackground, topNavActiveBackground: tokens.topNavActiveBackground ?? legacyTopNavTokens.topNavActiveBackground ?? legacyTopNavTokens.topNavHighlightBackground, topNavBrandLink: tokens.topNavBrandLink ?? legacyTopNavTokens.topNavBrandLink ?? legacyTopNavTokens.topNavLink, topNavMenuLink: tokens.topNavMenuLink ?? legacyTopNavTokens.topNavMenuLink ?? link, tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours) }) } export const getUserThemeSlotSelection = ( baseTheme: BuiltinThemeId, slotNo: UserThemeSlotNo, ): UserThemeSlotSelection => `user:${ baseTheme }:${ slotNo }` const isBuiltinThemeSlotSelection = ( value: string, ): value is `builtin:${ BuiltinThemeId }` => value === 'builtin:light' || value === 'builtin:dark' export const isUserThemeSlotSelection = ( value: unknown, ): value is UserThemeSlotSelection => typeof value === 'string' && USER_THEME_SLOT_SELECTIONS.includes (value as UserThemeSlotSelection) const normaliseThemeSlotSelection = ( value: unknown, ): ThemeSlotSelection | null => { if (value === 'system') return 'system' if (typeof value === 'string' && isBuiltinThemeSlotSelection (value)) return value if (isUserThemeSlotSelection (value)) return value return null } const baseThemeOfSelection = ( selection: ThemeSlotSelection, ): BuiltinThemeId | null => { if (selection === 'system') return null if (isBuiltinThemeSlotSelection (selection)) return selection.slice ('builtin:'.length) as BuiltinThemeId return selection.split (':')[1] as BuiltinThemeId } const slotNoOfSelection = ( selection: UserThemeSlotSelection, ): UserThemeSlotNo => Number (selection.split (':')[2]) as UserThemeSlotNo export const getDefaultUserThemeSlot = ( baseTheme: BuiltinThemeId, slotNo: UserThemeSlotNo, ): UserThemeSlot => ({ baseTheme, slotNo, tokens: normaliseThemeTokens ({ }, baseTheme) }) const normaliseUserThemeSlot = ( rawSlot: unknown, ): UserThemeSlot | null => { if (!(rawSlot) || typeof rawSlot !== 'object') return null const slot = rawSlot as Partial const baseTheme = slot.baseTheme const slotNo = slot.slotNo if (baseTheme == null || !(isBuiltinThemeId (baseTheme)) || slotNo == null || !(USER_THEME_SLOT_NOS.includes (slotNo as UserThemeSlotNo))) return null return { baseTheme, slotNo: slotNo as UserThemeSlotNo, tokens: normaliseThemeTokens (slot.tokens, baseTheme), createdAt: typeof slot.createdAt === 'string' ? slot.createdAt : undefined, updatedAt: typeof slot.updatedAt === 'string' ? slot.updatedAt : undefined } } export const normaliseUserThemeSlots = ( slots: UserThemeSlot[], ): UserThemeSlotMap => slots.reduce ( (map, slot) => { map[getUserThemeSlotSelection (slot.baseTheme, slot.slotNo)] = slot return map }, { }) const normaliseCustomTheme = ( rawTheme: unknown, ): CustomClientTheme | null => { if (!(rawTheme) || typeof rawTheme !== 'object') return null const theme = rawTheme as Record const id = typeof theme.id === 'string' ? theme.id : null const name = typeof theme.name === 'string' ? theme.name : null const baseTheme = typeof theme.baseTheme === 'string' ? theme.baseTheme : null if (id == null || !(isCustomThemeId (id)) || name == null || baseTheme == null || !(isBuiltinThemeId (baseTheme))) return null return { id, name, builtin: false, baseTheme, tokens: normaliseThemeTokens ( { ...(theme.tokens && typeof theme.tokens === 'object' ? theme.tokens as Partial : { }), tagColours: ( (theme.tokens && typeof theme.tokens === 'object' && 'tagColours' in theme.tokens) ? (theme.tokens as { tagColours?: Partial }).tagColours : theme.tagColours as Partial | undefined) }, baseTheme) } } export const getClientCustomThemes = (): Record => { const appearance = appearanceFromSettings () const customThemes = appearance.customThemes if (customThemes && typeof customThemes === 'object') { return Object.entries (customThemes).reduce> ( (themes, [key, rawTheme]) => { const theme = normaliseCustomTheme (rawTheme) if (theme != null) themes[key] = theme return themes }, { }) } const legacyTagColours = appearance.tagColours if (!(legacyTagColours) || Object.keys (legacyTagColours).length === 0) return { } const baseTheme = resolveThemeMode (appearance.theme ?? DEFAULT_USER_SETTINGS.theme) return { [LEGACY_MIGRATED_THEME_ID]: { id: LEGACY_MIGRATED_THEME_ID, name: themeCopyName (baseTheme), builtin: false, baseTheme, tokens: { ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS), tagColours: normaliseThemeTagColours ( legacyTagColours, defaultThemeTagColoursFor (baseTheme)) } } } } export const fetchUserThemeSlots = async (): Promise => { const raw = await apiGet> ('/users/theme_slots') return (raw .map (slot => normaliseUserThemeSlot (slot)) .filter ((slot): slot is UserThemeSlot => slot != null)) } export const upsertUserThemeSlot = async ( baseTheme: BuiltinThemeId, slotNo: UserThemeSlotNo, tokens: ThemeTokens, ): Promise => { const raw = await apiPut<{ baseTheme: BuiltinThemeId slotNo: UserThemeSlotNo tokens: ThemeTokens createdAt: string updatedAt: string }> (`/users/theme_slots/${ baseTheme }/${ slotNo }`, { tokens }) const slot = normaliseUserThemeSlot (raw) if (slot == null) throw new Error ('theme slot response is invalid') return slot } export const setCachedUserThemeSlots = (themeSlots: UserThemeSlotMap): void => { cachedUserThemeSlots = themeSlots } export const getCachedUserThemeSlots = (): UserThemeSlotMap => cachedUserThemeSlots export const resolveDisplayedTheme = ( { themeMode, activeLightThemeSlotNo, activeDarkThemeSlotNo }: { themeMode: ClientThemeMode activeLightThemeSlotNo: UserThemeSlotNo activeDarkThemeSlotNo: UserThemeSlotNo }, themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), ): ResolvedDisplayedTheme => { const baseTheme = resolveThemeMode (themeMode) const slotNo = baseTheme === 'dark' ? activeDarkThemeSlotNo : activeLightThemeSlotNo const selection = getUserThemeSlotSelection (baseTheme, slotNo) return { themeMode, baseTheme, selection, slotNo, tokens: (themeSlots[selection]?.tokens ?? getDefaultUserThemeSlot (baseTheme, slotNo).tokens) } } export const normaliseThemeSlotSelectionAgainstSlots = ( selection: ThemeSlotSelection, themeSlots: UserThemeSlotMap, ): ThemeSlotSelection => isUserThemeSlotSelection (selection) && themeSlots[selection] == null ? 'system' : selection export const getClientActiveThemeId = (): ActiveThemeId => { const appearance = appearanceFromSettings () const customThemes = getClientCustomThemes () if (typeof appearance.activeThemeId === 'string' && (appearance.activeThemeId === 'system' || isBuiltinThemeId (appearance.activeThemeId) || customThemes[appearance.activeThemeId] != null)) return appearance.activeThemeId if (Object.keys (customThemes).length > 0 && appearance.tagColours != null && Object.keys (appearance.tagColours).length > 0) return LEGACY_MIGRATED_THEME_ID if (typeof appearance.theme === 'string' && (appearance.theme === 'system' || appearance.theme === 'light' || appearance.theme === 'dark')) return appearance.theme return DEFAULT_USER_SETTINGS.theme } export const getThemeById = ( themeId: ThemeId, customThemes: Record, ): ClientTheme => isBuiltinThemeId (themeId) ? BUILTIN_THEMES[themeId] : (customThemes[themeId] ?? BUILTIN_THEMES.light) export const getResolvedThemeFromSelection = ( activeThemeId: ActiveThemeId, customThemes: Record, ): ClientTheme => activeThemeId === 'system' ? BUILTIN_THEMES[resolveThemeMode ('system')] : getThemeById (activeThemeId, customThemes) export const deriveUserThemeModeFromSelection = ( activeThemeId: ActiveThemeId, customThemes: Record, ): UserSettings['theme'] => { if (activeThemeId === 'system') return 'system' return getThemeById (activeThemeId, customThemes).baseTheme } export const resolveThemeFromSlotSelection = ( selection: ThemeSlotSelection, themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), ): ResolvedThemeSelection => { if (selection === 'system') { const baseTheme = resolveThemeMode ('system') return { selection, baseTheme, builtin: true, tokens: BUILTIN_THEMES[baseTheme].tokens } } if (isBuiltinThemeSlotSelection (selection)) { const baseTheme = selection.slice ('builtin:'.length) as BuiltinThemeId return { selection, baseTheme, builtin: true, tokens: BUILTIN_THEMES[baseTheme].tokens } } const baseTheme = baseThemeOfSelection (selection) ?? 'light' return { selection, baseTheme, builtin: false, tokens: (themeSlots[selection]?.tokens ?? getDefaultUserThemeSlot (baseTheme, slotNoOfSelection (selection)).tokens) } } export const deriveUserThemeModeFromSlotSelection = ( selection: ThemeSlotSelection, ): UserSettings['theme'] => selection === 'system' ? 'system' : (baseThemeOfSelection (selection) ?? DEFAULT_USER_SETTINGS.theme) export const createCustomThemeFromBase = ( baseTheme: BuiltinThemeId, tokens?: ThemeTokens, ): CustomClientTheme => { const id = createCustomThemeId () return { id, name: themeCopyName (baseTheme), builtin: false, baseTheme, tokens: tokens ?? { ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS) } } } export const getClientThemeTagColours = ( activeThemeId: ActiveThemeId = getClientActiveThemeId (), customThemes: Record = getClientCustomThemes (), ): ThemeTagColours => ({ ...getResolvedThemeFromSelection (activeThemeId, customThemes).tokens.tagColours }) export const setClientAppearanceThemes = ( activeThemeId: ActiveThemeId, customThemes: Record, ): void => { updateClientSettings (settings => ({ ...settings, appearance: { ...(settings.appearance ?? { }), activeThemeId, customThemes, theme: undefined, tagColours: undefined } })) } export const setClientThemeTagColours = ( tagColours: ThemeTagColours, activeThemeId: ActiveThemeId = getClientActiveThemeId (), customThemes: Record = getClientCustomThemes (), ): void => { if (activeThemeId === 'system' || isBuiltinThemeId (activeThemeId)) return setClientAppearanceThemes ( activeThemeId, { ...customThemes, [activeThemeId]: { ...customThemes[activeThemeId], tokens: { ...customThemes[activeThemeId].tokens, tagColours } } }) } export const applyThemeMode = (theme: UserSettings['theme']): void => { const root = document.documentElement const resolvedTheme = resolveThemeMode (theme) root.classList.toggle ('dark', resolvedTheme === 'dark') root.style.colorScheme = resolvedTheme } export const applyThemeTagColours = (tagColours: ThemeTagColours): void => { const root = document.documentElement CATEGORIES.forEach (category => { const colour = tagColours[category] root.style.setProperty (`--tag-colour-${ category }`, colour) root.style.setProperty ( `--tag-colour-${ category }-hover`, mixHexColour (colour, '#ffffff', .18)) }) } export const applyThemeTokens = (tokens: ThemeTokens): void => { const root = document.documentElement root.style.setProperty ('--background', tokens.background) root.style.setProperty ('--foreground', tokens.foreground) root.style.setProperty ('--card', tokens.card) root.style.setProperty ('--card-foreground', tokens.cardForeground) root.style.setProperty ('--popover', tokens.popover) root.style.setProperty ('--popover-foreground', tokens.popoverForeground) root.style.setProperty ('--primary', tokens.primary) root.style.setProperty ('--primary-foreground', tokens.primaryForeground) root.style.setProperty ('--secondary', tokens.secondary) root.style.setProperty ('--secondary-foreground', tokens.secondaryForeground) root.style.setProperty ('--muted', tokens.muted) root.style.setProperty ('--muted-foreground', tokens.mutedForeground) root.style.setProperty ('--accent', tokens.accent) root.style.setProperty ('--accent-foreground', tokens.accentForeground) root.style.setProperty ('--destructive', tokens.destructive) root.style.setProperty ('--destructive-foreground', tokens.destructiveForeground) root.style.setProperty ('--border', tokens.border) root.style.setProperty ('--input', tokens.input) root.style.setProperty ('--ring', tokens.ring) root.style.setProperty ('--theme-link', tokens.link) root.style.setProperty ('--top-nav-root-bg-mobile', tokens.topNavRootBackgroundMobile) root.style.setProperty ('--top-nav-root-bg-desktop', tokens.topNavRootBackgroundDesktop) root.style.setProperty ('--top-nav-active-bg', tokens.topNavActiveBackground) root.style.setProperty ('--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) root.style.setProperty ('--top-nav-brand-link', tokens.topNavBrandLink) root.style.setProperty ('--top-nav-menu-link', tokens.topNavMenuLink) applyThemeTagColours (tokens.tagColours) } export const applyThemeSelection = ( activeThemeSlot: ThemeSlotSelection, themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), ): void => { const theme = resolveThemeFromSlotSelection (activeThemeSlot, themeSlots) applyThemeMode (theme.baseTheme) applyThemeTokens (theme.tokens) } export const applyThemeAppearanceState = ( { themeMode, activeLightThemeSlotNo, activeDarkThemeSlotNo }: { themeMode: ClientThemeMode activeLightThemeSlotNo: UserThemeSlotNo activeDarkThemeSlotNo: UserThemeSlotNo }, themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), ): void => { const theme = resolveDisplayedTheme ({ themeMode, activeLightThemeSlotNo, activeDarkThemeSlotNo }, themeSlots) applyThemeMode (theme.baseTheme) applyThemeTokens (theme.tokens) } export const applyClientAppearance = ( themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (), ): void => { applyThemeAppearanceState ({ themeMode: getClientThemeMode (), activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (), activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo () }, themeSlots) } export const getClientListLimit = ( listKey: ClientListKey, ): ClientListLimit | null => { const value = loadClientSettings ().lists?.[listKey]?.limit return validListLimit (value) } export const getClientListOrder = ( listKey: ClientListKey, ): T | null => { const value = loadClientSettings ().lists?.[listKey]?.order return typeof value === 'string' ? value as T : null } export const setClientListSettings = ( listKey: ClientListKey, { limit, order }: { limit?: ClientListLimit order?: string }, ): void => { updateClientSettings (settings => ({ ...settings, lists: { ...(settings.lists ?? { }), [listKey]: { ...(settings.lists?.[listKey] ?? { }), ...(limit == null ? { } : { limit }), ...(order == null ? { } : { order }) } } })) } const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => { const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY) return ((value === 'threeColumns' || value === 'tagsBottom' || value === 'commentsBottom') ? value : null) } const legacyTheatreTagFlow = (): TheatreTagFlow | null => { const value = localStorage.getItem (LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY) return (value === 'vertical' || value === 'horizontal') ? value : null } const legacyGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode | null => { const value = localStorage.getItem (LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY) return (value === 'on' || value === 'calm' || value === 'off') ? value : null } export const getClientTheatreLayoutMode = (): TheatreLayoutMode => loadClientSettings ().theatre?.layoutMode ?? legacyTheatreLayoutMode () ?? 'threeColumns' export const setClientTheatreLayoutMode = (layoutMode: TheatreLayoutMode): void => { updateClientSettings (settings => ({ ...settings, theatre: { ...(settings.theatre ?? { }), layoutMode } })) } export const getClientTheatreTagFlow = (): TheatreTagFlow => loadClientSettings ().theatre?.tagFlow ?? legacyTheatreTagFlow () ?? 'vertical' export const setClientTheatreTagFlow = (tagFlow: TheatreTagFlow): void => { updateClientSettings (settings => ({ ...settings, theatre: { ...(settings.theatre ?? { }), tagFlow } })) } export const getClientGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode => loadClientSettings ().gekanator?.backgroundMotion ?? legacyGekanatorBackgroundMotion () ?? 'on' export const setClientGekanatorBackgroundMotion = ( backgroundMotion: GekanatorBackgroundMotionMode, ): void => { updateClientSettings (settings => ({ ...settings, gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } })) } export const getClientPaneWidthPx = ( paneKey: string, breakpoint: ClientPaneBreakpoint, ): number | null => { const value = loadClientSettings ().panes?.[paneKey]?.widthPxByBreakpoint?.[breakpoint] return typeof value === 'number' ? value : null } export const setClientPaneWidthPx = ( paneKey: string, breakpoint: ClientPaneBreakpoint, widthPx: number, ): void => { updateClientSettings (settings => ({ ...settings, panes: { ...(settings.panes ?? { }), [paneKey]: { ...(settings.panes?.[paneKey] ?? { }), widthPxByBreakpoint: { ...(settings.panes?.[paneKey]?.widthPxByBreakpoint ?? { }), [breakpoint]: widthPx } } } })) } export const setClientPaneCollapsed = ( paneKey: string, collapsed: boolean, ): void => { updateClientSettings (settings => ({ ...settings, panes: { ...(settings.panes ?? { }), [paneKey]: { ...(settings.panes?.[paneKey] ?? { }), collapsed } } })) }