@soulcraft/theme 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/oklch.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * @module oklch
3
+ * @description OKLCH color space conversion utilities.
4
+ *
5
+ * Implements the full conversion chain between hex/rgba CSS colors and OKLCH
6
+ * (CSS Color 4) using Björn Ottosson's OKLab math. All conversions are
7
+ * numerically accurate to 4 decimal places.
8
+ *
9
+ * Conversion chain:
10
+ * hex → linear sRGB → LMS → LMS' (cube root) → OKLab → OKLCH
11
+ * OKLCH → OKLab → LMS' → LMS → linear sRGB → sRGB → hex
12
+ *
13
+ * @see https://bottosson.github.io/posts/oklab/
14
+ */
15
+
16
+ import type { OklchColor } from './types.js';
17
+
18
+ // ─── Internal conversion helpers ─────────────────────────────────────────────
19
+
20
+ /**
21
+ * Expand sRGB gamma to linear light value.
22
+ * @param c - Component in [0, 1] sRGB space.
23
+ */
24
+ function gammaExpand(c: number): number {
25
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
26
+ }
27
+
28
+ /**
29
+ * Compress linear light to sRGB gamma value.
30
+ * @param c - Component in [0, 1] linear space.
31
+ */
32
+ function gammaCompress(c: number): number {
33
+ return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
34
+ }
35
+
36
+ /**
37
+ * Clamp a number to [0, 1].
38
+ * @param v - Input value.
39
+ */
40
+ function clamp01(v: number): number {
41
+ return Math.max(0, Math.min(1, v));
42
+ }
43
+
44
+ // ─── Public API ───────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Format numeric OKLCH components into a CSS OKLCH string.
48
+ *
49
+ * @param L - Lightness in [0, 1].
50
+ * @param C - Chroma in [0, 0.4+].
51
+ * @param H - Hue in [0, 360).
52
+ * @param alpha - Optional alpha in [0, 1]. Omitted if 1 or undefined.
53
+ * @returns A valid CSS `oklch(...)` color string.
54
+ *
55
+ * @example
56
+ * formatOklch(0.624, 0.082, 181.4) // → 'oklch(0.6240 0.0820 181.4)'
57
+ * formatOklch(1, 0, 0, 0.05) // → 'oklch(1.0000 0.0000 0.0 / 0.05)'
58
+ */
59
+ export function formatOklch(L: number, C: number, H: number, alpha?: number): OklchColor {
60
+ const l = L.toFixed(4);
61
+ const c = C.toFixed(4);
62
+ const h = H.toFixed(1);
63
+ if (alpha !== undefined && alpha < 1) {
64
+ return `oklch(${l} ${c} ${h} / ${alpha})`;
65
+ }
66
+ return `oklch(${l} ${c} ${h})`;
67
+ }
68
+
69
+ /**
70
+ * Parse a CSS OKLCH string into its numeric components.
71
+ *
72
+ * Handles both `oklch(L C H)` and `oklch(L C H / A)` formats.
73
+ * Returns null if the string cannot be parsed as OKLCH.
74
+ *
75
+ * @param oklch - An oklch() CSS color string.
76
+ * @returns Tuple `[L, C, H, alpha?]` or null on parse failure.
77
+ *
78
+ * @example
79
+ * parseOklch('oklch(0.624 0.082 181.4)') // → [0.624, 0.082, 181.4]
80
+ * parseOklch('oklch(1 0 0 / 0.05)') // → [1, 0, 0, 0.05]
81
+ */
82
+ export function parseOklch(oklch: OklchColor): [L: number, C: number, H: number, A?: number] | null {
83
+ const raw = oklch.trim();
84
+ if (!raw.startsWith('oklch(') || !raw.endsWith(')')) return null;
85
+ const inner = raw.slice(6, -1).trim();
86
+ // Avoid destructuring from .split()/.map() to keep element types concrete under
87
+ // noUncheckedIndexedAccess — use indexOf + slice instead.
88
+ const slashIdx = inner.indexOf('/');
89
+ const colorStr = (slashIdx === -1 ? inner : inner.slice(0, slashIdx)).trim();
90
+ const alphaStr = slashIdx === -1 ? undefined : inner.slice(slashIdx + 1).trim();
91
+ const parts = colorStr.split(/\s+/);
92
+ if (parts.length !== 3) return null;
93
+ // Number() accepts string|undefined (any), so index access is safe here.
94
+ const L = Number(parts[0]);
95
+ const C = Number(parts[1]);
96
+ const H = Number(parts[2]);
97
+ if (isNaN(L) || isNaN(C) || isNaN(H)) return null;
98
+ if (alphaStr !== undefined) {
99
+ const A = Number(alphaStr);
100
+ if (isNaN(A)) return null;
101
+ return [L, C, H, A];
102
+ }
103
+ return [L, C, H];
104
+ }
105
+
106
+ /**
107
+ * Convert a CSS hex color string to an OKLCH color string.
108
+ *
109
+ * Supports 3-digit (#abc), 6-digit (#aabbcc), and 8-digit (#aabbccdd) hex.
110
+ * Alpha from 8-digit hex is preserved in the output.
111
+ *
112
+ * @param hex - A CSS hex color string.
113
+ * @returns An OKLCH color string.
114
+ *
115
+ * @example
116
+ * hexToOklch('#2a9d8f') // → 'oklch(0.6240 0.0820 181.4)'
117
+ * hexToOklch('#0a0e1a') // → 'oklch(0.0519 0.0228 261.6)'
118
+ */
119
+ export function hexToOklch(hex: string): OklchColor {
120
+ let h = hex.replace('#', '');
121
+ // Expand 3-digit hex
122
+ // Use charAt() instead of bracket notation: with noUncheckedIndexedAccess,
123
+ // string[n] widens to string|undefined, but charAt() always returns string.
124
+ if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
125
+
126
+ const r8 = parseInt(h.slice(0, 2), 16);
127
+ const g8 = parseInt(h.slice(2, 4), 16);
128
+ const b8 = parseInt(h.slice(4, 6), 16);
129
+ const alpha = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : undefined;
130
+
131
+ const [L, C, H] = rgbToOklchComponents(r8 / 255, g8 / 255, b8 / 255);
132
+ return formatOklch(L, C, H, alpha !== undefined && alpha < 1 ? alpha : undefined);
133
+ }
134
+
135
+ /**
136
+ * Convert a CSS rgba() or rgb() string to an OKLCH color string.
137
+ *
138
+ * Preserves the alpha channel. Channel values are 0–255; alpha is 0–1.
139
+ *
140
+ * @param rgba - A CSS `rgba()` or `rgb()` color string.
141
+ * @returns An OKLCH color string, or a white fallback if parsing fails.
142
+ *
143
+ * @example
144
+ * rgbaToOklch('rgba(255, 255, 255, 0.05)') // → 'oklch(1.0000 0.0000 0.0 / 0.05)'
145
+ * rgbaToOklch('rgba(122, 162, 247, 0.2)') // → 'oklch(0.7148 0.1201 264.4 / 0.2)'
146
+ */
147
+ export function rgbaToOklch(rgba: string): OklchColor {
148
+ const m = rgba.match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)/);
149
+ if (!m) return 'oklch(1 0 0)';
150
+ const r = Number(m[1]) / 255;
151
+ const g = Number(m[2]) / 255;
152
+ const b = Number(m[3]) / 255;
153
+ const alpha = m[4] !== undefined ? Number(m[4]) : undefined;
154
+ const [L, C, H] = rgbToOklchComponents(r, g, b);
155
+ return formatOklch(L, C, H, alpha !== undefined && alpha < 1 ? alpha : undefined);
156
+ }
157
+
158
+ /**
159
+ * Convert an OKLCH color string to a CSS hex color string.
160
+ *
161
+ * Alpha values below 1.0 are encoded as the 8-digit hex `#rrggbbaa` format.
162
+ * Out-of-gamut values are clamped to the sRGB range.
163
+ *
164
+ * @param oklch - An OKLCH color string.
165
+ * @returns A lowercase hex color string (e.g. '#2a9d8f').
166
+ *
167
+ * @example
168
+ * oklchToHex('oklch(0.624 0.082 181.4)') // → '#2a9d8f'
169
+ * oklchToHex('oklch(1 0 0 / 0.05)') // → '#ffffff0d'
170
+ */
171
+ export function oklchToHex(oklch: OklchColor): string {
172
+ const parsed = parseOklch(oklch);
173
+ if (!parsed) return '#000000';
174
+ const [L, C, H, alpha] = parsed;
175
+
176
+ // OKLCH → OKLab
177
+ const H_rad = H * (Math.PI / 180);
178
+ const a = C * Math.cos(H_rad);
179
+ const b = C * Math.sin(H_rad);
180
+
181
+ // OKLab → LMS'
182
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
183
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
184
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
185
+
186
+ // LMS' → LMS
187
+ const l = l_ * l_ * l_;
188
+ const m = m_ * m_ * m_;
189
+ const s = s_ * s_ * s_;
190
+
191
+ // LMS → linear sRGB
192
+ const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
193
+ const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
194
+ const lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
195
+
196
+ // Linear sRGB → sRGB, clamp, convert to byte
197
+ const r = Math.round(clamp01(gammaCompress(lr)) * 255);
198
+ const g = Math.round(clamp01(gammaCompress(lg)) * 255);
199
+ const bv = Math.round(clamp01(gammaCompress(lb)) * 255);
200
+
201
+ const hex = '#' + [r, g, bv].map(v => v.toString(16).padStart(2, '0')).join('');
202
+ if (alpha !== undefined && alpha < 1) {
203
+ const a8 = Math.round(alpha * 255).toString(16).padStart(2, '0');
204
+ return hex + a8;
205
+ }
206
+ return hex;
207
+ }
208
+
209
+ /**
210
+ * Return an OKLCH color with the alpha component replaced or added.
211
+ *
212
+ * @param oklch - Source OKLCH color string.
213
+ * @param alpha - New alpha value in [0, 1].
214
+ * @returns OKLCH string with modified alpha.
215
+ *
216
+ * @example
217
+ * withAlpha('oklch(0.624 0.082 181.4)', 0.13) // → 'oklch(0.6240 0.0820 181.4 / 0.13)'
218
+ */
219
+ export function withAlpha(oklch: OklchColor, alpha: number): OklchColor {
220
+ const parsed = parseOklch(oklch);
221
+ if (!parsed) return oklch;
222
+ const [L, C, H] = parsed;
223
+ return formatOklch(L, C, H, alpha);
224
+ }
225
+
226
+ /**
227
+ * Return an OKLCH color with the lightness component modified by a delta.
228
+ *
229
+ * @param oklch - Source OKLCH color string.
230
+ * @param delta - Amount to add to lightness (negative to darken).
231
+ * @returns OKLCH string with modified lightness, clamped to [0, 1].
232
+ *
233
+ * @example
234
+ * adjustL('oklch(0.624 0.082 181.4)', 0.1) // → 'oklch(0.7240 0.0820 181.4)'
235
+ */
236
+ export function adjustL(oklch: OklchColor, delta: number): OklchColor {
237
+ const parsed = parseOklch(oklch);
238
+ if (!parsed) return oklch;
239
+ const [L, C, H, alpha] = parsed;
240
+ return formatOklch(clamp01(L + delta), C, H, alpha);
241
+ }
242
+
243
+ /**
244
+ * Return an OKLCH color with the hue rotated by the given degrees.
245
+ *
246
+ * @param oklch - Source OKLCH color string.
247
+ * @param degrees - Degrees to rotate the hue (can be negative).
248
+ * @returns OKLCH string with rotated hue in [0, 360).
249
+ *
250
+ * @example
251
+ * rotateH('oklch(0.624 0.082 181.4)', 150) // → 'oklch(0.6240 0.0820 331.4)'
252
+ */
253
+ export function rotateH(oklch: OklchColor, degrees: number): OklchColor {
254
+ const parsed = parseOklch(oklch);
255
+ if (!parsed) return oklch;
256
+ const [L, C, H, alpha] = parsed;
257
+ const newH = ((H + degrees) % 360 + 360) % 360;
258
+ return formatOklch(L, C, newH, alpha);
259
+ }
260
+
261
+ // ─── Internal: core conversion ────────────────────────────────────────────────
262
+
263
+ /**
264
+ * Convert normalized sRGB [0,1] components to OKLCH [L, C, H].
265
+ * Uses Björn Ottosson's OKLab conversion matrices.
266
+ *
267
+ * @param r - Red in [0, 1] sRGB space.
268
+ * @param g - Green in [0, 1] sRGB space.
269
+ * @param b - Blue in [0, 1] sRGB space.
270
+ * @returns Tuple [L, C, H].
271
+ */
272
+ function rgbToOklchComponents(r: number, g: number, b: number): [number, number, number] {
273
+ // sRGB → linear
274
+ const rl = gammaExpand(r);
275
+ const gl = gammaExpand(g);
276
+ const bl = gammaExpand(b);
277
+
278
+ // Linear sRGB → LMS (Björn Ottosson M1)
279
+ const l = 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl;
280
+ const m = 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl;
281
+ const s = 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl;
282
+
283
+ // LMS → LMS' (cube root, safe for negatives)
284
+ const l_ = Math.cbrt(l);
285
+ const m_ = Math.cbrt(m);
286
+ const s_ = Math.cbrt(s);
287
+
288
+ // LMS' → OKLab (Björn Ottosson M2)
289
+ const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
290
+ const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
291
+ const bv = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
292
+
293
+ // OKLab → OKLCH
294
+ const C = Math.sqrt(a * a + bv * bv);
295
+ let H = Math.atan2(bv, a) * (180 / Math.PI);
296
+ if (H < 0) H += 360;
297
+
298
+ return [L, C, H];
299
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @module resolve
3
+ * @description 4-level theme cascade resolver.
4
+ *
5
+ * Resolves the active theme by merging layers in priority order:
6
+ *
7
+ * 1. Catalog theme (base — predefined or soulcraft-dark fallback)
8
+ * 2. Kit seed (auto-derived palette from brand colors, merged on top)
9
+ * 3. Custom overrides (admin-edited per-deployment, merged on top)
10
+ * 4. User preference (if valid catalog ID, replaces the entire cascade result)
11
+ *
12
+ * Layer 4 (user preference) is the "escape hatch" for end-users who want
13
+ * a specific catalog theme regardless of what the kit author set.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * // Venue: kit provides seed, admin adds overrides
18
+ * const theme = resolveTheme({
19
+ * catalogId: 'soulcraft-light',
20
+ * kitSeed: { primary: 'oklch(0.72 0.15 25)', bgBase: 'oklch(0.96 0.02 80)' },
21
+ * customOverrides: { displayFont: 'Fraunces', bodyFont: 'Inter' },
22
+ * });
23
+ *
24
+ * // Workshop: user picks their own theme
25
+ * const theme = resolveTheme({ userThemeId: 'tokyo-night' });
26
+ * ```
27
+ */
28
+
29
+ import { CATALOG_BY_ID } from './catalog.js';
30
+ import { deriveFullPalette } from './derive.js';
31
+ import type { ThemeCascadeInput, ThemeDefinition, ThemeColors, ThemeFonts } from './types.js';
32
+
33
+ const DEFAULT_ID = 'soulcraft-dark';
34
+
35
+ /**
36
+ * Resolve the active theme from a 4-level cascade specification.
37
+ *
38
+ * @param input - Cascade inputs. All fields are optional; defaults to soulcraft-dark.
39
+ * @returns A fully-resolved ThemeDefinition ready for CSS injection.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * // Kit-branded Venue deployment
44
+ * const theme = resolveTheme({
45
+ * catalogId: 'soulcraft-light',
46
+ * kitSeed: venueKit.venue.theme,
47
+ * customOverrides: organization?.theme,
48
+ * });
49
+ *
50
+ * // Workshop user preference
51
+ * const theme = resolveTheme({ userThemeId: 'dracula' });
52
+ *
53
+ * // No inputs → soulcraft-dark
54
+ * const theme = resolveTheme({});
55
+ * ```
56
+ */
57
+ export function resolveTheme(input: ThemeCascadeInput): ThemeDefinition {
58
+ // ── Layer 4: user preference takes total precedence ──────────────────────
59
+ if (input.userThemeId) {
60
+ const userTheme = CATALOG_BY_ID.get(input.userThemeId);
61
+ if (userTheme) return userTheme;
62
+ // Invalid userThemeId → fall through to cascade
63
+ }
64
+
65
+ // ── Layer 1: catalog base ─────────────────────────────────────────────────
66
+ const base = CATALOG_BY_ID.get(input.catalogId ?? DEFAULT_ID) ??
67
+ CATALOG_BY_ID.get(DEFAULT_ID)!;
68
+
69
+ let colors: ThemeColors = { ...base.colors };
70
+ let fonts: ThemeFonts = { ...base.fonts };
71
+
72
+ // ── Layer 2: kit seed ─────────────────────────────────────────────────────
73
+ if (input.kitSeed) {
74
+ const derived = deriveFullPalette(input.kitSeed);
75
+ colors = { ...colors, ...derived };
76
+ if (input.kitSeed.displayFont) fonts.displayFont = input.kitSeed.displayFont;
77
+ if (input.kitSeed.bodyFont) fonts.bodyFont = input.kitSeed.bodyFont;
78
+ }
79
+
80
+ // ── Layer 3: custom overrides ─────────────────────────────────────────────
81
+ if (input.customOverrides) {
82
+ const { displayFont, bodyFont, ...colorOverrides } = input.customOverrides;
83
+ colors = { ...colors, ...colorOverrides };
84
+ if (displayFont) fonts.displayFont = displayFont;
85
+ if (bodyFont) fonts.bodyFont = bodyFont;
86
+ }
87
+
88
+ // Build resolved meta
89
+ const hasKit = !!input.kitSeed;
90
+ const hasCustm = !!input.customOverrides;
91
+ const resolvedId = hasKit || hasCustm
92
+ ? `${input.catalogId ?? DEFAULT_ID}+${hasKit ? 'kit' : ''}${hasCustm ? '+custom' : ''}`
93
+ : (input.catalogId ?? DEFAULT_ID);
94
+
95
+ return {
96
+ meta: {
97
+ ...base.meta,
98
+ id: resolvedId,
99
+ },
100
+ colors,
101
+ fonts,
102
+ };
103
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @module stores/font-size.svelte
3
+ * @description Svelte 5 font size store for content and interface scale control.
4
+ *
5
+ * Manages two independent font size settings:
6
+ * - `contentSize` — editor text, preview panels, chat messages (default 14px)
7
+ * - `interfaceSize` — docks, explorers, UI labels (default 13px)
8
+ *
9
+ * Persists to localStorage and injects `--font-size-content` and
10
+ * `--font-size-interface` CSS custom properties on `document.documentElement`.
11
+ *
12
+ * Ported from Workshop's `src/lib/stores/fontSizeStore.ts` (legacy Svelte writable)
13
+ * to Svelte 5 runes. API-compatible replacement.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { createFontSizeStore } from '@soulcraft/theme/stores/font-size.svelte';
18
+ * export const fontSizeStore = createFontSizeStore();
19
+ *
20
+ * // In a component:
21
+ * fontSizeStore.setContentSize(16);
22
+ * ```
23
+ */
24
+
25
+ const isBrowser = typeof window !== 'undefined';
26
+ const STORAGE_KEY = 'workshop:fontSizes';
27
+
28
+ /** The two independently-controllable font size axes. */
29
+ export interface FontSizeSettings {
30
+ /** Font size in px for editor, preview, and chat content areas. @default 14 */
31
+ contentSize: number;
32
+ /** Font size in px for dock labels, explorer items, and UI chrome. @default 13 */
33
+ interfaceSize: number;
34
+ }
35
+
36
+ const DEFAULTS: FontSizeSettings = { contentSize: 14, interfaceSize: 13 };
37
+
38
+ /** Preset options shown in the FontSizeControl picker UI. */
39
+ export const FONT_SIZE_PRESETS = {
40
+ content: [
41
+ { label: 'Small', value: 12 },
42
+ { label: 'Medium (default)', value: 14 },
43
+ { label: 'Large', value: 16 },
44
+ { label: 'X-Large', value: 18 },
45
+ ],
46
+ interface: [
47
+ { label: 'Small', value: 11 },
48
+ { label: 'Medium (default)', value: 13 },
49
+ { label: 'Large', value: 15 },
50
+ { label: 'X-Large', value: 17 },
51
+ ],
52
+ } as const;
53
+
54
+ /** Load persisted settings from localStorage, falling back to defaults. */
55
+ function loadSettings(): FontSizeSettings {
56
+ if (!isBrowser) return DEFAULTS;
57
+ try {
58
+ const stored = localStorage.getItem(STORAGE_KEY);
59
+ if (stored) {
60
+ const parsed = JSON.parse(stored) as Partial<FontSizeSettings>;
61
+ return {
62
+ contentSize: parsed.contentSize ?? DEFAULTS.contentSize,
63
+ interfaceSize: parsed.interfaceSize ?? DEFAULTS.interfaceSize,
64
+ };
65
+ }
66
+ } catch {
67
+ // Corrupted localStorage — use defaults
68
+ }
69
+ return DEFAULTS;
70
+ }
71
+
72
+ /**
73
+ * Reactive font size store using Svelte 5 runes.
74
+ */
75
+ class FontSizeStore {
76
+ /** Content area font size in px. Reactive; changes trigger CSS var update. */
77
+ contentSize = $state<number>(DEFAULTS.contentSize);
78
+ /** Interface chrome font size in px. Reactive; changes trigger CSS var update. */
79
+ interfaceSize = $state<number>(DEFAULTS.interfaceSize);
80
+
81
+ constructor() {
82
+ const saved = loadSettings();
83
+ this.contentSize = saved.contentSize;
84
+ this.interfaceSize = saved.interfaceSize;
85
+ this.applyCSSVariables(saved);
86
+ }
87
+
88
+ /**
89
+ * Set the content area font size and persist.
90
+ * @param size - Font size in pixels.
91
+ */
92
+ setContentSize(size: number): void {
93
+ this.contentSize = size;
94
+ const settings = { contentSize: size, interfaceSize: this.interfaceSize };
95
+ this.applyCSSVariables(settings);
96
+ this.saveSettings(settings);
97
+ }
98
+
99
+ /**
100
+ * Set the interface chrome font size and persist.
101
+ * @param size - Font size in pixels.
102
+ */
103
+ setInterfaceSize(size: number): void {
104
+ this.interfaceSize = size;
105
+ const settings = { contentSize: this.contentSize, interfaceSize: size };
106
+ this.applyCSSVariables(settings);
107
+ this.saveSettings(settings);
108
+ }
109
+
110
+ /**
111
+ * Reset both sizes to their defaults and persist.
112
+ */
113
+ reset(): void {
114
+ this.contentSize = DEFAULTS.contentSize;
115
+ this.interfaceSize = DEFAULTS.interfaceSize;
116
+ this.applyCSSVariables(DEFAULTS);
117
+ this.saveSettings(DEFAULTS);
118
+ }
119
+
120
+ /** Return current settings as a plain object. */
121
+ get current(): FontSizeSettings {
122
+ return { contentSize: this.contentSize, interfaceSize: this.interfaceSize };
123
+ }
124
+
125
+ // ─── Internal ────────────────────────────────────────────────────────────
126
+
127
+ private applyCSSVariables(s: FontSizeSettings): void {
128
+ if (!isBrowser) return;
129
+ document.documentElement.style.setProperty('--font-size-content', `${s.contentSize}px`);
130
+ document.documentElement.style.setProperty('--font-size-interface', `${s.interfaceSize}px`);
131
+ }
132
+
133
+ private saveSettings(s: FontSizeSettings): void {
134
+ if (!isBrowser) return;
135
+ try {
136
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
137
+ } catch {
138
+ // localStorage unavailable — ignore
139
+ }
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Create a FontSizeStore instance.
145
+ *
146
+ * @returns A reactive Svelte 5 FontSizeStore.
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * export const fontSizeStore = createFontSizeStore();
151
+ * fontSizeStore.setContentSize(16);
152
+ * ```
153
+ */
154
+ export function createFontSizeStore(): FontSizeStore {
155
+ return new FontSizeStore();
156
+ }
157
+
158
+ export type { FontSizeStore };