ファイル
btrc-hub/frontend/src/lib/settings.ts
T
みてるぞ f5b632ed89 設定画面 (#34) (#397)
Reviewed-on: #397
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-07 00:48:41 +09:00

1722 行
54 KiB
TypeScript

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<Category, string>
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<Record<UserThemeSlotSelection, UserThemeSlot>>
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<Record<ClientPaneBreakpoint, number>>
collapsed?: boolean }
type ClientListSettings = {
limit?: ClientListLimit
order?: string }
export type ClientKeyboardSettings = {
enabled?: boolean
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>> }
export type ClientAppearanceSettings = {
themeMode?: ClientThemeMode
activeLightThemeSlotNo?: UserThemeSlotNo
activeDarkThemeSlotNo?: UserThemeSlotNo
activeThemeSlot?: ThemeSlotSelection
activeThemeId?: ActiveThemeId
customThemes?: Record<string, CustomClientTheme>
// Legacy fields kept for migration from the earlier appearance model.
theme?: UserSettings['theme']
tagColours?: Partial<ThemeTagColours> }
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<string, ClientPaneSettings>
lists?: Partial<Record<ClientListKey, ClientListSettings>>
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<BuiltinThemeId, ClientTheme> = {
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<UserSettings> => {
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<UserSettings> => {
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<Record<ShortcutActionId, KeyBinding | null>> => {
if (!(bindings) || typeof bindings !== 'object')
return { }
return (
Object.keys (DEFAULT_KEY_BINDINGS)
.reduce<Partial<Record<ShortcutActionId, KeyBinding | null>>> ((normalised, actionId) => {
const value = (bindings as Record<string, unknown>)[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<Record<ShortcutActionId, KeyBinding | null>> =
getClientKeyboardSettings ().bindings ?? { },
): Record<ShortcutActionId, KeyBinding | null> =>
Object.keys (DEFAULT_KEY_BINDINGS).reduce<Record<ShortcutActionId, KeyBinding | null>> (
(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<ThemeTagColours> | null | undefined,
fallback: ThemeTagColours,
): ThemeTagColours =>
CATEGORIES.reduce<ThemeTagColours> (
(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<ThemeTokens>
: { }
const legacyTopNavTokens =
rawTokens && typeof rawTokens === 'object'
? rawTokens as Record<string, unknown>
: { }
const background =
normaliseThemeTokenValue (tokens.background, fallback.background)
const foreground =
normaliseThemeTokenValue (tokens.foreground, fallback.foreground)
const card =
normaliseThemeTokenValue (tokens.card, fallback.card)
const muted =
normaliseThemeTokenValue (tokens.muted, fallback.muted)
const primary =
normaliseThemeTokenValue (tokens.primary, fallback.primary)
const accent =
normaliseThemeTokenValue (tokens.accent, fallback.accent)
const border =
normaliseThemeTokenValue (tokens.border, fallback.border)
const link =
normaliseThemeTokenValue (tokens.link, fallback.link)
const topNavRootBackgroundMobile = normaliseTopNavColourValue (
tokens.topNavRootBackgroundMobile
?? legacyTopNavTokens.topNavRootBackgroundMobile
?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundMobile)
const topNavRootBackgroundDesktop = normaliseTopNavColourValue (
tokens.topNavRootBackgroundDesktop
?? legacyTopNavTokens.topNavRootBackgroundDesktop
?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundDesktop)
const topNavActiveBackground = normaliseTopNavColourValue (
tokens.topNavActiveBackground
?? legacyTopNavTokens.topNavActiveBackground
?? legacyTopNavTokens.topNavHighlightBackground
?? legacyTopNavTokens.topNavHighlightBackground,
fallback.topNavActiveBackground)
const topNavBrandLink = normaliseTopNavColourValue (
tokens.topNavBrandLink
?? legacyTopNavTokens.topNavBrandLink
?? legacyTopNavTokens.topNavLink,
fallback.topNavBrandLink)
const 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<ThemeTokens>
: { }
const legacyTopNavTokens =
rawTokens && typeof rawTokens === 'object'
? rawTokens as Record<string, unknown>
: { }
const background =
normaliseThemeTokenValue (tokens.background, fallback.background)
const foreground =
normaliseThemeTokenValue (tokens.foreground, fallback.foreground)
const card =
normaliseThemeTokenValue (tokens.card, fallback.card)
const muted =
normaliseThemeTokenValue (tokens.muted, fallback.muted)
const primary =
normaliseThemeTokenValue (tokens.primary, fallback.primary)
const accent =
normaliseThemeTokenValue (tokens.accent, fallback.accent)
const border =
normaliseThemeTokenValue (tokens.border, fallback.border)
const link =
normaliseThemeTokenValue (tokens.link, fallback.link)
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<UserThemeSlot>
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<UserThemeSlotMap> (
(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<string, unknown>
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<ThemeTokens>
: { }),
tagColours: (
(theme.tokens
&& typeof theme.tokens === 'object'
&& 'tagColours' in theme.tokens)
? (theme.tokens as { tagColours?: Partial<ThemeTagColours> }).tagColours
: theme.tagColours as Partial<ThemeTagColours> | undefined) },
baseTheme) }
}
export const getClientCustomThemes = (): Record<string, CustomClientTheme> => {
const appearance = appearanceFromSettings ()
const customThemes = appearance.customThemes
if (customThemes && typeof customThemes === 'object')
{
return Object.entries (customThemes).reduce<Record<string, CustomClientTheme>> (
(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<UserThemeSlot[]> => {
const raw = await apiGet<Array<{
baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo
tokens: ThemeTokens
createdAt: string
updatedAt: string }>> ('/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<UserThemeSlot> => {
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<string, CustomClientTheme>,
): ClientTheme =>
isBuiltinThemeId (themeId)
? BUILTIN_THEMES[themeId]
: (customThemes[themeId] ?? BUILTIN_THEMES.light)
export const getResolvedThemeFromSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): ClientTheme =>
activeThemeId === 'system'
? BUILTIN_THEMES[resolveThemeMode ('system')]
: getThemeById (activeThemeId, customThemes)
export const deriveUserThemeModeFromSelection = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): 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<string, CustomClientTheme> = getClientCustomThemes (),
): ThemeTagColours =>
({ ...getResolvedThemeFromSelection (activeThemeId, customThemes).tokens.tagColours })
export const setClientAppearanceThemes = (
activeThemeId: ActiveThemeId,
customThemes: Record<string, CustomClientTheme>,
): void => {
updateClientSettings (settings => ({
...settings,
appearance: {
...(settings.appearance ?? { }),
activeThemeId,
customThemes,
theme: undefined,
tagColours: undefined } }))
}
export const setClientThemeTagColours = (
tagColours: ThemeTagColours,
activeThemeId: ActiveThemeId = getClientActiveThemeId (),
customThemes: Record<string, CustomClientTheme> = 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 = <T extends string>(
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 } } }))
}