@adea-ai/ui 0.124.0 → 0.127.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.
Files changed (82) hide show
  1. package/dist/NOTICE +58 -0
  2. package/dist/components/theme/index.d.ts +1 -1
  3. package/dist/components/theme/index.d.ts.map +1 -1
  4. package/dist/components/theme/index.js +4 -3
  5. package/dist/components/theme/theme-document.d.ts +31 -1
  6. package/dist/components/theme/theme-document.d.ts.map +1 -1
  7. package/dist/components/theme/theme-document.js +25 -5
  8. package/dist/components/theme/theme-document.js.map +1 -1
  9. package/dist/components/theme/theme-persistence.d.ts +87 -0
  10. package/dist/components/theme/theme-persistence.d.ts.map +1 -1
  11. package/dist/components/theme/theme-persistence.js +65 -3
  12. package/dist/components/theme/theme-persistence.js.map +1 -1
  13. package/dist/components/theme/theme-provider.d.ts +49 -40
  14. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  15. package/dist/components/theme/theme-provider.js +67 -52
  16. package/dist/components/theme/theme-provider.js.map +1 -1
  17. package/dist/components/theme/theme-script.d.ts +100 -0
  18. package/dist/components/theme/theme-script.d.ts.map +1 -0
  19. package/dist/components/theme/theme-script.js +57 -0
  20. package/dist/components/theme/theme-script.js.map +1 -0
  21. package/dist/components/theme/theme-state.d.ts +85 -2
  22. package/dist/components/theme/theme-state.d.ts.map +1 -1
  23. package/dist/components/theme/theme-state.js +41 -2
  24. package/dist/components/theme/theme-state.js.map +1 -1
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -4
  28. package/dist/lib/accent-roles.d.ts +124 -0
  29. package/dist/lib/accent-roles.d.ts.map +1 -0
  30. package/dist/lib/accent-roles.js +163 -0
  31. package/dist/lib/accent-roles.js.map +1 -0
  32. package/dist/lib/font-zoom.d.ts +25 -0
  33. package/dist/lib/font-zoom.d.ts.map +1 -0
  34. package/dist/lib/font-zoom.js +45 -0
  35. package/dist/lib/font-zoom.js.map +1 -0
  36. package/dist/lib/preference-store.d.ts +126 -0
  37. package/dist/lib/preference-store.d.ts.map +1 -0
  38. package/dist/lib/preference-store.js +126 -0
  39. package/dist/lib/preference-store.js.map +1 -0
  40. package/dist/lib/theme-surface.d.ts +71 -0
  41. package/dist/lib/theme-surface.d.ts.map +1 -0
  42. package/dist/lib/theme-surface.js +62 -0
  43. package/dist/lib/theme-surface.js.map +1 -0
  44. package/dist/lib/themes.d.ts +32 -1
  45. package/dist/lib/themes.d.ts.map +1 -1
  46. package/dist/lib/themes.js +34 -13
  47. package/dist/lib/themes.js.map +1 -1
  48. package/dist/lib/tokens.d.ts.map +1 -1
  49. package/dist/lib/tokens.js +15 -0
  50. package/dist/lib/tokens.js.map +1 -1
  51. package/dist/r/lib.json +20 -0
  52. package/dist/r/registry.json +25 -0
  53. package/dist/r/src/components/theme/index.ts +6 -0
  54. package/dist/r/src/components/theme/theme-document.ts +69 -4
  55. package/dist/r/src/components/theme/theme-persistence.ts +172 -2
  56. package/dist/r/src/components/theme/theme-provider.tsx +137 -51
  57. package/dist/r/src/components/theme/theme-script.ts +201 -0
  58. package/dist/r/src/components/theme/theme-state.ts +124 -3
  59. package/dist/r/src/lib/accent-roles.ts +338 -0
  60. package/dist/r/src/lib/font-zoom.ts +74 -0
  61. package/dist/r/src/lib/preference-store.ts +216 -0
  62. package/dist/r/src/lib/theme-surface.ts +110 -0
  63. package/dist/r/src/lib/themes.ts +58 -19
  64. package/dist/r/src/lib/tokens.ts +17 -0
  65. package/dist/r/src/styles/theme.css +38 -0
  66. package/dist/r/theming.json +5 -0
  67. package/package.json +1 -1
  68. package/registry.json +25 -0
  69. package/src/components/theme/index.ts +6 -0
  70. package/src/components/theme/theme-document.ts +69 -4
  71. package/src/components/theme/theme-persistence.ts +172 -2
  72. package/src/components/theme/theme-provider.tsx +137 -51
  73. package/src/components/theme/theme-script.ts +201 -0
  74. package/src/components/theme/theme-state.ts +124 -3
  75. package/src/index.ts +34 -0
  76. package/src/lib/accent-roles.ts +338 -0
  77. package/src/lib/font-zoom.ts +74 -0
  78. package/src/lib/preference-store.ts +216 -0
  79. package/src/lib/theme-surface.ts +110 -0
  80. package/src/lib/themes.ts +58 -19
  81. package/src/lib/tokens.ts +17 -0
  82. package/src/styles/theme.css +38 -0
