@adea-ai/ui 0.124.0 → 0.125.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 +18 -0
- package/dist/components/theme/theme-document.d.ts +6 -0
- package/dist/components/theme/theme-document.d.ts.map +1 -1
- package/dist/components/theme/theme-document.js +7 -2
- package/dist/components/theme/theme-document.js.map +1 -1
- package/dist/components/theme/theme-persistence.d.ts.map +1 -1
- package/dist/components/theme/theme-persistence.js +2 -1
- package/dist/components/theme/theme-persistence.js.map +1 -1
- package/dist/components/theme/theme-provider.d.ts +9 -0
- package/dist/components/theme/theme-provider.d.ts.map +1 -1
- package/dist/components/theme/theme-provider.js +13 -3
- package/dist/components/theme/theme-provider.js.map +1 -1
- package/dist/components/theme/theme-state.d.ts +27 -2
- package/dist/components/theme/theme-state.d.ts.map +1 -1
- package/dist/components/theme/theme-state.js +20 -2
- package/dist/components/theme/theme-state.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- 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/themes.d.ts +23 -1
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +23 -13
- package/dist/lib/themes.js.map +1 -1
- package/dist/r/lib.json +5 -0
- package/dist/r/registry.json +5 -0
- package/dist/r/src/components/theme/theme-document.ts +18 -2
- package/dist/r/src/components/theme/theme-persistence.ts +5 -2
- package/dist/r/src/components/theme/theme-provider.tsx +22 -1
- package/dist/r/src/components/theme/theme-state.ts +52 -3
- package/dist/r/src/lib/accent-roles.ts +338 -0
- package/dist/r/src/lib/themes.ts +44 -19
- package/package.json +1 -1
- package/registry.json +5 -0
- package/src/components/theme/theme-document.ts +18 -2
- package/src/components/theme/theme-persistence.ts +5 -2
- package/src/components/theme/theme-provider.tsx +22 -1
- package/src/components/theme/theme-state.ts +52 -3
- package/src/index.ts +8 -0
- package/src/lib/accent-roles.ts +338 -0
- package/src/lib/themes.ts +44 -19
|
@@ -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
|
+
}
|
package/dist/r/src/lib/themes.ts
CHANGED
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
import {
|
|
37
37
|
ACCENTS,
|
|
38
38
|
accentForeground,
|
|
39
|
-
accentRoles,
|
|
40
39
|
chartSeries,
|
|
41
40
|
getAccent,
|
|
42
41
|
getTheme,
|
|
@@ -58,6 +57,7 @@ import {
|
|
|
58
57
|
} from '@adea-ai/themes'
|
|
59
58
|
import { shadcnVariables } from '@adea-ai/themes/adapters/shadcn'
|
|
60
59
|
import { oklchToHex, parseColor } from '@adea-ai/themes/oklch'
|
|
60
|
+
import { accentOverrideVariables, resolveAccentRoles, type AccentRoles } from './accent-roles'
|
|
61
61
|
import { isThemeAccentId } from './theme-accents'
|
|
62
62
|
|
|
63
63
|
export { isThemeAccentId } from './theme-accents'
|
|
@@ -566,26 +566,51 @@ export function accentVariables(
|
|
|
566
566
|
pair: { lightThemeId: string; darkThemeId: string } = {
|
|
567
567
|
lightThemeId: defaultLightThemeId,
|
|
568
568
|
darkThemeId: defaultDarkThemeId,
|
|
569
|
-
}
|
|
569
|
+
},
|
|
570
|
+
options: AccentVariableOptions = {}
|
|
570
571
|
): Record<string, string> | undefined {
|
|
572
|
+
const roles = accentRolesForSelection(accentId, appearance, pair, options)
|
|
573
|
+
return roles ? accentOverrideVariables(roles) : undefined
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** What an accent is held to when it is resolved against a surface. */
|
|
577
|
+
export type AccentVariableOptions = {
|
|
578
|
+
/** The surface the accent must read on: the variant's `colors.background`. */
|
|
579
|
+
background?: string
|
|
580
|
+
/**
|
|
581
|
+
* The contrast floor against `background`. Absent, a preset is painted verbatim
|
|
582
|
+
* and only a custom `#rrggbb` is held to the default floor.
|
|
583
|
+
*/
|
|
584
|
+
floor?: number
|
|
585
|
+
/** The variant the accent is applied to, so the tint takes its measured strength. */
|
|
586
|
+
variantId?: string
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* An accent selection resolved to the full {@link AccentRoles}, against a theme
|
|
591
|
+
* pair. {@link accentVariables} is the document half of this; an element-scoped
|
|
592
|
+
* paint (`paintAccentRoles`) wants all of it, including the tint.
|
|
593
|
+
*/
|
|
594
|
+
export function accentRolesForSelection(
|
|
595
|
+
accentId: string,
|
|
596
|
+
appearance: ThemeAppearance,
|
|
597
|
+
pair: { lightThemeId: string; darkThemeId: string } = {
|
|
598
|
+
lightThemeId: defaultLightThemeId,
|
|
599
|
+
darkThemeId: defaultDarkThemeId,
|
|
600
|
+
},
|
|
601
|
+
options: AccentVariableOptions = {}
|
|
602
|
+
): AccentRoles | undefined {
|
|
571
603
|
if (accentId === 'theme') return undefined
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
const roles = accentRoles(preset, appearance)
|
|
583
|
-
return {
|
|
584
|
-
'--primary': roles.primary,
|
|
585
|
-
'--primary-foreground': roles.primaryForeground,
|
|
586
|
-
'--primary-hover': roles.primaryHover,
|
|
587
|
-
'--ring': roles.ring,
|
|
588
|
-
}
|
|
604
|
+
const record = options.variantId ? getTheme(options.variantId) : undefined
|
|
605
|
+
return resolveAccentRoles(accentId, {
|
|
606
|
+
target: record ?? appearance,
|
|
607
|
+
background: options.background,
|
|
608
|
+
floor: options.floor,
|
|
609
|
+
// Only an `ansi-*` id needs the pair's offering, and measuring it is not free.
|
|
610
|
+
themeAccents: isThemeAccentId(accentId)
|
|
611
|
+
? themeAccentsFor(pair.lightThemeId, pair.darkThemeId)
|
|
612
|
+
: undefined,
|
|
613
|
+
})
|
|
589
614
|
}
|
|
590
615
|
|
|
591
616
|
/**
|
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -2644,6 +2644,11 @@
|
|
|
2644
2644
|
],
|
|
2645
2645
|
"registryDependencies": [],
|
|
2646
2646
|
"files": [
|
|
2647
|
+
{
|
|
2648
|
+
"path": "src/lib/accent-roles.ts",
|
|
2649
|
+
"type": "registry:lib",
|
|
2650
|
+
"target": "lib/accent-roles.ts"
|
|
2651
|
+
},
|
|
2647
2652
|
{
|
|
2648
2653
|
"path": "src/lib/appearance-font-settings.ts",
|
|
2649
2654
|
"type": "registry:lib",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ThemeAppearance,
|
|
9
9
|
type ThemeVariant,
|
|
10
10
|
} from '#lib/themes'
|
|
11
|
+
import { isCustomAccent } from '#lib/accent-roles'
|
|
11
12
|
import { resolvedDensity, type ThemeSelection } from './theme-state'
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -35,6 +36,12 @@ export type ThemeDocumentOptions = {
|
|
|
35
36
|
* must return the full map; a name it drops is not written.
|
|
36
37
|
*/
|
|
37
38
|
transformTokens?: (tokens: Record<string, string>) => Record<string, string>
|
|
39
|
+
/**
|
|
40
|
+
* The contrast an accent is held to against the variant's background. Absent, a
|
|
41
|
+
* preset is painted verbatim (the catalogue measured it for its own themes) and
|
|
42
|
+
* only a custom `#rrggbb` is held to the default floor of 3:1.
|
|
43
|
+
*/
|
|
44
|
+
accentContrastFloor?: number
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/**
|
|
@@ -53,7 +60,11 @@ export function themeDocumentTokens(
|
|
|
53
60
|
options: ThemeDocumentOptions = {}
|
|
54
61
|
): { tokens: Record<string, string>; accented: boolean } {
|
|
55
62
|
const properties = { ...themeCssVariables(state.variant) }
|
|
56
|
-
const accent = accentVariables(state.selection.accent, state.appearance, state.selection
|
|
63
|
+
const accent = accentVariables(state.selection.accent, state.appearance, state.selection, {
|
|
64
|
+
background: state.variant.colors.background,
|
|
65
|
+
floor: options.accentContrastFloor,
|
|
66
|
+
variantId: state.variant.id,
|
|
67
|
+
})
|
|
57
68
|
if (accent) Object.assign(properties, accent)
|
|
58
69
|
return {
|
|
59
70
|
tokens: options.transformTokens ? options.transformTokens(properties) : properties,
|
|
@@ -92,8 +103,13 @@ export function applyThemeToRoot(
|
|
|
92
103
|
root.dataset['appearance'] = state.appearance
|
|
93
104
|
root.style.colorScheme = state.appearance
|
|
94
105
|
|
|
106
|
+
// A custom colour has no stylesheet block to select, and its value is the
|
|
107
|
+
// attribute's worst possible content, so it is reported as `custom`.
|
|
95
108
|
if (!accented) root.removeAttribute('data-accent')
|
|
96
|
-
else
|
|
109
|
+
else
|
|
110
|
+
root.dataset['accent'] = isCustomAccent(state.selection.accent)
|
|
111
|
+
? 'custom'
|
|
112
|
+
: state.selection.accent
|
|
97
113
|
|
|
98
114
|
applyAppearanceFontSettings(
|
|
99
115
|
root,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeCustomAccent } from '#lib/accent-roles'
|
|
1
2
|
import { accentPresets } from '#lib/tokens'
|
|
2
3
|
import { normalizeAppearanceEditorFontSettings } from '#lib/appearance-font-settings'
|
|
3
4
|
import { defaultDarkThemeId, defaultLightThemeId, isThemeAccentId, themeById } from '#lib/themes'
|
|
@@ -67,13 +68,15 @@ function readSelection(storageKey: string): ThemeSelection {
|
|
|
67
68
|
: defaultDarkThemeId,
|
|
68
69
|
// A theme accent is kept whenever it names a slot, even one the stored pair
|
|
69
70
|
// cannot offer: the id is role-shaped so it survives a theme switch, and an
|
|
70
|
-
// unoffered slot already resolves to the theme's own primary when applied.
|
|
71
|
+
// unoffered slot already resolves to the theme's own primary when applied. A
|
|
72
|
+
// custom `#rrggbb` is kept in its one stored spelling; its readability is
|
|
73
|
+
// settled when it is painted, not when it is read.
|
|
71
74
|
accent:
|
|
72
75
|
typeof parsed.accent === 'string' &&
|
|
73
76
|
(accentPresets.some((preset) => preset.id === parsed.accent) ||
|
|
74
77
|
isThemeAccentId(parsed.accent))
|
|
75
78
|
? parsed.accent
|
|
76
|
-
: defaultThemeSelection.accent,
|
|
79
|
+
: (normalizeCustomAccent(parsed.accent) ?? defaultThemeSelection.accent),
|
|
77
80
|
font: fonts.ui.family,
|
|
78
81
|
fonts,
|
|
79
82
|
// Every axis has to be read back here, or it is lost twice: the provider's
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ThemePersistence,
|
|
18
18
|
} from './theme-persistence'
|
|
19
19
|
import {
|
|
20
|
+
accentRolesForState,
|
|
20
21
|
initialThemeSelection,
|
|
21
22
|
mergeRegistry,
|
|
22
23
|
mergeThemeSelection,
|
|
@@ -111,6 +112,15 @@ export type ThemeProviderProps = {
|
|
|
111
112
|
* element to `<head>` for two frames on every change.
|
|
112
113
|
*/
|
|
113
114
|
disableTransitionOnChange?: boolean
|
|
115
|
+
/**
|
|
116
|
+
* The contrast ratio an accent is held to against the variant's background,
|
|
117
|
+
* repaired to the nearest passing colour when it falls short. Absent, presets
|
|
118
|
+
* are painted verbatim — the catalogue measured them for its own themes — and
|
|
119
|
+
* only a custom `#rrggbb` is held to 3:1. A host that adds themes the catalogue
|
|
120
|
+
* never measured passes 3 to hold the presets to it as well; for the catalogue's
|
|
121
|
+
* own themes the floor changes no preset.
|
|
122
|
+
*/
|
|
123
|
+
accentContrastFloor?: number
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
export function ThemeProvider(props: ThemeProviderProps) {
|
|
@@ -123,6 +133,7 @@ export function ThemeProvider(props: ThemeProviderProps) {
|
|
|
123
133
|
'themes',
|
|
124
134
|
'transformTokens',
|
|
125
135
|
'disableTransitionOnChange',
|
|
136
|
+
'accentContrastFloor',
|
|
126
137
|
])
|
|
127
138
|
const storageKey = () => local.storageKey ?? 'adea-appearance'
|
|
128
139
|
const persistence = local.persistence ?? localStoragePersistence(storageKey)
|
|
@@ -184,7 +195,10 @@ export function ThemeProvider(props: ThemeProviderProps) {
|
|
|
184
195
|
appearance: shown().appearance,
|
|
185
196
|
variant: shown().variant,
|
|
186
197
|
}
|
|
187
|
-
const options = {
|
|
198
|
+
const options = {
|
|
199
|
+
transformTokens: local.transformTokens,
|
|
200
|
+
accentContrastFloor: local.accentContrastFloor,
|
|
201
|
+
}
|
|
188
202
|
const apply = () => {
|
|
189
203
|
written = applyThemeToRoot(document.documentElement, state, options, written)
|
|
190
204
|
}
|
|
@@ -212,6 +226,13 @@ export function ThemeProvider(props: ThemeProviderProps) {
|
|
|
212
226
|
setDraft(next ? { ...next } : undefined)
|
|
213
227
|
},
|
|
214
228
|
previewing: () => draft() !== undefined,
|
|
229
|
+
accentRolesFor: (accent, variantId) =>
|
|
230
|
+
accentRolesForState(accent, variantId, {
|
|
231
|
+
selection: selection(),
|
|
232
|
+
variant: variant(),
|
|
233
|
+
registry: registry(),
|
|
234
|
+
accentContrastFloor: local.accentContrastFloor,
|
|
235
|
+
}),
|
|
215
236
|
setSelection(patch) {
|
|
216
237
|
const next = mergeThemeSelection(selection(), patch)
|
|
217
238
|
setSelectionState(next)
|
|
@@ -4,7 +4,9 @@ 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'
|
|
7
8
|
import {
|
|
9
|
+
accentRolesForSelection,
|
|
8
10
|
defaultDarkThemeId,
|
|
9
11
|
defaultLightThemeId,
|
|
10
12
|
resolveVariant,
|
|
@@ -41,8 +43,12 @@ export type ThemeSelection = {
|
|
|
41
43
|
darkThemeId: string
|
|
42
44
|
/**
|
|
43
45
|
* An `accentPresets` id, a theme accent id from `themeAccents()` (`ansi-blue`…),
|
|
44
|
-
*
|
|
45
|
-
* not offer falls back to the variant's own primary.
|
|
46
|
+
* `theme` for the variant's own primary, or a custom colour as `#rrggbb`. A theme
|
|
47
|
+
* accent the current pair does not offer falls back to the variant's own primary.
|
|
48
|
+
*
|
|
49
|
+
* A custom colour is stored lower case and is raised to the contrast floor when it
|
|
50
|
+
* is painted, never when it is stored: the selection keeps what the user chose.
|
|
51
|
+
* Anything else that is not an id is dropped at paint, not rejected here.
|
|
46
52
|
*/
|
|
47
53
|
accent: string
|
|
48
54
|
/** UI-family alias for consumers of the original single-font preference. */
|
|
@@ -129,6 +135,14 @@ export type ThemeContextValue = {
|
|
|
129
135
|
preview: (draft: Partial<ThemeSelection> | undefined) => void
|
|
130
136
|
/** True while a {@link ThemeContextValue.preview} draft is on the document. */
|
|
131
137
|
previewing: () => boolean
|
|
138
|
+
/**
|
|
139
|
+
* The roles `accent` paints over `variantId` (default: the variant in use), held
|
|
140
|
+
* to the provider's `accentContrastFloor`, for an element-scoped override with
|
|
141
|
+
* `paintAccentRoles` — a workspace that carries its own colour. `undefined` for
|
|
142
|
+
* `null`, for `theme`, for an id that resolves to nothing, and for a variant the
|
|
143
|
+
* registry does not hold: each means "keep the inherited accent".
|
|
144
|
+
*/
|
|
145
|
+
accentRolesFor: (accent: string | null, variantId?: string) => AccentRoles | undefined
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
/** A selection with the `undefined` keys of a patch left out. */
|
|
@@ -158,7 +172,13 @@ export function mergeThemeSelection(
|
|
|
158
172
|
fonts = normalizeAppearanceEditorFontSettings(merged.fonts, merged.font).settings
|
|
159
173
|
}
|
|
160
174
|
|
|
161
|
-
return {
|
|
175
|
+
return {
|
|
176
|
+
...merged,
|
|
177
|
+
// One spelling for a custom colour, so equal choices compare and store equal.
|
|
178
|
+
accent: normalizeCustomAccent(merged.accent) ?? merged.accent,
|
|
179
|
+
fonts,
|
|
180
|
+
font: fonts.ui.family,
|
|
181
|
+
}
|
|
162
182
|
}
|
|
163
183
|
|
|
164
184
|
/**
|
|
@@ -223,3 +243,32 @@ export function mergeRegistry(
|
|
|
223
243
|
): readonly ThemeVariant[] {
|
|
224
244
|
return extra && extra.length > 0 ? [...builtin, ...extra] : builtin
|
|
225
245
|
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The roles an accent paints over a variant, for {@link ThemeContextValue.accentRolesFor}.
|
|
249
|
+
*
|
|
250
|
+
* Resolved against the committed selection's theme pair, so an `ansi-*` accent means
|
|
251
|
+
* the same colour here as it does on `<html>`.
|
|
252
|
+
*/
|
|
253
|
+
export function accentRolesForState(
|
|
254
|
+
accent: string | null,
|
|
255
|
+
variantId: string | undefined,
|
|
256
|
+
state: {
|
|
257
|
+
selection: ThemeSelection
|
|
258
|
+
variant: ThemeVariant
|
|
259
|
+
registry: readonly ThemeVariant[]
|
|
260
|
+
accentContrastFloor?: number
|
|
261
|
+
}
|
|
262
|
+
): AccentRoles | undefined {
|
|
263
|
+
if (accent === null) return undefined
|
|
264
|
+
const variant =
|
|
265
|
+
variantId === undefined
|
|
266
|
+
? state.variant
|
|
267
|
+
: state.registry.find((candidate) => candidate.id === variantId)
|
|
268
|
+
if (!variant) return undefined
|
|
269
|
+
return accentRolesForSelection(accent, variant.appearance, state.selection, {
|
|
270
|
+
background: variant.colors.background,
|
|
271
|
+
floor: state.accentContrastFloor,
|
|
272
|
+
variantId: variant.id,
|
|
273
|
+
})
|
|
274
|
+
}
|
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,
|