| 1 | import { get, writable, type Writable } from 'svelte/store'; |
| 2 | import { THEME_COLOURS, isCustomTheme, type CustomTheme, type ThemeDocument } from './custom-themes'; |
| 3 | |
| 4 | export const MESSAGE_SPACING_MIN = 0; |
| 5 | export const MESSAGE_SPACING_MAX = 16; |
| 6 | export const MESSAGE_SPACING_DEFAULT = 3; |
| 7 | |
| 8 | function persisted<T>(key: string, fallback: T, valid: (v: unknown) => v is T): Writable<T> { |
| 9 | let initial = fallback; |
| 10 | try { |
| 11 | const raw = localStorage.getItem(key); |
| 12 | if (raw !== null) { |
| 13 | const parsed: unknown = JSON.parse(raw); |
| 14 | if (valid(parsed)) initial = parsed; |
| 15 | } |
| 16 | } catch {} |
| 17 | const store = writable(initial); |
| 18 | store.subscribe((value) => { |
| 19 | try { |
| 20 | localStorage.setItem(key, JSON.stringify(value)); |
| 21 | } catch {} |
| 22 | }); |
| 23 | return store; |
| 24 | } |
| 25 | |
| 26 | const isBool = (v: unknown): v is boolean => typeof v === 'boolean'; |
| 27 | const isSpacing = (v: unknown): v is number => |
| 28 | typeof v === 'number' && Number.isFinite(v) && v >= MESSAGE_SPACING_MIN && v <= MESSAGE_SPACING_MAX; |
| 29 | |
| 30 | export const navFolded = persisted('malice.navFolded', false, isBool); |
| 31 | export const railFolded = persisted('malice.railFolded', false, isBool); |
| 32 | |
| 33 | export const SIDE_WIDTH = { min: 200, max: 480, default: 260 }; |
| 34 | export const MEMBERS_WIDTH = { min: 200, max: 420, default: 240 }; |
| 35 | const widthIn = (range: { min: number; max: number }) => (v: unknown): v is number => |
| 36 | typeof v === 'number' && Number.isFinite(v) && v >= range.min && v <= range.max; |
| 37 | export const sideWidth = persisted('malice.sideWidth', SIDE_WIDTH.default, widthIn(SIDE_WIDTH)); |
| 38 | export const membersWidth = persisted('malice.membersWidth', MEMBERS_WIDTH.default, widthIn(MEMBERS_WIDTH)); |
| 39 | export const messageSpacing = persisted('malice.messageSpacing', MESSAGE_SPACING_DEFAULT, isSpacing); |
| 40 | |
| 41 | export const LINE_HEIGHT_MIN = 1.1; |
| 42 | export const LINE_HEIGHT_MAX = 2; |
| 43 | export const LINE_HEIGHT_DEFAULT = 1.5; |
| 44 | |
| 45 | const isLineHeight = (v: unknown): v is number => |
| 46 | typeof v === 'number' && Number.isFinite(v) && v >= LINE_HEIGHT_MIN && v <= LINE_HEIGHT_MAX; |
| 47 | |
| 48 | export const lineHeight = persisted('malice.lineHeight', LINE_HEIGHT_DEFAULT, isLineHeight); |
| 49 | |
| 50 | const isStringRecord = (v: unknown): v is Record<string, string> => |
| 51 | typeof v === 'object' && v !== null && !Array.isArray(v) && Object.values(v).every((x) => typeof x === 'string'); |
| 52 | |
| 53 | export const lastSpaceRooms = persisted<Record<string, string>>('malice.lastSpaceRooms', {}, isStringRecord); |
| 54 | |
| 55 | export const THEMES = [ |
| 56 | { id: 'system', label: 'Match system' }, |
| 57 | { id: 'dark', label: 'Dark' }, |
| 58 | { id: 'light', label: 'Light' }, |
| 59 | { id: 'black', label: 'Black' }, |
| 60 | { id: 'nord', label: 'Nord' }, |
| 61 | { id: 'malicious', label: 'Malicious' } |
| 62 | ] as const; |
| 63 | |
| 64 | export type ThemeId = (typeof THEMES)[number]['id'] | CustomTheme['id']; |
| 65 | |
| 66 | const isCustomThemeList = (v: unknown): v is CustomTheme[] => Array.isArray(v) && v.every(isCustomTheme); |
| 67 | export const customThemes = persisted<CustomTheme[]>('malice.customThemes', [], isCustomThemeList); |
| 68 | export const themePreview = writable<ThemeDocument | null>(null); |
| 69 | |
| 70 | const isTheme = (v: unknown): v is ThemeId => THEMES.some((t) => t.id === v) || get(customThemes).some((t) => t.id === v); |
| 71 | const isAccent = (v: unknown): v is string | null => v === null || (typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v)); |
| 72 | |
| 73 | export const theme = persisted<ThemeId>('malice.theme', 'dark', isTheme); |
| 74 | export const accent = persisted<string | null>('malice.accent', null, isAccent); |
| 75 | |
| 76 | if (typeof document !== 'undefined') { |
| 77 | const root = document.documentElement; |
| 78 | const prefersLight = window.matchMedia('(prefers-color-scheme: light)'); |
| 79 | const apply = () => { |
| 80 | const current = get(theme); |
| 81 | const custom = get(themePreview) ?? get(customThemes).find((t) => t.id === current); |
| 82 | const base = current === 'system' ? (prefersLight.matches ? 'light' : 'dark') : current; |
| 83 | root.dataset.theme = custom?.scheme ?? (current.startsWith('custom:') ? 'dark' : base); |
| 84 | for (const [key] of THEME_COLOURS) { |
| 85 | if (custom) root.style.setProperty(`--${key}`, custom.colours[key]); |
| 86 | else root.style.removeProperty(`--${key}`); |
| 87 | } |
| 88 | if (custom) { |
| 89 | root.style.setProperty('--name-lightness', `${custom.nameLightness}%`); |
| 90 | root.style.colorScheme = custom.scheme; |
| 91 | } else { |
| 92 | root.style.removeProperty('--name-lightness'); |
| 93 | root.style.removeProperty('color-scheme'); |
| 94 | } |
| 95 | const override = get(accent); |
| 96 | if (override && !get(themePreview)) root.style.setProperty('--accent', override); |
| 97 | }; |
| 98 | theme.subscribe(apply); |
| 99 | customThemes.subscribe(apply); |
| 100 | themePreview.subscribe(apply); |
| 101 | prefersLight.addEventListener('change', apply); |
| 102 | accent.subscribe(apply); |
| 103 | } |