@@ -4,7 +4,11 @@ import {
4
4
  type AppearanceEditorFontSettings,
5
5
  } from '#lib/appearance-font-settings'
6
6
  import type { AccentPreset } from '@adea-ai/themes'
7
+ import { normalizeCustomAccent, type AccentRoles } from '#lib/accent-roles'
8
+ import { stepAppearanceFontSizes, type FontZoomIntent } from '#lib/font-zoom'
9
+ import type { ThemeSurface } from '#lib/theme-surface'
7
10
  import {
11
+ accentRolesForSelection,
8
12
  defaultDarkThemeId,
9
13
  defaultLightThemeId,
10
14
  resolveVariant,
@@ -41,8 +45,12 @@ export type ThemeSelection = {
41
45
  darkThemeId: string
42
46
  /**
43
47
  * An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),
44
- * or `theme` for the variant's own primary. A theme accent the current pair does
45
- * not offer falls back to the variant's own primary.
48
+ * `theme` for the variant's own primary, or a custom colour as `#rrggbb`. A theme
49
+ * accent the current pair does not offer falls back to the variant's own primary.
50
+ *
51
+ * A custom colour is stored lower case and is raised to the contrast floor when it
52
+ * is painted, never when it is stored: the selection keeps what the user chose.
53
+ * Anything else that is not an id is dropped at paint, not rejected here.
46
54
  */
47
55
  accent: string
48
56
  /** UI-family alias for consumers of the original single-font preference. */
@@ -57,6 +65,30 @@ export type ThemeSelection = {
57
65
  * keeps working and `ThemeSelection` stays assignable without it.
58
66
  */
59
67
  density?: ThemeDensity
68
+ /**
69
+ * The terminal either follows the interface theme (`theme`, or absent) or is pinned
70
+ * to another theme's terminal palette by that theme's id. The pin is written over
71
+ * the interface variant's own `--terminal-*` properties, so a light interface can
72
+ * carry a dark terminal.
73
+ *
74
+ * An id the registry does not hold paints the interface palette and stays in the
75
+ * selection, the same rule as an unknown theme id.
76
+ *
77
+ * Optional and not defaulted: see {@link defaultThemeSelection}.
78
+ */
79
+ terminalThemeId?: string
80
+ /**
81
+ * How opaque elevated panels are. Written to `<html>` only when the provider's
82
+ * `surface` prop is on or this is set; absent, a host paints no surface axis at
83
+ * all. Resolved against the OS and the host's capability before it is painted —
84
+ * see `resolveSurface` — so the value stored is a preference, never the outcome.
85
+ */
86
+ surface?: ThemeSurface
87
+ /**
88
+ * The user's own Reduce Transparency switch, which forces the `opaque` surface the
89
+ * same way the operating system's setting does.
90
+ */
91
+ reduceTransparency?: boolean
60
92
  }
61
93
 
62
94
  /**
@@ -129,6 +161,32 @@ export type ThemeContextValue = {
129
161
  preview: (draft: Partial<ThemeSelection> | undefined) => void
130
162
  /** True while a {@link ThemeContextValue.preview} draft is on the document. */
131
163
  previewing: () => boolean
164
+ /**
165
+ * The roles `accent` paints over `variantId` (default: the variant in use), held
166
+ * to the provider's `accentContrastFloor`, for an element-scoped override with
167
+ * `paintAccentRoles` — a workspace that carries its own colour. `undefined` for
168
+ * `null`, for `theme`, for an id that resolves to nothing, and for a variant the
169
+ * registry does not hold: each means "keep the inherited accent".
170
+ */
171
+ accentRolesFor: (accent: string | null, variantId?: string) => AccentRoles | undefined
172
+ /**
173
+ * The surface the committed selection paints, after Reduce Transparency and the
174
+ * host's translucency capability. `opaque` when the axis is off. Follows the
175
+ * committed selection, not a preview, like {@link ThemeContextValue.variant}.
176
+ */
177
+ effectiveSurface: () => ThemeSurface
178
+ /**
179
+ * True while the operating system or the selection's `reduceTransparency` forces
180
+ * opaque surfaces. Always false when the surface axis is off, because nothing is
181
+ * then listening to the operating system.
182
+ */
183
+ reduceTransparencyActive: () => boolean
184
+ /**
185
+ * Step the three text sizes together, or put them back to the defaults: what the
186
+ * `fontZoom` prop's chords do, for a host that binds its own keys or a menu. Goes
187
+ * through {@link ThemeContextValue.setSelection}, so it persists and ends a preview.
188
+ */
189
+ zoomFonts: (intent: FontZoomIntent) => void
132
190
  }
133
191
 
134
192
  /** A selection with the `undefined` keys of a patch left out. */
@@ -158,7 +216,13 @@ export function mergeThemeSelection(
158
216
  fonts = normalizeAppearanceEditorFontSettings(merged.fonts, merged.font).settings
159
217
  }
160
218
 
161
- return { ...merged, fonts, font: fonts.ui.family }
219
+ return {
220
+ ...merged,
221
+ // One spelling for a custom colour, so equal choices compare and store equal.
222
+ accent: normalizeCustomAccent(merged.accent) ?? merged.accent,
223
+ fonts,
224
+ font: fonts.ui.family,
225
+ }
162
226
  }
