@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
@@ -0,0 +1,201 @@
1
+ import { fontSettingsBootstrapScript } from '../../lib/appearance-font-settings'
2
+ import { isCustomAccent } from '../../lib/accent-roles'
3
+ import { safeScriptStringLiteral } from '../../lib/safe-script'
4
+ import { REDUCED_TRANSPARENCY_QUERY, surfaceAlpha } from '../../lib/theme-surface'
5
+ import { accentPresets } from '../../lib/tokens'
6
+ import { builtinThemes, defaultDarkThemeId, defaultLightThemeId } from '../../lib/themes'
7
+ import type { ThemeStorageSchema } from './theme-persistence'
8
+
9
+ /**
10
+ * A string as a JavaScript literal that is also safe inside an inline `<script>`.
11
+ *
12
+ * `JSON.stringify` produces a correct JavaScript literal, and it is **not** safe
13
+ * here: it leaves `<` and `>` alone, so a value containing `</script>` ends the tag
14
+ * early and everything after it is parsed as HTML. That is the classic inline-script
15
+ * injection, and it is a real one even though the value is a developer-supplied prop
16
+ * rather than user input — a prop can come from configuration, and a key that happens
17
+ * to contain a closing tag should not be able to execute.
18
+ *
19
+ * The escape is also correct JavaScript: `\u003c` is `<` to the parser, so the value
20
+ * is byte-identical at runtime and inert to the HTML tokenizer. `\u2028` and `\u2029`
21
+ * are escaped for the neighbouring reason — they are legal inside a JSON string and
22
+ * were, before ES2019, line terminators to a JavaScript parser.
23
+ *
24
+ * CodeQL flags the unescaped form as `js/bad-code-sanitization`, which is how this
25
+ * was found; `tests/theme-script.test.ts` pins it so it cannot come back.
26
+ */
27
+ export function inlineScriptLiteral(value: string): string {
28
+ return safeScriptStringLiteral(value)
29
+ }
30
+
31
+ /**
32
+ * Options for the host-shaped no-flash script: the document's {@link ThemeStorageSchema}
33
+ * plus what the script paints from it.
34
+ */
35
+ export type ThemeScriptOptions = ThemeStorageSchema & {
36
+ /**
37
+ * The accent painted when the document has none, an unrecognised one, or the
38
+ * legacy `theme` spelling — the host's default (adea: `violet`). Without it those
39
+ * cases paint no `data-accent`, which is what the provider writes for `theme`.
40
+ * Setting it means the `[data-accent]` block of the default preset paints on the
41
+ * first frame instead of after hydration.
42
+ */
43
+ defaultAccent?: string
44
+ /**
45
+ * Also resolve the surface axis (`data-surface`, `data-reduce-transparency`,
46
+ * `--surface-alpha`) from the document and the operating system's Reduce
47
+ * Transparency setting. A `translucent` preference resolves to `frosted`: the
48
+ * host's translucency capability is not known until the provider mounts, and the
49
+ * provider re-resolves it then. Match the provider's `surface` prop.
50
+ */
51
+ surface?: boolean
52
+ /**
53
+ * Match the provider's `palette` prop. The attributes written are the same either
54
+ * way: a catalogue variant's palette is the host stylesheet's job (`stylesheet`) or
55
+ * the provider's, at mount (`inline`), and a stored theme's own tokens are written
56
+ * inline in both.
57
+ */
58
+ palette?: 'inline' | 'stylesheet'
59
+ /** The only `version` of the library document read. Default `1`. */
60
+ libraryVersion?: number
61
+ }
62
+
63
+ /**
64
+ * The no-flash script.
65
+ *
66
+ * Rendered inline in `<head>` before any stylesheet that matters. Without it a dark
67
+ * preference paints the light theme first and then swaps, which is the flash every
68
+ * themed application has to solve and the one thing a provider cannot do from
69
+ * inside the body.
70
+ *
71
+ * ## The string form
72
+ *
73
+ * `themeScript('key')` serves a provider that stores an unversioned
74
+ * `localStorage[key]` document. Deliberately tiny and dependency-free: it reads the
75
+ * stored preference, resolves `system`, and sets the class. It does not apply the
76
+ * variant's roles — the default theme in `theme.css` covers the first paint, and the
77
+ * provider writes the rest on mount. Its output is a compatibility surface (a
78
+ * consumer may have it in a snapshot), so it does not change.
79
+ *
80
+ * ## The options form
81
+ *
82
+ * `themeScript({ key, ... })` serves a host with a versioned document (see
83
+ * `createVersionedPersistence`) and writes what the provider will write, so mounting
84
+ * repaints nothing:
85
+ *
86
+ * - `class="dark"`, `color-scheme`, `data-theme`, `data-appearance` and, for a
87
+ * compact document, `data-density`;
88
+ * - `data-accent`: a preset id as stored, `custom` for a `#rrggbb` (never the hex
89
+ * itself, which is the worst possible attribute content), `defaultAccent` when the
90
+ * document has none, nothing for a theme-derived `ansi-*` accent (it has no
91
+ * stylesheet block; the provider paints it inline);
92
+ * - the theme: the stored id when the catalogue or the stored library holds it, else
93
+ * the default of the resolved appearance, exactly as the provider resolves it. A
94
+ * library theme's own tokens are written inline, so an imported theme paints on the
95
+ * first frame;
96
+ * - the surface axis, when `surface` is set;
97
+ * - the font settings, guarded to the document's `version`.
98
+ *
99
+ * The document's `version`, when the schema names one, must match exactly. The legacy
100
+ * key is read only while the main key is absent: an unreadable main document is not
101
+ * "no preference", so it does not resurrect an older value.
102
+ *
103
+ * The script reads values out of `localStorage` and writes them to `dataset` and
104
+ * `style`, which are *property* assignments rather than code: a stored id, accent or
105
+ * token can set an attribute and cannot execute. The values that reach the source as
106
+ * text (keys, ids, the default accent) go through {@link inlineScriptLiteral} for the
107
+ * reason documented there.
108
+ */
109
+ export function themeScript(storageKey?: string): string
110
+ export function themeScript(options: ThemeScriptOptions): string
111
+ export function themeScript(input: string | ThemeScriptOptions = 'adea-appearance'): string {
112
+ if (typeof input === 'string') return stringFormScript(input)
113
+ return optionsFormScript(input)
114
+ }
115
+
116
+ function stringFormScript(storageKey: string): string {
117
+ const script = `(function(){try{var s=localStorage.getItem(${inlineScriptLiteral(storageKey)});var p=s?JSON.parse(s):{};var a=p.appearance||'system';var d=a==='dark'||(a==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);var r=document.documentElement;if(d)r.classList.add('dark');r.style.colorScheme=d?'dark':'light';if(p.accent&&p.accent!=='theme')r.dataset.accent=p.accent;if(p.density==='compact')r.dataset.density='compact';}catch(e){}})();`
118
+ return `${script}${fontSettingsBootstrapScript(storageKey)}`
119
+ }
120
+
121
+ const literal = safeScriptStringLiteral
122
+
123
+ function optionsFormScript(options: ThemeScriptOptions): string {
124
+ const field = options.appearanceField ?? 'appearance'
125
+ const versionGuard =
126
+ options.version === undefined ? '' : `&&x.version===${Number(options.version)}`
127
+ const presets = accentPresets
128
+ .filter((preset) => preset.id !== 'theme')
129
+ .map((preset) => literal(preset.id))
130
+ const defaultAccent =
131
+ options.defaultAccent === undefined
132
+ ? ''
133
+ : isCustomAccent(options.defaultAccent)
134
+ ? 'custom'
135
+ : options.defaultAccent
136
+
137
+ const parts: string[] = []
138
+ parts.push(
139
+ `(function(){try{`,
140
+ `var r=document.documentElement,s=r.style;`,
141
+ `var q=function(m){try{return window.matchMedia(m).matches}catch(e){return false}};`,
142
+ `var get=function(k){try{return localStorage.getItem(k)}catch(e){return null}};`,
143
+ // The main document. `raw !== null` rather than truthiness: an empty string is a
144
+ // stored (corrupt) value, so it does not fall through to the legacy key.
145
+ `var raw=get(${literal(options.key)}),doc=null,legacy=null;`,
146
+ `if(raw!==null){try{var x=JSON.parse(raw);if(x&&typeof x==='object'&&!Array.isArray(x)${versionGuard})doc=x}catch(e){}}`
147
+ )
148
+ if (options.legacyAppearanceKey !== undefined) {
149
+ parts.push(`else legacy=get(${literal(options.legacyAppearanceKey)});`)
150
+ }
151
+ parts.push(
152
+ `var m=doc?doc[${literal(field)}]:legacy;`,
153
+ `var mode=m==='light'||m==='dark'?m:'system';`,
154
+ `var dark=mode==='dark'||(mode==='system'&&q('(prefers-color-scheme: dark)'));`,
155
+ `var fallback=dark?${literal(defaultDarkThemeId)}:${literal(defaultLightThemeId)};`,
156
+ `var want=doc?(dark?doc.darkThemeId:doc.lightThemeId):null;`,
157
+ `if(typeof want!=='string')want=fallback;`,
158
+ `var ids=[${builtinThemes.map((theme) => literal(theme.id)).join(',')}];`,
159
+ `var id=ids.indexOf(want)!==-1?want:null,tokens=null;`
160
+ )
161
+ if (options.libraryKey !== undefined) {
162
+ parts.push(
163
+ `if(id===null){try{var lib=JSON.parse(get(${literal(options.libraryKey)})||'null');`,
164
+ `if(lib&&typeof lib==='object'&&lib.version===${Number(options.libraryVersion ?? 1)}&&Array.isArray(lib.themes)){`,
165
+ `for(var i=0;i!==lib.themes.length;i++){var t=lib.themes[i];`,
166
+ `if(t&&t.id===want&&t.flatTokens&&typeof t.flatTokens==='object'){id=want;tokens=t.flatTokens;break}}}}catch(e){}}`
167
+ )
168
+ }
169
+ parts.push(
170
+ `if(id===null)id=fallback;`,
171
+ `r.classList.toggle('dark',dark);`,
172
+ `s.colorScheme=dark?'dark':'light';`,
173
+ `r.dataset.theme=id;`,
174
+ `r.dataset.appearance=dark?'dark':'light';`
175
+ )
176
+ if (options.libraryKey !== undefined) {
177
+ parts.push(
178
+ `if(tokens){for(var n in tokens){if(Object.prototype.hasOwnProperty.call(tokens,n)&&n.slice(0,2)==='--'&&typeof tokens[n]==='string')s.setProperty(n,tokens[n])}}`
179
+ )
180
+ }
181
+ parts.push(
182
+ `var a=doc&&typeof doc.accent==='string'?doc.accent:'';`,
183
+ `var accent=[${presets.join(',')}].indexOf(a)!==-1?a:/^#[0-9a-fA-F]{6}$/.test(a)?'custom':a.slice(0,5)==='ansi-'?'':${literal(defaultAccent)};`,
184
+ `if(accent)r.dataset.accent=accent;`,
185
+ `if(doc&&doc.density==='compact')r.dataset.density='compact';`
186
+ )
187
+ if (options.surface) {
188
+ parts.push(
189
+ // Reduce Transparency forces the opaque surface; a translucent request resolves
190
+ // to frosted because the host's capability is unknown before the provider mounts.
191
+ `var reduce=q(${literal(REDUCED_TRANSPARENCY_QUERY)})||!!(doc&&doc.reduceTransparency===true);`,
192
+ `var surface=reduce?'opaque':doc&&(doc.surface==='frosted'||doc.surface==='translucent')?'frosted':'opaque';`,
193
+ `r.dataset.surface=surface;`,
194
+ `r.dataset.reduceTransparency=reduce?'true':'false';`,
195
+ `s.setProperty('--surface-alpha',${JSON.stringify(surfaceAlpha)}[surface]);`
196
+ )
197
+ }
198
+ parts.push(`}catch(e){}})();`)
199
+
200
+ return `${parts.join('')}${fontSettingsBootstrapScript(options.key, options.version)}`
201
+ }
@@ -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
+ }