import { CATEGORIES } from '@/consts' import { apiGet, apiPatch } 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 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 tagColours: ThemeTagColours } 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 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 = { activeThemeId?: ActiveThemeId customThemes?: Record // Legacy fields kept for migration from the earlier appearance model. theme?: UserSettings['theme'] tagColours?: Partial } export type ClientBehaviorSettings = { animation?: ClientAnimationMode 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 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%', 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%', 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 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 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< Partial> > ( (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 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 (), embedAutoLoad: getClientEmbedAutoLoadMode (), thumbnailMode: getClientThumbnailMode (), }) export const setClientBehaviorSettings = ( behavior: ClientBehaviorSettings, ): ClientBehaviorSettings => { const next: ClientBehaviorSettings = { animation: ( normaliseAnimationMode (behavior.animation) ?? getClientAnimationMode () ), 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 ?? { } export const hasStoredClientThemeSelection = (): boolean => { const appearance = appearanceFromSettings () return ( 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 ('') }` } export const getClientThemeMode = (): UserSettings['theme'] => (() => { const appearance = appearanceFromSettings () const customThemes = getClientCustomThemes () if (appearance.activeThemeId === 'system') return 'system' if (appearance.activeThemeId === 'light' || appearance.activeThemeId === 'dark') return appearance.activeThemeId if ( typeof appearance.activeThemeId === 'string' && customThemes[appearance.activeThemeId] != null ) return customThemes[appearance.activeThemeId].baseTheme return appearance.theme ?? DEFAULT_USER_SETTINGS.theme }) () export const setClientThemeMode = (theme: UserSettings['theme']): void => { updateClientSettings (settings => ({ ...settings, appearance: { ...(settings.appearance ?? { }), activeThemeId: theme, theme, }, })) } 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 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: { ...( baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS ), ...(theme.tokens && typeof theme.tokens === 'object' ? theme.tokens as Partial : { }), tagColours: normaliseThemeTagColours ( ( theme.tokens && typeof theme.tokens === 'object' && 'tagColours' in theme.tokens ) ? (theme.tokens as { tagColours?: Partial }).tagColours : theme.tagColours as Partial | undefined, defaultThemeTagColoursFor (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 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 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 => { return { ...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) applyThemeTagColours (tokens.tagColours) } export const applyThemeSelection = ( activeThemeId: ActiveThemeId, customThemes: Record, ): void => { const theme = getResolvedThemeFromSelection (activeThemeId, customThemes) applyThemeMode (theme.baseTheme) applyThemeTokens (theme.tokens) } export const applyClientAppearance = (): void => { applyThemeSelection (getClientActiveThemeId (), getClientCustomThemes ()) } 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, }, }, })) }