atagen

malice

public
Star0
Watch0
53c53aaab97e3a99a47abfd29359d0b2c3349105
2
ssh://brewery@pls.lobotomise.me:33/atagen/malice.git
web/src/lib/appearance.tsTypescript87 lines3.5 KB
1
import { writable, type Writable } from 'svelte/store';
2
3
export const MESSAGE_SPACING_MIN = 0;
4
export const MESSAGE_SPACING_MAX = 16;
5
export const MESSAGE_SPACING_DEFAULT = 3;
6
7
function persisted<T>(key: string, fallback: T, valid: (v: unknown) => v is T): Writable<T> {
8
  let initial = fallback;
9
  try {
10
    const raw = localStorage.getItem(key);
11
    if (raw !== null) {
12
      const parsed: unknown = JSON.parse(raw);
13
      if (valid(parsed)) initial = parsed;
14
    }
15
  } catch {}
16
  const store = writable(initial);
17
  store.subscribe((value) => {
18
    try {
19
      localStorage.setItem(key, JSON.stringify(value));
20
    } catch {}
21
  });
22
  return store;
23
}
24
25
const isBool = (v: unknown): v is boolean => typeof v === 'boolean';
26
const isSpacing = (v: unknown): v is number =>
27
  typeof v === 'number' && Number.isFinite(v) && v >= MESSAGE_SPACING_MIN && v <= MESSAGE_SPACING_MAX;
28
29
export const navFolded = persisted('malice.navFolded', false, isBool);
30
export const railFolded = persisted('malice.railFolded', false, isBool);
31
export const markdownEnabled = persisted('malice.markdown', true, 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
] as const;
62
63
export type ThemeId = (typeof THEMES)[number]['id'];
64
65
const isTheme = (v: unknown): v is ThemeId => THEMES.some((t) => t.id === v);
66
const isAccent = (v: unknown): v is string | null => v === null || (typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v));
67
68
export const theme = persisted<ThemeId>('malice.theme', 'dark', isTheme);
69
export const accent = persisted<string | null>('malice.accent', null, isAccent);
70
71
if (typeof document !== 'undefined') {
72
  const root = document.documentElement;
73
  const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
74
  let current: ThemeId = 'system';
75
  const apply = () => {
76
    root.dataset.theme = current === 'system' ? (prefersLight.matches ? 'light' : 'dark') : current;
77
  };
78
  theme.subscribe((t) => {
79
    current = t;
80
    apply();
81
  });
82
  prefersLight.addEventListener('change', apply);
83
  accent.subscribe((a) => {
84
    if (a) root.style.setProperty('--accent', a);
85
    else root.style.removeProperty('--accent');
86
  });
87
}