diff --git a/.changeset/7722-wrapper-class-five-more.md b/.changeset/7722-wrapper-class-five-more.md index c5d11dcb0d..49204c01f4 100644 --- a/.changeset/7722-wrapper-class-five-more.md +++ b/.changeset/7722-wrapper-class-five-more.md @@ -41,5 +41,8 @@ checks and a validator enforces. Two verdicts move, in opposite directions: Keys outside the five are untouched: an undeclared key of any type is still admitted unexamined on all five mirrors, pinned per mirror with a control key the renderer does not read. `InputSchema.wrapperClass`, declared on the TS face -only, is a recorded row of the parity ledger (`UnmirroredDeclared`) and stays -there; the new sweep pin carries it as a self-expiring exemption. +only, was a recorded row of the parity ledger (`UnmirroredDeclared`) and this +card left it there; the new sweep pin carried it as a self-expiring exemption. +objectui#8072 has since mirrored that key, so the row and the exemption are both +gone — the exemption expired exactly as designed, and the sweep now judges all +nine readers alike. diff --git a/.changeset/8072-input-wrapper-class-mirrored.md b/.changeset/8072-input-wrapper-class-mirrored.md new file mode 100644 index 0000000000..52aaf6aa86 --- /dev/null +++ b/.changeset/8072-input-wrapper-class-mirrored.md @@ -0,0 +1,51 @@ +--- +'@object-ui/types': patch +--- + +Mirror `wrapperClass` on `InputSchema` (objectui#8072 — the last +`schema.wrapperClass` reader whose value the validator admitted unexamined). + +`packages/components/src/renderers/form/input.tsx` reads +`cn("grid w-full items-center gap-1.5", schema.wrapperClass)` onto the wrapper +`div` around the input and its label. The TypeScript face has declared the key +all along (`form.ts`, docblock "Input wrapper CSS class"); the zod mirror never +did, so the value rode through `.passthrough()` and +`{ type: 'input', wrapperClass: 42 }` validated GREEN — while the identical +document on any of the other eight `schema.wrapperClass` readers (`checkbox`, +`file-upload`, `filter-builder` — objectui#6150 / #6938; `switch`, `textarea`, +`date-picker`, `select`, `list` — objectui#7722) was refused at the key. + +**patch, and one step below objectui#6938's patch on the surface dimension.** +objectui#7722 graded its batch `minor` because five schemas each gained a member +of the shipped `.d.ts`. Nothing of the sort happens here: the published +TypeScript face does not move by one byte — the key was already on it — and only +the zod mirror gains the member it was always supposed to restate. Two verdicts +move, in opposite directions: + +- **A non-string `wrapperClass` on `input` is now REFUSED at the key.** + `{ type: 'input', wrapperClass: 42 }` parsed green before, on the mirror and + through `safeValidateSchema`; it is refused now, at `wrapperClass`. That is + enforcement of the type the TypeScript face already published, not a new + capability — but it is a behaviour change for a document that carried a + wrong-typed value there, a value the renderer would have interpolated into the + class string as text. +- **The derived strict authoring face stops refusing the key.** + `StrictAnyComponentSchema` (objectui#8345) is derived from these mirrors and + closes unknown keys, so an authored *string* `wrapperClass` on `input` — the + spelling the published TypeScript declaration invites — was refused there + outright. It parses now. The two faces move apart: the tolerant one narrows, + the strict one stops contradicting the TS declaration. + +Nothing well-typed stops validating: an authored string `wrapperClass` on +`input` parsed green before and parses green now, with the value surviving the +parse, and an undeclared key of any type is still admitted unexamined on this +mirror (pinned with a control key the renderer does not read). The +`InputShorthandSchema` arm stops re-declaring the key locally and inherits it +from `InputSchema` like every other key, with its membership and its refusal +pinned so the inheritance is measured rather than assumed. + +The gap was a recorded row of the parity ledger +(`UnmirroredDeclared['form.zod.ts#InputSchema']`), so closing it moves that +file's census figures — the entry's only key, so entry count and key total move +together — and expires the self-expiring exemption objectui#7722's sweep carried +for this one reader, which is deleted rather than weakened. diff --git a/packages/types/src/__tests__/input-wrapper-class-mirrored-8072.test.ts b/packages/types/src/__tests__/input-wrapper-class-mirrored-8072.test.ts new file mode 100644 index 0000000000..df95880347 --- /dev/null +++ b/packages/types/src/__tests__/input-wrapper-class-mirrored-8072.test.ts @@ -0,0 +1,239 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#8072 — `InputSchema.wrapperClass`, the last `schema.wrapperClass` + * reader whose value was admitted unexamined. + * + * ## The defect + * + * `packages/components/src/renderers/form/input.tsx` reads + * `cn("grid w-full items-center gap-1.5", schema.wrapperClass)` — classes on + * the wrapper `div` around the input and its label. The TypeScript face + * declared the key (`../form.ts`, docblock "Input wrapper CSS class"); the zod + * mirror never did, so the value parsed through `.passthrough()` and + * `{ type: 'input', wrapperClass: 42 }` validated GREEN. The same document on + * any of the eight sibling readers — `checkbox`, `file-upload`, + * `filter-builder` (objectui#6150 / #6938), `switch`, `textarea`, + * `date-picker`, `select`, `list` (objectui#7722) — was already refused at the + * key. That asymmetry, not the missing declaration, is what this file pins. + * + * The gap was a RECORDED row of the parity ledger + * (`UnmirroredDeclared['form.zod.ts#InputSchema']` in + * `zod-mirror-parity.test.ts`), so closing it moves that file's census figures + * and retires the self-expiring exemption + * `wrapper-class-declared-7722.test.ts` carried for this one reader. + * + * ## The assertion that matters, and its three controls + * + * ⭐ The verdict under test is NOT "the key is declared" — under + * `.passthrough()` a parse that accepts proves nothing about membership. It is + * that `{ type: 'input', wrapperClass: 42 }` moved from ACCEPTED to REFUSED, + * and each control keeps that reading from being something else: + * + * - **The authored string still parses**, on both faces and through the + * published union entry point — otherwise the measurement would be of a + * broken schema rather than of a closed gap. + * - **A SIBLING refuses the same wrong-typed document**, and refused it + * before this change too: this diff touches no mirror but `InputSchema`, + * so the `checkbox` leg reads the same on either side of it. That is what + * makes `input` the outlier rather than this file the instrument. + * - **An UNDECLARED key still admits any value unexamined**, so the mirror's + * unknown-key policy is byte-for-byte what it was and exactly one key + * moved. + * + * ## The strict authoring twin moves in the OPPOSITE direction, deliberately + * + * `StrictAnyComponentSchema` (objectui#8345) is DERIVED from these mirrors and + * closes unknown keys, so before this change it refused an authored + * `wrapperClass` on `input` outright — a key the published TypeScript face + * invites. Declaring it narrows the tolerant face and, in the same move, lets + * the strict face accept what the TS declaration always promised. Both legs + * are pinned below because the two faces move apart, not together. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { CheckboxSchema, InputSchema, InputShorthandSchema } from '../zod/form.zod'; +import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod'; +import type { InputSchema as TsInputSchema } from '../form'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const READER = 'packages/components/src/renderers/form/input.tsx'; +const READ_TEXT = 'cn("grid w-full items-center gap-1.5", schema.wrapperClass)'; + +const KEY = 'wrapperClass'; +/** + * A plausible-looking class key the renderer never reads: the label's classes + * are hard-coded beside `schema.required`. It stays undeclared on both faces. + */ +const CONTROL_KEY = 'labelClass'; +/** An undeclared key carried alongside the control, so the before-state stays visible. */ +const SENTINEL = 'undeclaredControlKey8072'; +/** A declared-keys-only document; every assertion below is a delta on it. */ +const CONTROL = { type: 'input', label: 'Name' }; +/** The same delta on a sibling that declared the key before this card. */ +const SIBLING_CONTROL = { type: 'checkbox', label: 'Accept' }; + +/* ── Type-level pins (invariant equality, house form) ─────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** The canonical `any` detector: only `any` absorbs `1 &` down to something `0` extends. */ +type IsAny = 0 extends (1 & T) ? true : false; + +// The TS face already declared this key; these guard it against being removed +// on the way past, in which case the read falls back to `BaseSchema`'s index +// signature and resolves to `any`, and `Equal` is false. +export type _WrapperClassIsString = Expect, string>>; +export type _WrapperClassIsNotAny = Expect, false>>; +// The control key is NOT declared: it resolves to `any` through the index +// signature, exactly as `wrapperClass` did on the MIRROR before this card. +export type _ControlKeyFallsThroughToIndexSignature = Expect>; + +// The TS face accepts the key on a literal. ⚠️ This is the WEAK half: the index +// signature would accept it undeclared too. The invariant pins above are the +// guard; this line only shows the declared spelling in use. +const literal: TsInputSchema = { type: 'input', label: 'Name', wrapperClass: 'gap-4' }; + +interface Mirror { + shape: Record; + safeParse: (v: unknown) => { + success: boolean; + data?: Record; + error?: { issues: { path: (string | number)[] }[] }; + }; +} + +const input = InputSchema as unknown as Mirror; +const checkbox = CheckboxSchema as unknown as Mirror; +const shorthand = InputShorthandSchema as unknown as Mirror; + +/** Every `schema.KEY` read in the renderer, off disk. */ +function rendererReads(): Set { + const src = readFileSync(join(REPO_ROOT, READER), 'utf8'); + return new Set([...src.matchAll(/\bschema\.([A-Za-z_$][\w$]*)/g)].map((m) => m[1])); +} + +describe('objectui#8072 — the renderer reads `wrapperClass`, which is the fact the mirror now records', () => { + it('the read is still there, as the exact text the docblocks cite', () => { + const src = readFileSync(join(REPO_ROOT, READER), 'utf8'); + expect(src, `${READER} no longer reads \`schema.${KEY}\` as \`${READ_TEXT}\``).toContain(READ_TEXT); + }); + + it('the read set, derived from the renderer, contains the key and NOT the control key', () => { + // Non-vacuity for the control: if the renderer ever starts reading + // `labelClass`, this turns red and the control must be re-chosen, not + // declared on the way past. + const reads = rendererReads(); + expect(reads.has(KEY)).toBe(true); + expect(reads.has(CONTROL_KEY)).toBe(false); + }); +}); + +describe('objectui#8072 — ACCEPTED to REFUSED, which is the verdict this card moves', () => { + it('refuses a wrong-typed value AT the key', () => { + // `{ type: 'input', wrapperClass: 42 }` parsed GREEN before this card: the + // key was undeclared and `.passthrough()` admitted it unexamined. This is + // the only verdict that moves, and it moves toward refusal. + const r = input.safeParse({ ...CONTROL, [KEY]: 42 }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path.join('.'))).toContain(KEY); + }); + + it('…and refuses it through the published union entry point, so the `input` arm is the one reached', () => { + const r = safeValidateSchema({ ...CONTROL, [KEY]: 42 }); + expect(r.success).toBe(false); + }); + + it('is a member of the mirror shape (membership cannot be read off acceptance under passthrough)', () => { + expect(Object.keys(input.shape)).toContain(KEY); + }); +}); + +describe('objectui#8072 — control: the authored STRING still parses green on both faces', () => { + it('the mirror accepts the declared value and the value SURVIVES the parse', () => { + const r = input.safeParse({ ...CONTROL, [KEY]: 'gap-4' }); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + expect(r.data![KEY]).toBe('gap-4'); + }); + + it('…and through the published union entry point', () => { + const r = safeValidateSchema({ ...CONTROL, [KEY]: 'gap-4' }); + expect(r.success, r.success ? '' : JSON.stringify(r.error.issues)).toBe(true); + if (r.success) expect((r.data as Record)[KEY]).toBe('gap-4'); + }); + + it('the declared-keys-only document parses green, before and after', () => { + expect(input.safeParse(CONTROL).success).toBe(true); + }); + + it('the type-level bindings above are referenced, so lint keeps them', () => { + expect(literal.wrapperClass).toBe('gap-4'); + }); +}); + +describe('objectui#8072 — control: a SIBLING refuses the same document, before this change too', () => { + it('`checkbox` refuses the wrong-typed value at the key', () => { + // This diff touches no mirror but `InputSchema`, so this leg reads the + // same on either side of it — which is what makes `input` the outlier + // rather than this file the instrument. (objectui#6938 declared it here.) + const r = checkbox.safeParse({ ...SIBLING_CONTROL, [KEY]: 42 }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path.join('.'))).toContain(KEY); + }); + + it('`checkbox` accepts the authored string, so the sibling leg is not uniformly red', () => { + expect(checkbox.safeParse({ ...SIBLING_CONTROL, [KEY]: 'gap-4' }).success).toBe(true); + }); +}); + +describe('objectui#8072 — control: the unknown-key policy did not move', () => { + it('the control key is ABSENT from the mirror shape', () => { + expect(Object.keys(input.shape)).not.toContain(CONTROL_KEY); + }); + + it('the SAME wrong-typed value under an UNDECLARED key is still admitted unexamined', () => { + // The before-state of `wrapperClass`, kept on purpose on keys that are not + // read: `.passthrough()` admits them, of any type, and they survive. + for (const undeclared of [CONTROL_KEY, SENTINEL]) { + const r = input.safeParse({ ...CONTROL, [undeclared]: 42 }); + expect(r.success).toBe(true); + expect(r.data![undeclared]).toBe(42); + } + }); +}); + +describe('objectui#8072 — the shorthand arm carries the key from `InputSchema`', () => { + it('`InputShorthandSchema` declares it and refuses the wrong-typed value', () => { + // The shorthand is `InputSchema.omit({ type, inputType }).extend({ … })`, so + // it used to re-declare `wrapperClass` LOCALLY to stand in for the gap this + // card closes. With the key on `InputSchema` the restatement is gone and the + // arm inherits it — this leg is what proves that removal changed nothing. + expect(Object.keys(shorthand.shape)).toContain(KEY); + const r = shorthand.safeParse({ type: 'password', label: 'Secret', [KEY]: 42 }); + expect(r.success).toBe(false); + expect(r.error!.issues.map((i) => i.path.join('.'))).toContain(KEY); + expect(shorthand.safeParse({ type: 'password', label: 'Secret', [KEY]: 'gap-4' }).success).toBe(true); + }); +}); + +describe('objectui#8072 — the derived STRICT authoring face moves the other way', () => { + it('accepts the authored string it used to refuse as an unknown key, and still refuses the wrong-typed one', () => { + // `StrictAnyComponentSchema` is derived from the mirrors and closes unknown + // keys (objectui#8345), so before this card it refused a `wrapperClass` the + // published TypeScript face invites an author to write. Both faces are + // pinned because they move APART: the tolerant one narrows, the strict one + // stops refusing a declared key. + const strict = StrictAnyComponentSchema as unknown as Mirror; + expect(strict.safeParse({ ...CONTROL, [KEY]: 'gap-4' }).success).toBe(true); + expect(strict.safeParse({ ...CONTROL, [KEY]: 42 }).success).toBe(false); + }); +}); diff --git a/packages/types/src/__tests__/wrapper-class-declared-7722.test.ts b/packages/types/src/__tests__/wrapper-class-declared-7722.test.ts index 636ea4c3c0..7df0286784 100644 --- a/packages/types/src/__tests__/wrapper-class-declared-7722.test.ts +++ b/packages/types/src/__tests__/wrapper-class-declared-7722.test.ts @@ -22,16 +22,21 @@ * declare the key on the TS face, and five do not, on either face. That is the * batch below — the card's five, measured, not inherited. * - * ## The sixth asymmetry, deliberately NOT in the batch + * ## The sixth asymmetry, deliberately NOT in the batch — and since CLOSED * - * `InputSchema` declares `wrapperClass` on the TS face (`../form.ts`) and NOT on - * its zod mirror. That is a recorded, reconciled row of the parity ledger — + * `InputSchema` declared `wrapperClass` on the TS face (`../form.ts`) and NOT on + * its zod mirror. That was a recorded, reconciled row of the parity ledger — * `UnmirroredDeclared['form.zod.ts#InputSchema']` in `zod-mirror-parity.test.ts` * — whose entry and key counts are pinned by that file's own census. Mirroring - * it moves those counts; it is that ledger's remedy, not this card's. The sweep - * carries it as a SELF-EXPIRING exemption: valid only while the mirror still - * lacks the key AND the ledger row is still on disk. Mirror it and the exemption - * turns red until it is deleted, so the carve-out cannot outlive the debt. + * it moved those counts, so it was that ledger's remedy and not this card's. The + * sweep carried it as a SELF-EXPIRING exemption: valid only while the mirror + * still lacked the key AND the ledger row was still on disk. + * + * ⭐ objectui#8072 mirrored it, and the exemption expired exactly as built — + * `LEDGERED_UNMIRRORED` is EMPTY now, and the sweep judges all nine readers + * alike. ⛔ The carve-out was deleted, not weakened to stay green: that is the + * whole return on writing an exemption that asserts its own precondition rather + * than one that simply names a type. * * ## What this file pins, and the two shapes it borrows * @@ -261,10 +266,14 @@ describe('objectui#7722 — the five renderers read `wrapperClass`, which is the * exact ledger row text; the exemption holds only while that text is on disk * AND the mirror still lacks the key. Mirror the key and BOTH conditions flip — * the exemption must then be deleted, never left to cover nothing. + * + * ⭐ EMPTY since objectui#8072 mirrored `InputSchema.wrapperClass`, the one entry + * this ever carried. An empty map makes the sweep below judge every reader alike, + * which is the state it is meant to reach — ⛔ it is not an invitation to refill. + * A new row belongs here only when the parity ledger really records the pair, and + * it expires the same way this one did. */ -const LEDGERED_UNMIRRORED: Record = { - InputSchema: "'form.zod.ts#InputSchema': 'wrapperClass'", -}; +const LEDGERED_UNMIRRORED: Record = {}; interface Reader { /** Renderer file, relative to the repo root. */ diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 9f8d5ea259..85a2f02721 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -290,7 +290,12 @@ * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and * `CalendarViewSchema` grew by `onEventClick`. - * - **14 entries** in `UnmirroredDeclared`, **86 keys** across them — 14 / 87 until + * - **13 entries** in `UnmirroredDeclared`, **85 keys** across them — 14 / 86 until + * objectui#8072 MIRRORED `InputSchema.wrapperClass`, the LAST `schema.wrapperClass` + * reader whose value the mirror admitted unexamined; it was that entry's ONLY key, + * so the entry emptied and BOTH figures moved together — read it against the next + * clause, where one key off a fifteen-key entry moved only the key total: 14 / 87 + * until * objectui#7762 MIRRORED `ObjectGridSchema.exportOptions` (the entry kept its other * fourteen keys, so the entry count held); 14 / 96 until * objectui#7779 closed nine of `ObjectViewSchema`'s ten keys (maintainer ruling @@ -372,8 +377,8 @@ * two. ⭐ The direction is the one this ledger is meant to drain in: a * runtime-only key leaves by being declared on the mirror, never by being * quietly refiled. - * **3 of the 3** are a subset of the **14** pairs above, so - * the union of the two unmirrored ledgers is **14** pairs — `UnmirroredDeclared` + * **3 of the 3** are a subset of the **13** pairs above, so + * the union of the two unmirrored ledgers is **13** pairs — `UnmirroredDeclared` * itself, exactly. ⭐ This sentence read `3 of the 4` / `15 pairs and not 14` * until objectui#7804's `TreeViewSchema` slice, and the pair it named as the * exception was the whole content of the difference: objectui#6150 had declared @@ -2222,7 +2227,7 @@ interface KnownDrift { * * objectui#6058 seeded this ledger at **121 keys**, and — on ONE line, because the * pin below reads this sentence off disk — - * **86 keys** is what this ledger records today. + * **85 keys** is what this ledger records today. * The movements between the two are different facts. objectui#6152 measured the 23 * callback-shaped (`on*`) keys and ruled that mirroring is the wrong remedy for every * one of them; @@ -2384,7 +2389,12 @@ interface KnownDrift { * spec schema does not model, which is objectui#2231's unification question and * NOT a local mirror edit. They are marked, not exempted: exempting them in the * instrument would re-blind exactly the pairs objectui#5927 leaned on hardest. - * - **LOCAL (11 entries, 69 keys)** — plain omissions from a hand-written mirror. + * - **LOCAL (10 entries, 68 keys)** — plain omissions from a hand-written mirror. + * It was 11 / 69 until objectui#8072 MIRRORED `InputSchema.wrapperClass`: the entry's + * ONLY key, so the entry left this half with it and no entry crossed to the + * SPEC-DERIVED half — `form.zod.ts#InputSchema` is not in `SPEC_DERIVED_PAIRS`, so + * that half did not move at all. A plain LOCAL repair of the kind objectui#6152's + * worklist is made of — ⛔ no reclassification, no retirement, no spec reference. * It was 12 / 84 until objectui#7762 MIRRORED `ObjectGridSchema.exportOptions`: one key * REPAIRED and the entry's other fourteen carried out of this half with it, because the * spec reference that repair introduced re-derives the pair into the SPEC-DERIVED half @@ -2423,10 +2433,13 @@ interface KnownDrift { * `ObjectGridSchema` with ONE key: mirroring `exportOptions` as the spec's object arm by * reference shrank the ledger 87 → 86 and re-derived the entry (fourteen keys) from the * LOCAL half into the SPEC-DERIVED one, so the entry count held while BOTH split figures - * moved. The seeded pair is no longer + * moved. objectui#8072 then MIRRORED `InputSchema.wrapperClass` — the opposite shape on + * every axis: one key that was its entry's WHOLE key set, on a pair no spec reference + * touches, so the entry emptied, the LOCAL half lost an entry and a key, and the + * SPEC-DERIVED half did not move. The seeded pair is no longer * among them, and the ledger now totals — on ONE line, because the pin below reads * this sentence off disk — - * **14 entries / 86 keys** — 3 / 17 spec-derived, 11 / 69 local. + * **13 entries / 85 keys** — 3 / 17 spec-derived, 10 / 68 local. * * ⛔ The four split figures above and this totals line are PINNED: 'objectui#7279' * at the bottom of this file derives every one of them from the `UnmirroredDeclared` @@ -2524,11 +2537,12 @@ interface UnmirroredDeclared { 'form.zod.ts#FormSchema': | 'defaultFieldTab' | 'fieldContainerClass' | 'fieldPanes' | 'fieldPanesOrientation' | 'fieldPanesResizable' | 'fieldTabs' | 'fieldTabsPosition' | 'mobileStickyActions'; - /** - * LOCAL. Verified by hand against both sources while measuring: declared once in - * `../form.ts`, zero occurrences in the mirror. - */ - 'form.zod.ts#InputSchema': 'wrapperClass'; + // `form.zod.ts#InputSchema` recorded `wrapperClass` here (LOCAL) — the entry's + // ONLY key, so objectui#8072 MIRRORING it took the whole entry with it. The pair + // keeps its `KnownDrift` entry above (`onChange`, a runtime slot the mirror + // refuses by name) and records nothing here. ⭐ That is why this move shrank the + // entry count and the key total together where objectui#7762's did not: one key + // off a one-key entry empties it, one key off a fifteen-key entry does not. /** LOCAL. */ 'form.zod.ts#LabelSchema': 'content'; /** LOCAL. */ @@ -2647,7 +2661,7 @@ interface UnmirroredDeclared { * * `UnmirroredDeclared` above was seeded at **121 keys** by objectui#6058, and — on * ONE line, because the pin below reads this sentence off disk — - * `UnmirroredDeclared` records **86 keys** today. + * `UnmirroredDeclared` records **85 keys** today. * These 23 moved here whole. Keys have since left that ledger by MIRRORING and by * RETIREMENT, but the move recorded HERE is neither and repaired nothing. ⛔ Nothing * was mirrored by it, no declaration was removed, no defect was repaired and nothing was diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index 94ce691020..2a664196b8 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -229,6 +229,13 @@ export const InputSchema = BaseSchema.extend({ description: z.string().optional().describe('Help text'), error: z.string().optional().describe('Error message'), onChange: handlerKeyRefusal('onChange', 'runtime-slot', 'Change handler'), + // `renderers/form/input.tsx` reads it onto the wrapper element — + // `cn("grid w-full items-center gap-1.5", schema.wrapperClass)` — and the TS + // face has declared it all along; the mirror had not, so a wrong-typed value + // rode through `.passthrough()` unexamined while all eight sibling readers + // refused it at the key (objectui#8072). + wrapperClass: z.string().optional() + .describe('Classes on the wrapper div around the input and its label (objectui#8072)'), min: z.number().optional().describe('Minimum value (for number type)'), max: z.number().optional().describe('Maximum value (for number type)'), step: z.number().optional().describe('Step value (for number type)'), @@ -988,15 +995,14 @@ export const InputShorthandSchema = InputSchema.omit({ type: true, inputType: tr // Declared and unwritable — the `.omit()` above removes the HONOURED enum // {@link InputSchema} carries, and this puts a named refusal in its place. inputType: retirementTombstone(SHORTHAND_INPUT_TYPE_REFUSAL), - // Declared here and not (yet) on {@link InputSchema}, which is the pair - // `__tests__/zod-mirror-parity.test.ts` records as unmirrored for this very key. - // The renderer both arms share reads it — `renderers/form/input.tsx:42`, - // `cn('grid w-full items-center gap-1.5', schema.wrapperClass)` — so declaring it - // is the read site talking. ⛔ Copying the gap into a NEW pair would have minted a - // second ledger row for a key that is demonstrably read; shrinking the existing - // row is `InputSchema`'s own repair (objectui#7722's family) and not this card's. - wrapperClass: z.string().optional() - .describe("Classes on the wrapper div around the input and its label"), + // ⭐ `wrapperClass` used to be RE-DECLARED here, because {@link InputSchema} + // did not carry it and copying the gap into a NEW pair would have minted a + // second parity-ledger row for a key that is demonstrably read. objectui#8072 + // shrank that row instead: the key is on {@link InputSchema} now, so this arm + // inherits it through the `.omit()` above like every other key, and the + // restatement is gone rather than left standing as a duplicate of it. + // `__tests__/input-wrapper-class-mirrored-8072.test.ts` pins this arm's + // membership and its refusal, so the inheritance is measured, not assumed. }); /**