@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.
- package/facet-registry.json +548 -0
- package/package.json +22 -0
- package/src/__tests__/facet-overrides.test.ts +91 -0
- package/src/__tests__/theme-definition-resolution.test.ts +235 -0
- package/src/__tests__/theme-store-parity.test.ts +283 -0
- package/src/facet-catalog.ts +75 -0
- package/src/facet-overrides.ts +322 -0
- package/src/facet-params.generated.ts +65 -0
- package/src/facet-scorecard.ts +60 -0
- package/src/index.ts +16 -0
- package/src/internals.ts +1313 -0
- package/src/provenance-trace.ts +153 -0
- package/src/store-machinery.ts +128 -0
- package/src/theme-definition.ts +566 -0
- package/src/theme-store.ts +456 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/// <reference path="../../../../js/src/types/bun-test.d.ts" />
|
|
2
|
+
// LLP 0269 Theme Verification: the definition-to-resolved-token golden.
|
|
3
|
+
//
|
|
4
|
+
// `resolve(definition)` is the styling layer's pure function. This golden pins
|
|
5
|
+
// the stable resolved anchors of the default definition (the shipped Facet
|
|
6
|
+
// look), asserts the resolver is deterministic, that every dial actually moves
|
|
7
|
+
// the tokens below it, that the runtime inputs (scheme / reduced motion / text
|
|
8
|
+
// scale / contrast / size class) resolve coherently, and that the default
|
|
9
|
+
// resolves AA-clean. A drift in any of these fails here before it ships.
|
|
10
|
+
|
|
11
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
resolveCompactInset,
|
|
15
|
+
resolveDensityPadding,
|
|
16
|
+
resolveFieldInputStyle,
|
|
17
|
+
validateFacetContrast,
|
|
18
|
+
type Scheme,
|
|
19
|
+
} from '../internals.js';
|
|
20
|
+
import {
|
|
21
|
+
resolveButtonPadding,
|
|
22
|
+
resolveCardPadding,
|
|
23
|
+
setFacetParamOverrides,
|
|
24
|
+
} from '../facet-overrides.js';
|
|
25
|
+
import {
|
|
26
|
+
concentricOuterRadius,
|
|
27
|
+
concentricRadius,
|
|
28
|
+
defaultThemeDefinition,
|
|
29
|
+
resolveFacetTheme,
|
|
30
|
+
type FacetThemeDefinition,
|
|
31
|
+
} from '../theme-definition.js';
|
|
32
|
+
|
|
33
|
+
describe('resolve(definition) golden (LLP 0269)', () => {
|
|
34
|
+
it('is deterministic — same definition + inputs yields a deep-equal theme', () => {
|
|
35
|
+
const a = resolveFacetTheme(defaultThemeDefinition, { scheme: 'light' });
|
|
36
|
+
const b = resolveFacetTheme(defaultThemeDefinition, { scheme: 'light' });
|
|
37
|
+
expect(a).toEqual(b);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('pins the default light resolved anchors', () => {
|
|
41
|
+
const t = resolveFacetTheme(defaultThemeDefinition, { scheme: 'light' });
|
|
42
|
+
expect(t.scheme).toBe('light');
|
|
43
|
+
// Surfaces: old surface→card (raised white), old surfaceAlt→surface (recessed).
|
|
44
|
+
expect(t.color.background.body).toBe('#fbfbfc');
|
|
45
|
+
expect(t.color.background.surface).toBe('#f3f3f5');
|
|
46
|
+
expect(t.color.background.card).toBe('#ffffff');
|
|
47
|
+
expect(t.color.text.primary).toBe('#1c1d21');
|
|
48
|
+
expect(t.color.text.secondary).toBe('#5f636c');
|
|
49
|
+
expect(t.color.border.default).toBe('#e4e4e9');
|
|
50
|
+
// Accent family generated from the single dial.
|
|
51
|
+
expect(t.color.accent.fill).toBe('#5e6ad2');
|
|
52
|
+
expect(t.color.accent.onFill).toBe('#ffffff');
|
|
53
|
+
expect(t.color.accent.hover).not.toBe(t.color.accent.fill);
|
|
54
|
+
expect(t.color.accent.pressed).not.toBe(t.color.accent.hover);
|
|
55
|
+
// Space / radius / density / type anchors.
|
|
56
|
+
expect(t.space).toEqual({ xs: 6, sm: 10, md: 14, lg: 20, xl: 28 });
|
|
57
|
+
expect(t.radius).toEqual({ none: 0, inner: 6, element: 8, container: 10, page: 14, full: 999 });
|
|
58
|
+
expect(t.density.mode).toBe('default');
|
|
59
|
+
expect(t.density.controlHeight.md).toBe(40);
|
|
60
|
+
expect(t.type.body.fontSize).toBe(14);
|
|
61
|
+
expect(t.type.body.lineHeight).toBe(21);
|
|
62
|
+
expect(t.type.caption.fontSize).toBe(12);
|
|
63
|
+
expect(t.type.title.fontSize).toBe(17);
|
|
64
|
+
expect(t.type.heading.fontSize).toBe(20);
|
|
65
|
+
expect(t.type.display.fontSize).toBe(24);
|
|
66
|
+
expect(t.zIndex.modal).toBe(1400);
|
|
67
|
+
expect(t.motion.reducedMotion).toBe(false);
|
|
68
|
+
expect(t.motion.duration.fast).toBe(100);
|
|
69
|
+
expect(t.adaptation.platform).toBe('web');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('pins the default dark resolved anchors', () => {
|
|
73
|
+
const t = resolveFacetTheme(defaultThemeDefinition, { scheme: 'dark' });
|
|
74
|
+
expect(t.scheme).toBe('dark');
|
|
75
|
+
expect(t.color.background.body).toBe('#08090a');
|
|
76
|
+
expect(t.color.background.card).toBe('#0f1011');
|
|
77
|
+
expect(t.color.background.surface).toBe('#141517');
|
|
78
|
+
expect(t.color.text.primary).toBe('#f7f8f8');
|
|
79
|
+
expect(t.color.accent.fill).toBe('#5e6ad2');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('every dial moves the tokens below it', () => {
|
|
83
|
+
const base = resolveFacetTheme(defaultThemeDefinition, { scheme: 'light' });
|
|
84
|
+
|
|
85
|
+
const accent = resolveFacetTheme({ ...defaultThemeDefinition, accent: '#1d9bf0' }, { scheme: 'light' });
|
|
86
|
+
expect(accent.color.accent.fill).toBe('#1d9bf0');
|
|
87
|
+
expect(accent.color.accent.hover).not.toBe(base.color.accent.hover);
|
|
88
|
+
|
|
89
|
+
const dense = resolveFacetTheme({ ...defaultThemeDefinition, density: 'compact' }, { scheme: 'light' });
|
|
90
|
+
expect(dense.density.controlHeight.md).toBeLessThan(base.density.controlHeight.md);
|
|
91
|
+
|
|
92
|
+
const ratio = resolveFacetTheme(
|
|
93
|
+
{ ...defaultThemeDefinition, type: { ...defaultThemeDefinition.type, ratio: 'golden' } },
|
|
94
|
+
{ scheme: 'light' },
|
|
95
|
+
);
|
|
96
|
+
expect(ratio.type.heading.fontSize).toBeGreaterThan(base.type.heading.fontSize);
|
|
97
|
+
|
|
98
|
+
const rounder = resolveFacetTheme(
|
|
99
|
+
{ ...defaultThemeDefinition, radius: { scale: 'facetDefault', multiplier: 2 } },
|
|
100
|
+
{ scheme: 'light' },
|
|
101
|
+
);
|
|
102
|
+
expect(rounder.radius.element).toBe(base.radius.element * 2);
|
|
103
|
+
|
|
104
|
+
const slower = resolveFacetTheme(
|
|
105
|
+
{ ...defaultThemeDefinition, motion: { multiplier: 2 } },
|
|
106
|
+
{ scheme: 'light' },
|
|
107
|
+
);
|
|
108
|
+
expect(slower.motion.duration.fast).toBe(base.motion.duration.fast * 2);
|
|
109
|
+
expect(slower.motion.multiplier).toBe(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('folds runtime inputs (reduced motion, text scale, contrast) coherently', () => {
|
|
113
|
+
const reduced = resolveFacetTheme(defaultThemeDefinition, { scheme: 'light', reducedMotion: true });
|
|
114
|
+
expect(reduced.motion.reducedMotion).toBe(true);
|
|
115
|
+
expect(reduced.motion.preset.controlPress.transform).toBeUndefined();
|
|
116
|
+
expect(reduced.adaptation.reducedMotion).toBe(true);
|
|
117
|
+
|
|
118
|
+
const scaled = resolveFacetTheme(defaultThemeDefinition, { scheme: 'light', textScale: 1.5 });
|
|
119
|
+
expect(scaled.type.body.fontSize).toBe(21); // round(14 * 1.5)
|
|
120
|
+
expect(scaled.adaptation.textScale).toBe(1.5);
|
|
121
|
+
|
|
122
|
+
const increased = resolveFacetTheme(defaultThemeDefinition, { scheme: 'light', contrast: 'increased' });
|
|
123
|
+
expect(increased.adaptation.contrast).toBe('increased');
|
|
124
|
+
expect(increased.color.text.secondary).not.toBe(
|
|
125
|
+
resolveFacetTheme(defaultThemeDefinition, { scheme: 'light' }).color.text.secondary,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('resolves the default AA-clean in both schemes', () => {
|
|
130
|
+
for (const scheme of ['light', 'dark'] as Scheme[]) {
|
|
131
|
+
expect(validateFacetContrast(resolveFacetTheme(defaultThemeDefinition, { scheme }))).toEqual([]);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('flips the accent on-color toward ink for a light accent', () => {
|
|
136
|
+
// A bright accent cannot carry white text; the resolver must pick the dark
|
|
137
|
+
// ink on-color (not blindly keep white), and that pairing stays legible.
|
|
138
|
+
const def: FacetThemeDefinition = { ...defaultThemeDefinition, accent: '#f5d90a' };
|
|
139
|
+
const t = resolveFacetTheme(def, { scheme: 'light' });
|
|
140
|
+
expect(t.color.accent.onFill).toBe('#0e0f12');
|
|
141
|
+
expect(validateFacetContrast(t).filter((f) => f.startsWith('accent.onFill'))).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// LLP 0283 D10 / LLP 0269 §5–§6: the recipe-consumption gate. The resolver
|
|
146
|
+
// emitting `paddingScale` and `concentricRadius` is not enough — these pins
|
|
147
|
+
// prove the apply-time mechanisms actually move recipe output, so the
|
|
148
|
+
// density dial is *felt* and concentricity is computed, not decorative.
|
|
149
|
+
describe('density and concentricity reach recipes (LLP 0283 D10)', () => {
|
|
150
|
+
const atDensity = (density: FacetThemeDefinition['density']) =>
|
|
151
|
+
resolveFacetTheme({ ...defaultThemeDefinition, density }, { scheme: 'light' });
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
setFacetParamOverrides(null);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('resolveDensityPadding is identity at default density (byte-identical shipped look)', () => {
|
|
158
|
+
const t = atDensity('default');
|
|
159
|
+
expect(t.density.paddingScale).toBe(1);
|
|
160
|
+
for (const base of [6, 10, 14, 20, 28]) {
|
|
161
|
+
expect(resolveDensityPadding(t, base)).toBe(base);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('resolveDensityPadding scales with the density dial and guards bad scales', () => {
|
|
166
|
+
expect(resolveDensityPadding(atDensity('compact'), 14)).toBe(12); // 14 * 0.85
|
|
167
|
+
expect(resolveDensityPadding(atDensity('comfortable'), 14)).toBe(16); // 14 * 1.15
|
|
168
|
+
expect(resolveDensityPadding(atDensity('gigantic'), 14)).toBe(20); // 14 * 1.4
|
|
169
|
+
const broken = atDensity('default');
|
|
170
|
+
(broken.density as { paddingScale: number }).paddingScale = Number.NaN;
|
|
171
|
+
expect(resolveDensityPadding(broken, 14)).toBe(14); // falls back to 1, never 0
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('button padding derives from the density dial (default pins the shipped 14/6)', () => {
|
|
175
|
+
expect(resolveButtonPadding(atDensity('default'), 'primary')).toEqual({
|
|
176
|
+
paddingX: 14,
|
|
177
|
+
paddingY: 6,
|
|
178
|
+
});
|
|
179
|
+
expect(resolveButtonPadding(atDensity('compact'), 'primary')).toEqual({
|
|
180
|
+
paddingX: 12,
|
|
181
|
+
paddingY: 5,
|
|
182
|
+
});
|
|
183
|
+
expect(resolveButtonPadding(atDensity('gigantic'), 'primary')).toEqual({
|
|
184
|
+
paddingX: 20,
|
|
185
|
+
paddingY: 8,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('card padding derives from the density dial (default pins the shipped 20)', () => {
|
|
190
|
+
expect(resolveCardPadding(atDensity('default'))).toBe(20);
|
|
191
|
+
expect(resolveCardPadding(atDensity('compact'))).toBe(17);
|
|
192
|
+
expect(resolveCardPadding(atDensity('gigantic'))).toBe(28);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('an explicit facet override is absolute — density does not re-scale it', () => {
|
|
196
|
+
setFacetParamOverrides({ FacetButton: { base: { paddingX: 24 } } });
|
|
197
|
+
expect(resolveButtonPadding(atDensity('default'), 'primary').paddingX).toBe(24);
|
|
198
|
+
expect(resolveButtonPadding(atDensity('gigantic'), 'primary').paddingX).toBe(24);
|
|
199
|
+
// The un-overridden axis still tracks the dial.
|
|
200
|
+
expect(resolveButtonPadding(atDensity('gigantic'), 'primary').paddingY).toBe(8);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('field input padding derives from the density dial (default pins the shipped 14/10)', () => {
|
|
204
|
+
const defaultStyle = resolveFieldInputStyle(atDensity('default'), {
|
|
205
|
+
variant: 'default',
|
|
206
|
+
loading: false,
|
|
207
|
+
});
|
|
208
|
+
expect(defaultStyle.paddingLeft).toBe(14);
|
|
209
|
+
expect(defaultStyle.paddingTop).toBe(10);
|
|
210
|
+
const gigantic = resolveFieldInputStyle(atDensity('gigantic'), {
|
|
211
|
+
variant: 'default',
|
|
212
|
+
loading: false,
|
|
213
|
+
});
|
|
214
|
+
expect(gigantic.paddingLeft).toBe(20);
|
|
215
|
+
expect(gigantic.paddingTop).toBe(14);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('concentric radii are computed at apply time from instance geometry', () => {
|
|
219
|
+
const t = atDensity('default');
|
|
220
|
+
// Checkbox control: inward concentricity across the compact inset —
|
|
221
|
+
// pins the shipped 6 (element 8 − inset 2).
|
|
222
|
+
expect(concentricRadius(t.radius.element, resolveCompactInset(t))).toBe(6);
|
|
223
|
+
// Field focus halo: outward concentricity across the ring inset —
|
|
224
|
+
// container 10 + inset 3 hugs the chrome (the pre-D10 hardcoded
|
|
225
|
+
// radius.page=14 did not track the radius dials).
|
|
226
|
+
expect(concentricOuterRadius(t.radius.container, Math.max(1, Math.round(t.space.xs / 2)))).toBe(13);
|
|
227
|
+
// Under a different radius scale the ring keeps hugging the chrome.
|
|
228
|
+
const round = resolveFacetTheme(
|
|
229
|
+
{ ...defaultThemeDefinition, radius: { scale: 'round', multiplier: 1 } },
|
|
230
|
+
{ scheme: 'light' },
|
|
231
|
+
);
|
|
232
|
+
expect(concentricOuterRadius(round.radius.container, 3)).toBe(25);
|
|
233
|
+
expect(concentricRadius(0, 5)).toBe(0); // clamped, never negative
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/// <reference path="../../../../js/src/types/bun-test.d.ts" />
|
|
2
|
+
// LLP 0157 §2 / LLP 0269: theme-store resolution matrix.
|
|
3
|
+
//
|
|
4
|
+
// After the LLP 0269 revision the store resolves the theme as
|
|
5
|
+
// `resolve(definition)` plus the override cascade. The reference arm is a
|
|
6
|
+
// frozen, independent copy of that resolution (definition → resolveFacetTheme →
|
|
7
|
+
// deep-merged overrides → reduced-motion + size-class), so the matrix still
|
|
8
|
+
// catches store/resolver drift across
|
|
9
|
+
// (scheme/system/custom theme × token overrides × agent override × reduced
|
|
10
|
+
// motion). A separate block exercises the definition-dial path the revision
|
|
11
|
+
// added.
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
_resetExperienceRegistryForTests,
|
|
17
|
+
getThemeOverride,
|
|
18
|
+
getThemeSnapshot,
|
|
19
|
+
setThemeOverride,
|
|
20
|
+
} from '@exact/core/agent';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
applyMotionPreference,
|
|
24
|
+
deepMerge,
|
|
25
|
+
resolveSizeClassForWidth,
|
|
26
|
+
type DeepPartial,
|
|
27
|
+
type FacetTheme,
|
|
28
|
+
type Scheme,
|
|
29
|
+
} from '../internals.js';
|
|
30
|
+
import {
|
|
31
|
+
darkTheme,
|
|
32
|
+
defaultThemeDefinition,
|
|
33
|
+
resolveFacetTheme,
|
|
34
|
+
} from '../theme-definition.js';
|
|
35
|
+
import { _resetFacetOverridesForTests, setThemeDefinitionOverrides } from '../facet-overrides.js';
|
|
36
|
+
import {
|
|
37
|
+
_resetThemeStoreForTesting,
|
|
38
|
+
registerThemeScope,
|
|
39
|
+
resolveThemeForConfig,
|
|
40
|
+
setThemeReducedMotion,
|
|
41
|
+
setThemeSystemAppearance,
|
|
42
|
+
type ThemeScopeConfig,
|
|
43
|
+
} from '../theme-store.js';
|
|
44
|
+
|
|
45
|
+
// The store's default viewport input (store-machinery createHostInput(390)).
|
|
46
|
+
const DEFAULT_VIEWPORT = 390;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Frozen, independent copy of the store's resolution (do not refactor to share
|
|
50
|
+
* code with the store — the duplication is the point of the parity test).
|
|
51
|
+
*/
|
|
52
|
+
function referenceResolve(
|
|
53
|
+
props: {
|
|
54
|
+
theme?: Scheme | 'system' | FacetTheme;
|
|
55
|
+
scheme?: Scheme | 'system';
|
|
56
|
+
overrides?: DeepPartial<FacetTheme>;
|
|
57
|
+
},
|
|
58
|
+
systemScheme: Scheme,
|
|
59
|
+
prefersReducedMotion: boolean,
|
|
60
|
+
registryOverride: Record<string, unknown> | null,
|
|
61
|
+
): FacetTheme {
|
|
62
|
+
const requestedTheme = props.theme ?? props.scheme ?? 'system';
|
|
63
|
+
const baseTheme =
|
|
64
|
+
typeof requestedTheme === 'object'
|
|
65
|
+
? requestedTheme
|
|
66
|
+
: resolveFacetTheme(defaultThemeDefinition, {
|
|
67
|
+
scheme: (requestedTheme === 'system' ? systemScheme : requestedTheme) === 'dark' ? 'dark' : 'light',
|
|
68
|
+
reducedMotion: prefersReducedMotion,
|
|
69
|
+
viewportWidth: DEFAULT_VIEWPORT,
|
|
70
|
+
});
|
|
71
|
+
const mergedTheme = deepMerge(
|
|
72
|
+
deepMerge(baseTheme, props.overrides),
|
|
73
|
+
registryOverride as DeepPartial<FacetTheme> | null | undefined,
|
|
74
|
+
);
|
|
75
|
+
return {
|
|
76
|
+
...mergedTheme,
|
|
77
|
+
sizeClass: resolveSizeClassForWidth(DEFAULT_VIEWPORT, mergedTheme.sizeClasses),
|
|
78
|
+
motion: applyMotionPreference(mergedTheme.motion, prefersReducedMotion),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const customTheme: FacetTheme = {
|
|
83
|
+
...darkTheme,
|
|
84
|
+
name: 'custom-test',
|
|
85
|
+
color: { ...darkTheme.color, accent: { ...darkTheme.color.accent, fill: '#123456' } },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const SCHEME_CELLS: Array<{
|
|
89
|
+
label: string;
|
|
90
|
+
config: ThemeScopeConfig;
|
|
91
|
+
}> = [
|
|
92
|
+
{ label: 'theme=light', config: { theme: 'light' } },
|
|
93
|
+
{ label: 'theme=dark', config: { theme: 'dark' } },
|
|
94
|
+
{ label: 'theme=system', config: { theme: 'system' } },
|
|
95
|
+
{ label: 'scheme=dark', config: { scheme: 'dark' } },
|
|
96
|
+
{ label: 'default (system)', config: {} },
|
|
97
|
+
{ label: 'custom theme object', config: { theme: customTheme } },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
const OVERRIDE_CELLS: Array<DeepPartial<FacetTheme> | undefined> = [
|
|
101
|
+
undefined,
|
|
102
|
+
{ color: { accent: { fill: '#ff5500' }, text: { primary: '#101010' } }, radius: { container: 5 } },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const AGENT_OVERRIDE_CELLS: Array<Record<string, unknown> | null> = [
|
|
106
|
+
null,
|
|
107
|
+
{ color: { background: { body: '#000011' }, accent: { fill: '#22ddaa' } } },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const SYSTEM_CELLS: Scheme[] = ['light', 'dark'];
|
|
111
|
+
const REDUCED_MOTION_CELLS = [false, true];
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
_resetThemeStoreForTesting();
|
|
115
|
+
_resetFacetOverridesForTests();
|
|
116
|
+
_resetExperienceRegistryForTests();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
_resetThemeStoreForTesting();
|
|
121
|
+
_resetFacetOverridesForTests();
|
|
122
|
+
_resetExperienceRegistryForTests();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('theme store resolution matrix (LLP 0157 §2 / LLP 0269)', () => {
|
|
126
|
+
it('matches an independent resolve(definition) + cascade for every matrix cell', () => {
|
|
127
|
+
for (const schemeCell of SCHEME_CELLS) {
|
|
128
|
+
for (const overrides of OVERRIDE_CELLS) {
|
|
129
|
+
for (const agentOverride of AGENT_OVERRIDE_CELLS) {
|
|
130
|
+
for (const systemScheme of SYSTEM_CELLS) {
|
|
131
|
+
for (const reducedMotion of REDUCED_MOTION_CELLS) {
|
|
132
|
+
setThemeOverride(agentOverride);
|
|
133
|
+
setThemeSystemAppearance(systemScheme);
|
|
134
|
+
setThemeReducedMotion(reducedMotion);
|
|
135
|
+
|
|
136
|
+
const config: ThemeScopeConfig = {
|
|
137
|
+
...schemeCell.config,
|
|
138
|
+
overrides,
|
|
139
|
+
};
|
|
140
|
+
const storeResolved = resolveThemeForConfig(config);
|
|
141
|
+
const reference = referenceResolve(
|
|
142
|
+
{
|
|
143
|
+
theme: schemeCell.config.theme,
|
|
144
|
+
scheme: schemeCell.config.scheme,
|
|
145
|
+
overrides,
|
|
146
|
+
},
|
|
147
|
+
systemScheme,
|
|
148
|
+
reducedMotion,
|
|
149
|
+
agentOverride,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(
|
|
153
|
+
storeResolved,
|
|
154
|
+
`cell: ${schemeCell.label} overrides=${!!overrides} agent=${!!agentOverride} system=${systemScheme} rm=${reducedMotion}`,
|
|
155
|
+
).toEqual(reference);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('matches when the render path supplies explicit inputs (React binding path)', () => {
|
|
164
|
+
setThemeSystemAppearance('light');
|
|
165
|
+
setThemeReducedMotion(false);
|
|
166
|
+
|
|
167
|
+
const resolved = resolveThemeForConfig(
|
|
168
|
+
{ theme: 'system' },
|
|
169
|
+
{ systemAppearance: 'dark', reducedMotion: true },
|
|
170
|
+
);
|
|
171
|
+
const reference = referenceResolve({ theme: 'system' }, 'dark', true, null);
|
|
172
|
+
expect(resolved).toEqual(reference);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('definition dials (LLP 0269)', () => {
|
|
177
|
+
it('a density dial edit on the definition flows into the resolved theme', () => {
|
|
178
|
+
const before = resolveThemeForConfig({ theme: 'light' });
|
|
179
|
+
expect(before.density.mode).toBe('default');
|
|
180
|
+
|
|
181
|
+
setThemeDefinitionOverrides({ density: 'comfortable' });
|
|
182
|
+
const after = resolveThemeForConfig({ theme: 'light' });
|
|
183
|
+
expect(after.density.mode).toBe('comfortable');
|
|
184
|
+
expect(after.density.controlHeight.md).toBeGreaterThan(before.density.controlHeight.md);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('an accent dial edit regenerates the accent family', () => {
|
|
188
|
+
setThemeDefinitionOverrides({ accent: '#1d9bf0' });
|
|
189
|
+
const resolved = resolveThemeForConfig({ theme: 'light' });
|
|
190
|
+
expect(resolved.color.accent.fill).toBe('#1d9bf0');
|
|
191
|
+
// Hover/pressed are generated from the dial, not left at the indigo default.
|
|
192
|
+
expect(resolved.color.accent.hover).not.toBe(darkTheme.color.accent.hover);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('agent snapshot golden shape (LLP 0157 §2, F0 checklist item 3)', () => {
|
|
197
|
+
it('publishes the default scope snapshot in the stable 7-token shape', () => {
|
|
198
|
+
const scope = registerThemeScope({ theme: 'dark' });
|
|
199
|
+
const snapshot = getThemeSnapshot();
|
|
200
|
+
|
|
201
|
+
expect(snapshot).not.toBeNull();
|
|
202
|
+
expect(Object.keys(snapshot!).sort()).toEqual(
|
|
203
|
+
['scheme', 'themeName', 'tokens', 'updatedAt'].sort(),
|
|
204
|
+
);
|
|
205
|
+
expect(Object.keys(snapshot!.tokens).sort()).toEqual(
|
|
206
|
+
['accent', 'background', 'border', 'muted', 'surface', 'surfaceAlt', 'text'].sort(),
|
|
207
|
+
);
|
|
208
|
+
expect(snapshot!.scheme).toBe('dark');
|
|
209
|
+
expect(snapshot!.themeName).toBe('facet');
|
|
210
|
+
expect(snapshot!.tokens.background).toBe(darkTheme.color.background.body);
|
|
211
|
+
expect(snapshot!.tokens.accent).toBe(darkTheme.color.accent.fill);
|
|
212
|
+
expect(typeof snapshot!.updatedAt).toBe('number');
|
|
213
|
+
scope.dispose();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('routes POST /agent/theme/override writes into resolution and the snapshot', () => {
|
|
217
|
+
const scope = registerThemeScope({ theme: 'light' });
|
|
218
|
+
// Same write the agent endpoint handler performs.
|
|
219
|
+
setThemeOverride({ color: { accent: { fill: '#ff0000' } } });
|
|
220
|
+
|
|
221
|
+
expect(getThemeOverride()).toEqual({ color: { accent: { fill: '#ff0000' } } });
|
|
222
|
+
expect(scope.getTheme().color.accent.fill).toBe('#ff0000');
|
|
223
|
+
// Agent override outranks scope overrides (highest precedence).
|
|
224
|
+
scope.update({ theme: 'light', overrides: { color: { accent: { fill: '#00ff00' } } } });
|
|
225
|
+
expect(scope.getTheme().color.accent.fill).toBe('#ff0000');
|
|
226
|
+
|
|
227
|
+
const snapshot = getThemeSnapshot();
|
|
228
|
+
expect(snapshot!.tokens.accent).toBe('#ff0000');
|
|
229
|
+
scope.dispose();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('notifies scope subscribers when the agent override changes', () => {
|
|
233
|
+
const scope = registerThemeScope({});
|
|
234
|
+
let notified = 0;
|
|
235
|
+
const unsubscribe = scope.subscribe(() => {
|
|
236
|
+
notified += 1;
|
|
237
|
+
});
|
|
238
|
+
setThemeOverride({ color: { accent: { fill: '#abcdef' } } });
|
|
239
|
+
expect(notified).toBeGreaterThan(0);
|
|
240
|
+
unsubscribe();
|
|
241
|
+
scope.dispose();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('designates the newest live scope as default and hands back on dispose', () => {
|
|
245
|
+
const first = registerThemeScope({ theme: 'light' });
|
|
246
|
+
expect(first.isDefault()).toBe(true);
|
|
247
|
+
expect(getThemeSnapshot()!.scheme).toBe('light');
|
|
248
|
+
|
|
249
|
+
const second = registerThemeScope({ theme: 'dark' });
|
|
250
|
+
expect(second.isDefault()).toBe(true);
|
|
251
|
+
expect(first.isDefault()).toBe(false);
|
|
252
|
+
expect(getThemeSnapshot()!.scheme).toBe('dark');
|
|
253
|
+
|
|
254
|
+
// A non-default scope's update must not steal the snapshot.
|
|
255
|
+
first.update({ theme: 'light' });
|
|
256
|
+
expect(getThemeSnapshot()!.scheme).toBe('dark');
|
|
257
|
+
|
|
258
|
+
second.dispose();
|
|
259
|
+
expect(first.isDefault()).toBe(true);
|
|
260
|
+
expect(getThemeSnapshot()!.scheme).toBe('light');
|
|
261
|
+
first.dispose();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('scope-config reactivity', () => {
|
|
266
|
+
it('resolves system scheme through the host-fed input', () => {
|
|
267
|
+
const scope = registerThemeScope({ theme: 'system' });
|
|
268
|
+
setThemeSystemAppearance('dark');
|
|
269
|
+
expect(scope.getTheme().scheme).toBe('dark');
|
|
270
|
+
setThemeSystemAppearance('light');
|
|
271
|
+
expect(scope.getTheme().scheme).toBe('light');
|
|
272
|
+
scope.dispose();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('applies reduced motion at resolution time', () => {
|
|
276
|
+
const scope = registerThemeScope({ theme: 'light' });
|
|
277
|
+
setThemeReducedMotion(true);
|
|
278
|
+
const theme = scope.getTheme();
|
|
279
|
+
expect(theme.motion.reducedMotion).toBe(true);
|
|
280
|
+
expect(theme.motion.preset.controlPress.transform).toBeUndefined();
|
|
281
|
+
scope.dispose();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import facetRegistryManifest from '../facet-registry.json';
|
|
2
|
+
|
|
3
|
+
export interface FacetCatalogVariantDefinition {
|
|
4
|
+
name: string;
|
|
5
|
+
values: string[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-framework source locations for a catalog entry (LLP 0157 §7).
|
|
10
|
+
* `sourceFilePath` remains the React-default legacy field; the
|
|
11
|
+
* `facet-catalog-schema` check enforces that the two never disagree.
|
|
12
|
+
*/
|
|
13
|
+
export interface FacetCatalogSources {
|
|
14
|
+
react: string;
|
|
15
|
+
contract?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FacetCatalogEntry {
|
|
19
|
+
name: string;
|
|
20
|
+
aliases?: string[];
|
|
21
|
+
category: string;
|
|
22
|
+
description: string;
|
|
23
|
+
tier: string;
|
|
24
|
+
status: string;
|
|
25
|
+
sourceFilePath: string;
|
|
26
|
+
sources?: FacetCatalogSources;
|
|
27
|
+
variants?: FacetCatalogVariantDefinition[];
|
|
28
|
+
dependencies?: string[];
|
|
29
|
+
annotations?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cloneVariants(
|
|
33
|
+
variants: FacetCatalogEntry['variants'],
|
|
34
|
+
): FacetCatalogEntry['variants'] {
|
|
35
|
+
return variants?.map((variant) => ({
|
|
36
|
+
...variant,
|
|
37
|
+
values: variant.values.slice(),
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cloneEntry(entry: FacetCatalogEntry): FacetCatalogEntry {
|
|
42
|
+
return {
|
|
43
|
+
...entry,
|
|
44
|
+
aliases: entry.aliases?.slice(),
|
|
45
|
+
dependencies: entry.dependencies?.slice(),
|
|
46
|
+
variants: cloneVariants(entry.variants),
|
|
47
|
+
sources: entry.sources ? { ...entry.sources } : undefined,
|
|
48
|
+
annotations: entry.annotations ? { ...entry.annotations } : undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Keep the runtime-facing catalog as a defensive clone of the manifest so the
|
|
53
|
+
// experience registry and tests cannot accidentally mutate the canonical data
|
|
54
|
+
// that the CLI also consumes.
|
|
55
|
+
export const facetCatalog: FacetCatalogEntry[] = (
|
|
56
|
+
facetRegistryManifest as unknown as FacetCatalogEntry[]
|
|
57
|
+
).map(cloneEntry);
|
|
58
|
+
|
|
59
|
+
export type FacetFramework = 'react' | 'contract';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Per-framework coverage status derived from the registry's `sources` field.
|
|
63
|
+
* Doubles as LLP 0156's conversion tracker for Facet-dependent surfaces.
|
|
64
|
+
*/
|
|
65
|
+
export function facetFrameworkCoverage(): Record<string, FacetFramework[]> {
|
|
66
|
+
const coverage: Record<string, FacetFramework[]> = {};
|
|
67
|
+
for (const entry of facetCatalog) {
|
|
68
|
+
const frameworks: FacetFramework[] = ['react'];
|
|
69
|
+
if (entry.sources?.contract) {
|
|
70
|
+
frameworks.push('contract');
|
|
71
|
+
}
|
|
72
|
+
coverage[entry.name] = frameworks;
|
|
73
|
+
}
|
|
74
|
+
return coverage;
|
|
75
|
+
}
|