163
227
 
164
228
  /**
@@ -211,6 +275,34 @@ export function resolveThemeState(
211
275
  }
212
276
  }
213
277
 
278
+ /**
279
+ * The variant whose terminal palette is pinned, or `undefined` when the terminal
280
+ * follows the interface theme.
281
+ *
282
+ * `theme`, an absent id and an id the registry does not hold all mean the interface
283
+ * palette: a terminal must never resolve to a blank or half-painted palette because a
284
+ * theme was deleted after it was pinned.
285
+ */
286
+ export function resolveTerminalVariant(
287
+ terminalThemeId: string | undefined,
288
+ registry: readonly ThemeVariant[]
289
+ ): ThemeVariant | undefined {
290
+ if (terminalThemeId === undefined || terminalThemeId === 'theme') return undefined
291
+ return registry.find((candidate) => candidate.id === terminalThemeId)
292
+ }
293
+
294
+ /**
295
+ * The font settings a zoom chord asks for: one pixel up or down on every axis, or
296
+ * the defaults for `reset`.
297
+ */
298
+ export function zoomedFontSettings(
299
+ selection: ThemeSelection,
300
+ intent: FontZoomIntent
301
+ ): AppearanceEditorFontSettings {
302
+ if (intent === 'reset') return { ...DEFAULT_APPEARANCE_EDITOR_FONT_SETTINGS }
303
+ return stepAppearanceFontSizes(selection.fonts, intent === 'in' ? 1 : -1)
304
+ }
305
+
214
306
  /**
215
307
  * The registry a provider resolves against: the catalogue, then the host's extras.
216
308
  *
@@ -223,3 +315,32 @@ export function mergeRegistry(
223
315
  ): readonly ThemeVariant[] {
224
316
  return extra && extra.length > 0 ? [...builtin, ...extra] : builtin
225
317
  }
318
+
319
+ /**
320
+ * The roles an accent paints over a variant, for {@link ThemeContextValue.accentRolesFor}.
321
+ *
322
+ * Resolved against the committed selection's theme pair, so an `ansi-*` accent means
323
+ * the same colour here as it does on `<html>`.
324
+ */
325
+ export function accentRolesForState(
326
+ accent: string | null,
327
+ variantId: string | undefined,
328
+ state: {
329
+ selection: ThemeSelection
330
+ variant: ThemeVariant
331
+ registry: readonly ThemeVariant[]
332
+ accentContrastFloor?: number
333
+ }
334
+ ): AccentRoles | undefined {
335
+ if (accent === null) return undefined
336
+ const variant =
337
+ variantId === undefined
338
+ ? state.variant
339
+ : state.registry.find((candidate) => candidate.id === variantId)
340
+ if (!variant) return undefined
341
+ return accentRolesForSelection(accent, variant.appearance, state.selection, {
342
+ background: variant.colors.background,
343
+ floor: state.accentContrastFloor,
344
+ variantId: variant.id,
345
+ })
346
+ }
package/src/index.ts CHANGED
@@ -117,6 +117,14 @@ export * from './components/composites/workspace-mark'
117
117
 
118
118
  /* --- Library ------------------------------------------------------------- */
119
119
  export { cn } from './lib/utils'
