@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.
- package/eslint.config.mjs +3 -3
- package/package.json +1 -1
- package/src/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
- package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
- package/src/components/Json/JsonTable/index.tsx +13 -6
- package/src/components/Json/JsonTable/styles.module.css +20 -0
- package/src/components/Json/JsonTable/types.ts +3 -5
- package/src/forms/CLAUDE.md +195 -41
- package/src/forms/elements/Input/index.tsx +2 -0
- package/src/forms/elements/Input/types.ts +2 -1
- package/src/forms/plan.md +146 -29
- package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
- package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
- package/src/forms/state/path/path.test.ts +71 -1
- package/src/forms/state/path/path.ts +50 -0
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
- package/src/forms/state/useFormState/deriveErrors.test.ts +94 -0
- package/src/forms/state/useFormState/deriveErrors.ts +10 -10
- package/src/forms/state/useFormState/errorAt.test.ts +3 -3
- package/src/forms/state/useFormState/errorAt.ts +8 -12
- package/src/forms/state/useFormState/inspectable.test.ts +9 -9
- package/src/forms/state/useFormState/inspectable.ts +5 -7
- package/src/forms/state/useFormState/types.ts +35 -4
- package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
- package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
- package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
- package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
- package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
- package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
- package/src/forms/state/useFormState/useFormState.ts +12 -3
- package/src/forms/state/validations/types.ts +77 -17
- package/src/forms/state/validations/walk.test.ts +159 -19
- package/src/forms/state/validations/walk.ts +86 -25
- 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 —
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// structural
|
|
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
|
|
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.
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
9
|
-
expect(validateEntry(pass, 'anything', ['a'])).
|
|
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
|
-
|
|
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'])).
|
|
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
|
|
28
|
-
expect(validateEntry([pass, pass, pass], 'v', ['a'])).
|
|
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'])).
|
|
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'])).
|
|
54
|
+
expect(validateEntry([], undefined, ['a'])).toEqual([]);
|
|
56
55
|
});
|
|
57
56
|
});
|
|
58
57
|
|
|
59
58
|
describe('validateEntry — path accumulation', () => {
|
|
60
|
-
test('
|
|
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
|
-
|
|
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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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:
|
|
73
|
+
entry: ConstraintEntry,
|
|
38
74
|
value: unknown,
|
|
39
75
|
path: readonly PathStep[],
|
|
40
|
-
): ValidationError
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
/* ---------------------------------------------------------------------------
|