@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/package.json +60 -0
- package/src/catalog.ts +313 -0
- package/src/components/ColorInput.svelte +150 -0
- package/src/components/ColorInput.svelte.d.ts +14 -0
- package/src/components/FontSizeControl.svelte +138 -0
- package/src/components/FontSizeControl.svelte.d.ts +16 -0
- package/src/components/ThemeCustomizer.svelte +359 -0
- package/src/components/ThemeCustomizer.svelte.d.ts +14 -0
- package/src/components/ThemePicker.svelte +129 -0
- package/src/components/ThemePicker.svelte.d.ts +13 -0
- package/src/components/ThemeSwatch.svelte +136 -0
- package/src/components/ThemeSwatch.svelte.d.ts +14 -0
- package/src/css.ts +324 -0
- package/src/derive.ts +110 -0
- package/src/index.ts +63 -0
- package/src/oklch.ts +299 -0
- package/src/resolve.ts +103 -0
- package/src/stores/font-size.svelte.ts +158 -0
- package/src/stores/theme.svelte.ts +320 -0
- package/src/tailwind/tokens.css +57 -0
- package/src/types.ts +217 -0
- package/tests/catalog.test.ts +199 -0
- package/tests/css.test.ts +250 -0
- package/tests/oklch.test.ts +256 -0
- package/tests/resolve.test.ts +192 -0
- package/tsconfig.base.json +21 -0
- package/tsconfig.json +4 -0
- package/vitest.config.ts +14 -0
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 };
|