120
+ export {
121
+ isCustomAccent,
122
+ normalizeCustomAccent,
123
+ paintAccentRoles,
124
+ resolveAccentRoles,
125
+ type AccentRoles,
126
+ type AccentTarget,
127
+ } from './lib/accent-roles'
120
128
  export {
121
129
  controlInteractive,
122
130
  controlSize,
@@ -136,6 +144,7 @@ export {
136
144
  isThemeAccentId,
137
145
  resolveAccentPreset,
138
146
  resolveVariant,
147
+ terminalCssVariables,
139
148
  themeAccentsFor,
140
149
  themeById,
141
150
  themeCssVariables,
@@ -156,6 +165,31 @@ export {
156
165
  type ThemeTerminalPalette,
157
166
  type ThemeVariant,
158
167
  } from './lib/themes'
168
+ export { fontZoomShortcut, stepAppearanceFontSizes, type FontZoomIntent } from './lib/font-zoom'
169
+ export {
170
+ REDUCED_TRANSPARENCY_QUERY,
171
+ normalizeSurface,
172
+ resolveSurface,
173
+ resolveSurfaceState,
174
+ surfaceAlpha,
175
+ themeSurfaces,
176
+ type SurfaceEnvironment,
177
+ type SurfaceState,
178
+ } from './lib/theme-surface'
179
+ export {
180
+ defaultPreferenceStorage,
181
+ quarantineRecord,
182
+ readLegacyValue,
183
+ readVersionedRecord,
184
+ recoveryEnvelope,
185
+ recoveryKeyFor,
186
+ writeVersionedRecord,
187
+ type PreferenceStorage,
188
+ type RecoveryEnvelope,
189
+ type RecoveryReason,
190
+ type VersionedRead,
191
+ type VersionedRecordSchema,
192
+ } from './lib/preference-store'
159
193
  export { keyedRows, type KeyedRow } from './lib/keyed-rows'
160
194
  export { writeClipboardText } from './lib/clipboard'
161
195
  export { formatBytes, formatReleaseDate, plainTextFromMarkdown } from './lib/version-notes'
@@ -0,0 +1,338 @@
1
+ /*
2
+ * Copyright (c) 2026 Wing
3
+ * SPDX-License-Identifier: MIT
4
+ *
5
+ * The colour helpers, the contrast gate and the custom-accent grammar are
6
+ * substantially translated from Zeron's `Color` and `AccentRoles`
7
+ * (https://github.com/zeronsh/zeron), as Adea's accepted port of them in
8
+ * `packages/ui/src/components/appearance.ts` expresses it. Extracted here so the
9
+ * shared provider and Adea run one implementation; `@adea-ai/themes` owns the
10
+ * colour engine (parsing, contrast, repair, the preset values and their roles).
11
+ * See NOTICE.
12
+ */
13
+
14
+ import {
15
+ ACCENTS,
16
+ accentRoles,
17
+ accentValue,
18
+ primarySubtleCss,
19
+ type AccentPreset,
20
+ type AccentRoles as CatalogueAccentRoles,
21
+ } from '@adea-ai/themes'
22
+ import {
23
+ contrastRatio as canonicalContrastRatio,
24
+ oklchToHex,
25
+ parseColor as parseCanonicalColor,
26
+ repairContrast,
27
+ type Oklch,
28
+ } from '@adea-ai/themes/oklch'
29
+ import type { ThemeAppearance } from './themes'
30
+
31
+ /**
32
+ * Accent colour maths and the roles an accent paints.
33
+ *
34
+ * Three questions live here, and none of them touches the document or a signal:
35
+ *
36
+ * - **What colour is this?** `parseColor`, `colorToHex`, `contrastRatio` and
37
+ * `ensureContrast` are the hex helpers a host needs around an accent (an
38
+ * imported theme's colours, a colour field). Hex is the host preference format;
39
+ * the catalogue's colour engine does the maths.
40
+ * - **Is this accent readable on this surface?** The *contrast floor*. A preset is
41
+ * curated, so the catalogue already measured it against its own themes and the
42
+ * floor is the identity for it; a custom `#rrggbb` is anyone's choice, so it is
43
+ * raised until it clears the floor and can never ship unreadable. A host that
44
+ * imports themes (whose backgrounds the catalogue never saw) turns the floor on
45
+ * for presets too.
46
+ * - **Which custom properties does an accent own?** `AccentRoles`, written on
47
+ * `<html>` by the provider and onto one element by {@link paintAccentRoles}.
48
+ */
49
+
50
+ /* Hex is the host preference format; the shared Themes package owns colour maths. */
51
+
52
+ export type RgbColor = Readonly<{ r: number; g: number; b: number; a: number }>
53
+
54
+ const HEX = /^#([0-9a-fA-F]+)$/
55
+
56
+ /** Parse a hex colour (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`). `undefined` for anything else. */
57
+ export function parseColor(value: string): RgbColor | undefined {
58
+ const match = HEX.exec(value.trim())
59
+ const digits = match?.[1]
60
+ if (!digits) return undefined
61
+ if (digits.length === 3 || digits.length === 4) {
62
+ // Each nibble is doubled: `#abc` is `#aabbcc`.
63
+ const [r, g, b, a] = [...digits].map((digit) => Number.parseInt(digit + digit, 16))
64
+ return { r: r!, g: g!, b: b!, a: a ?? 255 }
65
+ }
66
+ if (digits.length === 6 || digits.length === 8) {
67
+ const byte = (index: number) => Number.parseInt(digits.slice(index, index + 2), 16)
68
+ return { r: byte(0), g: byte(2), b: byte(4), a: digits.length === 8 ? byte(6) : 255 }
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ function toHexByte(value: number): string {
74
+ return value.toString(16).padStart(2, '0')
75
+ }
76
+
77
+ /** `#rrggbb`, or `#rrggbbaa` when the colour is not opaque. */
78
+ export function colorToHex(color: RgbColor): string {
79
+ const opaque = `#${toHexByte(color.r)}${toHexByte(color.g)}${toHexByte(color.b)}`
80
+ return color.a === 255 ? opaque : `${opaque}${toHexByte(color.a)}`
81
+ }
82
+
83
+ function blendOver(foreground: RgbColor, background: RgbColor): RgbColor {
84
+ const alpha = foreground.a / 255
85
+ const blend = (front: number, back: number) => Math.round(front * alpha + back * (1 - alpha))
86
+ return {
87
+ r: blend(foreground.r, background.r),
88
+ g: blend(foreground.g, background.g),
89
+ b: blend(foreground.b, background.b),
90
+ a: 255,
91
+ }
92
+ }
93
+
94
+ function toCanonicalColor(color: RgbColor): Oklch | undefined {
95
+ return parseCanonicalColor(colorToHex({ ...color, a: 255 }))
96
+ }
97
+
98
+ /** WCAG contrast ratio between two colours. Alpha composites over the background first. */
99
+ export function contrastRatio(
100
+ foreground: RgbColor | string,
101
+ background: RgbColor | string
102
+ ): number {
103
+ const front = typeof foreground === 'string' ? parseColor(foreground) : foreground
104
+ const back = typeof background === 'string' ? parseColor(background) : background
105
+ if (!front || !back) return 0
106
+ const opaqueFront = front.a === 255 ? front : blendOver(front, back)
107
+ const foregroundColor = toCanonicalColor(opaqueFront)
108
+ const backgroundColor = toCanonicalColor(back)
109
+ if (!foregroundColor || !backgroundColor) return 0
110
+ return canonicalContrastRatio(foregroundColor, backgroundColor)
111
+ }
112
+
113
+ const WHITE: RgbColor = { r: 255, g: 255, b: 255, a: 255 }
114
+ const WHITE_OKLCH: Oklch = { l: 1, c: 0, h: 0 }
115
+
116
+ /**
117
+ * The nearest colour to `source` that reaches `minimum` against `canvas`, as hex.
118
+ *
119
+ * The catalogue's `repairContrast` is asked for a little more than the floor, in
120
+ * 0.001 steps up to 0.05, because the result is rounded to 8-bit hex and the
121
+ * rounded colour is what is measured: asking for exactly the floor can land a hair
122
+ * under it after rounding. If nothing in that range survives the rounding, white or
123
+ * black stands in, whichever clears the floor.
124
+ */
125
+ function repairedHex(source: Oklch, canvas: Oklch, minimum: number): string {
126
+ for (let margin = 0; margin <= 0.05; margin = Number((margin + 0.001).toFixed(3))) {
127
+ const repaired = repairContrast(source, canvas, minimum + margin)
128
+ if (!repaired.satisfied) continue
129
+ const hex = oklchToHex(repaired.color)
130
+ const rounded = parseCanonicalColor(hex)
131
+ if (rounded && canonicalContrastRatio(rounded, canvas) >= minimum) return hex
132
+ }
133
+ return canonicalContrastRatio(WHITE_OKLCH, canvas) >= minimum ? '#ffffff' : '#000000'
134
+ }
135
+
136
+ /** Preserve the host hex shape while delegating contrast repair to the catalogue. */
137
+ export function ensureContrast(color: RgbColor, background: RgbColor, minimum: number): RgbColor {
138
+ if (contrastRatio(color, background) >= minimum) return color
139
+
140
+ const renderedColor = color.a === 255 ? color : blendOver(color, background)
141
+ const source = toCanonicalColor(renderedColor)
142
+ const canvas = toCanonicalColor(background)
143
+ if (!source || !canvas) return color
144
+ return parseColor(repairedHex(source, canvas, minimum))!
145
+ }
146
+
147
+ /** The contrast an accent is held to against its surface unless the host sets another. */
148
+ export const DEFAULT_ACCENT_CONTRAST_FLOOR = 3
149
+
150
+ /**
151
+ * Normalise a custom accent against a surface: unparseable input is rejected
152
+ * (`undefined`), and any accepted colour is raised to `minimum` so an accent can
153
+ * never ship unreadable. Accepts every CSS hex length; for the stored grammar use
154
+ * {@link normalizeCustomAccent}.
155
+ */
156
+ export function normalizeAccentValue(
157
+ value: string,
158
+ background: string,
159
+ minimum: number = DEFAULT_ACCENT_CONTRAST_FLOOR
160
+ ): string | undefined {
161
+ const parsed = parseColor(value)
162
+ if (!parsed) return undefined
163
+ const backdrop = parseColor(background) ?? WHITE
164
+ return colorToHex(ensureContrast(parsed, backdrop, minimum))
165
+ }
166
+
167
+ /**
168
+ * The stored form of a custom accent: `#rrggbb`, six digits, opaque, lower case.
169
+ *
170
+ * Deliberately narrower than {@link parseColor}. A stored accent is compared,
171
+ * deduplicated and used as an attribute, so it has one spelling; a host that
172
+ * accepts `#abc` in a field expands it before storing. Anything else — a name, an
173
+ * `rgb()`, a short or alpha form, a non-string — is not an accent and returns
174
+ * `undefined`.
175
+ */
176
+ export function normalizeCustomAccent(value: unknown): string | undefined {
177
+ return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value)
178
+ ? value.toLowerCase()
179
+ : undefined
180
+ }
181
+
182
+ /** Whether a selection's accent is the custom `#rrggbb` form (any letter case). */
183
+ export function isCustomAccent(value: unknown): boolean {
184
+ return normalizeCustomAccent(value) !== undefined
185
+ }
186
+
187
+ /**
188
+ * The roles an accent paints, as CSS values: the catalogue's `AccentRoles`, so the
189
+ * provider, the generated stylesheet and a host's own paint cannot disagree about
190
+ * what a role is. `primarySubtle` is an expression over `var(--primary)`; the
191
+ * document writer never sets it (see {@link accentOverrideVariables}), an element
192
+ * paint must.
193
+ */
194
+ export type AccentRoles = CatalogueAccentRoles
195
+
196
+ /** What an accent resolves against: the surface it sits on and the theme it sits in. */
197
+ export type AccentTarget = {
198
+ /** The theme, or only its appearance. A theme gives the tint its measured strength. */
199
+ target: Parameters<typeof accentRoles>[1]
200
+ /** The surface the accent must read on. Without it nothing can be measured or repaired. */
201
+ background: string | undefined
202
+ /**
203
+ * The contrast to hold the accent to against `background`. Absent, a preset is
204
+ * painted verbatim and a custom colour is held to {@link DEFAULT_ACCENT_CONTRAST_FLOOR}.
205
+ */
206
+ floor?: number
207
+ /**
208
+ * The accents the selected theme pair carries (`ansi-blue`…), for an id that is
209
+ * neither a preset nor a colour.
210
+ */
211
+ themeAccents?: readonly AccentPreset[]
212
+ }
213
+
214
+ function appearanceOf(target: AccentTarget['target']): ThemeAppearance {
215
+ return typeof target === 'string' ? target : target.appearance
216
+ }
217
+
218
+ /**
219
+ * `value` raised to `floor` against `background` as hex, or `value` itself when it
220
+ * already clears the floor, is not a colour, or there is no surface to measure it on.
221
+ *
222
+ * Returning the argument itself is what makes the floor the identity for a preset
223
+ * that already reads: the caller compares and keeps the catalogue's own roles.
224
+ */
225
+ function gateColor(value: string, background: string | undefined, floor: number): string {
226
+ const source = parseCanonicalColor(value)
227
+ const canvas = background ? parseCanonicalColor(background) : undefined
228
+ if (!source || !canvas) return value
229
+ if (canonicalContrastRatio(source, canvas) >= floor) return value
230
+ return repairedHex(source, canvas, floor)
231
+ }
232
+
233
+ /**
234
+ * The roles for a preset (or a custom colour made into one), held to the floor.
235
+ *
236
+ * A preset that clears the floor keeps the catalogue's roles untouched. One that
237
+ * does not is repaired to the nearest passing colour and the whole role set is
238
+ * rebuilt from the repaired value, so the label, hover and ring follow it instead
239
+ * of describing a colour that is no longer on screen.
240
+ */
241
+ export function accentRolesForPreset(
242
+ preset: AccentPreset,
243
+ context: AccentTarget
244
+ ): AccentRoles | undefined {
245
+ const appearance = appearanceOf(context.target)
246
+ const requested = accentValue(preset, appearance)
247
+ if (!parseCanonicalColor(requested)) return undefined
248
+
249
+ const floor = context.floor
250
+ if (floor === undefined) return accentRoles(preset, context.target)
251
+
252
+ const gated = gateColor(requested, context.background, floor)
253
+ if (gated === requested) return accentRoles(preset, context.target)
254
+
255
+ const roles = accentRoles({ ...preset, light: gated, dark: gated }, context.target)
256
+ // The tint's strength is measured against the primary it will sit over, and the
257
+ // repaired primary is not one the theme's own measurement saw.
258
+ return { ...roles, primarySubtle: primarySubtleCss(context.target, '--primary', [gated]) }
259
+ }
260
+
261
+ /**
262
+ * Resolve an accent selection to its roles, or `undefined` when it overrides nothing.
263
+ *
264
+ * `undefined` is "use the theme's own primary": the literal `theme`, an id that is
265
+ * neither a preset, an offered theme accent nor a colour, and a colour that does not
266
+ * parse. A host that wants one of those to mean something else decides before
267
+ * asking (Adea's codec maps a stored `theme` to its default preset when it reads).
268
+ */
269
+ export function resolveAccentRoles(accent: string, context: AccentTarget): AccentRoles | undefined {
270
+ if (accent === 'theme') return undefined
271
+
272
+ const custom = normalizeCustomAccent(accent)
273
+ if (custom) {
274
+ const floor = context.floor ?? DEFAULT_ACCENT_CONTRAST_FLOOR
275
+ return accentRolesForPreset(
276
+ { id: 'custom', label: 'Custom', description: '', light: custom, dark: custom },
277
+ { ...context, floor }
278
+ )
279
+ }
280
+
281
+ const preset =
282
+ ACCENTS.find((candidate) => candidate.id === accent) ??
283
+ context.themeAccents?.find((candidate) => candidate.id === accent)
284
+ return preset ? accentRolesForPreset(preset, context) : undefined
285
+ }
286
+
287
+ /**
288
+ * The custom properties an accent override owns on `<html>`.
289
+ *
290
+ * `--primary-subtle` is deliberately not among them. It is a `color-mix()` over
291
+ * `var(--primary)`, so on the same element it follows an accent by itself, and its
292
+ * strength is the theme's, measured to keep body text at 4.5:1 on the tint.
293
+ * Writing one here would replace that measurement with the argument's.
294
+ */
295
+ export function accentOverrideVariables(roles: AccentRoles): Record<string, string> {
296
+ return {
297
+ '--primary': roles.primary,
298
+ '--primary-foreground': roles.primaryForeground,
299
+ '--primary-hover': roles.primaryHover,
300
+ '--ring': roles.ring,
301
+ }
302
+ }
303
+
304
+ /** The custom properties {@link paintAccentRoles} writes and clears. */
305
+ export const ACCENT_PAINT_PROPERTIES = [
306
+ '--primary',
307
+ '--primary-foreground',
308
+ '--primary-hover',
309
+ '--primary-subtle',
310
+ '--ring',
311
+ ] as const
312
+
313
+ /**
314
+ * Scope an accent to one element and its descendants.
315
+ *
316
+ * Unlike the document, an element must write `--primary-subtle` too. A custom
317
+ * property that uses `var()` is resolved where it is declared, so the tint inherited
318
+ * from `<html>` is already a mix over *`<html>`'s* primary and an element that only
319
+ * overrides `--primary` would keep the old tint. Declared here, the same expression
320
+ * resolves against this element's primary.
321
+ *
322
+ * `undefined` clears every property, and the element inherits the document's accent
323
+ * again.
324
+ */
325
+ export function paintAccentRoles(
326
+ element: { style: Pick<CSSStyleDeclaration, 'setProperty' | 'removeProperty'> },
327
+ roles: AccentRoles | undefined
328
+ ): void {
329
+ if (!roles) {
330
+ for (const property of ACCENT_PAINT_PROPERTIES) element.style.removeProperty(property)
331
+ return
332
+ }
333
+ element.style.setProperty('--primary', roles.primary)
334
+ element.style.setProperty('--primary-foreground', roles.primaryForeground)
335
+ element.style.setProperty('--primary-hover', roles.primaryHover)
336
+ element.style.setProperty('--primary-subtle', roles.primarySubtle)
337
+ element.style.setProperty('--ring', roles.ring)
338
+ }
@@ -0,0 +1,74 @@
1
+ /*
2
+ * Copyright (c) 2026 Wing
3
+ * Licensed under the MIT License.
4
+ *
5
+ * The platform zoom chords, mapped onto Adea's three text roles.
6
+ *
7
+ * macOS (and Windows/Linux) zoom shortcuts — Cmd/Ctrl with `=`, `+`, `-`, or
8
+ * `0` — normally scale the browser's page zoom. In Adea every text element is
9
+ * already tied to one of the three appearance font tiers (the UI tier drives
10
+ * the whole `text-*` scale through `--font-ui-scale`; content and code roles
11
+ * scale through their own tokens), so stepping those tiers reproduces zoom
12
+ * while keeping the app inside its tokenized, persisted type system: the page
13
+ * layout, menus, sheets, and terminals reflow live from the same projection
14
+ * the appearance editor writes.
15
+ *
16
+ * Translated from Adea's accepted port (`packages/ui/src/components/font-zoom.ts`) so
17
+ * the shared provider and Adea run one implementation. See NOTICE.
18
+ */
19
+ import {
20
+ APPEARANCE_EDITOR_FONT_AXES,
21
+ APPEARANCE_EDITOR_FONT_SIZE_MAX,
22
+ APPEARANCE_EDITOR_FONT_SIZE_MIN,
23
+ normalizeAppearanceEditorFontSettings,
24
+ type AppearanceEditorFontSettings,
25
+ } from '#lib/appearance-font-settings'
26
+
27
+ /** What the user's zoom chord asked for. */
28
+ export type FontZoomIntent = 'in' | 'out' | 'reset'
29
+
30
+ /**
31
+ * Classify the platform zoom chord: Cmd (macOS) or Ctrl (other platforms)
32
+ * with `=`/`+` zooming in, `-` zooming out, and `0` resetting to the System
33
+ * defaults. Unmodified keys, Alt-modified chords, and IME composition never
34
+ * count, so typing and text editing are untouched.
35
+ */
36
+ export function fontZoomShortcut(event: {
37
+ key: string
38
+ metaKey: boolean
39
+ ctrlKey: boolean
40
+ altKey: boolean
41
+ isComposing?: boolean
42
+ }): FontZoomIntent | undefined {
43
+ if (event.isComposing || event.altKey) return undefined
44
+ if (!(event.metaKey || event.ctrlKey)) return undefined
45
+ if (event.key === '=' || event.key === '+') return 'in'
46
+ if (event.key === '-') return 'out'
47
+ if (event.key === '0') return 'reset'
48
+ return undefined
49
+ }
50
+
51
+ /** Clamp a pixel size to the shared range the appearance editor enforces. */
52
+ function clampFontSize(size: number): number {
53
+ return Math.min(APPEARANCE_EDITOR_FONT_SIZE_MAX, Math.max(APPEARANCE_EDITOR_FONT_SIZE_MIN, size))
54
+ }
55
+
56
+ /**
57
+ * Step every text tier by `delta` pixels, clamped to that range. The axes move
58
+ * together — that is what makes the chord read as zoom — and each keeps its
59
+ * own family choice.
60
+ */
61
+ export function stepAppearanceFontSizes(
62
+ fonts: unknown,
63
+ delta: 1 | -1
64
+ ): AppearanceEditorFontSettings {
65
+ const current = normalizeAppearanceEditorFontSettings(fonts).settings
66
+ return {
67
+ ui: { ...current.ui, size: clampFontSize(current.ui.size + delta) },
68
+ content: { ...current.content, size: clampFontSize(current.content.size + delta) },
69
+ code: { ...current.code, size: clampFontSize(current.code.size + delta) },
70
+ }
71
+ }
72
+
73
+ /** Every axis name, for callers that want to report or test the stepping. */
74
+ export const FONT_ZOOM_AXES = APPEARANCE_EDITOR_FONT_AXES