@structuralists/scaffolding 0.13.0 → 0.15.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,451 @@
1
+ import { describe, it, expectTypeOf } from 'vitest';
2
+ import { useFormState } from './useFormState';
3
+ import { perField } from '../validations/perField';
4
+ import { matches, min, minLength, notEmpty } from '../validators/validators';
5
+ import type { FormFieldProps } from './types';
6
+ import type { Refine, Validations } from '../validations/types';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // The stress tier (plan phase 4, composition hardening) — the phase-2 type
10
+ // spike's stress fixtures, pinned against the REAL hook. Where
11
+ // useFormState.test-d.ts probes the grammar at chunky (~30-leaf) scale, this
12
+ // file pushes past it to hold the measured headroom: a ~110-leaf form at 5–6
13
+ // levels with the large applicant spec written inline twice, the same spec
14
+ // pre-built ONCE via perField and reused for both applicant sections (the
15
+ // pre-built entry point at realistic scale), a 12-level nested-object spec
16
+ // ladder, a 4-deep list-in-list `each` ladder, and deep Path bindings on the
17
+ // mega form. If the recursion budget regresses, tsc slows or fails here
18
+ // first — watch the instantiation count in the plan's recursion-budget
19
+ // section, not just success.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ type UsAddress = {
23
+ line1: string | undefined;
24
+ line2: string | undefined;
25
+ city: string | undefined;
26
+ state: string | undefined;
27
+ postalCode: string | undefined;
28
+ };
29
+
30
+ type Incident = {
31
+ date: string | undefined;
32
+ kind: string | undefined;
33
+ claimAmountUsd: number | null;
34
+ };
35
+
36
+ type Driver = {
37
+ name: string | undefined;
38
+ licenseNumber: string | undefined;
39
+ licenseState: string | undefined;
40
+ incidents: Incident[];
41
+ };
42
+
43
+ type Vehicle = {
44
+ vin: string | undefined;
45
+ year: number | undefined;
46
+ make: string | undefined;
47
+ model: string | undefined;
48
+ primaryDriverName: string | undefined;
49
+ garagingAddress: UsAddress;
50
+ };
51
+
52
+ type Applicant = {
53
+ firstName: string | undefined;
54
+ lastName: string | undefined;
55
+ email: string | undefined;
56
+ phone: string | undefined;
57
+ dateOfBirth: string | undefined;
58
+ employer: string | null;
59
+ jobTitle: string | null;
60
+ yearsEmployed: number | null;
61
+ annualIncomeUsd: number | null;
62
+ homeAddress: UsAddress;
63
+ mailingAddress: UsAddress | undefined;
64
+ drivers: Driver[];
65
+ vehicles: Vehicle[];
66
+ };
67
+
68
+ type MegaQuoteForm = {
69
+ primary: Applicant;
70
+ secondary: Applicant | undefined;
71
+ household: {
72
+ address: UsAddress;
73
+ members: Array<{
74
+ name: string | undefined;
75
+ relation: string | undefined;
76
+ dateOfBirth: string | undefined;
77
+ }>;
78
+ };
79
+ quotes: Array<{
80
+ coverageType: string | undefined;
81
+ deductibleUsd: number | undefined;
82
+ startDate: string | undefined;
83
+ riders: Array<{
84
+ code: string | undefined;
85
+ amountUsd: number | null;
86
+ }>;
87
+ }>;
88
+ referralSource: string | null;
89
+ notes: string | undefined;
90
+ agreedToTerms: boolean | undefined;
91
+ paperlessBilling: boolean;
92
+ };
93
+
94
+ describe('useFormState stress: ~110-leaf form, large inline spec written twice', () => {
95
+ it('composes and refines with the applicant spec duplicated inline', () => {
96
+ useFormState({
97
+ initialValues: {} as MegaQuoteForm,
98
+ constraints: {
99
+ primary: {
100
+ firstName: notEmpty('firstName'),
101
+ lastName: [notEmpty('lastName')],
102
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
103
+ phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
104
+ dateOfBirth: notEmpty('dateOfBirth'),
105
+ yearsEmployed: min('yearsEmployed', 0),
106
+ annualIncomeUsd: min('annualIncomeUsd', 0),
107
+ homeAddress: {
108
+ line1: notEmpty('line1'),
109
+ city: notEmpty('city'),
110
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
111
+ },
112
+ mailingAddress: {
113
+ city: notEmpty('mailing city'),
114
+ },
115
+ drivers: {
116
+ each: {
117
+ name: notEmpty('name'),
118
+ licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
119
+ incidents: {
120
+ each: {
121
+ date: notEmpty('date'),
122
+ claimAmountUsd: min('claimAmountUsd', 0),
123
+ },
124
+ },
125
+ },
126
+ },
127
+ vehicles: {
128
+ each: {
129
+ vin: [notEmpty('vin')],
130
+ year: min('year', 1900),
131
+ garagingAddress: {
132
+ line1: notEmpty('garaging line1'),
133
+ postalCode: [notEmpty('garaging postalCode')],
134
+ },
135
+ },
136
+ },
137
+ },
138
+ // The same large spec again, on the nullable secondary applicant —
139
+ // simulates a real page where big sections each get full specs.
140
+ secondary: {
141
+ firstName: notEmpty('firstName'),
142
+ lastName: [notEmpty('lastName')],
143
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
144
+ phone: matches('phone', /^[\d\s()+-]+$/, 'a phone number'),
145
+ dateOfBirth: notEmpty('dateOfBirth'),
146
+ yearsEmployed: min('yearsEmployed', 0),
147
+ annualIncomeUsd: min('annualIncomeUsd', 0),
148
+ homeAddress: {
149
+ line1: notEmpty('line1'),
150
+ city: notEmpty('city'),
151
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
152
+ },
153
+ mailingAddress: {
154
+ city: notEmpty('mailing city'),
155
+ },
156
+ drivers: {
157
+ each: {
158
+ name: notEmpty('name'),
159
+ licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
160
+ incidents: {
161
+ each: {
162
+ date: notEmpty('date'),
163
+ claimAmountUsd: min('claimAmountUsd', 0),
164
+ },
165
+ },
166
+ },
167
+ },
168
+ vehicles: {
169
+ each: {
170
+ vin: [notEmpty('vin')],
171
+ year: min('year', 1900),
172
+ garagingAddress: {
173
+ line1: notEmpty('garaging line1'),
174
+ postalCode: [notEmpty('garaging postalCode')],
175
+ },
176
+ },
177
+ },
178
+ },
179
+ household: {
180
+ address: {
181
+ city: notEmpty('household city'),
182
+ },
183
+ members: {
184
+ each: {
185
+ name: notEmpty('member name'),
186
+ dateOfBirth: notEmpty('member dateOfBirth'),
187
+ },
188
+ },
189
+ },
190
+ quotes: {
191
+ each: {
192
+ coverageType: notEmpty('coverageType'),
193
+ deductibleUsd: min('deductibleUsd', 0),
194
+ startDate: notEmpty('startDate'),
195
+ riders: {
196
+ each: {
197
+ code: notEmpty('code'),
198
+ amountUsd: min('amountUsd', 0),
199
+ },
200
+ },
201
+ },
202
+ },
203
+ referralSource: notEmpty('referralSource'),
204
+ agreedToTerms: notEmpty('agreedToTerms'),
205
+ },
206
+ onSubmit: (values) => {
207
+ // Depth 5–6 refinements on both applicant sections.
208
+ expectTypeOf(values.primary.email).toEqualTypeOf<string>();
209
+ expectTypeOf(values.primary.homeAddress.postalCode).toEqualTypeOf<string>();
210
+ expectTypeOf(values.primary.drivers[0].incidents[0].date).toEqualTypeOf<string>();
211
+ expectTypeOf(values.primary.vehicles[0].garagingAddress.line1).toEqualTypeOf<string>();
212
+ expectTypeOf(values.primary.drivers[0].licenseState).toEqualTypeOf<string | undefined>();
213
+ expectTypeOf(values.secondary?.homeAddress.city).toEqualTypeOf<string | undefined>();
214
+ expectTypeOf(values.secondary?.drivers[0].incidents[0].date).toEqualTypeOf<
215
+ string | undefined
216
+ >();
217
+ expectTypeOf(values.household.members[0].name).toEqualTypeOf<string>();
218
+ expectTypeOf(values.quotes[0].riders[0].code).toEqualTypeOf<string>();
219
+ expectTypeOf(values.quotes[0].riders[0].amountUsd).toEqualTypeOf<number | null>();
220
+ expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
221
+ expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
222
+ },
223
+ });
224
+ });
225
+
226
+ it('Path machinery holds at mega scale: deep bindings on the hook result', () => {
227
+ const form = useFormState({ initialValues: {} as MegaQuoteForm });
228
+
229
+ expectTypeOf(
230
+ form.getFormFieldPropsAt(['primary', 'drivers', 0, 'incidents', 0, 'date']),
231
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
232
+ expectTypeOf(
233
+ form.getFormFieldPropsAt(['secondary', 'vehicles', 0, 'garagingAddress', 'postalCode']),
234
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
235
+ expectTypeOf(
236
+ form.getFormFieldPropsAt(['quotes', 0, 'riders', 0, 'amountUsd']),
237
+ ).toEqualTypeOf<FormFieldProps<number | null>>();
238
+ expectTypeOf(
239
+ form.getFormFieldPropsAt(['household', 'members', 0, 'relation']),
240
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
241
+
242
+ // @ts-expect-error 'drvers' is not a field of the primary applicant
243
+ form.getFormFieldPropsAt(['primary', 'drvers', 0, 'name']);
244
+ });
245
+ });
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // perField at realistic scale (plan phase 4): the pre-built entry point on
249
+ // the full recursive grammar — one applicant spec built once, reused for
250
+ // both applicant sections, mixing each-in-each, nested-in-each, and a
251
+ // whole-list leaf validator on a structural field. Before this phase,
252
+ // perField was only ever exercised on a 3-key flat form.
253
+ // ---------------------------------------------------------------------------
254
+
255
+ describe('useFormState stress: perField pre-built specs at mega scale', () => {
256
+ it('preserves markers through a perField applicant spec reused across sections', () => {
257
+ const applicantSpec = perField({
258
+ firstName: notEmpty('firstName'),
259
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
260
+ yearsEmployed: min('yearsEmployed', 0),
261
+ homeAddress: {
262
+ city: notEmpty('city'),
263
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
264
+ },
265
+ mailingAddress: {
266
+ city: notEmpty('mailing city'),
267
+ },
268
+ drivers: {
269
+ each: {
270
+ name: notEmpty('name'),
271
+ licenseNumber: [notEmpty('licenseNumber'), minLength('licenseNumber', 5)],
272
+ incidents: {
273
+ each: {
274
+ date: notEmpty('date'),
275
+ claimAmountUsd: min('claimAmountUsd', 0),
276
+ },
277
+ },
278
+ },
279
+ },
280
+ // Whole-list leaf validator on a structural field: leaf and structural
281
+ // forms are mutually exclusive per key, so vehicles takes the
282
+ // list-as-a-value form while drivers takes `each`.
283
+ vehicles: [
284
+ (val: Vehicle[]) => (val.length > 0 ? null : 'at least one vehicle'),
285
+ ],
286
+ }) satisfies Validations<Applicant>;
287
+
288
+ const constraints = {
289
+ primary: applicantSpec,
290
+ secondary: applicantSpec,
291
+ household: {
292
+ members: {
293
+ each: { name: notEmpty('member name') },
294
+ },
295
+ },
296
+ agreedToTerms: notEmpty('agreedToTerms'),
297
+ } as const satisfies Validations<MegaQuoteForm>;
298
+
299
+ // Direct Refine application on the composed pre-built object.
300
+ type Submit = Refine<MegaQuoteForm, typeof constraints>;
301
+ expectTypeOf<Submit['primary']['email']>().toEqualTypeOf<string>();
302
+ expectTypeOf<Submit['primary']['homeAddress']['postalCode']>().toEqualTypeOf<string>();
303
+ expectTypeOf<
304
+ Submit['primary']['drivers'][number]['incidents'][number]['date']
305
+ >().toEqualTypeOf<string>();
306
+ expectTypeOf<Submit['primary']['vehicles']>().toEqualTypeOf<Vehicle[]>();
307
+ expectTypeOf<Submit['secondary']>().toEqualTypeOf<
308
+ Refine<Applicant, typeof applicantSpec> | undefined
309
+ >();
310
+ expectTypeOf<Submit['household']['members'][number]['name']>().toEqualTypeOf<string>();
311
+ expectTypeOf<Submit['quotes']>().toEqualTypeOf<MegaQuoteForm['quotes']>();
312
+
313
+ // And through the hook boundary.
314
+ useFormState({
315
+ initialValues: {} as MegaQuoteForm,
316
+ constraints,
317
+ onSubmit: (values) => {
318
+ expectTypeOf(values.primary.drivers[0].incidents[0].date).toEqualTypeOf<string>();
319
+ expectTypeOf(values.secondary?.homeAddress.city).toEqualTypeOf<string | undefined>();
320
+ expectTypeOf(values.agreedToTerms).toEqualTypeOf<boolean>();
321
+ expectTypeOf(values.paperlessBilling).toEqualTypeOf<boolean>();
322
+ },
323
+ });
324
+ });
325
+ });
326
+
327
+ // --- Object-depth ladder: specs 12 levels deep --------------------------------
328
+
329
+ type L12 = { leaf: string | undefined; tag: string | undefined };
330
+ type L11 = { next: L12; tag: string | undefined };
331
+ type L10 = { next: L11; tag: string | undefined };
332
+ type L9 = { next: L10; tag: string | undefined };
333
+ type L8 = { next: L9; tag: string | undefined };
334
+ type L7 = { next: L8; tag: string | undefined };
335
+ type L6 = { next: L7; tag: string | undefined };
336
+ type L5 = { next: L6; tag: string | undefined };
337
+ type L4 = { next: L5; tag: string | undefined };
338
+ type L3 = { next: L4; tag: string | undefined };
339
+ type L2 = { next: L3; tag: string | undefined };
340
+ type L1 = { next: L2; tag: string | undefined };
341
+ type DeepObjectForm = { root: L1 };
342
+
343
+ describe('useFormState stress: 12-level nested-object spec ladder', () => {
344
+ it('refines a leaf 13 levels down, with a validator at every level', () => {
345
+ useFormState({
346
+ initialValues: {} as DeepObjectForm,
347
+ constraints: {
348
+ root: {
349
+ tag: notEmpty('t1'),
350
+ next: {
351
+ tag: notEmpty('t2'),
352
+ next: {
353
+ tag: notEmpty('t3'),
354
+ next: {
355
+ tag: notEmpty('t4'),
356
+ next: {
357
+ tag: notEmpty('t5'),
358
+ next: {
359
+ tag: notEmpty('t6'),
360
+ next: {
361
+ tag: notEmpty('t7'),
362
+ next: {
363
+ tag: notEmpty('t8'),
364
+ next: {
365
+ tag: notEmpty('t9'),
366
+ next: {
367
+ tag: notEmpty('t10'),
368
+ next: {
369
+ tag: notEmpty('t11'),
370
+ next: {
371
+ tag: notEmpty('t12'),
372
+ leaf: [notEmpty('leaf'), minLength('leaf', 2)],
373
+ },
374
+ },
375
+ },
376
+ },
377
+ },
378
+ },
379
+ },
380
+ },
381
+ },
382
+ },
383
+ },
384
+ },
385
+ },
386
+ onSubmit: (values) => {
387
+ expectTypeOf(
388
+ values.root.next.next.next.next.next.next.next.next.next.next.next.leaf,
389
+ ).toEqualTypeOf<string>();
390
+ expectTypeOf(
391
+ values.root.next.next.next.next.next.next.next.next.next.next.next.tag,
392
+ ).toEqualTypeOf<string>();
393
+ expectTypeOf(values.root.tag).toEqualTypeOf<string>();
394
+ },
395
+ });
396
+ });
397
+ });
398
+
399
+ // --- List-depth ladder: `each` 4 levels deep ----------------------------------
400
+
401
+ type DeepListForm = {
402
+ xs: Array<{
403
+ label: string | undefined;
404
+ ys: Array<{
405
+ label: string | undefined;
406
+ zs: Array<{
407
+ label: string | undefined;
408
+ ws: Array<{
409
+ v: string | undefined;
410
+ n: number | null;
411
+ }>;
412
+ }>;
413
+ }>;
414
+ }>;
415
+ };
416
+
417
+ describe('useFormState stress: 4-deep list-in-list each ladder', () => {
418
+ it('refines through four nested each specs', () => {
419
+ useFormState({
420
+ initialValues: {} as DeepListForm,
421
+ constraints: {
422
+ xs: {
423
+ each: {
424
+ label: notEmpty('x label'),
425
+ ys: {
426
+ each: {
427
+ label: notEmpty('y label'),
428
+ zs: {
429
+ each: {
430
+ label: notEmpty('z label'),
431
+ ws: {
432
+ each: {
433
+ v: notEmpty('v'),
434
+ n: min('n', 0),
435
+ },
436
+ },
437
+ },
438
+ },
439
+ },
440
+ },
441
+ },
442
+ },
443
+ },
444
+ onSubmit: (values) => {
445
+ expectTypeOf(values.xs[0].ys[0].zs[0].ws[0].v).toEqualTypeOf<string>();
446
+ expectTypeOf(values.xs[0].ys[0].zs[0].ws[0].n).toEqualTypeOf<number | null>();
447
+ expectTypeOf(values.xs[0].ys[0].zs[0].label).toEqualTypeOf<string>();
448
+ },
449
+ });
450
+ });
451
+ });
@@ -1,14 +1,25 @@
1
+ // The loose, form-type-agnostic shape of one constraint: a validator, a
2
+ // validator array, or an object of more of the same — which covers both
3
+ // nested specs and `{ each: … }` (an `each` value is itself a spec object).
4
+ // `(val: never) => …` admits any validator input contravariantly. This bound
5
+ // only rejects obvious garbage (strings, numbers, null values); the real
6
+ // shape check — every key a field, every validator's input compatible,
7
+ // structural forms only where the field type permits — happens at the
8
+ // caller's `satisfies Validations<FormType>`, which is where the form type
9
+ // first enters the picture.
10
+ type LooseFieldConstraint =
11
+ | ((val: never) => string | null)
12
+ | readonly ((val: never) => string | null)[]
13
+ | { readonly [key: string]: LooseFieldConstraint };
14
+
1
15
  // Aggregation entry point for building a `Validations<T>`-shaped constraints
2
- // object. Runtime identity; the value is in the type: the `const` type
3
- // parameter freezes each validator's precise type (including `Refinement`
4
- // markers) instead of letting function types widen. Callers still apply
5
- // `as const satisfies Validations<FormType>` to shape-check against their
6
- // form type without losing that precision see src/forms/CLAUDE.md.
7
- export const perField = <
8
- const V extends Record<
9
- string,
10
- ((val: never) => string | null) | readonly ((val: never) => string | null)[]
11
- >,
12
- >(
16
+ // object the full recursive grammar (leaf validators and arrays, nested
17
+ // object specs, list `each` specs, freely composed). Runtime identity; the
18
+ // value is in the type: the `const` type parameter freezes each validator's
19
+ // precise type (including `Refinement` markers) instead of letting function
20
+ // types widen. Callers still apply `satisfies Validations<FormType>` to
21
+ // shape-check against their form type without losing that precision — see
22
+ // src/forms/CLAUDE.md.
23
+ export const perField = <const V extends Record<string, LooseFieldConstraint>>(
13
24
  validations: V,
14
25
  ): V => validations;
@@ -205,6 +205,114 @@ describe('Refine<T, V> — union-typed members inside arrays', () => {
205
205
  });
206
206
  });
207
207
 
208
+ // ---------------------------------------------------------------------------
209
+ // perField at the recursive grammar (plan phase 4): the pre-built-spec entry
210
+ // point admits the full grammar — nested object specs, list `each` specs,
211
+ // arrays at every level — and preserves refinement markers through all of it.
212
+ // perField's own bound is shape-only (validators / arrays / objects of the
213
+ // same); the form-type shape check happens at `satisfies Validations<T>`.
214
+ // ---------------------------------------------------------------------------
215
+
216
+ type NestedFormType = {
217
+ email: string | undefined;
218
+ homeAddress: {
219
+ city: string | undefined;
220
+ postalCode: string | undefined;
221
+ };
222
+ mailingAddress: { city: string | undefined } | undefined;
223
+ drivers: Array<{
224
+ name: string | undefined;
225
+ incidents: Array<{ date: string | undefined }>;
226
+ }>;
227
+ };
228
+
229
+ describe('perField — recursive grammar', () => {
230
+ it('preserves markers through nested specs and each-in-each specs', () => {
231
+ const constraints = perField({
232
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
233
+ homeAddress: {
234
+ city: notEmpty('city'),
235
+ postalCode: [notEmpty('postalCode'), minLength('postalCode', 5)],
236
+ },
237
+ mailingAddress: {
238
+ city: notEmpty('mailing city'),
239
+ },
240
+ drivers: {
241
+ each: {
242
+ name: notEmpty('name'),
243
+ incidents: { each: { date: notEmpty('date') } },
244
+ },
245
+ },
246
+ }) satisfies Validations<NestedFormType>;
247
+
248
+ type Result = Refine<NestedFormType, typeof constraints>;
249
+ expectTypeOf<Result['email']>().toEqualTypeOf<string>();
250
+ expectTypeOf<Result['homeAddress']['city']>().toEqualTypeOf<string>();
251
+ expectTypeOf<Result['homeAddress']['postalCode']>().toEqualTypeOf<string>();
252
+ // Nullability survives around the refined interior of a nullable section.
253
+ expectTypeOf<Result['mailingAddress']>().toEqualTypeOf<
254
+ { city: string } | undefined
255
+ >();
256
+ expectTypeOf<Result['drivers'][number]['name']>().toEqualTypeOf<string>();
257
+ expectTypeOf<
258
+ Result['drivers'][number]['incidents'][number]['date']
259
+ >().toEqualTypeOf<string>();
260
+ });
261
+
262
+ it('a pre-built section spec composes as a nested-spec value', () => {
263
+ // Building one section's spec via perField and placing it under the
264
+ // section key in a larger constraints object — the reuse shape phase 4
265
+ // exists to keep working.
266
+ const addressSpec = perField({
267
+ city: notEmpty('city'),
268
+ }) satisfies Validations<NestedFormType['homeAddress']>;
269
+
270
+ const constraints = {
271
+ homeAddress: addressSpec,
272
+ mailingAddress: addressSpec,
273
+ } as const satisfies Validations<NestedFormType>;
274
+
275
+ type Result = Refine<NestedFormType, typeof constraints>;
276
+ expectTypeOf<Result['homeAddress']['city']>().toEqualTypeOf<string>();
277
+ expectTypeOf<Result['homeAddress']['postalCode']>().toEqualTypeOf<
278
+ string | undefined
279
+ >();
280
+ expectTypeOf<Result['mailingAddress']>().toEqualTypeOf<
281
+ { city: string } | undefined
282
+ >();
283
+ });
284
+
285
+ it('rejects non-constraint values at the perField call itself', () => {
286
+ perField({
287
+ // @ts-expect-error a string is not a validator, array, or spec
288
+ email: 'not a validator',
289
+ });
290
+ perField({
291
+ // @ts-expect-error garbage stays rejected at depth (the TS2322
292
+ // anchors at the parent key, same as deep validator-input mismatches)
293
+ homeAddress: {
294
+ city: 42,
295
+ },
296
+ });
297
+ });
298
+
299
+ it('form-type shape errors surface at the satisfies check', () => {
300
+ // perField is form-type-agnostic, so a wrong key passes its bound and
301
+ // is caught where the form type first appears.
302
+ const wrongKey = perField({
303
+ homeAddress: { cityy: notEmpty('cityy') },
304
+ });
305
+ // @ts-expect-error 'cityy' is not a field of homeAddress
306
+ wrongKey satisfies Validations<NestedFormType>;
307
+
308
+ const eachOnObject = perField({
309
+ homeAddress: { each: { city: notEmpty('city') } },
310
+ });
311
+ // @ts-expect-error an `each` spec is not legal on an object field
312
+ eachOnObject satisfies Validations<NestedFormType>;
313
+ });
314
+ });
315
+
208
316
  describe('Refine<T, V> — union-typed single constraints (conditional pick)', () => {
209
317
  it('narrows a conditionally-picked validator to the union of branch results', () => {
210
318
  // Only ONE branch runs at runtime, so the sound result is the UNION of
@@ -23,6 +23,15 @@ What still applies: CSS modules, `--ui-*` tokens, no HTML attribute pass-through
23
23
 
24
24
  If a story util starts getting reached for by app code, that's a signal to build a real primitive, not to promote the story util. A real `Placeholder` for empty states would be a different primitive with proper typography, icon support, and action slots — not this dashed box. Keep the lanes separate.
25
25
 
26
+ ## Showcase story files live here too
27
+
28
+ Besides the utils, this folder hosts the cross-component showcase stories:
29
+ `Composition.stories.tsx` (`Composition/…`) and `Demo.stories.tsx` (`Demo/…`
30
+ — the polished mini-app demos; conventions in the root AGENTS.md "The Demo
31
+ section"). They are ordinary stories, not utils: they compose the library's
32
+ public pieces, keep their datasets deterministic, and carry `play`-function
33
+ integration tests. The relaxed util rules above don't apply to them.
34
+
26
35
  ## Adding a new util
27
36
 
28
37
  - New folder under `src/_StoryUtils/<Name>/` with `index.tsx`.