@structuralists/scaffolding 0.10.2 → 0.12.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.
Files changed (35) hide show
  1. package/eslint.config.mjs +3 -3
  2. package/package.json +1 -1
  3. package/src/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
  4. package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
  5. package/src/components/Json/JsonTable/index.tsx +13 -6
  6. package/src/components/Json/JsonTable/styles.module.css +20 -0
  7. package/src/components/Json/JsonTable/types.ts +3 -5
  8. package/src/forms/CLAUDE.md +195 -41
  9. package/src/forms/elements/Input/index.tsx +2 -0
  10. package/src/forms/elements/Input/types.ts +2 -1
  11. package/src/forms/plan.md +146 -29
  12. package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
  13. package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
  14. package/src/forms/state/path/path.test.ts +71 -1
  15. package/src/forms/state/path/path.ts +50 -0
  16. package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
  17. package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
  18. package/src/forms/state/useFormState/deriveErrors.test.ts +94 -0
  19. package/src/forms/state/useFormState/deriveErrors.ts +10 -10
  20. package/src/forms/state/useFormState/errorAt.test.ts +3 -3
  21. package/src/forms/state/useFormState/errorAt.ts +8 -12
  22. package/src/forms/state/useFormState/inspectable.test.ts +9 -9
  23. package/src/forms/state/useFormState/inspectable.ts +5 -7
  24. package/src/forms/state/useFormState/types.ts +35 -4
  25. package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
  26. package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
  27. package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
  28. package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
  29. package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
  30. package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
  31. package/src/forms/state/useFormState/useFormState.ts +12 -3
  32. package/src/forms/state/validations/types.ts +77 -17
  33. package/src/forms/state/validations/walk.test.ts +159 -19
  34. package/src/forms/state/validations/walk.ts +86 -25
  35. package/tokens.css +55 -0
@@ -1,4 +1,4 @@
1
- import type { FormValuesObject } from '../useFormState/types';
1
+ import type { FormValueList, FormValuesObject } from '../useFormState/types';
2
2
 
3
3
  // Phantom marker carried by validators. The runtime value of `__excludes`
4
4
  // is meaningless and never read — only its declared type matters. Required
@@ -23,16 +23,39 @@ export type Validator<Input, Excluded = never> =
23
23
  // structurally by `Refine<>`.
24
24
  export type FieldValidator<F> = (val: F) => string | null;
25
25
 
26
- // What a constraints key may map to — phase 1 of the target grammar (see
27
- // plan.md): the leaf forms only. A single validator, or an ordered list of
28
- // validators run first-error-wins (sugar over `allOf` at the constraint
29
- // site; `allOf` remains for building reusable composite validators). The
30
- // structural forms (nested `Validations`, list `each`) arrive in phases 2–3.
26
+ // What a constraints key may map to — the full target grammar (see plan.md).
27
+ // The leaf forms (a single validator, or an ordered list run
28
+ // first-error-wins sugar over `allOf` at the constraint site; `allOf`
29
+ // remains for building reusable composite validators) are legal for ANY
30
+ // field type, structural fields included: a bare arrow on an object field is
31
+ // a whole-section validator. The structural forms are directed by the
32
+ // FIELD's type, never guessed from the constraint's shape (see
33
+ // "Disambiguation" in plan.md): only an object-typed field admits a nested
34
+ // `Validations`, only a list-typed field admits `{ each: … }`. `F` is naked
35
+ // in the conditionals, so nullable sections/lists distribute — for
36
+ // `UsAddress | undefined` the object arm fires for the present member. Do
37
+ // NOT wrap `F` in `NonNullable` "to help": that breaks the distribution and
38
+ // blows the recursion stack (TS2589) — see the maintainer note in
39
+ // src/forms/CLAUDE.md.
31
40
  export type FieldConstraint<F> =
32
41
  | FieldValidator<F>
