@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,322 @@
1
+ // @system @ref LLP 0158 §6.2 — Design Mode D1: the facet overrides layer.
2
+ //
3
+ // Two process-global, file-backed override inputs plus the recipe-parameter
4
+ // resolver. This is the NEW resolution layer the round-1 review demanded be
5
+ // specified rather than assumed: theme overrides answer "what is
6
+ // theme.space.md"; THIS layer answers "what is FacetButton·primary's
7
+ // paddingX parameter before the recipe runs."
8
+ //
9
+ // Inputs are set by the app's generated authority files
10
+ // (js/src/theme.overrides.ts, js/src/facets.overrides.ts via their loader)
11
+ // and previewed in-memory by Design Mode; the theme store subscribes both
12
+ // and bumps its global version, so every binding re-renders on change.
13
+ // Precedence at parameter resolution time (LLP 0158 §6.2):
14
+ // override(variant) → override(base) → recipe default.
15
+ // A value is either a literal or a token rebind `{ token: "space.md" }` —
16
+ // the two stay distinct in source, provenance, and diffs.
17
+
18
+ import { resolveDensityPadding, type DeepPartial, type FacetTheme } from './internals.js';
19
+ import type { FacetThemeDefinition } from './theme-definition.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Recipe parameter declarations (LLP 0158 §6.2 "parameter reification")
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * The tunable-parameter table. D1 reifies the state-independent geometry
27
+ * parameters of the interactive trio; color/state-aware parameters need a
28
+ * richer declaration (per-state values) and are deliberately deferred —
29
+ * color edits in D1 are theme-scope token edits. Keys are the facet
30
+ * component call names as they appear at Contract boundaries.
31
+ *
32
+ * LLP 0163 §5.4 (E1c): the AUTHORITY is the `params` sections in the
33
+ * facet components' own .contract files; this table is GENERATED output
34
+ * (bun run generate:facet-params), re-exported here so every consumer —
35
+ * the overrides authority's types (`FacetParamOverrides`), the resolver's
36
+ * call sites, the Design Mode panel, the prompt lane — keeps reading the
37
+ * same neutral data, indifferent to who wrote it. facet-core has no
38
+ * runtime import on facet-contract or the compiler: the dependency is
39
+ * generator-time data flow only.
40
+ */
41
+ export { RECIPE_PARAMETERS } from './facet-params.generated.js';
42
+ import { RECIPE_PARAMETERS } from './facet-params.generated.js';
43
+
44
+ export type FacetParamComponent = keyof typeof RECIPE_PARAMETERS;
45
+
46
+ export type FacetParamValue = string | number | boolean | { token: string };
47
+
48
+ /** Shape of the facets.overrides.ts authority, derived from the table. */
49
+ export type FacetParamOverrides = {
50
+ [Component in FacetParamComponent]?: {
51
+ base?: Partial<Record<keyof (typeof RECIPE_PARAMETERS)[Component]['params'], FacetParamValue>>;
52
+ variants?: Record<
53
+ string,
54
+ Partial<Record<keyof (typeof RECIPE_PARAMETERS)[Component]['params'], FacetParamValue>>
55
+ >;
56
+ };
57
+ };
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Process-global state
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Definition-level dial overrides (the theme.definition.ts authority, LLP
64
+ * 0269). This is the *generative* layer Design Mode dials and agents edit:
65
+ * accent, density, type, radius, motion. It resolves below every token
66
+ * override — a dial is broad, a token/instance override wins locally. */
67
+ export type ThemeDefinitionOverrides = DeepPartial<FacetThemeDefinition>;
68
+
69
+ interface FacetOverridesState {
70
+ themeDefinitionOverrides: ThemeDefinitionOverrides | null;
71
+ appThemeOverrides: DeepPartial<FacetTheme> | null;
72
+ facetParamOverrides: FacetParamOverrides | null;
73
+ listeners: Set<() => void>;
74
+ }
75
+
76
+ declare global {
77
+ // eslint-disable-next-line no-var
78
+ var __exactFacetOverridesState: FacetOverridesState | undefined;
79
+ }
80
+
81
+ function getState(): FacetOverridesState {
82
+ if (globalThis.__exactFacetOverridesState) {
83
+ return globalThis.__exactFacetOverridesState;
84
+ }
85
+ const state: FacetOverridesState = {
86
+ themeDefinitionOverrides: null,
87
+ appThemeOverrides: null,
88
+ facetParamOverrides: null,
89
+ listeners: new Set(),
90
+ };
91
+ globalThis.__exactFacetOverridesState = state;
92
+ return state;
93
+ }
94
+
95
+ function notify(): void {
96
+ for (const listener of getState().listeners) {
97
+ listener();
98
+ }
99
+ }
100
+
101
+ /** Definition-level dial overrides (the theme.definition.ts authority). */
102
+ export function setThemeDefinitionOverrides(overrides: ThemeDefinitionOverrides | null): void {
103
+ getState().themeDefinitionOverrides =
104
+ overrides && Object.keys(overrides).length > 0 ? overrides : null;
105
+ notify();
106
+ }
107
+
108
+ export function getThemeDefinitionOverrides(): ThemeDefinitionOverrides | null {
109
+ return getState().themeDefinitionOverrides;
110
+ }
111
+
112
+ /** App-level theme token overrides (the theme.overrides.ts authority). */
113
+ export function setAppThemeOverrides(overrides: DeepPartial<FacetTheme> | null): void {
114
+ getState().appThemeOverrides = overrides && Object.keys(overrides).length > 0 ? overrides : null;
115
+ notify();
116
+ }
117
+
118
+ export function getAppThemeOverrides(): DeepPartial<FacetTheme> | null {
119
+ return getState().appThemeOverrides;
120
+ }
121
+
122
+ /** Facet recipe-parameter overrides (the facets.overrides.ts authority). */
123
+ export function setFacetParamOverrides(overrides: FacetParamOverrides | null): void {
124
+ getState().facetParamOverrides =
125
+ overrides && Object.keys(overrides).length > 0 ? overrides : null;
126
+ notify();
127
+ }
128
+
129
+ export function getFacetParamOverrides(): FacetParamOverrides | null {
130
+ return getState().facetParamOverrides;
131
+ }
132
+
133
+ /** One subscription covers both layers; the theme store bumps on either. */
134
+ export function subscribeFacetOverrides(listener: () => void): () => void {
135
+ const state = getState();
136
+ state.listeners.add(listener);
137
+ return () => {
138
+ state.listeners.delete(listener);
139
+ };
140
+ }
141
+
142
+ export function _resetFacetOverridesForTests(): void {
143
+ const state = getState();
144
+ state.themeDefinitionOverrides = null;
145
+ state.appThemeOverrides = null;
146
+ state.facetParamOverrides = null;
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Resolution
151
+ // ---------------------------------------------------------------------------
152
+
153
+ function readTokenPath(theme: FacetTheme, path: string): unknown {
154
+ let cursor: unknown = theme;
155
+ for (const segment of path.split('.')) {
156
+ if (cursor === null || typeof cursor !== 'object') {
157
+ return undefined;
158
+ }
159
+ cursor = (cursor as Record<string, unknown>)[segment];
160
+ }
161
+ return cursor;
162
+ }
163
+
164
+ /** Value kind of a theme token leaf (LLP 0166 §4.4 schema kinds). */
165
+ export type ThemeTokenValueKind = 'dimension' | 'color' | 'duration' | 'number' | 'string' | 'boolean';
166
+
167
+ /**
168
+ * Flattens a theme into the token schema `$` references validate against
169
+ * (LLP 0166 §4.4): every leaf path mapped to its value kind. Paths name
170
+ * schema positions, not concrete values — any complete theme yields the
171
+ * same path set, so callers typically pass a base theme.
172
+ */
173
+ export function collectThemeTokenSchema(theme: FacetTheme): {
174
+ paths: Record<string, ThemeTokenValueKind>;
175
+ } {
176
+ const paths: Record<string, ThemeTokenValueKind> = {};
177
+ const DURATION_LEAVES = new Set(['fast', 'normal', 'slow']);
178
+ const kindFor = (rootKey: string, leafKey: string, value: unknown): ThemeTokenValueKind => {
179
+ if (rootKey === 'color' || leafKey === 'color') {
180
+ return 'color';
181
+ }
182
+ if (rootKey === 'space' || rootKey === 'radius') {
183
+ return 'dimension';
184
+ }
185
+ if (rootKey === 'density') {
186
+ return leafKey === 'paddingScale'
187
+ ? 'number'
188
+ : typeof value === 'number'
189
+ ? 'dimension'
190
+ : typeof value === 'boolean'
191
+ ? 'boolean'
192
+ : 'string';
193
+ }
194
+ if (rootKey === 'type') {
195
+ if (leafKey === 'fontWeight' || leafKey === 'letterSpacing') {
196
+ return 'number';
197
+ }
198
+ return typeof value === 'number' ? 'dimension' : 'string';
199
+ }
200
+ if (rootKey === 'elevation') {
201
+ return typeof value === 'number' ? 'dimension' : 'string';
202
+ }
203
+ if (rootKey === 'motion' && typeof value === 'number') {
204
+ return DURATION_LEAVES.has(leafKey) ? 'duration' : 'number';
205
+ }
206
+ if (typeof value === 'number') {
207
+ return 'number';
208
+ }
209
+ if (typeof value === 'boolean') {
210
+ return 'boolean';
211
+ }
212
+ return 'string';
213
+ };
214
+ const walk = (value: unknown, prefix: string, rootKey: string): void => {
215
+ // Arrays (e.g. type.*.fontFamily stacks) are token leaves, not branches.
216
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
217
+ Object.keys(value as Record<string, unknown>).forEach((key) => {
218
+ walk(
219
+ (value as Record<string, unknown>)[key],
220
+ prefix === '' ? key : `${prefix}.${key}`,
221
+ prefix === '' ? key : rootKey,
222
+ );
223
+ });
224
+ return;
225
+ }
226
+ const leafKey = prefix.split('.').pop() ?? prefix;
227
+ paths[prefix] = kindFor(rootKey, leafKey, value);
228
+ };
229
+ const roots: Array<keyof FacetTheme> = [
230
+ 'color',
231
+ 'space',
232
+ 'density',
233
+ 'type',
234
+ 'radius',
235
+ 'elevation',
236
+ 'zIndex',
237
+ 'motion',
238
+ 'adaptation',
239
+ ];
240
+ roots.forEach((root) => {
241
+ walk(theme[root], root, root);
242
+ });
243
+ return { paths };
244
+ }
245
+
246
+ /**
247
+ * Resolves one recipe parameter: override(variant) → override(base) →
248
+ * `fallback` (the recipe's existing expression, so empty overrides are
249
+ * byte-identical to pre-reification output). Token rebinds read through the
250
+ * live theme — including the sentinel tracing twin, so traced origins stay
251
+ * correct under rebinding for free.
252
+ */
253
+ /**
254
+ * Button padding recipe (LLP 0269 §5, D10): the density-scaled space defaults
255
+ * behind the LLP 0158 §6.2 override layer. Both bindings (React `Button`,
256
+ * facet-contract `buttonStyle`) call this one helper, so the density dial and
257
+ * the override layer cannot drift per framework. An explicit override is an
258
+ * absolute author value and is NOT re-scaled by density (most-specific-wins:
259
+ * the override already states the final geometry).
260
+ */
261
+ export function resolveButtonPadding(
262
+ theme: FacetTheme,
263
+ variant: string | null | undefined,
264
+ ): { paddingX: number; paddingY: number } {
265
+ return {
266
+ paddingX: resolveRecipeParam(
267
+ theme,
268
+ 'FacetButton',
269
+ variant ?? null,
270
+ 'paddingX',
271
+ resolveDensityPadding(theme, theme.space.md),
272
+ ),
273
+ paddingY: resolveRecipeParam(
274
+ theme,
275
+ 'FacetButton',
276
+ variant ?? null,
277
+ 'paddingY',
278
+ resolveDensityPadding(theme, theme.space.xs),
279
+ ),
280
+ };
281
+ }
282
+
283
+ /** Card padding recipe (LLP 0269 §5, D10) — same contract as
284
+ * `resolveButtonPadding`: density-scaled default, absolute override wins. */
285
+ export function resolveCardPadding(theme: FacetTheme): number {
286
+ return resolveRecipeParam(
287
+ theme,
288
+ 'FacetCard',
289
+ null,
290
+ 'padding',
291
+ resolveDensityPadding(theme, theme.space.lg),
292
+ );
293
+ }
294
+
295
+ export function resolveRecipeParam<T>(
296
+ theme: FacetTheme,
297
+ component: string,
298
+ variant: string | null | undefined,
299
+ param: string,
300
+ fallback: T,
301
+ ): T {
302
+ const overrides = getState().facetParamOverrides?.[component as FacetParamComponent];
303
+ if (!overrides) {
304
+ return fallback;
305
+ }
306
+ const fromVariant =
307
+ variant != null
308
+ ? (overrides.variants?.[variant] as Record<string, FacetParamValue> | undefined)?.[param]
309
+ : undefined;
310
+ const value =
311
+ fromVariant !== undefined
312
+ ? fromVariant
313
+ : (overrides.base as Record<string, FacetParamValue> | undefined)?.[param];
314
+ if (value === undefined) {
315
+ return fallback;
316
+ }
317
+ if (value !== null && typeof value === 'object' && 'token' in value) {
318
+ const resolved = readTokenPath(theme, value.token);
319
+ return resolved === undefined ? fallback : (resolved as T);
320
+ }
321
+ return value as T;
322
+ }
@@ -0,0 +1,65 @@
1
+ // AUTO-GENERATED — DO NOT EDIT.
2
+ // AUTHORITY: the `params` sections in
3
+ // packages/exact-facet-contract/src/components/*.contract (LLP 0163 §5.4).
4
+ // Generator: bun run generate:facet-params (scripts/generate-facet-params.mjs).
5
+ //
6
+ // The reified recipe-parameter table (LLP 0158 §6.2): the overrides
7
+ // authority's types derive from it, resolveRecipeParam's callers consult
8
+ // it, the Design Mode panel enumerates it, and the prompt lane validates
9
+ // against it. It is DERIVED OUTPUT — change the params sections and
10
+ // regenerate; never hand-edit (verified by design-provenance-fixtures).
11
+
12
+ export const RECIPE_PARAMETERS = {
13
+ FacetBadge: {
14
+ variants: [
15
+ 'neutral',
16
+ 'accent',
17
+ 'success',
18
+ 'danger'
19
+ ],
20
+ params: {
21
+ radius: {
22
+ defaultToken: 'radius.full',
23
+ kind: 'dimension'
24
+ }
25
+ }
26
+ },
27
+ FacetButton: {
28
+ variants: [
29
+ 'primary',
30
+ 'secondary',
31
+ 'ghost'
32
+ ],
33
+ params: {
34
+ paddingX: {
35
+ defaultToken: 'space.md',
36
+ kind: 'dimension'
37
+ },
38
+ paddingY: {
39
+ defaultToken: 'space.xs',
40
+ kind: 'dimension'
41
+ },
42
+ radius: {
43
+ defaultToken: 'radius.element',
44
+ kind: 'dimension'
45
+ }
46
+ }
47
+ },
48
+ FacetCard: {
49
+ variants: [],
50
+ params: {
51
+ padding: {
52
+ defaultToken: 'space.lg',
53
+ kind: 'dimension'
54
+ },
55
+ gap: {
56
+ defaultToken: 'space.md',
57
+ kind: 'dimension'
58
+ },
59
+ radius: {
60
+ defaultToken: 'radius.container',
61
+ kind: 'dimension'
62
+ }
63
+ }
64
+ }
65
+ } as const;
@@ -0,0 +1,60 @@
1
+ export type FacetScoreValue = 0 | 1 | 2 | 3;
2
+
3
+ export interface FacetComponentScorecard {
4
+ animation: FacetScoreValue;
5
+ interaction: FacetScoreValue;
6
+ accessibility: FacetScoreValue;
7
+ testing: FacetScoreValue;
8
+ notes: string;
9
+ }
10
+
11
+ function scorecard(
12
+ animation: FacetScoreValue,
13
+ interaction: FacetScoreValue,
14
+ accessibility: FacetScoreValue,
15
+ testing: FacetScoreValue,
16
+ notes: string,
17
+ ): FacetComponentScorecard {
18
+ return {
19
+ animation,
20
+ interaction,
21
+ accessibility,
22
+ testing,
23
+ notes,
24
+ };
25
+ }
26
+
27
+ export const facetScorecards: Record<string, FacetComponentScorecard> = {
28
+ Button: scorecard(3, 3, 3, 3, 'Primary pathfinder component with dedicated tree, interaction, and screenshot coverage.'),
29
+ Input: scorecard(3, 3, 3, 3, 'Shared input chrome has hover, focus, validation, reduced-motion, and regression coverage.'),
30
+ Textarea: scorecard(3, 3, 3, 3, 'Textarea rides the same shell and tests as Input, including FormField wiring.'),
31
+ FormField: scorecard(1, 2, 3, 3, 'Static wrapper, but labels, descriptions, messages, and aria wiring are covered in the form matrix.'),
32
+ Card: scorecard(3, 3, 2, 3, 'Interactive cards now carry hover, focus, and press treatment with tree and screenshot checks.'),
33
+ Badge: scorecard(3, 3, 2, 3, 'Interactive badges share the control motion surface and have dedicated interaction assertions.'),
34
+ Label: scorecard(0, 1, 3, 2, 'Mostly semantic text, but it now participates in the shared form-field contract.'),
35
+ Separator: scorecard(0, 0, 2, 2, 'Pure presentational divider with basic catalog and tree coverage.'),
36
+ Avatar: scorecard(1, 0, 2, 2, 'Static feedback primitive with catalog and fixture coverage, but no richer interaction path.'),
37
+ Skeleton: scorecard(1, 0, 1, 2, 'Static loading placeholder with catalog coverage; no interaction surface.'),
38
+ Progress: scorecard(2, 0, 2, 2, 'Determinate progress has semantics and catalog coverage, but no dedicated interaction path.'),
39
+ Spinner: scorecard(2, 0, 2, 2, 'Indeterminate loading state is visually covered but intentionally non-interactive.'),
40
+ Alert: scorecard(2, 0, 3, 2, 'Live-region alert surface is semantically strong and visible in the catalog/fixture pages.'),
41
+ Toggle: scorecard(3, 3, 3, 3, 'Toggle now has full interaction-state coverage plus reduced-motion and tree assertions.'),
42
+ Checkbox: scorecard(3, 3, 3, 3, 'Checkbox carries hover, focus, press, and checked states with dedicated regression tests.'),
43
+ Slider: scorecard(2, 2, 3, 2, 'Accessible stepper fallback exists, but the composed fallback still lacks a richer drag-path test story.'),
44
+ RadioGroup: scorecard(3, 3, 3, 3, 'Roving focus, selection motion, and interaction-state coverage are in place.'),
45
+ Dialog: scorecard(3, 3, 3, 3, 'Dialog has presence-backed motion, focus behavior, and dedicated screenshot/tree coverage.'),
46
+ AlertDialog: scorecard(3, 2, 3, 2, 'Modal semantics are strong, but the screenshot matrix only covers it through the shared fixture surface today.'),
47
+ Sheet: scorecard(3, 2, 3, 2, 'Sheet reuses the dialog stack and motion surface, with baseline catalog coverage but fewer dedicated checks.'),
48
+ Accordion: scorecard(3, 3, 3, 3, 'Presence-backed content, trigger interaction states, and dedicated tree/screenshot tests are in place.'),
49
+ Collapsible: scorecard(3, 3, 3, 3, 'Standalone disclosure primitive ships with presence motion, interaction states, and dedicated behavior plus visual coverage.'),
50
+ Tabs: scorecard(3, 3, 3, 3, 'Animated indicator, focus states, and dedicated tree/screenshot coverage are in place.'),
51
+ Toast: scorecard(3, 2, 3, 3, 'Toast has presence-backed motion, semantics, and dedicated visual/tree regression coverage.'),
52
+ Popover: scorecard(3, 3, 2, 3, 'Floating placement and presence motion are covered in both interaction and screenshot tests.'),
53
+ Tooltip: scorecard(3, 2, 2, 2, 'Tooltip motion is polished, but focus/hover and screenshot coverage are still lighter than Dialog/Popover.'),
54
+ DropdownMenu: scorecard(3, 3, 2, 2, 'Menu behavior exists, but the visual regression surface is still mostly catalog-level.'),
55
+ Select: scorecard(3, 3, 3, 3, 'Select now has its own trigger path, value state, and both interaction and screenshot coverage.'),
56
+ Combobox: scorecard(3, 3, 3, 3, 'Combobox adds searchable filtering, keyboard selection, and dedicated regression coverage on top of the floating listbox stack.'),
57
+ Command: scorecard(3, 3, 3, 3, 'Command ships as a dialog-backed palette with grouped filtering, keyboard selection, and dedicated visual plus interaction coverage.'),
58
+ Table: scorecard(3, 3, 3, 3, 'Table now provides semantic row and header structure, sortable headers, interactive rows, and dedicated coverage.'),
59
+ ContextMenu: scorecard(3, 2, 2, 2, 'Context menu behavior exists, but its visual and accessibility checks are still less exhaustive than top-level menus.'),
60
+ };
package/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ // @system @ref LLP 0157 — @exact/facet-core public surface.
2
+ //
3
+ // Framework-neutral heart of the Facet design system: theme types and
4
+ // definitions, every variant recipe, motion builders, color and control
5
+ // math, catalog/scorecard metadata, and the consolidated theme store.
6
+ // `@exact/facet` (React) and `@exact/facet-contract` (Contract) are thin
7
+ // bindings over this package.
8
+
9
+ export * from './internals.js';
10
+ export * from './theme-definition.js';
11
+ export * from './facet-catalog.js';
12
+ export * from './facet-scorecard.js';
13
+ export * from './theme-store.js';
14
+ export * from './store-machinery.js';
15
+ export * from './provenance-trace.js';
16
+ export * from './facet-overrides.js';