@ccheever/exact-facet-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,566 @@
1
+ // @system @ref LLP 0269 Part III — the Facet theme definition and resolver.
2
+ //
3
+ // This is the styling layer's `projections = resolve(definition)` instance:
4
+ //
5
+ // FacetThemeDefinition — a small, serializable, generative set of dials
6
+ // (accent, density, type base+ratio, radius, motion).
7
+ // resolveFacetTheme() — a deterministic function: definition + runtime
8
+ // inputs (scheme, reduced motion, text scale, size
9
+ // class) -> the resolved semantic `FacetTheme`.
10
+ //
11
+ // A human turns dials and an agent emits structured edits over the *definition*;
12
+ // the resolver expands ~200 purpose-named tokens. Nobody edits the resolved
13
+ // output. Context-computed values (concentric radius, per-instance control
14
+ // sizing) are NOT pure functions of the definition, so the resolver emits the
15
+ // *inputs* (radius scale, density maps, `concentricRadius`) and recipes finish
16
+ // the computation at apply-time with the instance in hand (LLP 0269 §6).
17
+ //
18
+ // Pre-release execution note (LLP 0269): the resolver is the runtime path, not a
19
+ // build-time/native compiler, and there is no backward-compat layer — the old
20
+ // flat `FacetTheme` was deleted, not aliased.
21
+
22
+ import {
23
+ buildFacetMotion,
24
+ clamp,
25
+ defaultSizeClasses,
26
+ facetSansFontFamily,
27
+ mixColors,
28
+ resolveSizeClassForWidth,
29
+ resolveThemeColorContrastPair,
30
+ withAlpha,
31
+ type DensitySize,
32
+ type FacetAdaptationTokens,
33
+ type FacetColorTokens,
34
+ type FacetDensityTokens,
35
+ type FacetElevationTokens,
36
+ type FacetRadiusTokens,
37
+ type FacetTheme,
38
+ type FacetTypeRole,
39
+ type FacetTypeTokens,
40
+ type FacetZIndexTokens,
41
+ type PlatformFamily,
42
+ type Scheme,
43
+ type SizeClass,
44
+ type SizeClassTokens,
45
+ type StatusColorSet,
46
+ type ThemeContrast,
47
+ type ThemeDensity,
48
+ } from './internals.js';
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Definition
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /** Modular type-scale ratios (LLP 0269 §4). */
55
+ export type TypeRatioName =
56
+ | 'minorSecond'
57
+ | 'minorThird'
58
+ | 'majorThird'
59
+ | 'perfectFourth'
60
+ | 'augmentedFourth'
61
+ | 'perfectFifth'
62
+ | 'golden';
63
+
64
+ /** Named radius shape scales (LLP 0269 §6). */
65
+ export type RadiusScaleName = 'facetDefault' | 'platformSoft' | 'sharp' | 'round';
66
+
67
+ /** Optional per-status hue overrides on the definition. */
68
+ export interface StatusHueDefinition {
69
+ success?: string;
70
+ warning?: string;
71
+ danger?: string;
72
+ info?: string;
73
+ }
74
+
75
+ /**
76
+ * The serializable design artifact a human's dials and an agent's edits both
77
+ * write. Small and generative — this *is* the styling design. Everything a
78
+ * component reads is `resolve()`d from it.
79
+ */
80
+ export interface FacetThemeDefinition {
81
+ name: string;
82
+ version: 1;
83
+ baseScheme: 'light' | 'dark' | 'system';
84
+ /** The single brand accent the whole accent family generates from. */
85
+ accent: string;
86
+ /** Optional neutral tint applied to the built-in grey ramp. */
87
+ neutral?: string;
88
+ /** Optional status hue overrides; defaults reproduce the shipped palette. */
89
+ status?: StatusHueDefinition;
90
+ density: ThemeDensity;
91
+ type: {
92
+ baseSize: number;
93
+ ratio: TypeRatioName;
94
+ headingFamily?: string;
95
+ bodyFamily?: string;
96
+ monoFamily?: string;
97
+ };
98
+ radius: {
99
+ scale: RadiusScaleName;
100
+ multiplier: number;
101
+ };
102
+ motion: {
103
+ multiplier: number;
104
+ };
105
+ }
106
+
107
+ /** Runtime inputs the resolver folds in on top of the definition. They are
108
+ * *not* part of the design — they are the host/user environment (LLP 0269 §9). */
109
+ export interface ThemeResolveInputs {
110
+ scheme?: Scheme;
111
+ reducedMotion?: boolean;
112
+ textScale?: number;
113
+ contrast?: ThemeContrast;
114
+ platform?: PlatformFamily;
115
+ sizeClass?: SizeClass;
116
+ sizeClasses?: SizeClassTokens;
117
+ viewportWidth?: number;
118
+ }
119
+
120
+ /**
121
+ * The default Facet definition. Its resolved output is the shipped Facet look:
122
+ * a cool near-monochrome neutral ramp, hairline borders, and a single indigo
123
+ * accent — the Linear / Vercel / Stripe family. Turning these dials is how you
124
+ * rebrand, densify, round, or calm the entire system.
125
+ */
126
+ export const defaultThemeDefinition: FacetThemeDefinition = {
127
+ name: 'facet',
128
+ version: 1,
129
+ baseScheme: 'system',
130
+ accent: '#5e6ad2',
131
+ density: 'default',
132
+ type: { baseSize: 14, ratio: 'minorThird' },
133
+ radius: { scale: 'facetDefault', multiplier: 1 },
134
+ motion: { multiplier: 1 },
135
+ };
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Built-in ramps the resolver expands
139
+ // ---------------------------------------------------------------------------
140
+
141
+ interface NeutralRamp {
142
+ body: string;
143
+ /** The recessed neutral surface (secondary control fill). */
144
+ surface: string;
145
+ /** The raised card/panel surface. */
146
+ card: string;
147
+ popover: string;
148
+ inverted: string;
149
+ textPrimary: string;
150
+ textSecondary: string;
151
+ borderDefault: string;
152
+ overlayScrim: string;
153
+ overlayHover: string;
154
+ overlayPressed: string;
155
+ shadowColor: string;
156
+ }
157
+
158
+ // The shipped neutral ramps, carried verbatim from the pre-revision flat themes
159
+ // so the default definition resolves pixel-faithfully. `surface` is the old
160
+ // `surfaceAlt` (recessed) and `card` is the old `surface` (raised) — see the
161
+ // `FacetColorTokens.background` doc comment in internals.ts.
162
+ const LIGHT_NEUTRALS: NeutralRamp = {
163
+ body: '#fbfbfc',
164
+ surface: '#f3f3f5',
165
+ card: '#ffffff',
166
+ popover: '#ffffff',
167
+ inverted: '#1c1d21',
168
+ textPrimary: '#1c1d21',
169
+ textSecondary: '#5f636c',
170
+ borderDefault: '#e4e4e9',
171
+ overlayScrim: 'rgba(20, 21, 26, 0.40)',
172
+ overlayHover: 'rgba(28, 29, 33, 0.05)',
173
+ overlayPressed: 'rgba(28, 29, 33, 0.10)',
174
+ shadowColor: '#101828',
175
+ };
176
+
177
+ const DARK_NEUTRALS: NeutralRamp = {
178
+ body: '#08090a',
179
+ surface: '#141517',
180
+ card: '#0f1011',
181
+ popover: '#0f1011',
182
+ inverted: '#f7f8f8',
183
+ textPrimary: '#f7f8f8',
184
+ textSecondary: '#a4a8b0',
185
+ borderDefault: '#212327',
186
+ overlayScrim: 'rgba(2, 3, 4, 0.66)',
187
+ overlayHover: 'rgba(247, 248, 248, 0.06)',
188
+ overlayPressed: 'rgba(247, 248, 248, 0.10)',
189
+ shadowColor: '#000000',
190
+ };
191
+
192
+ const DEFAULT_STATUS: Required<StatusHueDefinition> = {
193
+ success: '#2a8447',
194
+ danger: '#c4392f',
195
+ warning: '#b7791f',
196
+ info: '#2563c9',
197
+ };
198
+
199
+ const TYPE_RATIOS: Record<TypeRatioName, number> = {
200
+ minorSecond: 1.067,
201
+ minorThird: 1.2,
202
+ majorThird: 1.25,
203
+ perfectFourth: 1.333,
204
+ augmentedFourth: 1.414,
205
+ perfectFifth: 1.5,
206
+ golden: 1.618,
207
+ };
208
+
209
+ // Semantic radius levels per scale. `none`/`full` are fixed anchors added by
210
+ // `resolveRadius`. `facetDefault` reproduces the shipped sm/md/lg (8/10/14).
211
+ const RADIUS_SCALES: Record<RadiusScaleName, { inner: number; element: number; container: number; page: number }> = {
212
+ facetDefault: { inner: 6, element: 8, container: 10, page: 14 },
213
+ platformSoft: { inner: 8, element: 12, container: 16, page: 22 },
214
+ sharp: { inner: 2, element: 4, container: 6, page: 8 },
215
+ round: { inner: 10, element: 16, container: 22, page: 28 },
216
+ };
217
+
218
+ const DENSITY_TABLE: Record<ThemeDensity, Omit<FacetDensityTokens, 'mode'>> = {
219
+ compact: {
220
+ controlHeight: { sm: 28, md: 34, lg: 40 },
221
+ rowHeight: 36,
222
+ iconSize: { sm: 14, md: 18, lg: 22 },
223
+ minHitTarget: 40,
224
+ paddingScale: 0.85,
225
+ },
226
+ default: {
227
+ controlHeight: { sm: 32, md: 40, lg: 48 },
228
+ rowHeight: 44,
229
+ iconSize: { sm: 16, md: 20, lg: 24 },
230
+ minHitTarget: 44,
231
+ paddingScale: 1,
232
+ },
233
+ comfortable: {
234
+ controlHeight: { sm: 36, md: 44, lg: 52 },
235
+ rowHeight: 52,
236
+ iconSize: { sm: 18, md: 22, lg: 26 },
237
+ minHitTarget: 48,
238
+ paddingScale: 1.15,
239
+ },
240
+ gigantic: {
241
+ controlHeight: { sm: 44, md: 56, lg: 68 },
242
+ rowHeight: 64,
243
+ iconSize: { sm: 22, md: 28, lg: 34 },
244
+ minHitTarget: 56,
245
+ paddingScale: 1.4,
246
+ },
247
+ };
248
+
249
+ const FACET_MONO_FAMILY = [
250
+ 'ui-monospace',
251
+ 'SFMono-Regular',
252
+ 'SF Mono',
253
+ 'Menlo',
254
+ 'Consolas',
255
+ 'Liberation Mono',
256
+ 'monospace',
257
+ ] as const;
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Helpers
261
+ // ---------------------------------------------------------------------------
262
+
263
+ /** Pick whichever of two foreground colours has the higher contrast on `bg`. */
264
+ function onColorFor(bg: string, light: string, dark: string): string {
265
+ const lightRatio = resolveThemeColorContrastPair(light, bg) ?? 0;
266
+ const darkRatio = resolveThemeColorContrastPair(dark, bg) ?? 0;
267
+ return lightRatio >= darkRatio ? light : dark;
268
+ }
269
+
270
+ /**
271
+ * Concentric inner radius: a rounded surface nested inside another with
272
+ * `padding` between them stays visually concentric when its radius is the
273
+ * outer radius minus the padding (LLP 0269 §6). Recipes call this at
274
+ * apply-time; it is exported as a resolver *input*, not a resolved token.
275
+ */
276
+ export function concentricRadius(outerRadius: number, padding: number): number {
277
+ return Math.max(0, Math.round(outerRadius - padding));
278
+ }
279
+
280
+ /**
281
+ * The outward form of concentricity (LLP 0269 §6): a surface that *wraps* a
282
+ * rounded inner chrome at `inset` distance (a focus halo around a field)
283
+ * stays visually concentric when its radius is the inner radius plus the
284
+ * inset. Like `concentricRadius`, this is a resolver *input* recipes call at
285
+ * apply-time with the instance's geometry in hand.
286
+ */
287
+ export function concentricOuterRadius(innerRadius: number, inset: number): number {
288
+ return Math.max(0, Math.round(innerRadius + inset));
289
+ }
290
+
291
+ function resolveNeutralRamp(scheme: Scheme, neutral: string | undefined): NeutralRamp {
292
+ const base = scheme === 'dark' ? DARK_NEUTRALS : LIGHT_NEUTRALS;
293
+ if (!neutral) {
294
+ return base;
295
+ }
296
+ // A neutral dial tints the grey ramp toward the chosen hue without disturbing
297
+ // the lightness relationships that keep contrast intact.
298
+ const tint = (color: string, amount: number): string => mixColors(color, neutral, amount);
299
+ return {
300
+ ...base,
301
+ body: tint(base.body, 0.04),
302
+ surface: tint(base.surface, 0.05),
303
+ card: tint(base.card, 0.03),
304
+ popover: tint(base.popover, 0.03),
305
+ borderDefault: tint(base.borderDefault, 0.08),
306
+ };
307
+ }
308
+
309
+ function resolveColor(
310
+ scheme: Scheme,
311
+ ramp: NeutralRamp,
312
+ accent: string,
313
+ status: Required<StatusHueDefinition>,
314
+ contrast: ThemeContrast,
315
+ ): FacetColorTokens {
316
+ // On-colors are chosen by the *fill's* lightness, not the scheme: a light
317
+ // amber warning needs dark ink in dark mode too. So the candidates are always
318
+ // paper-white and a fixed near-black ink, never the scheme's text color.
319
+ const onLight = '#ffffff';
320
+ const onDark = '#0e0f12';
321
+
322
+ const accentOnFill = onColorFor(accent, onLight, onDark);
323
+ const accentSet = {
324
+ fill: accent,
325
+ hover: mixColors(accent, '#000000', 0.1),
326
+ pressed: mixColors(accent, '#000000', 0.2),
327
+ // Link/text accent: lift toward the surface in dark mode for legibility on
328
+ // dark backgrounds, keep the brand accent in light mode.
329
+ text: scheme === 'dark' ? mixColors(accent, '#ffffff', 0.18) : accent,
330
+ onFill: accentOnFill,
331
+ };
332
+
333
+ const statusSet = (hue: string): StatusColorSet => ({
334
+ fill: hue,
335
+ muted: mixColors(hue, ramp.card, scheme === 'dark' ? 0.82 : 0.86),
336
+ text: scheme === 'dark' ? mixColors(hue, '#ffffff', 0.28) : mixColors(hue, '#000000', 0.12),
337
+ onFill: onColorFor(hue, onLight, onDark),
338
+ border: mixColors(hue, ramp.card, scheme === 'dark' ? 0.52 : 0.58),
339
+ });
340
+
341
+ const textSecondary = contrast === 'increased'
342
+ ? mixColors(ramp.textSecondary, ramp.textPrimary, 0.4)
343
+ : ramp.textSecondary;
344
+ const textDisabled = mixColors(ramp.textSecondary, ramp.card, 0.45);
345
+ const borderDefault = contrast === 'increased'
346
+ ? mixColors(ramp.borderDefault, ramp.textPrimary, 0.3)
347
+ : ramp.borderDefault;
348
+ const borderSubtle = mixColors(ramp.borderDefault, ramp.body, 0.5);
349
+ const borderStrong = mixColors(ramp.borderDefault, ramp.textPrimary, 0.3);
350
+
351
+ return {
352
+ background: {
353
+ body: ramp.body,
354
+ surface: ramp.surface,
355
+ card: ramp.card,
356
+ popover: ramp.popover,
357
+ inverted: ramp.inverted,
358
+ },
359
+ text: {
360
+ primary: ramp.textPrimary,
361
+ secondary: textSecondary,
362
+ disabled: textDisabled,
363
+ accent: accentSet.text,
364
+ onAccent: accentSet.onFill,
365
+ onDanger: onColorFor(status.danger, onLight, onDark),
366
+ onSuccess: onColorFor(status.success, onLight, onDark),
367
+ onWarning: onColorFor(status.warning, onLight, onDark),
368
+ },
369
+ icon: {
370
+ primary: ramp.textPrimary,
371
+ secondary: textSecondary,
372
+ disabled: textDisabled,
373
+ accent: accentSet.fill,
374
+ },
375
+ border: {
376
+ subtle: borderSubtle,
377
+ default: borderDefault,
378
+ strong: borderStrong,
379
+ focus: accentSet.fill,
380
+ },
381
+ status: {
382
+ success: statusSet(status.success),
383
+ warning: statusSet(status.warning),
384
+ danger: statusSet(status.danger),
385
+ info: statusSet(status.info),
386
+ },
387
+ overlay: {
388
+ scrim: ramp.overlayScrim,
389
+ hover: ramp.overlayHover,
390
+ pressed: ramp.overlayPressed,
391
+ selected: withAlpha(accent, scheme === 'dark' ? 0.22 : 0.12),
392
+ },
393
+ accent: accentSet,
394
+ };
395
+ }
396
+
397
+ function resolveType(
398
+ def: FacetThemeDefinition,
399
+ textScale: number,
400
+ ): FacetTypeTokens {
401
+ const ratio = TYPE_RATIOS[def.type.ratio] ?? TYPE_RATIOS.minorThird;
402
+ const base = def.type.baseSize * (textScale > 0 ? textScale : 1);
403
+ const sizeAt = (steps: number): number => Math.round(base * Math.pow(ratio, steps));
404
+
405
+ const bodyFamily = def.type.bodyFamily
406
+ ? [def.type.bodyFamily, ...facetSansFontFamily]
407
+ : [...facetSansFontFamily];
408
+ const headingFamily = def.type.headingFamily
409
+ ? [def.type.headingFamily, ...facetSansFontFamily]
410
+ : bodyFamily;
411
+ const monoFamily = def.type.monoFamily
412
+ ? [def.type.monoFamily, ...FACET_MONO_FAMILY]
413
+ : [...FACET_MONO_FAMILY];
414
+
415
+ const role = (
416
+ fontSize: number,
417
+ fontWeight: number,
418
+ fontFamily: readonly string[],
419
+ tight: boolean,
420
+ letterSpacing?: number,
421
+ ): FacetTypeRole => ({
422
+ fontSize,
423
+ lineHeight: Math.round(fontSize * (tight ? 1.25 : 1.5)),
424
+ fontWeight,
425
+ fontFamily,
426
+ letterSpacing,
427
+ });
428
+
429
+ return {
430
+ caption: role(sizeAt(-1), 400, bodyFamily, false),
431
+ body: role(sizeAt(0), 400, bodyFamily, false),
432
+ bodyStrong: role(sizeAt(0), 600, bodyFamily, false),
433
+ label: role(sizeAt(0), 500, bodyFamily, false),
434
+ title: role(sizeAt(1), 600, headingFamily, true),
435
+ heading: role(sizeAt(2), 700, headingFamily, true, -0.2),
436
+ display: role(sizeAt(3), 700, headingFamily, true, -0.4),
437
+ code: role(sizeAt(0), 400, monoFamily, false),
438
+ families: { body: bodyFamily, heading: headingFamily, mono: monoFamily },
439
+ };
440
+ }
441
+
442
+ function resolveRadius(def: FacetThemeDefinition): FacetRadiusTokens {
443
+ const scale = RADIUS_SCALES[def.radius.scale] ?? RADIUS_SCALES.facetDefault;
444
+ const multiplier = Number.isFinite(def.radius.multiplier) ? clamp(def.radius.multiplier, 0, 4) : 1;
445
+ const scaled = (value: number): number => Math.round(value * multiplier);
446
+ return {
447
+ none: 0,
448
+ inner: scaled(scale.inner),
449
+ element: scaled(scale.element),
450
+ container: scaled(scale.container),
451
+ page: scaled(scale.page),
452
+ full: 999,
453
+ };
454
+ }
455
+
456
+ function resolveDensity(mode: ThemeDensity): FacetDensityTokens {
457
+ const table = DENSITY_TABLE[mode] ?? DENSITY_TABLE.default;
458
+ return {
459
+ mode,
460
+ controlHeight: { ...table.controlHeight } as Record<DensitySize, number>,
461
+ rowHeight: table.rowHeight,
462
+ iconSize: { ...table.iconSize } as Record<DensitySize, number>,
463
+ minHitTarget: table.minHitTarget,
464
+ paddingScale: table.paddingScale,
465
+ };
466
+ }
467
+
468
+ function resolveElevation(scheme: Scheme): FacetElevationTokens {
469
+ if (scheme === 'dark') {
470
+ return {
471
+ low: { x: 0, y: 1, blur: 2, color: '#000000', opacity: 0.5 },
472
+ medium: { x: 0, y: 8, blur: 24, color: '#000000', opacity: 0.52 },
473
+ high: { x: 0, y: 18, blur: 50, color: '#000000', opacity: 0.55 },
474
+ };
475
+ }
476
+ return {
477
+ low: { x: 0, y: 1, blur: 2, color: '#101828', opacity: 0.07 },
478
+ medium: { x: 0, y: 6, blur: 20, color: '#101828', opacity: 0.12 },
479
+ high: { x: 0, y: 16, blur: 48, color: '#101828', opacity: 0.16 },
480
+ };
481
+ }
482
+
483
+ const ZINDEX: FacetZIndexTokens = {
484
+ base: 0,
485
+ dropdown: 1000,
486
+ sticky: 1100,
487
+ modal: 1400,
488
+ toast: 1700,
489
+ tooltip: 1800,
490
+ };
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // The resolver
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /**
497
+ * `resolve(definition)`. Deterministic: the same definition + inputs always
498
+ * yields the same resolved theme (the property the golden check asserts).
499
+ * System scheme is resolved upstream (theme-store passes a concrete scheme);
500
+ * if absent it falls back to the definition's `baseScheme`.
501
+ */
502
+ export function resolveFacetTheme(
503
+ definition: FacetThemeDefinition,
504
+ inputs: ThemeResolveInputs = {},
505
+ ): FacetTheme {
506
+ const scheme: Scheme = inputs.scheme
507
+ ?? (definition.baseScheme === 'dark' ? 'dark' : 'light');
508
+ const reducedMotion = inputs.reducedMotion ?? false;
509
+ const textScale = inputs.textScale ?? 1;
510
+ const contrast = inputs.contrast ?? 'standard';
511
+ const platform = inputs.platform ?? 'web';
512
+ const sizeClasses = inputs.sizeClasses ?? defaultSizeClasses;
513
+ const sizeClass = inputs.sizeClass
514
+ ?? resolveSizeClassForWidth(inputs.viewportWidth ?? 0, sizeClasses);
515
+
516
+ const ramp = resolveNeutralRamp(scheme, definition.neutral);
517
+ const status: Required<StatusHueDefinition> = { ...DEFAULT_STATUS, ...(definition.status ?? {}) };
518
+
519
+ const color = resolveColor(scheme, ramp, definition.accent, status, contrast);
520
+ const motion = buildFacetMotion(reducedMotion, definition.motion.multiplier);
521
+
522
+ const adaptation: FacetAdaptationTokens = {
523
+ scheme,
524
+ sizeClass,
525
+ reducedMotion,
526
+ textScale,
527
+ contrast,
528
+ platform,
529
+ };
530
+
531
+ // Space is the base scale; density's `paddingScale` is applied by recipes at
532
+ // apply-time rather than mutating the shared spacing tokens.
533
+ const space = { xs: 6, sm: 10, md: 14, lg: 20, xl: 28 };
534
+
535
+ return {
536
+ name: definition.name,
537
+ scheme,
538
+ sizeClass,
539
+ sizeClasses,
540
+ color,
541
+ space,
542
+ density: resolveDensity(definition.density),
543
+ type: resolveType(definition, textScale),
544
+ radius: resolveRadius(definition),
545
+ elevation: resolveElevation(scheme),
546
+ zIndex: { ...ZINDEX },
547
+ motion,
548
+ adaptation,
549
+ };
550
+ }
551
+
552
+ // The shipped light/dark themes are now resolver output, not hand-authored
553
+ // token bags. `index.ts` re-exports these as the package's `lightTheme` /
554
+ // `darkTheme`.
555
+ export const lightTheme: FacetTheme = resolveFacetTheme(defaultThemeDefinition, { scheme: 'light' });
556
+ export const darkTheme: FacetTheme = resolveFacetTheme(defaultThemeDefinition, { scheme: 'dark' });
557
+
558
+ /** Convenience for `definition.baseScheme === 'system'`-aware resolution that a
559
+ * host has already collapsed to a concrete scheme. */
560
+ export function resolveSchemeTheme(
561
+ definition: FacetThemeDefinition,
562
+ scheme: Scheme,
563
+ inputs: Omit<ThemeResolveInputs, 'scheme'> = {},
564
+ ): FacetTheme {
565
+ return resolveFacetTheme(definition, { ...inputs, scheme });
566
+ }