33
- | readonly FieldValidator<F>[];
42
+ | readonly FieldValidator<F>[]
43
+ | (F extends FormValuesObject ? Validations<F> : never)
44
+ | (F extends FormValueList ? ListConstraint<F[number]> : never);
34
45
 
35
- // The constraint for a per-field validation map.
46
+ // The constraint form for a list field: a spec applied to each element.
47
+ // NOTE: the type-level grammar admits `each` (proven with the rest of the
48
+ // recursive grammar by the phase-2 type spike), but the runtime walk for it
49
+ // is plan phase 3 — until it lands, an `each` constraint throws a clear
50
+ // error from the walk instead of silently not validating.
51
+ export type ListConstraint<Element extends FormValuesObject> = {
52
+ readonly each: Validations<Element>;
53
+ // room to grow, e.g. a `self` slot for list-level rules (min count,
54
+ // uniqueness) — not in scope for v1
55
+ };
56
+
57
+ // The constraint for a per-field validation map — recursive through nested
58
+ // object specs and list `each` specs.
36
59
  export type Validations<T extends FormValuesObject> = {
37
60
  readonly [K in keyof T]?: FieldConstraint<T[K]>;
38
61
  };
@@ -84,19 +107,56 @@ export type MemberExcludes<C extends readonly unknown[]> = {
84
107
  // validator *array* is the opposite regime: every member runs, so the union
85
108
  // ACROSS members is earned in a single `Exclude` — but each member's own
86
109
  // contribution is per-member sound (`MemberExcludes`): a union-typed member
87
- // claims only the intersection of its branches' excludes. Branch order
88
- // matters once the grammar grows structural forms (validator functions are
89
- // objects) `Refinement` stays first.
110
+ // claims only the intersection of its branches' excludes.
111
+ //
112
+ // Branch order: `Refinement` first (validator functions are objects — a
113
+ // marked validator must not fall into a structural arm), then arrays, then
114
+ // the structural arms. The structural arms interrogate the VALUE MODEL
115
+ // (`F`) before the constraint's shape, per the disambiguation doctrine: an
116
+ // object field that legitimately owns a key named `each` must be refined as
117
+ // a nested spec, so `F extends FormValueList` is asked before looking for
118
+ // `each`. (`F` also distributes here, which is what carries `| null` /
119
+ // `| undefined` on nullable sections/lists through around the refined
120
+ // interior.) A bare marker-less validator on a structural field lands in
121
+ // `RefineObject<F, C>` with `keyof C` empty, an identity map — refined type
122
+ // structurally unchanged, as a whole-value validator should be.
90
123
  type RefineField<F, C> = C extends Refinement<infer Excluded>
91
124
  ? Exclude<F, Excluded>
92
125
  : C extends readonly unknown[]
93
126
  ? Exclude<F, MemberExcludes<C>>
94
- : F;
127
+ : C extends object
128
+ ? F extends FormValueList
129
+ ? C extends { readonly each: infer E }
130
+ ? Array<RefineObject<F[number], E>>
131
+ : F
132
+ : F extends FormValuesObject
133
+ ? RefineObject<F, C>
134
+ : F
135
+ : F;
95
136
 
96
- // Applies each field's constraint to the form type: the submit-time type.
97
- // Singles narrow by their marker, arrays by the union of their members'
98
- // sound excludes; unconstrained fields and bare (marker-less) validators
99
- // pass through unchanged. Shallow by design — one mapped type, no recursion.
100
- export type Refine<T extends FormValuesObject, V extends Validations<T>> = {
137
+ type RefineObject<T, V> = {
101
138
  [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K];
102
139
  };
140
+
141
+ // Applies the constraints object to the form type, recursively: the
142
+ // submit-time type. Singles narrow by their marker, arrays by the union of
143
+ // their members' sound excludes, nested/`each` specs recurse; unconstrained
144
+ // fields and bare (marker-less) validators pass through unchanged.
145
+ //
146
+ // The identity gate up front is load-bearing, not an optimization (phase-2
147
+ // type spike, adjustment 2): with constraints omitted, the hook's default
148
+ // `V = Validations<T>` would send `FieldConstraint<F> | undefined` through
149
+ // the distributive walk at every key, and structural fields would come back
150
+ // as unions of structurally-identical mapped COPIES of each section —
151
+ // mutually assignable with `T` but not identity-equal (mangled hover types,
152
+ // fails strict type equality). `Validations<T> extends V` is true exactly
153
+ // when `V` is the default (or an empty/fully-widened literal — where no
154
+ // markers survive anyway), so short-circuit to `T` itself. Any concrete
155
+ // constraints literal is strictly narrower, so the real walk runs. Nested
156
+ // occurrences don't need the gate: for a concrete literal `V`, `keyof V`
157
+ // holds only the keys actually written, so uncovered fields take the `T[K]`
158
+ // arm verbatim at every level.
159
+ export type Refine<
160
+ T extends FormValuesObject,
161
+ V extends Validations<T>,
162
+ > = Validations<T> extends V ? T : RefineObject<T, V>;
@@ -5,27 +5,26 @@ const pass = () => null;
5
5
  const fail = (message: string) => () => message;
6
6
 
7
7
  describe('validateEntry — single validator', () => {
8
- test('a passing validator yields no error', () => {
9
- expect(validateEntry(pass, 'anything', ['a'])).toBeNull();
8
+ test('a passing validator yields no errors', () => {
9
+ expect(validateEntry(pass, 'anything', ['a'])).toEqual([]);
10
10
  });
11
11
 
12
12
  test('a failing validator yields its message at the given path', () => {
13
- expect(validateEntry(fail('nope'), 'anything', ['a'])).toEqual({
14
- path: ['a'],
15
- error: 'nope',
16
- });
13
+ expect(validateEntry(fail('nope'), 'anything', ['a'])).toEqual([
14
+ { path: ['a'], error: 'nope' },
15
+ ]);
17
16
  });
18
17
 
19
18
  test('the validator receives the field value', () => {
20
19
  const spy = mock((val: string) => (val === 'x' ? null : 'expected x'));
21
- expect(validateEntry(spy, 'x', ['a'])).toBeNull();
20
+ expect(validateEntry(spy, 'x', ['a'])).toEqual([]);
22
21
  expect(spy).toHaveBeenCalledWith('x');
23
22
  });
24
23
  });
25
24
 
26
25
  describe('validateEntry — validator arrays', () => {
27
- test('all passing yields no error', () => {
28
- expect(validateEntry([pass, pass, pass], 'v', ['a'])).toBeNull();
26
+ test('all passing yields no errors', () => {
27
+ expect(validateEntry([pass, pass, pass], 'v', ['a'])).toEqual([]);
29
28
  });
30
29
 
31
30
  test('validators run in array order and the first error wins', () => {
@@ -34,7 +33,7 @@ describe('validateEntry — validator arrays', () => {
34
33
  'v',
35
34
  ['a'],
36
35
  );
37
- expect(result).toEqual({ path: ['a'], error: 'second' });
36
+ expect(result).toEqual([{ path: ['a'], error: 'second' }]);
38
37
  });
39
38
 
40
39
  test('validators after the first failure are not called', () => {
@@ -46,20 +45,18 @@ describe('validateEntry — validator arrays', () => {
46
45
  test('every validator before the failure sees the value', () => {
47
46
  const first = mock(() => null);
48
47
  const second = mock(() => null);
49
- expect(validateEntry([first, second], 42, ['n'])).toBeNull();
48
+ expect(validateEntry([first, second], 42, ['n'])).toEqual([]);
50
49
  expect(first).toHaveBeenCalledWith(42);
51
50
  expect(second).toHaveBeenCalledWith(42);
52
51
  });
53
52
 
54
53
  test('an empty array passes', () => {
55
- expect(validateEntry([], undefined, ['a'])).toBeNull();
54
+ expect(validateEntry([], undefined, ['a'])).toEqual([]);
56
55
  });
57
56
  });
58
57
 
59
58
  describe('validateEntry — path accumulation', () => {
60
- test('the error carries the path it was given, verbatim', () => {
61
- // Phase 1 paths are single-key; the signature already speaks PathStep[]
62
- // so the phases-2/3 {path, error}[] model needs no walk rewrite.
59
+ test('a leaf error carries the path it was given, verbatim', () => {
63
60
  const result = validateEntry(fail('bad date'), undefined, [
64
61
  'drivers',
65
62
  3,
@@ -67,9 +64,152 @@ describe('validateEntry — path accumulation', () => {
67
64
  0,
68
65
  'date',
69
66
  ]);
70
- expect(result).toEqual({
71
- path: ['drivers', 3, 'incidents', 0, 'date'],
72
- error: 'bad date',
73
- });
67
+ expect(result).toEqual([
68
+ { path: ['drivers', 3, 'incidents', 0, 'date'], error: 'bad date' },
69
+ ]);
70
+ });
71
+ });
72
+
73
+ describe('validateEntry — nested object specs', () => {
74
+ test('recurses into a nested spec, extending the path per key', () => {
75
+ const result = validateEntry(
76
+ { city: fail('city required') },
77
+ { city: undefined, line1: '1 Main St' },
78
+ ['homeAddress'],
79
+ );
80
+ expect(result).toEqual([
81
+ { path: ['homeAddress', 'city'], error: 'city required' },
82
+ ]);
83
+ });
84
+
85
+ test('a constrained leaf validator receives the leaf value, not the section', () => {
86
+ const spy = mock((val: string) => (val === 'SF' ? null : 'not SF'));
87
+ expect(
88
+ validateEntry({ city: spy }, { city: 'SF' }, ['homeAddress']),
89
+ ).toEqual([]);
90
+ expect(spy).toHaveBeenCalledWith('SF');
91
+ });
92
+
93
+ test('specs nest to arbitrary depth, accumulating the full address', () => {
94
+ const result = validateEntry(
95
+ { shipping: { address: { postalCode: fail('5 digits') } } },
96
+ { shipping: { address: { postalCode: 'abc' } } },
97
+ ['order'],
98
+ );
99
+ expect(result).toEqual([
100
+ {
101
+ path: ['order', 'shipping', 'address', 'postalCode'],
102
+ error: '5 digits',
103
+ },
104
+ ]);
105
+ });
106
+
107
+ test('sibling results are independent: every failing node gets its own entry', () => {
108
+ const result = validateEntry(
109
+ {
110
+ city: fail('city required'),
111
+ state: pass,
112
+ postalCode: fail('postalCode required'),
113
+ },
114
+ { city: undefined, state: 'CA', postalCode: undefined },
115
+ ['homeAddress'],
116
+ );
117
+ expect(result).toEqual([
118
+ { path: ['homeAddress', 'city'], error: 'city required' },
119
+ { path: ['homeAddress', 'postalCode'], error: 'postalCode required' },
120
+ ]);
121
+ });
122
+
123
+ test('a failing sibling does not stop validation of the others', () => {
124
+ const after = mock(() => null);
125
+ validateEntry(
126
+ { a: fail('boom'), b: after },
127
+ { a: 'x', b: 'y' },
128
+ ['section'],
129
+ );
130
+ expect(after).toHaveBeenCalledWith('y');
131
+ });
132
+
133
+ test('leaf and nested constraints mix inside one spec', () => {
134
+ const result = validateEntry(
135
+ {
136
+ name: [pass, fail('name too short')],
137
+ address: { city: fail('city required') },
138
+ },
139
+ { name: 'x', address: { city: undefined } },
140
+ ['applicant'],
141
+ );
142
+ expect(result).toEqual([
143
+ { path: ['applicant', 'name'], error: 'name too short' },
144
+ { path: ['applicant', 'address', 'city'], error: 'city required' },
145
+ ]);
146
+ });
147
+
148
+ test('a null child entry (possible from untyped JS) is skipped', () => {
149
+ const spec = { city: null } as unknown as {
150
+ city: (val: unknown) => string | null;
151
+ };
152
+ expect(validateEntry(spec, { city: undefined }, ['homeAddress'])).toEqual(
153
+ [],
154
+ );
155
+ });
156
+ });
157
+
158
+ describe('validateEntry — absent sections are skipped', () => {
159
+ test('a nested spec on an undefined section validates nothing', () => {
160
+ // Decided with the phase-2 type spike: the type level refines only the
161
+ // present branch of a nullable section, so the runtime mirror is to
162
+ // skip. A "required section" is a leaf validator on the section field.
163
+ const never = mock(() => 'never reached');
164
+ expect(validateEntry({ city: never }, undefined, ['mailingAddress'])).toEqual([]);
165
+ expect(never).not.toHaveBeenCalled();
166
+ });
167
+
168
+ test('a nested spec on a null section validates nothing', () => {
169
+ expect(validateEntry({ city: fail('x') }, null, ['mailingAddress'])).toEqual([]);
170
+ });
171
+
172
+ test('a leaf validator on an absent section still runs (the "required section" form)', () => {
173
+ expect(validateEntry(fail('section required'), undefined, ['mailingAddress'])).toEqual([
174
+ { path: ['mailingAddress'], error: 'section required' },
175
+ ]);
176
+ });
177
+
178
+ test('a nested spec on a non-object value (shape mismatch from untyped JS) is skipped', () => {
179
+ expect(validateEntry({ city: fail('x') }, 'not an object', ['homeAddress'])).toEqual([]);
180
+ });
181
+ });
182
+
183
+ describe('validateEntry — value-model disambiguation', () => {
184
+ test('an object value owning a key named `each` is walked as a nested spec', () => {
185
+ // The value directs interpretation, never the constraint's shape: this
186
+ // spec has a single key `each`, but the value is a plain object, so
187
+ // `each` is just a field like any other.
188
+ const result = validateEntry(
189
+ { each: fail('audit each required') },
190
+ { each: undefined, reviewedBy: 'wl' },
191
+ ['audit'],
192
+ );
193
+ expect(result).toEqual([
194
+ { path: ['audit', 'each'], error: 'audit each required' },
195
+ ]);
196
+ });
197
+ });
198
+
199
+ describe('validateEntry — list `each` specs (runtime pending, phase 3)', () => {
200
+ test('an `each` spec on a present list throws instead of silently not validating', () => {
201
+ expect(() =>
202
+ validateEntry(
203
+ { each: { name: fail('name required') } },
204
+ [{ name: undefined }],
205
+ ['drivers'],
206
+ ),
207
+ ).toThrow(/each.*not validated at runtime yet.*drivers/);
208
+ });
209
+
210
+ test('an `each` spec on a null list is skipped — nothing to walk, matching phase-3 semantics', () => {
211
+ expect(
212
+ validateEntry({ each: { insurer: fail('x') } }, null, ['pastPolicies']),
213
+ ).toEqual([]);
74
214
  });
75
215
  });
@@ -1,11 +1,11 @@
1
1
  import type { PathStep } from '../path/types';
2
2
 
3
3
  // The runtime walk over a constraints object, kept separate from the hook so
4
- // its semantics are unit-testable without React. Phase 1 grammar is flat —
5
- // every path has exactly one step but errors already carry a `PathStep[]`
6
- // address, which the hook's structured `{path, error}[]` error model
7
- // (`FormErrors<T>`) consumes directly; the recursive grammar of plan phases
8
- // 2–3 just hands the walk longer paths, no second rewrite.
4
+ // its semantics are unit-testable without React. The grammar is recursive
5
+ // (nested object specs; list `each` specs arrive at runtime in plan phase 3),
6
+ // so the walk is a tree walk: it accumulates a `PathStep[]` address as it
7
+ // descends, and the hook's structured `{path, error}[]` error model
8
+ // (`FormErrors<T>`) consumes those addresses directly.
9
9
 
10
10
  export type ValidationError = {
11
11
  readonly path: readonly PathStep[];
@@ -21,33 +21,94 @@ type AnyFieldValidator = (val: never) => string | null;
21
21
  // The walk's view of one entry in a `Validations<T>` object. This type is
22
22
  // what lets the compiler police the walk's assumptions: the error derivation
23
23
  // (`useFormState/deriveErrors.ts`) assigns `constraints[key]` to it WITHOUT
24
- // a cast, so when the grammar grows a form
25
- // that is neither a function nor an array of them (nested spec, list `each`),
26
- // that assignment stops compiling and the walk must learn the new form —
27
- // instead of a stale walk misinterpreting it at runtime.
28
- export type FlatConstraintEntry =
24
+ // a cast, so if the grammar grows a form the walk doesn't understand, that
25
+ // assignment stops compiling and the walk must learn the new form instead
26
+ // of a stale walk misinterpreting it at runtime. Both structural grammar
27
+ // forms (a nested `Validations`, a `{ each: }` list constraint) look the
28
+ // same from here — an object of entries — because disambiguating them is the
29
+ // walk's job, done against the VALUE at the path, never the constraint's
30
+ // shape (see "Disambiguation" in plan.md).
31
+ export type ConstraintEntry =
29
32
  | AnyFieldValidator
30
- | readonly AnyFieldValidator[];
33
+ | readonly AnyFieldValidator[]
34
+ | ConstraintSpec;
31
35
 
32
- // Runs one constraint entry against the field value found at `path`.
33
- // Semantics for arrays (identical to `allOf`): validators run in array
36
+ type ConstraintSpec = {
37
+ readonly [key: string]: ConstraintEntry | undefined;
38
+ };
39
+
40
+ // Runs one constraint entry against the field value found at `path`,
41
+ // returning every failure in the entry's subtree (a structural spec can
42
+ // produce one entry per failing constrained node; sibling results are
43
+ // independent).
44
+ //
45
+ // Leaf semantics for arrays (identical to `allOf`): validators run in array
34
46
  // order, first error wins — later validators are not called once one fails.
35
47
  // An empty array passes, mirroring its refinement (`Exclude<F, never>`).
48
+ //
49
+ // Structural semantics — interpretation is directed by the value model:
50
+ // - value is an array ⇒ the spec is a `{ each: … }` list constraint.
51
+ // Runtime `each` walking is plan phase 3; until it lands the walk THROWS
52
+ // rather than silently not validating.
53
+ // - value is an object ⇒ the spec is a nested `Validations`: recurse into
54
+ // each constrained key, extending the path. (An object field owning a key
55
+ // literally named `each` lands here — the value directs, so its spec is a
56
+ // nested spec like any other.)
57
+ // - value is absent (null/undefined) ⇒ SKIP: a nested spec on an absent
58
+ // section validates nothing (decided with the type spike — the type level
59
+ // refines only the present branch of a nullable section, and this is the
60
+ // honest runtime mirror). A "required section" is a LEAF validator on the
61
+ // section field instead. A non-object value (unreachable through the typed
62
+ // grammar) has nothing to walk either, mirroring read()'s dead-step
63
+ // semantics.
64
+ // Predicate (not inline checks) because `Array.isArray`'s `arg is any[]`
65
+ // guard cannot remove a READONLY array member from its false branch — the
66
+ // structural-spec arm below needs that removal to type `entry[key]`.
67
+ const isLeafEntry = (
68
+ entry: ConstraintEntry,
69
+ ): entry is AnyFieldValidator | readonly AnyFieldValidator[] =>
70
+ typeof entry === 'function' || Array.isArray(entry);
71
+
36
72
  export const validateEntry = (
37
- entry: FlatConstraintEntry,
73
+ entry: ConstraintEntry,
38
74
  value: unknown,
39
75
  path: readonly PathStep[],
40
- ): ValidationError | null => {
41
- const validators = typeof entry === 'function' ? [entry] : entry;
42
-
43
- for (const validator of validators) {
44
- // Safe: the constraints object was type-checked against the form type
45
- // where it was built, so this validator accepts the value at `path`; the
46
- // walk just can't see that correlation. Same honest contravariant
47
- // widening as `allOf`'s part-call.
48
- const error = (validator as (val: unknown) => string | null)(value);
49
- if (error != null) return { path, error };
76
+ ): ValidationError[] => {
77
+ if (isLeafEntry(entry)) {
78
+ const validators = typeof entry === 'function' ? [entry] : entry;
79
+
80
+ for (const validator of validators) {
81
+ // Safe: the constraints object was type-checked against the form type
82
+ // where it was built, so this validator accepts the value at `path`;
83
+ // the walk just can't see that correlation. Same honest contravariant
84
+ // widening as `allOf`'s part-call.
85
+ const error = (validator as (val: unknown) => string | null)(value);
86
+ if (error != null) return [{ path, error }];
87
+ }
88
+
89
+ return [];
90
+ }
91
+
92
+ if (value == null || typeof value !== 'object') return [];
93
+
94
+ if (Array.isArray(value)) {
95
+ throw new Error(
96
+ `list 'each' constraints are not validated at runtime yet (forms plan ` +
97
+ `phase 3) — constraint at path [${path.join(', ')}]`,
98
+ );
99
+ }
100
+
101
+ const errors: ValidationError[] = [];
102
+ for (const key of Object.keys(entry)) {
103
+ const child = entry[key];
104
+ if (child == null) continue;
105
+ errors.push(
106
+ ...validateEntry(child, (value as Record<string, unknown>)[key], [
107
+ ...path,
108
+ key,
109
+ ]),
110
+ );
50
111
  }
51
112
 
52
- return null;
113
+ return errors;
53
114
  };
package/tokens.css CHANGED
@@ -43,6 +43,13 @@
43
43
  shadow-soft — gentler lift (hover elevation, cards)
44
44
  backdrop — modal overlay tint behind dialogs
45
45
 
46
+ JSON syntax (Chrome-inspector-style value coloring in JsonTable)
47
+ json-key — object keys / array indices (low salience)
48
+ json-string — string leaves (rendered with quotes)
49
+ json-number — number leaves
50
+ json-boolean — boolean leaves
51
+ json-nil — null / undefined leaves
52
+
46
53
  These values are scaffolds, not final design. Revise deliberately.
47
54
  --------------------------------------------------------------------------- */
48
55
 
@@ -112,6 +119,12 @@
112
119
  --ui-shadow: rgba(0, 0, 0, 0.15);
113
120
  --ui-shadow-soft: rgba(0, 0, 0, 0.08);
114
121
  --ui-backdrop: rgba(30, 28, 25, 0.35);
122
+
123
+ --ui-json-key: #6c6761;
124
+ --ui-json-string: #a05419;
125
+ --ui-json-number: #4966a9;
126
+ --ui-json-boolean: #8a35a0;
127
+ --ui-json-nil: #a74c4c;
115
128
  }
116
129
 
117
130
  /* System preference: dark → fall back to dark-warm when no data-theme is set.
@@ -140,6 +153,12 @@
140
153
  --ui-shadow: rgba(0, 0, 0, 0.5);
141
154
  --ui-shadow-soft: rgba(0, 0, 0, 0.3);
142
155
  --ui-backdrop: rgba(0, 0, 0, 0.55);
156
+
157
+ --ui-json-key: #a89f90;
158
+ --ui-json-string: #e6a268;
159
+ --ui-json-number: #8aa5d8;
160
+ --ui-json-boolean: #c98ad5;
161
+ --ui-json-nil: #f0a39e;
143
162
  }
144
163
  }
145
164
 
@@ -170,6 +189,12 @@
170
189
  --ui-shadow: rgba(0, 0, 0, 0.15);
171
190
  --ui-shadow-soft: rgba(0, 0, 0, 0.08);
172
191
  --ui-backdrop: rgba(30, 28, 25, 0.35);
192
+
193
+ --ui-json-key: #6c6761;
194
+ --ui-json-string: #a05419;
195
+ --ui-json-number: #4966a9;
196
+ --ui-json-boolean: #8a35a0;
197
+ --ui-json-nil: #a74c4c;
173
198
  }
174
199
 
175
200
  [data-theme='light-paper'] {
@@ -195,6 +220,12 @@
195
220
  --ui-shadow: rgba(0, 0, 0, 0.12);
196
221
  --ui-shadow-soft: rgba(0, 0, 0, 0.06);
197
222
  --ui-backdrop: rgba(20, 20, 20, 0.35);
223
+
224
+ --ui-json-key: #636360;
225
+ --ui-json-string: #9b4f16;
226
+ --ui-json-number: #4461a3;
227
+ --ui-json-boolean: #8a35a0;
228
+ --ui-json-nil: #a14747;
198
229
  }
199
230
 
200
231
  [data-theme='light-sepia'] {
@@ -220,6 +251,12 @@
220
251
  --ui-shadow: rgba(70, 50, 20, 0.18);
221
252
  --ui-shadow-soft: rgba(70, 50, 20, 0.09);
222
253
  --ui-backdrop: rgba(50, 35, 15, 0.4);
254
+
255
+ --ui-json-key: #6a5e45;
256
+ --ui-json-string: #954a10;
257
+ --ui-json-number: #3a5da0;
258
+ --ui-json-boolean: #7a2a8a;
259
+ --ui-json-nil: #9a4444;
223
260
  }
224
261
 
225
262
  [data-theme='dark-warm'] {
@@ -245,6 +282,12 @@
245
282
  --ui-shadow: rgba(0, 0, 0, 0.5);
246
283
  --ui-shadow-soft: rgba(0, 0, 0, 0.3);
247
284
  --ui-backdrop: rgba(0, 0, 0, 0.55);
285
+
286
+ --ui-json-key: #a89f90;
287
+ --ui-json-string: #e6a268;
288
+ --ui-json-number: #8aa5d8;
289
+ --ui-json-boolean: #c98ad5;
290
+ --ui-json-nil: #f0a39e;
248
291
  }
249
292
 
250
293
  [data-theme='dark-neutral'] {
@@ -270,6 +313,12 @@
270
313
  --ui-shadow: rgba(0, 0, 0, 0.5);
271
314
  --ui-shadow-soft: rgba(0, 0, 0, 0.3);
272
315
  --ui-backdrop: rgba(0, 0, 0, 0.6);
316
+
317
+ --ui-json-key: #a0a0a0;
318
+ --ui-json-string: #e29964;
319
+ --ui-json-number: #8ab0e0;
320
+ --ui-json-boolean: #c990d6;
321
+ --ui-json-nil: #ea9a9a;
273
322
  }
274
323
 
275
324
  [data-theme='dark-dimmed'] {
@@ -295,6 +344,12 @@
295
344
  --ui-shadow: rgba(0, 0, 0, 0.4);
296
345
  --ui-shadow-soft: rgba(0, 0, 0, 0.25);
297
346
  --ui-backdrop: rgba(0, 0, 0, 0.55);
347
+
348
+ --ui-json-key: #8a91a0;
349
+ --ui-json-string: #d99970;
350
+ --ui-json-number: #8ab0e0;
351
+ --ui-json-boolean: #c08adc;
352
+ --ui-json-nil: #e89898;
298
353
  }
299
354
 
300
355
  /* ---------------------------------------------------------------------------