@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,153 @@
1
+ // @system @ref LLP 0158 — Design Mode D0: recipe origin tracing.
2
+ //
3
+ // Builds a sentinel twin of a FacetTheme: primitive leaves are replaced by
4
+ // unique sentinel values and object leaves (shadow specs, transition
5
+ // presets) are registered by identity, each remembering its token path.
6
+ // Running a pure recipe against the twin — a second, dev-only call, never
7
+ // the render path — lets the caller map each output property back to the
8
+ // token it passed through unchanged. Values transformed on the way out
9
+ // (color math, arithmetic, string templates) do not survive as sentinels
10
+ // and honestly report as computed (LLP 0158 §4.1: tracing is exact or it
11
+ // is nothing; no value-matching heuristics).
12
+
13
+ import type { FacetTheme } from './internals.js';
14
+
15
+ const STRING_SENTINEL_PREFIX = '__exact_tok__:';
16
+ const STRING_SENTINEL_SUFFIX = '__';
17
+
18
+ // 2^25 base plus exact binary fractions: survives identity passthrough,
19
+ // vanishingly unlikely to collide with anything a recipe computes.
20
+ const NUMBER_SENTINEL_BASE = 33554432;
21
+ const NUMBER_SENTINEL_STEP = 0.0009765625; // 2^-10, exact in float64
22
+
23
+ export interface ThemeTraceHandle {
24
+ /** Sentinel twin, shaped like the source theme. */
25
+ theme: FacetTheme;
26
+ /** Maps a traced output value back to a token path, or null. */
27
+ resolve(value: unknown): string | null;
28
+ }
29
+
30
+ export interface TracedOrigin {
31
+ kind: 'token' | 'computed';
32
+ tokenPath?: string;
33
+ }
34
+
35
+ export type TracedStyleOrigins = Record<string, TracedOrigin>;
36
+
37
+ export function createTracingTheme(real: FacetTheme): ThemeTraceHandle {
38
+ const numberPaths = new Map<number, string>();
39
+ const objectPaths = new WeakMap<object, string>();
40
+ let numberIndex = 0;
41
+
42
+ const twin = (value: unknown, path: string[]): unknown => {
43
+ if (typeof value === 'string') {
44
+ return `${STRING_SENTINEL_PREFIX}${path.join('.')}${STRING_SENTINEL_SUFFIX}`;
45
+ }
46
+ if (typeof value === 'number') {
47
+ // `density.paddingScale` is a multiplier recipes FOLD INTO geometry
48
+ // (LLP 0269 §5 apply-time density), never a value they pass through;
49
+ // a sentinel here would poison every padding trace even when the
50
+ // scale is inert. Carrying the real value keeps tracing honest both
51
+ // ways: at scale 1 the geometry token's sentinel survives (token),
52
+ // at any real scale the arithmetic reports computed.
53
+ if (path.length === 2 && path[0] === 'density' && path[1] === 'paddingScale') {
54
+ return value;
55
+ }
56
+ numberIndex += 1;
57
+ const sentinel = NUMBER_SENTINEL_BASE + numberIndex * NUMBER_SENTINEL_STEP;
58
+ numberPaths.set(sentinel, path.join('.'));
59
+ return sentinel;
60
+ }
61
+ if (Array.isArray(value)) {
62
+ const copy = value.map((entry, index) => twin(entry, [...path, String(index)]));
63
+ objectPaths.set(copy, path.join('.'));
64
+ return copy;
65
+ }
66
+ if (value !== null && typeof value === 'object') {
67
+ const copy: Record<string, unknown> = {};
68
+ for (const [key, entry] of Object.entries(value)) {
69
+ copy[key] = twin(entry, [...path, key]);
70
+ }
71
+ objectPaths.set(copy, path.join('.'));
72
+ return copy;
73
+ }
74
+ // booleans / null / undefined are not traceable (two-valued or empty
75
+ // domains); pass the real value through.
76
+ return value;
77
+ };
78
+
79
+ const theme = twin(real, []) as FacetTheme;
80
+
81
+ const resolve = (value: unknown): string | null => {
82
+ if (typeof value === 'string') {
83
+ if (
84
+ value.startsWith(STRING_SENTINEL_PREFIX) &&
85
+ value.endsWith(STRING_SENTINEL_SUFFIX)
86
+ ) {
87
+ return value.slice(
88
+ STRING_SENTINEL_PREFIX.length,
89
+ value.length - STRING_SENTINEL_SUFFIX.length,
90
+ );
91
+ }
92
+ return null;
93
+ }
94
+ if (typeof value === 'number') {
95
+ return numberPaths.get(value) ?? null;
96
+ }
97
+ if (value !== null && typeof value === 'object') {
98
+ return objectPaths.get(value) ?? null;
99
+ }
100
+ return null;
101
+ };
102
+
103
+ return { theme, resolve };
104
+ }
105
+
106
+ function isThemeLikeArg(value: unknown): value is FacetTheme {
107
+ if (value === null || typeof value !== 'object') {
108
+ return false;
109
+ }
110
+ const candidate = value as Record<string, unknown>;
111
+ return 'color' in candidate && 'scheme' in candidate && 'space' in candidate;
112
+ }
113
+
114
+ /**
115
+ * Re-runs a pure style recipe with the theme argument replaced by a
116
+ * sentinel twin and maps each output property to its origin. Returns null
117
+ * when the call has no theme-shaped argument or the traced run throws —
118
+ * callers fall back to a plain computed origin, never a wrong one.
119
+ */
120
+ export function traceStyleOrigins(
121
+ fn: unknown,
122
+ args: readonly unknown[],
123
+ ): TracedStyleOrigins | null {
124
+ if (typeof fn !== 'function') {
125
+ return null;
126
+ }
127
+ const themeIndex = args.findIndex(isThemeLikeArg);
128
+ if (themeIndex === -1) {
129
+ return null;
130
+ }
131
+
132
+ try {
133
+ const handle = createTracingTheme(args[themeIndex] as FacetTheme);
134
+ const tracedArgs = args.slice();
135
+ tracedArgs[themeIndex] = handle.theme;
136
+ const output = (fn as (...a: unknown[]) => unknown)(...tracedArgs);
137
+ if (output === null || typeof output !== 'object') {
138
+ return null;
139
+ }
140
+
141
+ const origins: TracedStyleOrigins = {};
142
+ for (const [prop, value] of Object.entries(output as Record<string, unknown>)) {
143
+ if (value === undefined) {
144
+ continue;
145
+ }
146
+ const tokenPath = handle.resolve(value);
147
+ origins[prop] = tokenPath ? { kind: 'token', tokenPath } : { kind: 'computed' };
148
+ }
149
+ return origins;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
@@ -0,0 +1,128 @@
1
+ // @system @ref LLP 0157 — shared store machinery for theme-preference stores.
2
+ //
3
+ // This is the generic part of the repo's theme stores: a persisted string
4
+ // preference plus host-fed inputs with subscribe/version semantics. The
5
+ // facet-core theme store (`theme-store.ts`) and the lab/demo design system
6
+ // (`js/src/_design/theme.ts`) both build on this machinery; per the
7
+ // four-layer styling charter they share machinery, not tokens.
8
+ //
9
+ // Persistence is injected. Core never touches `localStorage` or platform
10
+ // settings itself — the binding that owns persistence (e.g. `_design` on
11
+ // web) supplies an adapter.
12
+
13
+ export interface PreferencePersistenceAdapter {
14
+ load(): string | null;
15
+ save(value: string): void;
16
+ }
17
+
18
+ export interface PreferenceStore<T extends string> {
19
+ get(): T;
20
+ /** Sets the preference, persists it through the adapter, notifies. */
21
+ set(next: T): void;
22
+ subscribe(listener: () => void): () => void;
23
+ /** Monotonic change counter, usable with useSyncExternalStore-style bindings. */
24
+ version(): number;
25
+ /**
26
+ * Installs the persistence adapter. Loads the persisted value immediately
27
+ * (if any) and persists subsequent writes.
28
+ */
29
+ configurePersistence(adapter: PreferencePersistenceAdapter | null): void;
30
+ }
31
+
32
+ export function createPreferenceStore<T extends string>(options: {
33
+ defaultValue: T;
34
+ /** Validates/normalizes a raw persisted string; return null to reject it. */
35
+ normalize?: (raw: string) => T | null;
36
+ }): PreferenceStore<T> {
37
+ const listeners = new Set<() => void>();
38
+ let value: T = options.defaultValue;
39
+ let storeVersion = 0;
40
+ let persistence: PreferencePersistenceAdapter | null = null;
41
+
42
+ function notify(): void {
43
+ storeVersion += 1;
44
+ for (const listener of listeners) {
45
+ listener();
46
+ }
47
+ }
48
+
49
+ return {
50
+ get(): T {
51
+ return value;
52
+ },
53
+ set(next: T): void {
54
+ if (next === value) {
55
+ return;
56
+ }
57
+ value = next;
58
+ try {
59
+ persistence?.save(next);
60
+ } catch {
61
+ // Persistence is best-effort; preference still applies in-memory.
62
+ }
63
+ notify();
64
+ },
65
+ subscribe(listener: () => void): () => void {
66
+ listeners.add(listener);
67
+ return () => {
68
+ listeners.delete(listener);
69
+ };
70
+ },
71
+ version(): number {
72
+ return storeVersion;
73
+ },
74
+ configurePersistence(adapter: PreferencePersistenceAdapter | null): void {
75
+ persistence = adapter;
76
+ if (!adapter) {
77
+ return;
78
+ }
79
+ let raw: string | null = null;
80
+ try {
81
+ raw = adapter.load();
82
+ } catch {
83
+ raw = null;
84
+ }
85
+ if (raw == null) {
86
+ return;
87
+ }
88
+ const normalized = options.normalize ? options.normalize(raw) : (raw as T);
89
+ if (normalized != null && normalized !== value) {
90
+ value = normalized;
91
+ notify();
92
+ }
93
+ },
94
+ };
95
+ }
96
+
97
+ export interface HostInput<T> {
98
+ get(): T;
99
+ /** Host integrations feed this (system appearance listener, reduced-motion media query, ...). */
100
+ set(next: T): void;
101
+ subscribe(listener: () => void): () => void;
102
+ }
103
+
104
+ export function createHostInput<T>(initial: T): HostInput<T> {
105
+ const listeners = new Set<() => void>();
106
+ let value = initial;
107
+
108
+ return {
109
+ get(): T {
110
+ return value;
111
+ },
112
+ set(next: T): void {
113
+ if (next === value) {
114
+ return;
115
+ }
116
+ value = next;
117
+ for (const listener of listeners) {
118
+ listener();
119
+ }
120
+ },
121
+ subscribe(listener: () => void): () => void {
122
+ listeners.add(listener);
123
+ return () => {
124
+ listeners.delete(listener);
125
+ };
126
+ },
127
+ };
128
+ }