@adea-ai/ui 0.28.0 → 0.30.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.
@@ -0,0 +1,1055 @@
1
+ /*
2
+ * Copyright (c) 2026 Wing
3
+ * Licensed under the MIT License.
4
+ *
5
+ * Appearance domain model substantially translated from Zeron
6
+ * crates/theme/src/lib.rs and crates/ui/src/appearance.rs, revision
7
+ * 30a9a9537c5ec96226c87f4bf349b6f77c5dfb59: color math with WCAG contrast,
8
+ * accent role derivation, independent light/dark theme selection, the
9
+ * system/light/dark mode resolver, surface preference resolution, the theme
10
+ * registry with deterministic built-in fallback, and its validation rules.
11
+ * Modified for TypeScript, Adea's token layer, custom accent validation,
12
+ * the translucent surface capability, and the user reduced-transparency
13
+ * policy Zeron lacks. See NOTICE and docs/research/dev-view-donor-audit.md.
14
+ *
15
+ * This module is the declared token layer for the built-in theme palette
16
+ * data: the color string literals below are the theme itself, exactly like
17
+ * the declarations in `styles/theme.css` (see
18
+ * `scripts/check-theme-colors.mjs`).
19
+ */
20
+
21
+ /**
22
+ * The versioned client appearance preference (Dev Runtime spec,
23
+ * "Appearance and App Library"). Stored as one JSON document; the legacy
24
+ * single `theme` key migrates into it without deleting the old value.
25
+ */
26
+ export type AppearancePreferencesV2 = Readonly<{
27
+ version: 2
28
+ mode: AppearanceMode
29
+ lightThemeId: string
30
+ darkThemeId: string
31
+ /** `'theme'`, a built-in preset id, or a validated `#rrggbb` color. */
32
+ accent: 'theme' | string
33
+ surface: 'opaque' | 'frosted' | 'translucent'
34
+ reduceTransparency: boolean
35
+ }>
36
+
37
+ export type AppearanceMode = 'system' | 'light' | 'dark'
38
+ export type ResolvedAppearance = 'light' | 'dark'
39
+ export type SurfacePreference = 'opaque' | 'frosted' | 'translucent'
40
+
41
+ export const APPEARANCE_STORAGE_KEY = 'appearance'
42
+ /** The pre-#425 key. Migration reads it and never deletes it. */
43
+ export const LEGACY_THEME_STORAGE_KEY = 'theme'
44
+
45
+ export const DARK_QUERY = '(prefers-color-scheme: dark)'
46
+ export const REDUCED_TRANSPARENCY_QUERY = '(prefers-reduced-transparency: reduce)'
47
+
48
+ export const defaultAppearancePreferences: AppearancePreferencesV2 = Object.freeze({
49
+ version: 2,
50
+ mode: 'system',
51
+ lightThemeId: 'adea-light',
52
+ darkThemeId: 'adea-dark',
53
+ accent: 'theme',
54
+ surface: 'opaque',
55
+ reduceTransparency: false,
56
+ })
57
+
58
+ /**
59
+ * Combine the user's choice with the OS state. A pinned mode ignores the OS;
60
+ * `system` follows it. (Zeron `resolve`.)
61
+ */
62
+ export function resolveAppearanceMode(
63
+ mode: AppearanceMode,
64
+ system: ResolvedAppearance
65
+ ): ResolvedAppearance {
66
+ if (mode === 'system') return system
67
+ return mode
68
+ }
69
+
70
+ /*
71
+ * Color math — Zeron `Color`, translated. Values are `#rgb`, `#rgba`,
72
+ * `#rrggbb`, or `#rrggbbaa` strings.
73
+ */
74
+
75
+ export type RgbColor = Readonly<{ r: number; g: number; b: number; a: number }>
76
+
77
+ export class ColorParseError extends Error {
78
+ constructor() {
79
+ super('expected a CSS hex color (#rgb, #rgba, #rrggbb, or #rrggbbaa)')
80
+ this.name = 'ColorParseError'
81
+ }
82
+ }
83
+
84
+ const NIBBLES: Record<string, number> = {
85
+ '0': 0,
86
+ '1': 1,
87
+ '2': 2,
88
+ '3': 3,
89
+ '4': 4,
90
+ '5': 5,
91
+ '6': 6,
92
+ '7': 7,
93
+ '8': 8,
94
+ '9': 9,
95
+ a: 10,
96
+ b: 11,
97
+ c: 12,
98
+ d: 13,
99
+ e: 14,
100
+ f: 15,
101
+ A: 10,
102
+ B: 11,
103
+ C: 12,
104
+ D: 13,
105
+ E: 14,
106
+ F: 15,
107
+ }
108
+
109
+ function expandNibble(nibble: number): number {
110
+ return (nibble << 4) | nibble
111
+ }
112
+
113
+ function parseByte(high: number, low: number): number {
114
+ return (high << 4) | low
115
+ }
116
+
117
+ /** Parse a hex color. Returns `undefined` for anything unsupported. */
118
+ export function parseColor(value: string): RgbColor | undefined {
119
+ const trimmed = value.trim()
120
+ if (!trimmed.startsWith('#')) return undefined
121
+ const bytes = trimmed.slice(1)
122
+ if (bytes.length === 3 || bytes.length === 4) {
123
+ const nibbles: number[] = []
124
+ for (const character of bytes) {
125
+ const nibble = NIBBLES[character]
126
+ if (nibble === undefined) return undefined
127
+ nibbles.push(nibble)
128
+ }
129
+ return {
130
+ r: expandNibble(nibbles[0]!),
131
+ g: expandNibble(nibbles[1]!),
132
+ b: expandNibble(nibbles[2]!),
133
+ a: nibbles.length === 4 ? expandNibble(nibbles[3]!) : 255,
134
+ }
135
+ }
136
+ if (bytes.length === 6 || bytes.length === 8) {
137
+ const nibbles: number[] = []
138
+ for (const character of bytes) {
139
+ const nibble = NIBBLES[character]
140
+ if (nibble === undefined) return undefined
141
+ nibbles.push(nibble)
142
+ }
143
+ const pair = (index: number) => parseByte(nibbles[index]!, nibbles[index + 1]!)
144
+ return {
145
+ r: pair(0),
146
+ g: pair(2),
147
+ b: pair(4),
148
+ a: nibbles.length === 8 ? pair(6) : 255,
149
+ }
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ function toHexByte(value: number): string {
155
+ return value.toString(16).padStart(2, '0')
156
+ }
157
+
158
+ export function colorToHex(color: RgbColor): string {
159
+ if (color.a === 255) return `#${toHexByte(color.r)}${toHexByte(color.g)}${toHexByte(color.b)}`
160
+ return `#${toHexByte(color.r)}${toHexByte(color.g)}${toHexByte(color.b)}${toHexByte(color.a)}`
161
+ }
162
+
163
+ function blendOver(foreground: RgbColor, background: RgbColor): RgbColor {
164
+ const alpha = foreground.a / 255
165
+ const blend = (front: number, back: number) => Math.round(front * alpha + back * (1 - alpha))
166
+ return {
167
+ r: blend(foreground.r, background.r),
168
+ g: blend(foreground.g, background.g),
169
+ b: blend(foreground.b, background.b),
170
+ a: 255,
171
+ }
172
+ }
173
+
174
+ function mixColors(left: RgbColor, right: RgbColor, amount: number): RgbColor {
175
+ const clamped = Math.min(1, Math.max(0, amount))
176
+ const mix = (a: number, b: number) => Math.round(a + (b - a) * clamped)
177
+ return { r: mix(left.r, right.r), g: mix(left.g, right.g), b: mix(left.b, right.b), a: 255 }
178
+ }
179
+
180
+ function linearChannel(channel: number): number {
181
+ const value = channel / 255
182
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
183
+ }
184
+
185
+ function relativeLuminance(color: RgbColor): number {
186
+ return (
187
+ 0.2126 * linearChannel(color.r) +
188
+ 0.7152 * linearChannel(color.g) +
189
+ 0.0722 * linearChannel(color.b)
190
+ )
191
+ }
192
+
193
+ /** WCAG contrast ratio between two colors. Alpha composites over the background first. */
194
+ export function contrastRatio(
195
+ foreground: RgbColor | string,
196
+ background: RgbColor | string
197
+ ): number {
198
+ const front = typeof foreground === 'string' ? parseColor(foreground) : foreground
199
+ const back = typeof background === 'string' ? parseColor(background) : background
200
+ if (!front || !back) return 0
201
+ const opaqueFront = front.a === 255 ? front : blendOver(front, back)
202
+ const a = relativeLuminance(opaqueFront)
203
+ const b = relativeLuminance(back)
204
+ return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
205
+ }
206
+
207
+ function bestOnColor(color: RgbColor): RgbColor {
208
+ return contrastRatio(WHITE, color) >= contrastRatio(BLACK, color) ? WHITE : BLACK
209
+ }
210
+
211
+ const WHITE: RgbColor = { r: 255, g: 255, b: 255, a: 255 }
212
+ const BLACK: RgbColor = { r: 0, g: 0, b: 0, a: 255 }
213
+
214
+ /** Move toward black or white until the requested contrast is met. (Zeron `ensure_contrast`.) */
215
+ function ensureContrast(color: RgbColor, background: RgbColor, minimum: number): RgbColor {
216
+ if (contrastRatio(color, background) >= minimum) return color
217
+ const target =
218
+ contrastRatio(BLACK, background) >= contrastRatio(WHITE, background) ? BLACK : WHITE
219
+ for (let step = 1; step <= 20; step++) {
220
+ const candidate = mixColors(color, target, step / 20)
221
+ if (contrastRatio(candidate, background) >= minimum) return candidate
222
+ }
223
+ return target
224
+ }
225
+
226
+ /*
227
+ * Accent — Zeron `AccentPreset`/`AccentSelection`/`AccentRoles` with Adea's
228
+ * custom-color validation. Presets carry per-appearance values; a custom
229
+ * color is normalized to the 3:1 interaction minimum instead of rejected
230
+ * outright, and unparseable values fall back to the theme default.
231
+ */
232
+
233
+ export type AccentPreset = Readonly<{
234
+ id: string
235
+ label: string
236
+ dark: string
237
+ light: string
238
+ }>
239
+
240
+ export const accentPresets: readonly AccentPreset[] = Object.freeze([
241
+ { id: 'violet', label: 'Violet', dark: '#a78bfa', light: '#6d28d9' },
242
+ { id: 'blue', label: 'Blue', dark: '#60a5fa', light: '#2563eb' },
243
+ { id: 'green', label: 'Green', dark: '#4ade80', light: '#15803d' },
244
+ { id: 'amber', label: 'Amber', dark: '#fbbf24', light: '#b45309' },
245
+ { id: 'cyan', label: 'Cyan', dark: '#22d3ee', light: '#0e7490' },
246
+ { id: 'pink', label: 'Pink', dark: '#f472b6', light: '#be185d' },
247
+ ])
248
+
249
+ /**
250
+ * Normalize a custom accent color against a variant background: unparseable
251
+ * input is rejected (`undefined`), and any accepted color is raised to the
252
+ * 3:1 interaction minimum so an accent can never ship unreadable.
253
+ */
254
+ export function normalizeAccentValue(value: string, background: string): string | undefined {
255
+ const parsed = parseColor(value)
256
+ if (!parsed) return undefined
257
+ const backdrop = parseColor(background) ?? WHITE
258
+ return colorToHex(ensureContrast(parsed, backdrop, 3))
259
+ }
260
+
261
+ export function accentPresetById(id: string): AccentPreset | undefined {
262
+ return accentPresets.find((preset) => preset.id === id)
263
+ }
264
+
265
+ export type AccentRoles = Readonly<{
266
+ /** Interactive primary. */
267
+ primary: string
268
+ /** Text drawn on `primary`-filled controls; ≥ 4.5:1 against it. */
269
+ onPrimary: string
270
+ /** Hover/press emphasis derived from the primary. */
271
+ strong: string
272
+ /** Focus ring color. */
273
+ ring: string
274
+ /** False when the selection is the variant's own accent and no role should be overridden. */
275
+ overrides: boolean
276
+ }>
277
+
278
+ /**
279
+ * Derive the accent roles for a selection against a variant. `'theme'` keeps
280
+ * the variant's own accent; presets and custom colors are normalized to the
281
+ * 3:1 interaction minimum (Zeron `AccentRoles::derive`, thresholds preserved).
282
+ */
283
+ export function deriveAccentRoles(selection: string, variant: ThemeVariant): AccentRoles {
284
+ const background = variant.colors.background
285
+ let primary = variant.colors.primary
286
+ let overrides = false
287
+ if (selection !== 'theme') {
288
+ const preset = accentPresetById(selection)
289
+ const requested = preset
290
+ ? parseColor(variant.appearance === 'dark' ? preset.dark : preset.light)
291
+ : parseColor(selection)
292
+ if (requested) {
293
+ primary = colorToHex(ensureContrast(requested, parseColor(background)!, 3))
294
+ overrides = true
295
+ }
296
+ }
297
+ const primaryRgb = parseColor(primary)!
298
+ const onPrimary = colorToHex(bestOnColor(primaryRgb))
299
+ let strong = primary
300
+ if (contrastRatio(onPrimary, strong) < 4.5) {
301
+ strong = colorToHex(ensureContrast(primaryRgb, parseColor(onPrimary)!, 4.5))
302
+ }
303
+ return { primary, onPrimary, strong, ring: strong, overrides }
304
+ }
305
+
306
+ /*
307
+ * Surface — Zeron `SurfacePreference.resolve` extended with Adea's
308
+ * translucent capability gate and the reduced-transparency policy: the OS
309
+ * setting or the user preference forces an accessible opaque fallback, and a
310
+ * translucent request on a host without native translucency renders the
311
+ * tokenized frosted surface instead of pretending to OS vibrancy.
312
+ */
313
+ export type EffectiveSurface = 'opaque' | 'frosted' | 'translucent'
314
+
315
+ export function resolveSurface(
316
+ preference: SurfacePreference,
317
+ environment: Readonly<{
318
+ osReducedTransparency: boolean
319
+ userReducedTransparency: boolean
320
+ nativeTranslucency: boolean
321
+ }>
322
+ ): EffectiveSurface {
323
+ if (environment.osReducedTransparency || environment.userReducedTransparency) return 'opaque'
324
+ if (preference === 'translucent' && !environment.nativeTranslucency) return 'frosted'
325
+ return preference
326
+ }
327
+
328
+ /*
329
+ * Theme registry — Zeron `ThemeVariant`/`ThemeRegistry`, narrowed to Adea's
330
+ * semantic roles. Every runtime component consumes the CSS custom properties
331
+ * declared here; terminal ANSI and editor roles come from the same manifest
332
+ * so a theme switch updates them live without remounting a terminal or
333
+ * editor.
334
+ */
335
+
336
+ export type ThemeTerminalPalette = Readonly<{
337
+ background: string
338
+ foreground: string
339
+ cursor: string
340
+ selection: string
341
+ /** The 16 xterm ANSI slots, in protocol order. */
342
+ ansi: readonly string[]
343
+ }>
344
+
345
+ export type ThemeEditorRoles = Readonly<{
346
+ keyword: string
347
+ string: string
348
+ number: string
349
+ comment: string
350
+ function: string
351
+ variable: string
352
+ type: string
353
+ tag: string
354
+ attribute: string
355
+ operator: string
356
+ heading: string
357
+ link: string
358
+ diffAdd: string
359
+ diffDelete: string
360
+ diffHunk: string
361
+ searchMatch: string
362
+ }>
363
+
364
+ export type ThemeChartRoles = Readonly<{
365
+ chart1: string
366
+ chart2: string
367
+ chart3: string
368
+ chart4: string
369
+ chart5: string
370
+ chart6: string
371
+ }>
372
+
373
+ export type ThemeColors = Readonly<{
374
+ background: string
375
+ foreground: string
376
+ card: string
377
+ cardForeground: string
378
+ popover: string
379
+ popoverForeground: string
380
+ primary: string
381
+ primaryForeground: string
382
+ secondary: string
383
+ secondaryForeground: string
384
+ muted: string
385
+ mutedForeground: string
386
+ accent: string
387
+ accentForeground: string
388
+ destructive: string
389
+ success: string
390
+ border: string
391
+ input: string
392
+ ring: string
393
+ }>
394
+
395
+ export type ThemeVariant = Readonly<{
396
+ id: string
397
+ familyId: string
398
+ familyName: string
399
+ name: string
400
+ appearance: ResolvedAppearance
401
+ colors: ThemeColors
402
+ terminal: ThemeTerminalPalette
403
+ editor: ThemeEditorRoles
404
+ charts: ThemeChartRoles
405
+ }>
406
+
407
+ /**
408
+ * The independent per-appearance theme selection (Zeron `ThemeSelection`,
409
+ * carrying the V2 preference's field names).
410
+ */
411
+ export type ThemeSelection = Readonly<{ lightThemeId: string; darkThemeId: string }>
412
+
413
+ export function themeSelectionVariantId(
414
+ selection: ThemeSelection,
415
+ appearance: ResolvedAppearance
416
+ ): string {
417
+ return appearance === 'dark' ? selection.darkThemeId : selection.lightThemeId
418
+ }
419
+
420
+ export type ValidationIssue = Readonly<{
421
+ variantId: string
422
+ severity: 'error' | 'warning'
423
+ message: string
424
+ }>
425
+
426
+ /**
427
+ * Registry validation, ported from Zeron `ThemeRegistry::validate`: core text
428
+ * and muted text must reach 4.5:1, the accent 3:1, on-accent 4.5:1, the
429
+ * terminal foreground 4.5:1, and every chromatic ANSI slot 3:1 (slots 0 and 8
430
+ * are structural black/dim colors). Provenance completeness is a Zeron
431
+ * library concept; M12 ships only built-ins, so the structural check here is
432
+ * id/non-empty-color integrity.
433
+ */
434
+ export function validateThemeRegistry(registry: readonly ThemeVariant[]): ValidationIssue[] {
435
+ const issues: ValidationIssue[] = []
436
+ const seen = new Set<string>()
437
+ for (const variant of registry) {
438
+ if (variant.id.trim().length === 0 || seen.has(variant.id)) {
439
+ issues.push({
440
+ variantId: variant.id,
441
+ severity: 'error',
442
+ message: 'variant id must be unique',
443
+ })
444
+ }
445
+ seen.add(variant.id)
446
+ const check = (role: string, foreground: string, background: string, minimum: number) => {
447
+ const actual = contrastRatio(foreground, background)
448
+ if (actual < minimum) {
449
+ issues.push({
450
+ variantId: variant.id,
451
+ severity: 'error',
452
+ message: `${role} contrast is ${actual.toFixed(2)}:1; expected ${minimum.toFixed(1)}:1`,
453
+ })
454
+ }
455
+ }
456
+ check('text', variant.colors.foreground, variant.colors.background, 4.5)
457
+ check('muted text', variant.colors.mutedForeground, variant.colors.background, 4.5)
458
+ check('primary', variant.colors.primary, variant.colors.background, 3)
459
+ check('on-primary', variant.colors.primaryForeground, variant.colors.primary, 4.5)
460
+ check('terminal foreground', variant.terminal.foreground, variant.terminal.background, 4.5)
461
+ for (const [index, color] of variant.terminal.ansi.entries()) {
462
+ if (index % 8 === 0) continue
463
+ const actual = contrastRatio(color, variant.terminal.background)
464
+ if (actual < 3) {
465
+ issues.push({
466
+ variantId: variant.id,
467
+ severity: 'warning',
468
+ message: `terminal ANSI slot ${index} is below 3:1`,
469
+ })
470
+ }
471
+ }
472
+ check('editor comment', variant.editor.comment, variant.colors.background, 4.5)
473
+ check('diff delete', variant.editor.diffDelete, variant.colors.background, 3)
474
+ check('diff add', variant.editor.diffAdd, variant.colors.background, 3)
475
+ }
476
+ return issues
477
+ }
478
+
479
+ /**
480
+ * Resolve the variant for a selection, falling back deterministically to the
481
+ * default variant of the same appearance when the stored id is missing or
482
+ * broken — a corrupt preference degrades the palette, never the UI.
483
+ * (Zeron `ThemeRegistry::resolve`.)
484
+ */
485
+ export function resolveThemeVariant(
486
+ registry: readonly ThemeVariant[],
487
+ selection: ThemeSelection,
488
+ appearance: ResolvedAppearance
489
+ ): ThemeVariant {
490
+ const wanted = themeSelectionVariantId(selection, appearance)
491
+ const found = registry.find((variant) => variant.id === wanted)
492
+ if (found) return found
493
+ const fallbackId =
494
+ appearance === 'dark'
495
+ ? defaultAppearancePreferences.darkThemeId
496
+ : defaultAppearancePreferences.lightThemeId
497
+ const fallback = registry.find((variant) => variant.id === fallbackId)
498
+ if (fallback) return fallback
499
+ const sameAppearance = registry.find((variant) => variant.appearance === appearance)
500
+ if (sameAppearance) return sameAppearance
501
+ return registry[0]!
502
+ }
503
+
504
+ /*
505
+ * Built-in registry — Adea's curated M12 set. `adea-light`/`adea-dark` mirror
506
+ * the token declarations in `styles/theme.css` (which stay authoritative for
507
+ * the defaults so first paint never shifts); `slate` is a cool quiet pair and
508
+ * `contrast` an AA-emphasized pair. Terminal/editor/charts ship one curated
509
+ * template per appearance so every variant keeps ANSI/syntax/diff legibility.
510
+ */
511
+
512
+ const lightTerminal: ThemeTerminalPalette = Object.freeze({
513
+ background: '#ffffff',
514
+ foreground: '#1b1f24',
515
+ cursor: '#24292f',
516
+ selection: '#b6c7ff',
517
+ ansi: Object.freeze([
518
+ '#1b1f24',
519
+ '#b91c1c',
520
+ '#116a2e',
521
+ '#8a5a1b',
522
+ '#0b57d0',
523
+ '#a0186f',
524
+ '#0e7490',
525
+ '#57606a',
526
+ '#57606a',
527
+ '#c94d4d',
528
+ '#1f9d4f',
529
+ '#a9752c',
530
+ '#3b82f6',
531
+ '#c04a92',
532
+ '#0891b2',
533
+ '#24292f',
534
+ ]),
535
+ })
536
+
537
+ const darkTerminal: ThemeTerminalPalette = Object.freeze({
538
+ background: '#0d1117',
539
+ foreground: '#e6edf3',
540
+ cursor: '#e6edf3',
541
+ selection: '#264f78',
542
+ ansi: Object.freeze([
543
+ '#2f3742',
544
+ '#ff8183',
545
+ '#56d364',
546
+ '#e3b341',
547
+ '#6ca4f8',
548
+ '#db61a2',
549
+ '#39c5cf',
550
+ '#d5dde5',
551
+ '#57606a',
552
+ '#ff9494',
553
+ '#79dd8a',
554
+ '#f0c264',
555
+ '#8db9ff',
556
+ '#e87cb4',
557
+ '#66d3dc',
558
+ '#eef2f6',
559
+ ]),
560
+ })
561
+
562
+ const lightEditor: ThemeEditorRoles = Object.freeze({
563
+ keyword: '#0b57d0',
564
+ string: '#116a2e',
565
+ number: '#8a5a1b',
566
+ comment: '#57606a',
567
+ function: '#a0186f',
568
+ variable: '#1b1f24',
569
+ type: '#0e7490',
570
+ tag: '#b91c1c',
571
+ attribute: '#8a5a1b',
572
+ operator: '#1b1f24',
573
+ heading: '#1b1f24',
574
+ link: '#0b57d0',
575
+ diffAdd: '#116a2e',
576
+ diffDelete: '#b91c1c',
577
+ diffHunk: '#57606a',
578
+ searchMatch: '#8a5a1b',
579
+ })
580
+
581
+ const darkEditor: ThemeEditorRoles = Object.freeze({
582
+ keyword: '#6ca4f8',
583
+ string: '#56d364',
584
+ number: '#e3b341',
585
+ comment: '#8b949e',
586
+ function: '#db61a2',
587
+ variable: '#e6edf3',
588
+ type: '#39c5cf',
589
+ tag: '#ff8183',
590
+ attribute: '#e3b341',
591
+ operator: '#e6edf3',
592
+ heading: '#e6edf3',
593
+ link: '#6ca4f8',
594
+ diffAdd: '#56d364',
595
+ diffDelete: '#ff8183',
596
+ diffHunk: '#8b949e',
597
+ searchMatch: '#e3b341',
598
+ })
599
+
600
+ const lightCharts: ThemeChartRoles = Object.freeze({
601
+ chart1: '#0b57d0',
602
+ chart2: '#116a2e',
603
+ chart3: '#8a5a1b',
604
+ chart4: '#a0186f',
605
+ chart5: '#0e7490',
606
+ chart6: '#57606a',
607
+ })
608
+
609
+ const darkCharts: ThemeChartRoles = Object.freeze({
610
+ chart1: '#6ca4f8',
611
+ chart2: '#56d364',
612
+ chart3: '#e3b341',
613
+ chart4: '#db61a2',
614
+ chart5: '#39c5cf',
615
+ chart6: '#8b949e',
616
+ })
617
+
618
+ function defineVariant(
619
+ id: string,
620
+ familyId: string,
621
+ familyName: string,
622
+ name: string,
623
+ appearance: ResolvedAppearance,
624
+ colors: ThemeColors
625
+ ): ThemeVariant {
626
+ const light = appearance === 'light'
627
+ return {
628
+ id,
629
+ familyId,
630
+ familyName,
631
+ name,
632
+ appearance,
633
+ colors,
634
+ terminal: light ? lightTerminal : darkTerminal,
635
+ editor: light ? lightEditor : darkEditor,
636
+ charts: light ? lightCharts : darkCharts,
637
+ }
638
+ }
639
+
640
+ /** The `adea-light`/`adea-dark` entries mirror `styles/theme.css`. */
641
+ export const builtinThemeRegistry: readonly ThemeVariant[] = Object.freeze([
642
+ defineVariant('adea-light', 'adea', 'Adea', 'Adea Light', 'light', {
643
+ background: '#ffffff',
644
+ foreground: '#252525',
645
+ card: '#ffffff',
646
+ cardForeground: '#252525',
647
+ popover: '#ffffff',
648
+ popoverForeground: '#252525',
649
+ primary: '#343434',
650
+ primaryForeground: '#fcfcfc',
651
+ secondary: '#f7f7f7',
652
+ secondaryForeground: '#343434',
653
+ muted: '#f7f7f7',
654
+ mutedForeground: '#6f6f6f',
655
+ accent: '#f7f7f7',
656
+ accentForeground: '#343434',
657
+ destructive: '#c53c2b',
658
+ success: '#1a7f37',
659
+ border: '#ebebeb',
660
+ input: '#ebebeb',
661
+ ring: '#a3a3a3',
662
+ }),
663
+ defineVariant('adea-dark', 'adea', 'Adea', 'Adea Dark', 'dark', {
664
+ background: '#252525',
665
+ foreground: '#fcfcfc',
666
+ card: '#343434',
667
+ cardForeground: '#fcfcfc',
668
+ popover: '#343434',
669
+ popoverForeground: '#fcfcfc',
670
+ primary: '#ebebeb',
671
+ primaryForeground: '#343434',
672
+ secondary: '#444444',
673
+ secondaryForeground: '#fcfcfc',
674
+ muted: '#444444',
675
+ mutedForeground: '#a3a3a3',
676
+ accent: '#444444',
677
+ accentForeground: '#fcfcfc',
678
+ destructive: '#e07060',
679
+ success: '#3fb950',
680
+ border: 'rgba(255, 255, 255, 0.16)',
681
+ input: 'rgba(255, 255, 255, 0.2)',
682
+ ring: '#7c7c7c',
683
+ }),
684
+ defineVariant('slate-light', 'slate', 'Slate', 'Slate Light', 'light', {
685
+ background: '#f8fafc',
686
+ foreground: '#0f172a',
687
+ card: '#ffffff',
688
+ cardForeground: '#0f172a',
689
+ popover: '#ffffff',
690
+ popoverForeground: '#0f172a',
691
+ primary: '#0f172a',
692
+ primaryForeground: '#f8fafc',
693
+ secondary: '#e2e8f0',
694
+ secondaryForeground: '#0f172a',
695
+ muted: '#e2e8f0',
696
+ mutedForeground: '#475569',
697
+ accent: '#e2e8f0',
698
+ accentForeground: '#0f172a',
699
+ destructive: '#b91c1c',
700
+ success: '#15803d',
701
+ border: '#cbd5e1',
702
+ input: '#cbd5e1',
703
+ ring: '#64748b',
704
+ }),
705
+ defineVariant('slate-dark', 'slate', 'Slate', 'Slate Dark', 'dark', {
706
+ background: '#0f172a',
707
+ foreground: '#f1f5f9',
708
+ card: '#1e293b',
709
+ cardForeground: '#f1f5f9',
710
+ popover: '#1e293b',
711
+ popoverForeground: '#f1f5f9',
712
+ primary: '#e2e8f0',
713
+ primaryForeground: '#0f172a',
714
+ secondary: '#334155',
715
+ secondaryForeground: '#f1f5f9',
716
+ muted: '#334155',
717
+ mutedForeground: '#94a3b8',
718
+ accent: '#334155',
719
+ accentForeground: '#f1f5f9',
720
+ destructive: '#f87171',
721
+ success: '#4ade80',
722
+ border: 'rgba(148, 163, 184, 0.2)',
723
+ input: 'rgba(148, 163, 184, 0.25)',
724
+ ring: '#64748b',
725
+ }),
726
+ defineVariant('contrast-light', 'contrast', 'High Contrast', 'High Contrast Light', 'light', {
727
+ background: '#ffffff',
728
+ foreground: '#000000',
729
+ card: '#ffffff',
730
+ cardForeground: '#000000',
731
+ popover: '#ffffff',
732
+ popoverForeground: '#000000',
733
+ primary: '#143d8f',
734
+ primaryForeground: '#ffffff',
735
+ secondary: '#f0f0f0',
736
+ secondaryForeground: '#000000',
737
+ muted: '#f0f0f0',
738
+ mutedForeground: '#333333',
739
+ accent: '#f0f0f0',
740
+ accentForeground: '#000000',
741
+ destructive: '#b91c1c',
742
+ success: '#14532d',
743
+ border: '#767676',
744
+ input: '#767676',
745
+ ring: '#000000',
746
+ }),
747
+ defineVariant('contrast-dark', 'contrast', 'High Contrast', 'High Contrast Dark', 'dark', {
748
+ background: '#000000',
749
+ foreground: '#ffffff',
750
+ card: '#0a0a0a',
751
+ cardForeground: '#ffffff',
752
+ popover: '#0a0a0a',
753
+ popoverForeground: '#ffffff',
754
+ primary: '#8ab4ff',
755
+ primaryForeground: '#000000',
756
+ secondary: '#1a1a1a',
757
+ secondaryForeground: '#ffffff',
758
+ muted: '#1a1a1a',
759
+ mutedForeground: '#e5e5e5',
760
+ accent: '#1a1a1a',
761
+ accentForeground: '#ffffff',
762
+ destructive: '#ff6b6b',
763
+ success: '#4ade80',
764
+ border: '#8f8f8f',
765
+ input: '#8f8f8f',
766
+ ring: '#ffffff',
767
+ }),
768
+ ])
769
+
770
+ /*
771
+ * Preference storage: normalization, legacy migration, corrupt retention.
772
+ */
773
+
774
+ function normalizeMode(value: unknown): AppearanceMode {
775
+ return value === 'light' || value === 'dark' || value === 'system' ? value : 'system'
776
+ }
777
+
778
+ function normalizeSurface(value: unknown): SurfacePreference {
779
+ return value === 'frosted' || value === 'translucent' || value === 'opaque' ? value : 'opaque'
780
+ }
781
+
782
+ function normalizeThemeId(value: unknown, fallback: string): string {
783
+ return typeof value === 'string' && value.trim().length > 0 ? value : fallback
784
+ }
785
+
786
+ function normalizeAccent(value: unknown): string {
787
+ if (value === 'theme') return 'theme'
788
+ if (typeof value !== 'string') return 'theme'
789
+ if (accentPresetById(value)) return value
790
+ const parsed = parseColor(value)
791
+ return parsed ? colorToHex(parsed) : 'theme'
792
+ }
793
+
794
+ export type NormalizedPreferences = Readonly<{
795
+ /** The strict v2 document, with every unknown field corrected to defaults. */
796
+ value: AppearancePreferencesV2
797
+ /**
798
+ * The raw stored document when it was not a valid v2 record. Retained so a
799
+ * future version (or a repaired write) never loses the user's data.
800
+ */
801
+ retainedRaw?: unknown
802
+ }>
803
+
804
+ /**
805
+ * Parse a stored appearance document. Anything that is not a strict version-2
806
+ * record falls back to the defaults and keeps the raw value for retention;
807
+ * individual unknown values inside a v2 record are corrected field by field.
808
+ */
809
+ export function normalizeAppearancePreferences(raw: unknown): NormalizedPreferences {
810
+ if (typeof raw !== 'object' || raw === null || (raw as { version?: unknown }).version !== 2) {
811
+ return { value: defaultAppearancePreferences, retainedRaw: raw }
812
+ }
813
+ const record = raw as Record<string, unknown>
814
+ return {
815
+ value: {
816
+ version: 2,
817
+ mode: normalizeMode(record.mode),
818
+ lightThemeId: normalizeThemeId(
819
+ record.lightThemeId,
820
+ defaultAppearancePreferences.lightThemeId
821
+ ),
822
+ darkThemeId: normalizeThemeId(record.darkThemeId, defaultAppearancePreferences.darkThemeId),
823
+ accent: normalizeAccent(record.accent),
824
+ surface: normalizeSurface(record.surface),
825
+ reduceTransparency: record.reduceTransparency === true,
826
+ },
827
+ }
828
+ }
829
+
830
+ /** The legacy single-key preference, mapped into v2 during migration. */
831
+ export function migrateLegacyThemeValue(
832
+ stored: string | null
833
+ ): AppearancePreferencesV2 | undefined {
834
+ if (stored !== 'light' && stored !== 'dark' && stored !== 'system') return undefined
835
+ return { ...defaultAppearancePreferences, mode: stored }
836
+ }
837
+
838
+ type AppearanceStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
839
+
840
+ /**
841
+ * Read the appearance preferences: the v2 key first, then the legacy `theme`
842
+ * key, then defaults. Storage failures degrade to defaults like every other
843
+ * blocked-storage consumer.
844
+ */
845
+ export function readAppearancePreferences(
846
+ storage: AppearanceStorage | undefined
847
+ ): AppearancePreferencesV2 {
848
+ if (!storage) return defaultAppearancePreferences
849
+ try {
850
+ const raw = storage.getItem(APPEARANCE_STORAGE_KEY)
851
+ if (raw !== null) {
852
+ let parsed: unknown
853
+ try {
854
+ parsed = JSON.parse(raw)
855
+ } catch {
856
+ return defaultAppearancePreferences
857
+ }
858
+ return normalizeAppearancePreferences(parsed).value
859
+ }
860
+ return (
861
+ migrateLegacyThemeValue(storage.getItem(LEGACY_THEME_STORAGE_KEY)) ??
862
+ defaultAppearancePreferences
863
+ )
864
+ } catch {
865
+ return defaultAppearancePreferences
866
+ }
867
+ }
868
+
869
+ export function writeAppearancePreferences(
870
+ storage: AppearanceStorage | undefined,
871
+ preferences: AppearancePreferencesV2
872
+ ): void {
873
+ if (!storage) return
874
+ try {
875
+ storage.setItem(APPEARANCE_STORAGE_KEY, JSON.stringify(preferences))
876
+ } catch {
877
+ // Persistence is best-effort: private modes and full quotas keep the
878
+ // in-memory preference.
879
+ }
880
+ }
881
+
882
+ /*
883
+ * Document application. Both the provider and the no-flash inline script
884
+ * resolve preferences to the same document shape, so a pre-paint restore and
885
+ * a live change cannot disagree.
886
+ */
887
+
888
+ export type ResolvedAppearanceState = Readonly<{
889
+ resolvedMode: ResolvedAppearance
890
+ variant: ThemeVariant
891
+ accent: AccentRoles
892
+ effectiveSurface: EffectiveSurface
893
+ reduceTransparencyActive: boolean
894
+ }>
895
+
896
+ /** Resolve preferences against the environment into the applied document state. */
897
+ export function resolveAppearanceState(
898
+ preferences: AppearancePreferencesV2,
899
+ environment: Readonly<{
900
+ systemAppearance: ResolvedAppearance
901
+ osReducedTransparency: boolean
902
+ nativeTranslucency: boolean
903
+ }>,
904
+ registry: readonly ThemeVariant[] = builtinThemeRegistry
905
+ ): ResolvedAppearanceState {
906
+ const resolvedMode = resolveAppearanceMode(preferences.mode, environment.systemAppearance)
907
+ const variant = resolveThemeVariant(registry, preferences, resolvedMode)
908
+ return {
909
+ resolvedMode,
910
+ variant,
911
+ accent: deriveAccentRoles(preferences.accent, variant),
912
+ effectiveSurface: resolveSurface(preferences.surface, {
913
+ osReducedTransparency: environment.osReducedTransparency,
914
+ userReducedTransparency: preferences.reduceTransparency,
915
+ nativeTranslucency: environment.nativeTranslucency,
916
+ }),
917
+ reduceTransparencyActive: environment.osReducedTransparency || preferences.reduceTransparency,
918
+ }
919
+ }
920
+
921
+ const SURFACE_BACKGROUND_ALPHA: Record<EffectiveSurface, string> = {
922
+ opaque: '1',
923
+ frosted: '0.92',
924
+ translucent: '0.8',
925
+ }
926
+
927
+ /**
928
+ * The custom properties a non-default variant owns, as a flat map. This one
929
+ * mapping feeds both the live provider and the pre-paint no-flash script, so
930
+ * a restored first paint and a later live switch cannot disagree. Default
931
+ * variants return an empty map: `styles/theme.css` declares those tokens.
932
+ */
933
+ export function flatVariantTokens(variant: ThemeVariant): Record<string, string> {
934
+ if (isDefaultVariant(variant)) return {}
935
+ const tokens: Record<string, string> = {}
936
+ for (const [role, value] of Object.entries(variant.colors)) {
937
+ tokens[`--${kebabCase(role)}`] = value
938
+ }
939
+ const terminal = variant.terminal
940
+ tokens['--terminal-background'] = terminal.background
941
+ tokens['--terminal-foreground'] = terminal.foreground
942
+ tokens['--terminal-cursor'] = terminal.cursor
943
+ tokens['--terminal-selection'] = terminal.selection
944
+ const ansiNames = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
945
+ for (const [index, color] of terminal.ansi.entries()) {
946
+ const name = ansiNames[index % 8]!
947
+ tokens[index < 8 ? `--terminal-ansi-${name}` : `--terminal-ansi-bright-${name}`] = color
948
+ }
949
+ for (const [role, value] of Object.entries(variant.editor)) {
950
+ tokens[`--editor-${kebabCase(role)}`] = value
951
+ }
952
+ for (const [index, value] of Object.values(variant.charts).entries()) {
953
+ tokens[`--chart-${index + 1}`] = value
954
+ }
955
+ return tokens
956
+ }
957
+
958
+ /**
959
+ * Apply a resolved state to a document: the `dark` class and color scheme for
960
+ * the palette, the diagnostic data attributes, the accent role overrides, and
961
+ * the resolved variant's semantic tokens (including terminal ANSI and editor
962
+ * roles). CSS custom properties cascade, so terminals and editors update live
963
+ * without remounting.
964
+ *
965
+ * The default `adea-light`/`adea-dark` variants skip the palette override —
966
+ * `styles/theme.css` declares those tokens already, and leaving them CSS-owned
967
+ * keeps first paint byte-identical to the pre-#425 app.
968
+ */
969
+ export function applyAppearanceToDocument(
970
+ document: Document,
971
+ state: ResolvedAppearanceState
972
+ ): void {
973
+ const root = document.documentElement
974
+ const style = root.style
975
+
976
+ root.classList.toggle('dark', state.resolvedMode === 'dark')
977
+ style.colorScheme = state.resolvedMode
978
+ root.dataset.theme = state.variant.id
979
+ root.dataset.appearanceMode = state.resolvedMode
980
+ root.dataset.surface = state.effectiveSurface
981
+ root.dataset.accent = state.accent.overrides ? 'custom' : 'theme'
982
+ root.dataset.reduceTransparency = state.reduceTransparencyActive ? 'true' : 'false'
983
+
984
+ const setToken = (name: string, value: string) => style.setProperty(name, value)
985
+ setToken('--surface-alpha', SURFACE_BACKGROUND_ALPHA[state.effectiveSurface])
986
+ // `theme` keeps every interactive role CSS-owned; an override touches only
987
+ // the accent roles, never the surface or text palette.
988
+ if (state.accent.overrides) {
989
+ setToken('--primary', state.accent.primary)
990
+ setToken('--primary-foreground', state.accent.onPrimary)
991
+ setToken('--ring', state.accent.ring)
992
+ }
993
+ for (const [name, value] of Object.entries(flatVariantTokens(state.variant))) {
994
+ setToken(name, value)
995
+ }
996
+ }
997
+
998
+ /**
999
+ * The no-flash preload script rendered in the document head. It re-resolves
1000
+ * the stored (or legacy) preference against the OS before first paint and
1001
+ * applies the same palette state the provider would, so hydration never shows
1002
+ * the wrong palette. Accent overrides land with the provider: they decorate
1003
+ * the resolved palette and cannot produce a wrong-palette flash.
1004
+ */
1005
+ export function appearanceThemeScript(): string {
1006
+ const preload = builtinThemeRegistry.map((variant) => ({
1007
+ id: variant.id,
1008
+ dark: variant.appearance === 'dark',
1009
+ tokens: flatVariantTokens(variant),
1010
+ }))
1011
+ const registry = JSON.stringify(preload)
1012
+ const alpha = JSON.stringify(SURFACE_BACKGROUND_ALPHA)
1013
+ return `(function(){try{
1014
+ var prefs=null;var raw=null;
1015
+ try{raw=localStorage.getItem('${APPEARANCE_STORAGE_KEY}')}catch(e){}
1016
+ if(raw){try{var parsed=JSON.parse(raw);if(parsed&&parsed.version===2)prefs=parsed}catch(e){}}
1017
+ var mode='system';
1018
+ if(prefs){if(prefs.mode==='light'||prefs.mode==='dark')mode=prefs.mode}
1019
+ else{try{var t=localStorage.getItem('${LEGACY_THEME_STORAGE_KEY}');if(t==='light'||t==='dark')mode=t}catch(e){}}
1020
+ var dark=mode==='dark'||(mode==='system'&&window.matchMedia('${DARK_QUERY}').matches);
1021
+ var registry=${registry};
1022
+ var wanted=dark?(prefs&&prefs.darkThemeId)||'${defaultAppearancePreferences.darkThemeId}':(prefs&&prefs.lightThemeId)||'${defaultAppearancePreferences.lightThemeId}';
1023
+ var variant=null;
1024
+ for(var i=0;i<registry.length;i++){if(registry[i].id===wanted)variant=registry[i]}
1025
+ if(!variant){for(var j=0;j<registry.length;j++){if(registry[j].dark===dark)variant=registry[j]}}
1026
+ if(!variant)variant=registry[0];
1027
+ var reduce=window.matchMedia('${REDUCED_TRANSPARENCY_QUERY}').matches||!!(prefs&&prefs.reduceTransparency);
1028
+ /* Pre-hydration the host translucency capability is unknown, so the script
1029
+ resolves every glass request to the conservative frosted tokens; the
1030
+ provider re-resolves with the real capability once mounted. */
1031
+ var surface=reduce?'opaque':(prefs&&prefs.surface==='frosted'||prefs&&prefs.surface==='translucent'?'frosted':'opaque');
1032
+ if(surface!=='opaque'&&surface!=='frosted')surface='opaque';
1033
+ var r=document.documentElement,s=r.style;
1034
+ r.classList.toggle('dark',dark);
1035
+ s.colorScheme=dark?'dark':'light';
1036
+ r.dataset.theme=variant.id;
1037
+ r.dataset.appearanceMode=mode;
1038
+ r.dataset.surface=surface;
1039
+ r.dataset.reduceTransparency=reduce?'true':'false';
1040
+ s.setProperty('--surface-alpha',${alpha}[surface]||'1');
1041
+ var tokens=variant.tokens||{};
1042
+ for(var name in tokens){s.setProperty(name,tokens[name])}
1043
+ }catch(e){}})();`
1044
+ }
1045
+
1046
+ function isDefaultVariant(variant: ThemeVariant): boolean {
1047
+ return (
1048
+ variant.id === defaultAppearancePreferences.lightThemeId ||
1049
+ variant.id === defaultAppearancePreferences.darkThemeId
1050
+ )
1051
+ }
1052
+
1053
+ function kebabCase(value: string): string {
1054
+ return value.replaceAll(/[A-Z]/g, (character) => `-${character.toLocaleLowerCase()}`)
1055
+ }