@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,456 @@
1
+ // @system @ref LLP 0157 §2 — the consolidated Facet theme store.
2
+ //
3
+ // One store, many bindings: the React `FacetThemeProvider` registers a scope
4
+ // per provider instance (preserving today's subtree semantics), a Contract
5
+ // root registers a scope through the theme capability, and the agent
6
+ // endpoints read/write the same process-global inputs. The semantics here
7
+ // are a written contract (LLP 0157 §2) with parity tests — resolution must
8
+ // match the legacy provider exactly:
9
+ //
10
+ // precedence (highest wins): agent override → scope `overrides`
11
+ // → scope custom theme → base scheme
12
+ // animation passes through applyMotionPreference(reduced motion) at
13
+ // resolution time
14
+ // the designated default scope publishes the agent snapshot in the
15
+ // legacy shape `{ scheme, themeName, tokens{7}, updatedAt }`
16
+ // contrast validation warns when any override is active
17
+
18
+ import {
19
+ getThemeOverride,
20
+ setThemeSnapshot,
21
+ subscribeThemeOverride,
22
+ } from '@exact/core/agent/experience-registry';
23
+
24
+ import {
25
+ applyMotionPreference,
26
+ deepMerge,
27
+ resolveSizeClassForWidth,
28
+ validateFacetContrast,
29
+ warnFacetOnce,
30
+ type DeepPartial,
31
+ type FacetTheme,
32
+ type Scheme,
33
+ } from './internals.js';
34
+ import {
35
+ defaultThemeDefinition,
36
+ resolveFacetTheme,
37
+ type FacetThemeDefinition,
38
+ } from './theme-definition.js';
39
+ import {
40
+ createHostInput,
41
+ createPreferenceStore,
42
+ type HostInput,
43
+ type PreferenceStore,
44
+ } from './store-machinery.js';
45
+ import {
46
+ getAppThemeOverrides,
47
+ getThemeDefinitionOverrides,
48
+ subscribeFacetOverrides,
49
+ } from './facet-overrides.js';
50
+
51
+ export type ThemeSchemePreference = Scheme | 'system';
52
+
53
+ export interface ThemeScopeConfig {
54
+ /** Mirrors `FacetThemeProvider`'s `theme` prop: a scheme, 'system', or a full custom theme. */
55
+ theme?: ThemeSchemePreference | FacetTheme;
56
+ /** Mirrors `FacetThemeProvider`'s `scheme` prop; used when `theme` is absent. */
57
+ scheme?: ThemeSchemePreference;
58
+ /** Deep-partial token overrides, merged above the base theme. */
59
+ overrides?: DeepPartial<FacetTheme> | null;
60
+ }
61
+
62
+ export interface ThemeScope {
63
+ readonly id: number;
64
+ /** Resolved theme for this scope (cached until an input or config changes). */
65
+ getTheme(): FacetTheme;
66
+ subscribe(listener: () => void): () => void;
67
+ update(config: ThemeScopeConfig): void;
68
+ dispose(): void;
69
+ /** Whether this scope currently backs the agent snapshot. */
70
+ isDefault(): boolean;
71
+ }
72
+
73
+ interface ScopeRecord {
74
+ id: number;
75
+ config: ThemeScopeConfig;
76
+ listeners: Set<() => void>;
77
+ cachedTheme: FacetTheme | null;
78
+ cachedAtVersion: number;
79
+ disposed: boolean;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Process-global inputs (LLP 0157 §2 "Inputs")
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /** Host-fed system appearance. Bindings feed this from their host signal. */
87
+ const systemAppearanceInput: HostInput<Scheme> = createHostInput<Scheme>('light');
88
+
89
+ /** Host-fed reduced-motion preference. */
90
+ const reducedMotionInput: HostInput<boolean> = createHostInput<boolean>(false);
91
+
92
+ /** Host-fed viewport width for LLP 0195 size-class resolution. */
93
+ const viewportWidthInput: HostInput<number> = createHostInput<number>(390);
94
+
95
+ /**
96
+ * Process-global scheme preference with injected persistence (used by scopes
97
+ * that do not pin a theme/scheme locally; the React provider always passes
98
+ * its props, so this input only matters for preference-driven bindings).
99
+ */
100
+ const schemePreferenceStore: PreferenceStore<ThemeSchemePreference> = createPreferenceStore({
101
+ defaultValue: 'system',
102
+ normalize: (raw) => (raw === 'light' || raw === 'dark' || raw === 'system' ? raw : null),
103
+ });
104
+
105
+ let globalVersion = 0;
106
+ const scopes = new Map<number, ScopeRecord>();
107
+ const scopeOrder: number[] = [];
108
+ const storeListeners = new Set<() => void>();
109
+ let nextScopeId = 1;
110
+ let agentOverrideSubscription: (() => void) | null = null;
111
+
112
+ function bumpGlobalVersion(): void {
113
+ globalVersion += 1;
114
+ for (const id of scopeOrder) {
115
+ const record = scopes.get(id);
116
+ if (!record) {
117
+ continue;
118
+ }
119
+ for (const listener of record.listeners) {
120
+ listener();
121
+ }
122
+ }
123
+ for (const listener of storeListeners) {
124
+ listener();
125
+ }
126
+ publishDefaultScopeSnapshot();
127
+ }
128
+
129
+ systemAppearanceInput.subscribe(bumpGlobalVersion);
130
+ reducedMotionInput.subscribe(bumpGlobalVersion);
131
+ viewportWidthInput.subscribe(bumpGlobalVersion);
132
+ schemePreferenceStore.subscribe(bumpGlobalVersion);
133
+
134
+ function ensureAgentOverrideSubscription(): void {
135
+ // Design Mode's file-backed overrides (LLP 0158 §6.2) are an input like
136
+ // the agent registry: a change to either layer bumps the global version.
137
+ ensureFacetOverridesSubscription();
138
+ if (agentOverrideSubscription) {
139
+ return;
140
+ }
141
+ // The pre-F0 standalone override registry in @exact/core folds into the
142
+ // store as an input: POST /agent/theme/override still writes the registry,
143
+ // and the store reacts to it like any other input.
144
+ agentOverrideSubscription = subscribeThemeOverride(bumpGlobalVersion);
145
+ }
146
+
147
+ let facetOverridesSubscription: (() => void) | null = null;
148
+
149
+ function ensureFacetOverridesSubscription(): void {
150
+ if (facetOverridesSubscription) {
151
+ return;
152
+ }
153
+ facetOverridesSubscription = subscribeFacetOverrides(bumpGlobalVersion);
154
+ }
155
+
156
+ export function setThemeSystemAppearance(scheme: Scheme): void {
157
+ systemAppearanceInput.set(scheme);
158
+ }
159
+
160
+ export function getThemeSystemAppearance(): Scheme {
161
+ return systemAppearanceInput.get();
162
+ }
163
+
164
+ export function setThemeReducedMotion(reducedMotion: boolean): void {
165
+ reducedMotionInput.set(reducedMotion);
166
+ }
167
+
168
+ export function getThemeReducedMotion(): boolean {
169
+ return reducedMotionInput.get();
170
+ }
171
+
172
+ export function setThemeViewportWidth(width: number): void {
173
+ viewportWidthInput.set(Number.isFinite(width) ? Math.max(0, width) : 0);
174
+ }
175
+
176
+ export function getThemeViewportWidth(): number {
177
+ return viewportWidthInput.get();
178
+ }
179
+
180
+ export function setThemeSchemePreference(preference: ThemeSchemePreference): void {
181
+ schemePreferenceStore.set(preference);
182
+ }
183
+
184
+ export function getThemeSchemePreference(): ThemeSchemePreference {
185
+ return schemePreferenceStore.get();
186
+ }
187
+
188
+ export const configureThemePreferencePersistence =
189
+ schemePreferenceStore.configurePersistence.bind(schemePreferenceStore);
190
+
191
+ /**
192
+ * Subscribes to any process-global theme input change (system appearance,
193
+ * reduced motion, scheme preference, agent override). Bindings use this with
194
+ * `getThemeStoreVersion` for useSyncExternalStore-style integration.
195
+ */
196
+ export function subscribeToThemeStore(listener: () => void): () => void {
197
+ ensureAgentOverrideSubscription();
198
+ storeListeners.add(listener);
199
+ return () => {
200
+ storeListeners.delete(listener);
201
+ };
202
+ }
203
+
204
+ export function getThemeStoreVersion(): number {
205
+ return globalVersion;
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Resolution (verbatim semantics from the legacy FacetThemeProvider)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export interface ThemeResolutionInputs {
213
+ /** Overrides the host-fed system appearance for this resolution. */
214
+ systemAppearance?: Scheme;
215
+ /** Overrides the host-fed reduced-motion preference for this resolution. */
216
+ reducedMotion?: boolean;
217
+ /** Overrides the host-fed viewport width for size-class token resolution. */
218
+ viewportWidth?: number;
219
+ }
220
+
221
+ function resolveScopeTheme(config: ThemeScopeConfig, inputs?: ThemeResolutionInputs): {
222
+ theme: FacetTheme;
223
+ hasCustomThemeOverride: boolean;
224
+ } {
225
+ const registryOverride = getThemeOverride();
226
+ const requestedTheme = config.theme ?? config.scheme ?? schemePreferenceStore.get();
227
+ const systemAppearance = inputs?.systemAppearance ?? systemAppearanceInput.get();
228
+ const prefersReducedMotion = inputs?.reducedMotion ?? reducedMotionInput.get();
229
+ const viewportWidth = inputs?.viewportWidth ?? viewportWidthInput.get();
230
+
231
+ // LLP 0269 cascade, lowest → highest: definition → resolved tokens (level 1)
232
+ // < app token overrides (theme.overrides.ts, level 3) < scope/instance
233
+ // overrides < agent registry override. A dial edit is broad; a token/instance
234
+ // override wins locally ("most-specific wins").
235
+ const definitionOverrides = getThemeDefinitionOverrides();
236
+ const activeDefinition: FacetThemeDefinition = definitionOverrides
237
+ ? deepMerge(defaultThemeDefinition, definitionOverrides)
238
+ : defaultThemeDefinition;
239
+
240
+ const baseTheme: FacetTheme =
241
+ typeof requestedTheme === 'object'
242
+ // A fully-resolved custom theme passed directly (rare; the React provider
243
+ // still allows it). Used verbatim as the cascade base.
244
+ ? requestedTheme
245
+ : resolveFacetTheme(activeDefinition, {
246
+ scheme:
247
+ (requestedTheme === 'system' ? systemAppearance : requestedTheme) === 'dark'
248
+ ? 'dark'
249
+ : 'light',
250
+ reducedMotion: prefersReducedMotion,
251
+ viewportWidth,
252
+ });
253
+
254
+ const appOverrides = getAppThemeOverrides();
255
+ const mergedTheme = deepMerge(
256
+ deepMerge(deepMerge(baseTheme, appOverrides), config.overrides),
257
+ registryOverride as DeepPartial<FacetTheme> | null | undefined,
258
+ );
259
+ const hasCustomThemeOverride =
260
+ typeof requestedTheme === 'object' ||
261
+ config.overrides != null ||
262
+ appOverrides != null ||
263
+ definitionOverrides != null ||
264
+ registryOverride != null;
265
+ const theme: FacetTheme = {
266
+ ...mergedTheme,
267
+ sizeClass: resolveSizeClassForWidth(viewportWidth, mergedTheme.sizeClasses),
268
+ motion: applyMotionPreference(mergedTheme.motion, prefersReducedMotion),
269
+ };
270
+
271
+ return { theme, hasCustomThemeOverride };
272
+ }
273
+
274
+ function validateScopeContrast(theme: FacetTheme, hasCustomThemeOverride: boolean): void {
275
+ if (!hasCustomThemeOverride) {
276
+ return;
277
+ }
278
+ const failures = validateFacetContrast(theme);
279
+ if (failures.length === 0) {
280
+ return;
281
+ }
282
+ warnFacetOnce(`[Facet] Theme override contrast check failed: ${failures.join('; ')}`);
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Snapshot publication (default scope only)
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function defaultScopeRecord(): ScopeRecord | null {
290
+ // Newest live scope backs the agent snapshot. This matches the legacy
291
+ // multi-provider outcome at mount (every provider's effect republished
292
+ // last-write-wins, and React runs parent effects after children, so the
293
+ // outermost provider won — and it registers last here too).
294
+ for (let index = scopeOrder.length - 1; index >= 0; index -= 1) {
295
+ const record = scopes.get(scopeOrder[index]);
296
+ if (record && !record.disposed) {
297
+ return record;
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ function publishDefaultScopeSnapshot(): void {
304
+ const record = defaultScopeRecord();
305
+ if (!record) {
306
+ return;
307
+ }
308
+ const { theme, hasCustomThemeOverride } = resolveScopeTheme(record.config);
309
+ validateScopeContrast(theme, hasCustomThemeOverride);
310
+ setThemeSnapshot({
311
+ scheme: theme.scheme,
312
+ themeName: theme.name,
313
+ // The agent snapshot keeps its stable 7-token shape, now sourced from the
314
+ // resolved semantic taxonomy (LLP 0269): old surface→card (raised),
315
+ // old surfaceAlt→surface (recessed).
316
+ tokens: {
317
+ background: theme.color.background.body,
318
+ surface: theme.color.background.card,
319
+ surfaceAlt: theme.color.background.surface,
320
+ border: theme.color.border.default,
321
+ text: theme.color.text.primary,
322
+ accent: theme.color.accent.fill,
323
+ muted: theme.color.text.secondary,
324
+ },
325
+ updatedAt: Date.now(),
326
+ });
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Scopes (LLP 0157 §2 "Scopes")
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * Registers a theme scope. The first live scope is the designated default
335
+ * scope and backs the agent snapshot; when it disposes, the next-oldest
336
+ * scope takes over. (Pre-F0, multiple React providers raced on the snapshot
337
+ * effect last-write-wins; first-registered-wins is the strictly-better
338
+ * clarification recorded in LLP 0157 §2.)
339
+ */
340
+ export function registerThemeScope(config: ThemeScopeConfig = {}): ThemeScope {
341
+ ensureAgentOverrideSubscription();
342
+
343
+ const record: ScopeRecord = {
344
+ id: nextScopeId++,
345
+ config,
346
+ listeners: new Set(),
347
+ cachedTheme: null,
348
+ cachedAtVersion: -1,
349
+ disposed: false,
350
+ };
351
+ scopes.set(record.id, record);
352
+ scopeOrder.push(record.id);
353
+ publishDefaultScopeSnapshot();
354
+
355
+ let scopeVersion = 0;
356
+
357
+ function resolutionVersion(): number {
358
+ // Cache key over every input that can change resolution output.
359
+ return globalVersion * 1_000_000 + scopeVersion;
360
+ }
361
+
362
+ return {
363
+ id: record.id,
364
+
365
+ getTheme(): FacetTheme {
366
+ const version = resolutionVersion();
367
+ if (record.cachedTheme && record.cachedAtVersion === version) {
368
+ return record.cachedTheme;
369
+ }
370
+ const { theme, hasCustomThemeOverride } = resolveScopeTheme(record.config);
371
+ validateScopeContrast(theme, hasCustomThemeOverride);
372
+ record.cachedTheme = theme;
373
+ record.cachedAtVersion = version;
374
+ return theme;
375
+ },
376
+
377
+ subscribe(listener: () => void): () => void {
378
+ record.listeners.add(listener);
379
+ return () => {
380
+ record.listeners.delete(listener);
381
+ };
382
+ },
383
+
384
+ update(config: ThemeScopeConfig): void {
385
+ record.config = config;
386
+ record.cachedTheme = null;
387
+ scopeVersion += 1;
388
+ for (const listener of record.listeners) {
389
+ listener();
390
+ }
391
+ if (defaultScopeRecord() === record) {
392
+ publishDefaultScopeSnapshot();
393
+ }
394
+ },
395
+
396
+ dispose(): void {
397
+ if (record.disposed) {
398
+ return;
399
+ }
400
+ const wasDefault = defaultScopeRecord() === record;
401
+ record.disposed = true;
402
+ record.listeners.clear();
403
+ scopes.delete(record.id);
404
+ const index = scopeOrder.indexOf(record.id);
405
+ if (index >= 0) {
406
+ scopeOrder.splice(index, 1);
407
+ }
408
+ if (wasDefault) {
409
+ publishDefaultScopeSnapshot();
410
+ }
411
+ },
412
+
413
+ isDefault(): boolean {
414
+ return defaultScopeRecord() === record;
415
+ },
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Resolves a theme without registering a scope (one-shot resolution with
421
+ * identical semantics, including the contrast warning when any override is
422
+ * active — the legacy provider warned per instance). The React binding uses
423
+ * this for its synchronous render path, passing its hook-read inputs; tests
424
+ * use it for the parity matrix.
425
+ */
426
+ export function resolveThemeForConfig(
427
+ config: ThemeScopeConfig = {},
428
+ inputs?: ThemeResolutionInputs,
429
+ ): FacetTheme {
430
+ const { theme, hasCustomThemeOverride } = resolveScopeTheme(config, inputs);
431
+ validateScopeContrast(theme, hasCustomThemeOverride);
432
+ return theme;
433
+ }
434
+
435
+ // Wire the agent-override input eagerly so an override write that lands
436
+ // before any scope/binding subscribes still bumps the store version (the
437
+ // legacy provider's useSyncExternalStore snapshot caught exactly this race).
438
+ ensureAgentOverrideSubscription();
439
+
440
+ /** Test-only: clears all scopes and global inputs back to initial state. */
441
+ export function _resetThemeStoreForTesting(): void {
442
+ scopes.clear();
443
+ scopeOrder.length = 0;
444
+ storeListeners.clear();
445
+ nextScopeId = 1;
446
+ systemAppearanceInput.set('light');
447
+ reducedMotionInput.set(false);
448
+ viewportWidthInput.set(390);
449
+ if (schemePreferenceStore.get() !== 'system') {
450
+ schemePreferenceStore.set('system');
451
+ }
452
+ if (agentOverrideSubscription) {
453
+ agentOverrideSubscription();
454
+ agentOverrideSubscription = null;
455
+ }
456
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "baseUrl": ".",
4
+ "target": "ES2022",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowImportingTsExtensions": true,
8
+ "resolveJsonModule": true,
9
+ "lib": ["ES2022"],
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "paths": {
14
+ "@exact/core": ["../exact-core/src/index.ts"],
15
+ "@exact/core/*": ["../exact-core/src/*"]
16
+ }
17
+ },
18
+ "include": ["src/**/*", "facet-registry.json"]
19
+ }