@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.
- package/dist/NOTICE +58 -0
- package/dist/components/theme/index.d.ts +1 -1
- package/dist/components/theme/index.d.ts.map +1 -1
- package/dist/components/theme/index.js +4 -3
- package/dist/components/theme/theme-document.d.ts +31 -1
- package/dist/components/theme/theme-document.d.ts.map +1 -1
- package/dist/components/theme/theme-document.js +25 -5
- package/dist/components/theme/theme-document.js.map +1 -1
- package/dist/components/theme/theme-persistence.d.ts +87 -0
- package/dist/components/theme/theme-persistence.d.ts.map +1 -1
- package/dist/components/theme/theme-persistence.js +65 -3
- package/dist/components/theme/theme-persistence.js.map +1 -1
- package/dist/components/theme/theme-provider.d.ts +49 -40
- package/dist/components/theme/theme-provider.d.ts.map +1 -1
- package/dist/components/theme/theme-provider.js +67 -52
- package/dist/components/theme/theme-provider.js.map +1 -1
- package/dist/components/theme/theme-script.d.ts +100 -0
- package/dist/components/theme/theme-script.d.ts.map +1 -0
- package/dist/components/theme/theme-script.js +57 -0
- package/dist/components/theme/theme-script.js.map +1 -0
- package/dist/components/theme/theme-state.d.ts +85 -2
- package/dist/components/theme/theme-state.d.ts.map +1 -1
- package/dist/components/theme/theme-state.js +41 -2
- package/dist/components/theme/theme-state.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/lib/accent-roles.d.ts +124 -0
- package/dist/lib/accent-roles.d.ts.map +1 -0
- package/dist/lib/accent-roles.js +163 -0
- package/dist/lib/accent-roles.js.map +1 -0
- package/dist/lib/font-zoom.d.ts +25 -0
- package/dist/lib/font-zoom.d.ts.map +1 -0
- package/dist/lib/font-zoom.js +45 -0
- package/dist/lib/font-zoom.js.map +1 -0
- package/dist/lib/preference-store.d.ts +126 -0
- package/dist/lib/preference-store.d.ts.map +1 -0
- package/dist/lib/preference-store.js +126 -0
- package/dist/lib/preference-store.js.map +1 -0
- package/dist/lib/theme-surface.d.ts +71 -0
- package/dist/lib/theme-surface.d.ts.map +1 -0
- package/dist/lib/theme-surface.js +62 -0
- package/dist/lib/theme-surface.js.map +1 -0
- package/dist/lib/themes.d.ts +32 -1
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +34 -13
- package/dist/lib/themes.js.map +1 -1
- package/dist/lib/tokens.d.ts.map +1 -1
- package/dist/lib/tokens.js +15 -0
- package/dist/lib/tokens.js.map +1 -1
- package/dist/r/lib.json +20 -0
- package/dist/r/registry.json +25 -0
- package/dist/r/src/components/theme/index.ts +6 -0
- package/dist/r/src/components/theme/theme-document.ts +69 -4
- package/dist/r/src/components/theme/theme-persistence.ts +172 -2
- package/dist/r/src/components/theme/theme-provider.tsx +137 -51
- package/dist/r/src/components/theme/theme-script.ts +201 -0
- package/dist/r/src/components/theme/theme-state.ts +124 -3
- package/dist/r/src/lib/accent-roles.ts +338 -0
- package/dist/r/src/lib/font-zoom.ts +74 -0
- package/dist/r/src/lib/preference-store.ts +216 -0
- package/dist/r/src/lib/theme-surface.ts +110 -0
- package/dist/r/src/lib/themes.ts +58 -19
- package/dist/r/src/lib/tokens.ts +17 -0
- package/dist/r/src/styles/theme.css +38 -0
- package/dist/r/theming.json +5 -0
- package/package.json +1 -1
- package/registry.json +25 -0
- package/src/components/theme/index.ts +6 -0
- package/src/components/theme/theme-document.ts +69 -4
- package/src/components/theme/theme-persistence.ts +172 -2
- package/src/components/theme/theme-provider.tsx +137 -51
- package/src/components/theme/theme-script.ts +201 -0
- package/src/components/theme/theme-state.ts +124 -3
- package/src/index.ts +34 -0
- package/src/lib/accent-roles.ts +338 -0
- package/src/lib/font-zoom.ts +74 -0
- package/src/lib/preference-store.ts +216 -0
- package/src/lib/theme-surface.ts +110 -0
- package/src/lib/themes.ts +58 -19
- package/src/lib/tokens.ts +17 -0
- package/src/styles/theme.css +38 -0
|
@@ -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 './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
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Wing
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*
|
|
5
|
+
* The recovery behaviour here (quarantine before any write, the envelope shape, the
|
|
6
|
+
* legacy key that is read once and never deleted) is Adea's accepted port of Zeron's
|
|
7
|
+
* preference store (https://github.com/zeronsh/zeron),
|
|
8
|
+
* `packages/ui/src/components/appearance.ts`, moved here so the shared provider and
|
|
9
|
+
* Adea run one implementation. See NOTICE.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A versioned preference store: read one JSON document, refuse to guess about what
|
|
14
|
+
* it cannot read, and keep what it refused.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here knows what a preference *is*. A host hands the engine a key, a
|
|
17
|
+
* version and (through `createVersionedPersistence`) a codec; the engine owns only
|
|
18
|
+
* the part every versioned store gets wrong the same way, which is what happens to
|
|
19
|
+
* a document that is not the one the code expects:
|
|
20
|
+
*
|
|
21
|
+
* - A document that is not JSON, or not a record, or carries a version the code
|
|
22
|
+
* does not know (usually a newer build's) is **quarantined, byte for byte, into a
|
|
23
|
+
* recovery key** before anything else happens. The store then answers as if
|
|
24
|
+
* nothing were stored. Because the quarantine runs at read time, ahead of any
|
|
25
|
+
* write, saving valid preferences later cannot destroy the unread original.
|
|
26
|
+
* - A legacy key is consulted only when the main key is *absent*, never when it is
|
|
27
|
+
* unreadable: a corrupt main document is not "no preference", and falling back to
|
|
28
|
+
* an older, staler key would quietly restore a choice the user has since changed.
|
|
29
|
+
* The legacy key is never deleted.
|
|
30
|
+
* - Blocked storage (a sandboxed iframe, a privacy mode) throws on the mere access
|
|
31
|
+
* of the `localStorage` global. Every path here treats that as "nothing stored".
|
|
32
|
+
*
|
|
33
|
+
* The reads and writes are against a {@link PreferenceStorage}, so the rules run in a
|
|
34
|
+
* test without a DOM.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** The part of `Storage` the engine uses. */
|
|
38
|
+
export type PreferenceStorage = Pick<Storage, 'getItem' | 'setItem'>
|
|
39
|
+
|
|
40
|
+
/** Why an unread document was quarantined. */
|
|
41
|
+
export type RecoveryReason = 'corrupt_json' | 'unsupported_record'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What is written to the recovery key.
|
|
45
|
+
*
|
|
46
|
+
* The shape is a compatibility surface: Adea's stored envelopes already have it, and
|
|
47
|
+
* a tool that inspects a user's storage reads it. Do not rename a field.
|
|
48
|
+
*/
|
|
49
|
+
export type RecoveryEnvelope = Readonly<{
|
|
50
|
+
schemaVersion: 1
|
|
51
|
+
reason: RecoveryReason
|
|
52
|
+
/** An ISO-8601 timestamp of the capture; refreshed by each read of the same bytes. */
|
|
53
|
+
capturedAt: string
|
|
54
|
+
/** The document exactly as it was stored. */
|
|
55
|
+
raw: string
|
|
56
|
+
}>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The preference store, or nothing.
|
|
60
|
+
*
|
|
61
|
+
* The `typeof` guard sits inside the `try` for a reason: where storage is blocked,
|
|
62
|
+
* merely *reading* the `localStorage` global throws, so a guard in front of the `try`
|
|
63
|
+
* is itself the crash.
|
|
64
|
+
*/
|
|
65
|
+
export function defaultPreferenceStorage(): PreferenceStorage | undefined {
|
|
66
|
+
try {
|
|
67
|
+
return typeof localStorage === 'undefined' ? undefined : localStorage
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The envelope for a document, stamped with `now`. */
|
|
74
|
+
export function recoveryEnvelope(
|
|
75
|
+
raw: string,
|
|
76
|
+
reason: RecoveryReason,
|
|
77
|
+
now: Date = new Date()
|
|
78
|
+
): RecoveryEnvelope {
|
|
79
|
+
return { schemaVersion: 1, reason, capturedAt: now.toISOString(), raw }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Quarantine an unread document into `recoveryKey`.
|
|
84
|
+
*
|
|
85
|
+
* Idempotent, so re-reading the same unread value refreshes the capture without
|
|
86
|
+
* losing it. Best effort: a store that cannot be written leaves the caller to fail
|
|
87
|
+
* closed on the defaults, which is the same outcome as not having a recovery key at
|
|
88
|
+
* all. Never throws.
|
|
89
|
+
*
|
|
90
|
+
* Exported so a host quarantines its *other* documents (a theme library, say) with
|
|
91
|
+
* the same envelope. Give each document its own `recoveryKey`: two documents that
|
|
92
|
+
* share one overwrite each other's capture.
|
|
93
|
+
*/
|
|
94
|
+
export function quarantineRecord(
|
|
95
|
+
storage: PreferenceStorage,
|
|
96
|
+
recoveryKey: string,
|
|
97
|
+
raw: string,
|
|
98
|
+
reason: RecoveryReason,
|
|
99
|
+
now: () => Date = () => new Date()
|
|
100
|
+
): void {
|
|
101
|
+
try {
|
|
102
|
+
storage.setItem(recoveryKey, JSON.stringify(recoveryEnvelope(raw, reason, now())))
|
|
103
|
+
} catch {
|
|
104
|
+
// Quarantine is best effort; the active preference still fails closed.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Where a versioned document lives and which version the code understands. */
|
|
109
|
+
export type VersionedRecordSchema = {
|
|
110
|
+
/** The storage key of the document. */
|
|
111
|
+
key: string
|
|
112
|
+
/**
|
|
113
|
+
* The only `version` the code reads. Absent, the document is unversioned and any
|
|
114
|
+
* JSON object is accepted; present, a record whose top-level `version` is not
|
|
115
|
+
* exactly this number is unsupported (newer or older), not "close enough".
|
|
116
|
+
*/
|
|
117
|
+
version?: number
|
|
118
|
+
/** Where an unread document is kept. Default `${key}.recovery`. */
|
|
119
|
+
recoveryKey?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What reading the document found.
|
|
124
|
+
*
|
|
125
|
+
* - `absent`: nothing is stored under the key.
|
|
126
|
+
* - `record`: a JSON object this code can read; `raw` is the stored text.
|
|
127
|
+
* - `quarantined`: something was stored and cannot be read. It has already been
|
|
128
|
+
* written to the recovery key.
|
|
129
|
+
* - `unavailable`: the store itself could not be read.
|
|
130
|
+
*/
|
|
131
|
+
export type VersionedRead =
|
|
132
|
+
| { status: 'absent' }
|
|
133
|
+
| { status: 'record'; record: Readonly<Record<string, unknown>>; raw: string }
|
|
134
|
+
| { status: 'quarantined'; reason: RecoveryReason; raw: string }
|
|
135
|
+
| { status: 'unavailable' }
|
|
136
|
+
|
|
137
|
+
/** The default recovery key for a document key. */
|
|
138
|
+
export function recoveryKeyFor(schema: VersionedRecordSchema): string {
|
|
139
|
+
return schema.recoveryKey ?? `${schema.key}.recovery`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Read the document under `schema.key`.
|
|
144
|
+
*
|
|
145
|
+
* The classification is the contract: a value that is not valid JSON, or is JSON but
|
|
146
|
+
* not an object (`null`, a number, a string), is `corrupt_json`; an object (or array)
|
|
147
|
+
* that is not a record of the supported version is `unsupported_record`. Both are
|
|
148
|
+
* quarantined before this returns. An empty string is a stored value, so it is
|
|
149
|
+
* corrupt rather than absent.
|
|
150
|
+
*/
|
|
151
|
+
export function readVersionedRecord(
|
|
152
|
+
storage: PreferenceStorage,
|
|
153
|
+
schema: VersionedRecordSchema,
|
|
154
|
+
now?: () => Date
|
|
155
|
+
): VersionedRead {
|
|
156
|
+
let raw: string | null
|
|
157
|
+
try {
|
|
158
|
+
raw = storage.getItem(schema.key)
|
|
159
|
+
} catch {
|
|
160
|
+
return { status: 'unavailable' }
|
|
161
|
+
}
|
|
162
|
+
if (raw === null) return { status: 'absent' }
|
|
163
|
+
|
|
164
|
+
const recoveryKey = recoveryKeyFor(schema)
|
|
165
|
+
let parsed: unknown
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(raw)
|
|
168
|
+
} catch {
|
|
169
|
+
quarantineRecord(storage, recoveryKey, raw, 'corrupt_json', now)
|
|
170
|
+
return { status: 'quarantined', reason: 'corrupt_json', raw }
|
|
171
|
+
}
|
|
172
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
173
|
+
quarantineRecord(storage, recoveryKey, raw, 'corrupt_json', now)
|
|
174
|
+
return { status: 'quarantined', reason: 'corrupt_json', raw }
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
Array.isArray(parsed) ||
|
|
178
|
+
(schema.version !== undefined && (parsed as { version?: unknown }).version !== schema.version)
|
|
179
|
+
) {
|
|
180
|
+
quarantineRecord(storage, recoveryKey, raw, 'unsupported_record', now)
|
|
181
|
+
return { status: 'quarantined', reason: 'unsupported_record', raw }
|
|
182
|
+
}
|
|
183
|
+
return { status: 'record', record: parsed as Record<string, unknown>, raw }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The legacy single-key value, or `null`.
|
|
188
|
+
*
|
|
189
|
+
* Only the caller decides whether to look: the engine's rule is to ask when the main
|
|
190
|
+
* key is `absent`, not when it was quarantined.
|
|
191
|
+
*/
|
|
192
|
+
export function readLegacyValue(storage: PreferenceStorage, legacyKey: string): string | null {
|
|
193
|
+
try {
|
|
194
|
+
return storage.getItem(legacyKey)
|
|
195
|
+
} catch {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Write `document` to `key` as JSON. Returns whether it was stored; a full or blocked
|
|
202
|
+
* store returns `false` and never throws, because losing a preference must not break
|
|
203
|
+
* the switch that tried to save it.
|
|
204
|
+
*/
|
|
205
|
+
export function writeVersionedRecord(
|
|
206
|
+
storage: PreferenceStorage,
|
|
207
|
+
key: string,
|
|
208
|
+
document: unknown
|
|
209
|
+
): boolean {
|
|
210
|
+
try {
|
|
211
|
+
storage.setItem(key, JSON.stringify(document))
|
|
212
|
+
return true
|
|
213
|
+
} catch {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
}
|