From 545719f017b1e5fd132466988b2da4d509d12128 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:52:08 +0000 Subject: [PATCH 1/5] feat(types): the object-form zod mirror declares the members its TypeScript twin declared (objectui#6152 round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UnmirroredDeclared['objectql.zod.ts#ObjectFormSchema']` recorded 21 keys the published `ObjectFormSchema` interface invites and the zod mirror had never heard of. The tolerant face kept any value there unexamined, and the strict authoring face refused the keys although tsc accepted them — objectui#5250's M3 class (iii). Nineteen are measured authored and read by `ObjectForm` (or relayed into it by `ObjectView` / `RecordFormPage` / `ScreenView`), so each is now declared on the mirror, shaped as the twin declares it; `nextText` / `prevText` by reference to the spec's `I18nLabelSchema`. The entry keeps `open` and `submitHandler`, whose routes are open on the card. Ledger 12 entries / 84 keys -> 12 / 65, every figure the file pins moved with it. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .../6152-object-form-unmirrored-members.md | 46 ++++++ ...ject-form-i18n-label-members-10993.test.ts | 35 +++-- ...bject-form-unmirrored-members-6152.test.ts | 137 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 73 +++++++--- packages/types/src/zod/objectql.zod.ts | 104 ++++++++++++- 5 files changed, 355 insertions(+), 40 deletions(-) create mode 100644 .changeset/6152-object-form-unmirrored-members.md create mode 100644 packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts diff --git a/.changeset/6152-object-form-unmirrored-members.md b/.changeset/6152-object-form-unmirrored-members.md new file mode 100644 index 0000000000..f828ffa115 --- /dev/null +++ b/.changeset/6152-object-form-unmirrored-members.md @@ -0,0 +1,46 @@ +--- +'@object-ui/types': minor +--- + +feat(types): the `object-form` zod mirror declares the members its TypeScript twin already declared (objectui#6152, round 1) + +`ObjectFormSchema` in `@object-ui/types` (`objectql.ts`) declared a set of members that its zod +mirror in `@object-ui/types/zod` had never heard of. Every one of them is read by the `object-form` +renderer (`ObjectForm` in `@object-ui/plugin-form`). The two published faces answered differently: + +- the tolerant validator (`AnyComponentSchema`, `safeValidateSchema`) kept any value at those keys + without examining it, so `{ "formType": "carousel" }` parsed green and the form fell back to its + simple layout; +- the strict authoring face (`StrictAnyComponentSchema`) refused the keys outright, although the + published TypeScript type invites them. + +The mirror now declares each member, shaped as the TypeScript type declares it: + +- `formType`, `sections`, `defaultTab`, `tabPosition`; +- `allowSkip`, `showStepIndicator`, `nextText`, `prevText` (the last two are `@objectstack/spec`'s + `I18nLabel`, by reference, like the form's other five label members); +- `splitDirection`, `splitSize`, `splitResizable`; +- `drawerSide`, `drawerWidth`, `modalSize`, `modalCloseButton`; +- `mobile`, `buttons`, `defaults`, `subforms`. + +The `object-view` node's `form` slot is this mirror minus `type`, `objectName` and `mode`, so it +gains the same members. + +What an author sees change: + +- **Strict authoring face.** A document using any of these keys is no longer refused for them. The + catalog's tabbed-sections form, for example, now parses strict. +- **Tolerant face (breaking for invalid documents).** A value of the wrong type at one of these keys + is now refused at the key, where it used to be kept unexamined. Examples are an unknown `formType`, + a string `splitSize`, or a `subforms` entry without `childObject`. A section entry is judged + member by member. Its `fields` entries are not judged here, which is the same policy as the + mirror's existing `customFields`. On the strict face a section is also closed, so an undeclared + section key is named there. `@object-ui/types` is in the fixed release group, so this ships as a + minor bump, per the repository's version policy. + +Two members of the same TypeScript type are deliberately not mirrored: + +- `submitHandler` is a function slot, and a string handler dialect is ruled out (objectui#6182); +- `open` is a boolean that only in-code hosts set. + +Their routes are open on objectui#6152. No TypeScript declaration changed. diff --git a/packages/types/src/__tests__/object-form-i18n-label-members-10993.test.ts b/packages/types/src/__tests__/object-form-i18n-label-members-10993.test.ts index 9e1617fe30..00f20ad6c6 100644 --- a/packages/types/src/__tests__/object-form-i18n-label-members-10993.test.ts +++ b/packages/types/src/__tests__/object-form-i18n-label-members-10993.test.ts @@ -13,14 +13,16 @@ * * ## The zod mirror * - * The mirror declares five of the seven, and it used to declare them - * `z.string()`: a map the row and the renderer both accept was refused with - * `invalid_type` at the member. Each is now the spec's `I18nLabelSchema` by - * reference. The rows below are behavioural: a map and a string parse, and a - * number is still refused AT THE MEMBER, so the widening is exactly one arm and - * not an opening. `nextText` and `prevText` are not mirrored (they stay in this - * pair's `UnmirroredDeclared` entry in `./zod-mirror-parity.test.ts`), and a - * row records that here so the ledger and this file cannot disagree silently. + * This card found the mirror declaring five of the seven, as `z.string()`: a map + * the row and the renderer both accept was refused with `invalid_type` at the + * member. Each became the spec's `I18nLabelSchema` by reference. The rows below + * are behavioural: a map and a string parse, and a number is still refused AT + * THE MEMBER, so the widening is exactly one arm and not an opening. The other + * two, `nextText` and `prevText`, stayed in this pair's `UnmirroredDeclared` + * entry (`./zod-mirror-parity.test.ts`) until objectui#6152 round 1 mirrored + * them by the same reference, so all seven rows now run here; the last row + * holds the shape to the seven, so the ledger and this file cannot disagree + * silently. * * ## The TypeScript face * @@ -48,8 +50,11 @@ export type assertionLabelMembersAreI18nLabel = [ Expect>, ]; -/** The five members the mirror declares. */ -const MIRRORED = ['title', 'description', 'submitText', 'cancelText', 'successMessage'] as const; +/** + * The seven members the mirror declares: five since this card, `nextText` and + * `prevText` since objectui#6152 round 1. + */ +const MIRRORED = ['title', 'description', 'submitText', 'cancelText', 'successMessage', 'nextText', 'prevText'] as const; /** A minimal document the mirror accepts. */ const BASE = { type: 'object-form', objectName: 'order', mode: 'create' } as const; @@ -87,13 +92,11 @@ describe('object-form — the I18nLabel members on the zod mirror (objectui#1099 expect(parsed.success).toBe(false); }); - it('`nextText` and `prevText` are not mirrored, so the mirror does not judge them', () => { - // Recorded, not endorsed: the pair's `UnmirroredDeclared` entry carries both - // keys, and this row goes red when either is mirrored, so the two records - // move together. + it('all seven `I18nLabel` members are mirrored (objectui#6152 round 1 added `nextText` / `prevText`)', () => { + // This row stood as "`nextText` and `prevText` are not mirrored" while the + // pair's `UnmirroredDeclared` entry carried both; objectui#6152 round 1 moved + // both records together, as the row asked. const shape = ObjectFormMirror.shape as Record; - expect('nextText' in shape).toBe(false); - expect('prevText' in shape).toBe(false); for (const key of MIRRORED) expect(key in shape, key).toBe(true); }); }); diff --git a/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts b/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts new file mode 100644 index 0000000000..7a5701ddad --- /dev/null +++ b/packages/types/src/__tests__/object-form-unmirrored-members-6152.test.ts @@ -0,0 +1,137 @@ +/** + * 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#6152 round 1 — the `ObjectFormSchema` members the TypeScript face + * declared and the zod mirror had never heard of. + * + * ## The defect + * + * `UnmirroredDeclared['objectql.zod.ts#ObjectFormSchema']` in + * `zod-mirror-parity.test.ts` recorded keys `../objectql.ts` invites an author + * to write and `../zod/objectql.zod.ts` did not declare. Two faces paid for it: + * + * - the TOLERANT face (`BaseSchema` is `.passthrough()`) kept any value at + * those keys unexamined, so `{ formType: 'carousel' }` parsed green and + * `ObjectForm` fell through to its simple layout; + * - the STRICT authoring face (`StrictAnyComponentSchema`, objectui#8345, + * derived from the mirrors) refused the keys outright, although `tsc` + * accepted them — objectui#5250's M3 class (iii), the refusals + * `objectui validate` would print wrongly once it judges strict. + * + * Each key was measured before it was mirrored: authored (a document, the + * object-view `form` slot, or a form view relayed into the node), and READ by a + * shipped renderer (`ObjectForm` in `@object-ui/plugin-form`). The readings are + * in objectui#6152's round-1 report; this file pins the result, not the census. + * + * ## What each row asserts + * + * - a VALID authored value parses on BOTH faces — the strict leg is the one + * that moved (refused → accepted), the tolerant leg shows the shape is not a + * broken schema; + * - a WRONG-TYPED value is refused AT THE KEY on the tolerant face — the leg + * that moved the other way (kept unexamined → refused). + * + * `open` and `submitHandler` stay out of the mirror on purpose (their routes are + * open on objectui#6152; objectui#6182 rules out a string handler), and a row + * holds them out so a later card cannot mirror them without that ruling. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { ObjectFormSchema as ObjectFormMirror } from '../zod/objectql.zod.js'; +import { AnyComponentSchema, StrictAnyComponentSchema } from '../zod/index.zod.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); + +/** A minimal document both faces accept; every row below is a delta on it. */ +const BASE = { type: 'object-form', objectName: 'order', mode: 'create' } as const; + +/** Each mirrored key: one authored value, and one value its declaration refuses. */ +const ROWS: ReadonlyArray<{ key: string; valid: unknown; wrong: unknown }> = [ + { key: 'formType', valid: 'wizard', wrong: 'carousel' }, + { + key: 'sections', + valid: [ + { name: 'basics', label: 'Basics', columns: 2, fields: ['name', { name: 'email', type: 'email' }] }, + { group: 'contact_info', pane: 'secondary', visibleWhen: { dialect: 'cel', source: 'record.kind == "b2b"' } }, + ], + wrong: 'basics', + }, + { key: 'defaultTab', valid: 'basics', wrong: 3 }, + { key: 'tabPosition', valid: 'left', wrong: 'middle' }, + { key: 'allowSkip', valid: true, wrong: 'yes' }, + { key: 'showStepIndicator', valid: false, wrong: 'no' }, + { key: 'nextText', valid: { en: 'Next', fr: 'Suivant' }, wrong: 42 }, + { key: 'prevText', valid: 'Back', wrong: 42 }, + { key: 'splitDirection', valid: 'vertical', wrong: 'diagonal' }, + { key: 'splitSize', valid: 40, wrong: '40%' }, + { key: 'splitResizable', valid: false, wrong: 'false' }, + { key: 'drawerSide', valid: 'left', wrong: 'center' }, + { key: 'drawerWidth', valid: '40%', wrong: 40 }, + { key: 'modalSize', valid: 'lg', wrong: 'huge' }, + { key: 'modalCloseButton', valid: false, wrong: 'no' }, + { key: 'mobile', valid: { stickyActions: true, stepper: 'auto', stepperMinFields: 6 }, wrong: { stepper: 'sometimes' } }, + { key: 'buttons', valid: { submit: { show: true, label: 'Save' }, reset: { show: false } }, wrong: { submit: { show: 'yes' } } }, + { key: 'defaults', valid: { status: 'open', priority: 2 }, wrong: ['status'] }, + { key: 'subforms', valid: [{ childObject: 'order_line', minRows: 1 }], wrong: [{ relationshipField: 'order' }] }, +]; + +describe('objectui#6152 round 1 — `object-form` members the mirror now declares', () => { + it('CONTROL: the minimal document parses on both faces', () => { + expect(AnyComponentSchema.safeParse(BASE).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(BASE).success).toBe(true); + }); + + it.each(ROWS)('`$key` is a member of the mirror', ({ key }) => { + expect(key in (ObjectFormMirror.shape as Record)).toBe(true); + }); + + it.each(ROWS)('an authored `$key` parses on the strict face and the tolerant face', ({ key, valid }) => { + const doc = { ...BASE, [key]: valid }; + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success, JSON.stringify(strict.error?.issues)).toBe(true); + const tolerant = AnyComponentSchema.safeParse(doc); + expect(tolerant.success, JSON.stringify(tolerant.error?.issues)).toBe(true); + }); + + it.each(ROWS)('a wrong-typed `$key` is refused at the key on the tolerant face', ({ key, wrong }) => { + const parsed = ObjectFormMirror.safeParse({ ...BASE, [key]: wrong }); + expect(parsed.success).toBe(false); + for (const issue of parsed.error?.issues ?? []) expect(issue.path[0]).toBe(key); + }); + + it('the object-view `form` slot takes the same members (it is this mirror minus the identity keys)', () => { + const doc = { type: 'object-view', objectName: 'order', form: { formType: 'drawer', drawerSide: 'left', sections: [{ fields: ['name'] }] } }; + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success, JSON.stringify(strict.error?.issues)).toBe(true); + expect(AnyComponentSchema.safeParse({ ...doc, form: { formType: 'carousel' } }).success).toBe(false); + }); + + it('a section is a closed shape on the strict face: an undeclared section key is named there', () => { + // `className` is deliberately undeclared on `ObjectFormSection` (objectui#7200). + const strict = StrictAnyComponentSchema.safeParse({ ...BASE, sections: [{ name: 'a', className: 'p-4', fields: ['a'] }] }); + expect(strict.success).toBe(false); + expect(JSON.stringify(strict.error?.issues)).toContain('"className"'); + }); + + it('the catalog document objectui#5250 M3 charged with three of these keys now parses on the strict face', () => { + const doc = JSON.parse(readFileSync(join(REPO_ROOT, 'examples/schema-catalog/src/schemas/plugin-form/object-form-tabbed-sections.json'), 'utf8')); + // Non-vacuity: the document really carries the keys this row is about. + expect(Object.keys(doc)).toEqual(expect.arrayContaining(['formType', 'defaultTab', 'sections'])); + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success, JSON.stringify(strict.error?.issues)).toBe(true); + }); + + it('`open` and `submitHandler` stay out of the mirror until their routes are ruled', () => { + const shape = ObjectFormMirror.shape as Record; + expect('open' in shape).toBe(false); + expect('submitHandler' in shape).toBe(false); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index ad8234b1d8..3e0e7c2a31 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -364,7 +364,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`. - * - **12 entries** in `UnmirroredDeclared`, **84 keys** across them — 13 / 85 until + * - **12 entries** in `UnmirroredDeclared`, **65 keys** across them — 12 / 84 until + * objectui#6152 round 1 MIRRORED nineteen of `ObjectFormSchema`'s twenty-one keys, + * each measured authored and READ (`ObjectForm` reads them off its schema, or + * `ObjectView` / `RecordFormPage` / `ScreenView` relay them into the node it + * reads): the entry kept `open` and `submitHandler`, so only the key total moved, + * and the TypeScript face did not move at all; 13 / 85 until * objectui#7928 MIRRORED `ObjectViewSchema.listViews` BY REFERENCE to the spec's * `ViewSchema.shape.listViews` (maintainer ruling A, staged behind objectui#8254 and * objectui#8255): the key objectui#7779 left on its fallback clause, and that entry's @@ -2470,7 +2475,7 @@ interface KnownDrift { * * objectui#6058 seeded this ledger at **121 keys**, and — on ONE line, because the * pin below reads this sentence off disk — - * **84 keys** is what this ledger records today. + * **65 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; @@ -2489,7 +2494,11 @@ interface KnownDrift { * keys — growth, and the only growth this ledger has seen since the seed. Then * objectui#7779 MIRRORED eight of `ObjectViewSchema`'s keys BY REFERENCE and RETIRED a * ninth (`viewTabBar`) — the largest single shrink, and the movement this docstring - * did not follow: the figure above read 94, fully green, until objectui#8243. + * did not follow: the figure above read 94, fully green, until objectui#8243. Then + * objectui#6152 round 1 MIRRORED nineteen of `ObjectFormSchema`'s keys, each measured + * authored and read — larger than objectui#7779's shrink, and all of it by REPAIR. + * The entry stays, holding the two keys that round left open (`open`, + * `submitHandler`). * * ## The decomposition of "121" — a reading at NAMED REVISIONS, so it cannot rot * @@ -2574,7 +2583,9 @@ interface KnownDrift { * explicit that forcing the 121 per-key decisions now would be wrong. Two splits * are recorded here so whoever works them off does not re-derive them: * - * - **SPEC-DERIVED (3 entries, 37 keys)** — it was 2 / 16 until objectui#10993 MIRRORED + * - **SPEC-DERIVED (3 entries, 18 keys)** — it was 3 / 37 until objectui#6152 round 1 + * MIRRORED nineteen of `ObjectFormSchema`'s keys: the entry kept two, so it stayed in + * this half and only the keys moved. It was 2 / 16 until objectui#10993 MIRRORED * five `ObjectFormSchema` members by reference to the spec's `I18nLabelSchema`: none * of them was in the ledger, so no key closed, but the spec reference re-derives the * pair's entry (21 keys) into this half, the objectui#7762 membership move with ZERO @@ -2694,10 +2705,12 @@ interface KnownDrift { * `ObjectViewSchema.listViews`, that entry's last key, by reference: the SPEC-DERIVED * half lost the entry, and the LOCAL half did not move. objectui#10993 then moved * `ObjectFormSchema`'s entry (21 keys) from the LOCAL half to the SPEC-DERIVED one by - * membership alone, without moving the totals. The seeded pair is no longer + * membership alone, without moving the totals. objectui#6152 round 1 then MIRRORED + * nineteen of that entry's keys: the SPEC-DERIVED half lost keys and no entry, and + * the LOCAL 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 — - * **12 entries / 84 keys** — 3 / 37 spec-derived, 9 / 47 local. + * **12 entries / 65 keys** — 3 / 18 spec-derived, 9 / 47 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` @@ -2813,24 +2826,40 @@ interface UnmirroredDeclared { // entry above (`onRowClick`, a runtime slot the mirror refuses by name) and records // nothing here. /** - * SPEC-DERIVED by MEMBERSHIP since objectui#10993, LOCAL before it, and still the - * second-largest at 21. That card mirrored five `I18nLabel` members BY REFERENCE to - * the spec's `I18nLabelSchema`, none of them in this entry, which puts a `Spec…` - * symbol in the mirror's initializer: the `ObjectGridSchema` (objectui#7762) shape. - * ⚠️ Read the half as MEMBERSHIP, not as a remedy: these 21 keys are still plain - * hand-written omissions on a `BaseSchema.extend({…})` mirror. `nextText` and - * `prevText` are the row's other two `I18nLabel` members, left unmirrored by that - * card on purpose (`object-form-i18n-label-members-10993.test.ts` records it). - * It was 26: the five `on*` keys are in + * SPEC-DERIVED by MEMBERSHIP since objectui#10993, LOCAL before it. That card + * mirrored five `I18nLabel` members BY REFERENCE to the spec's `I18nLabelSchema`, + * none of them in this entry, which puts a `Spec…` symbol in the mirror's + * initializer: the `ObjectGridSchema` (objectui#7762) shape. ⚠️ Read the half as + * MEMBERSHIP, not as a remedy: the keys below are plain hand-written omissions on a + * `BaseSchema.extend({…})` mirror. + * + * It was 21 until objectui#6152 round 1 MIRRORED nineteen of them — the ledger's + * largest single shrink by REPAIR. Each was measured authored (a document, the + * object-view `form` slot, or a form view relayed into the node) and READ (`ObjectForm` + * reads the variant keys off `schema.*`; `buttons` / `defaults` / `subforms` through + * casts after `ObjectView` / `RecordFormPage` / `ScreenView` relay them), and is shaped + * as the twin declares it, `nextText` / `prevText` by reference to the spec's + * `I18nLabelSchema` as objectui#10993 bound their five siblings. The two keys left + * are the entry's MEASURED EXCEPTIONS, and mirroring repairs neither: + * + * - `submitHandler` — a FUNCTION slot `ObjectForm` calls in place of the data + * source's write (`await schema.submitHandler(…)`), supplied in code by + * `MasterDetailForm` and authored nowhere. objectui#6182 ruled the handler-string + * dialect out, so it is ⛔ never mirrored as a string; and `RuntimeOnlyDeclared`'s + * shape pin admits `/^on[A-Z]/` spellings only, so filing it there means relaxing + * that pin — a ruling, not a refiling. Its route is open on objectui#6152. + * - `open` — a BOOLEAN the drawer and modal variants read (`open: schema.open`), + * written only by hosts that build the node in code (`AppContent`, + * `useActionModal`, `ObjectManager`, `FieldDesigner`), authored in no document, and + * outside the spec's `ComponentPropsMap['object-form']`. Not callback-shaped, so + * not `RuntimeOnlyDeclared`'s either; its route is open on objectui#6152. + * + * It was 26 before that: the five `on*` keys are in * `RuntimeOnlyDeclared` below (objectui#6152). ⚠️ `submitHandler` is NOT among them * — the reclassification took the measured `/^on[A-Z]/` set and nothing else, so a - * handler-shaped key with another name stays here until someone measures it. + * handler-shaped key with another name stayed here until round 1 measured it. */ - 'objectql.zod.ts#ObjectFormSchema': - | 'allowSkip' | 'buttons' | 'defaultTab' | 'defaults' | 'drawerSide' | 'drawerWidth' - | 'formType' | 'mobile' | 'modalCloseButton' | 'modalSize' | 'nextText' | 'open' | 'prevText' - | 'sections' | 'showStepIndicator' | 'splitDirection' | 'splitResizable' | 'splitSize' - | 'subforms' | 'submitHandler' | 'tabPosition'; + 'objectql.zod.ts#ObjectFormSchema': 'open' | 'submitHandler'; /** * SPEC-DERIVED by MEMBERSHIP since objectui#7762, LOCAL before it: that card mirrored * `exportOptions` as the spec's OBJECT ARM by reference, which puts a `Spec…` symbol in @@ -2879,7 +2908,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 **84 keys** today. + * `UnmirroredDeclared` records **65 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/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 65e2cb7a99..c724e668e6 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -415,6 +415,42 @@ const OBJECT_FORM_NEITHER_CHANNEL = neitherContentChannelGuidance( 'a form over `objectName` built from `fields` / `sections` / `customFields`, in the declared `mode` and `formType`', ); +/** + * objectui#6152 round 1 — one entry of `ObjectFormSchema.sections`, restating the + * local `ObjectFormSection` interface (`../objectql.ts`) member for member. Not + * exported, so it is judged through the `sections` key of the registered + * `ObjectFormSchema` pair rather than as a pair of its own. + * + * ⚠️ A `fields` entry is `z.any()`, the precedent this mirror already set for + * `customFields`: the declared entry is `string | FormField`, and + * `FormFieldSchema` (`./form.zod.ts`) carries its own `KnownDrift` (`validation`) + * and `UnmirroredDeclared` (`field`) rows, so binding it here would import that + * drift into this pair. So every SECTION-level member is judged, and a field + * entry is not judged on this key at all — stated here rather than implied by a + * string arm that an `any` arm beside it would make decorative. + */ +const ObjectFormSectionEntrySchema = z.object({ + name: z.string().optional().describe('Section identifier'), + label: z.string().optional().describe('Section label'), + description: z.string().optional().describe('Section description'), + collapsible: z.boolean().optional().describe('Whether the section can be collapsed (dropped on a wizard step)'), + collapsed: z.boolean().optional().describe('Whether the section starts collapsed (dropped on a wizard step)'), + columns: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).optional().describe('Field columns in this section'), + pane: z.enum(['primary', 'secondary']).optional().describe('Split-form panel this section renders in (split forms only)'), + fields: z.array(z.any()).optional().describe('Field names or inline field configurations; the other way to declare members is `group`'), + group: z.string().optional().describe('Key of a declared object field group whose members and presentation this section inherits'), + visibleWhen: z + .union([z.string(), z.object({ dialect: z.string().optional(), source: z.string() })]) + .optional() + .describe('Predicate gating the whole section: a bare string or the { dialect?, source } envelope'), +}); + +/** objectui#6152 round 1 — one flat button toggle inside `ObjectFormSchema.buttons`. */ +const ObjectFormButtonToggleSchema = z.object({ + show: z.boolean().optional().describe('Show this button'), + label: z.string().optional().describe('Button label'), +}); + /** * ObjectForm Schema */ @@ -433,8 +469,8 @@ export const ObjectFormSchema = BaseSchema.extend({ // against the active UI language, above its `formType` fork (objectui#10993). // This mirror used to narrow the five it declares to `z.string()`, refusing a // map the row and the renderer both accept. The row's other two, `nextText` - // and `prevText`, are not mirrored here; they stay in this pair's - // `UnmirroredDeclared` entry in `../__tests__/zod-mirror-parity.test.ts`. + // and `prevText`, stayed unmirrored until objectui#6152 round 1 declared them + // below, by the same reference. title: stripImportedDefaults(SpecI18nLabelSchema).optional() .describe('Form title: the drawer and modal heading. @objectstack/spec I18nLabel, a plain string or an inline locale map'), description: stripImportedDefaults(SpecI18nLabelSchema).optional() @@ -470,6 +506,70 @@ export const ObjectFormSchema = BaseSchema.extend({ showReset: z.boolean().optional().describe('Show reset button'), initialValues: z.record(z.string(), z.any()).optional().describe('Initial values'), readOnly: z.boolean().optional().describe('Read-only mode'), + // ⭐ objectui#6152 round 1 — members the TypeScript twin declared and this + // mirror had never heard of, so the strict authoring face refused each one + // although `tsc` invited it (objectui#5250's M3 class (iii)). Every one below + // is READ by a shipped renderer: `ObjectForm` (`plugin-form`) reads the variant + // keys off its schema (`schema.formType`, `schema.sections`, + // `schema.defaultTab`, `schema.showStepIndicator`, `schema.drawerSide`, …), and + // `buttons` / `defaults` / `subforms` through casts in the same file + // (`foldFormButtons`, `(schema as any).subforms`) after `ObjectView`, + // `RecordFormPage` and `ScreenView` relay them from an object's form view. So + // each is DECLARED here, shaped as the twin declares it; `nextText` / + // `prevText` are the spec's `I18nLabel` by reference because the twin takes + // that type from the spec. `buttons`, `defaults` and `subforms` are members of + // the spec's `FormViewSchema` and not of its `ComponentPropsMap['object-form']`; + // every other key below is a member of `ComponentPropsMap['object-form']`. + // + // ⛔ Two keys of the same ledger entry are NOT here, on purpose: + // `submitHandler` (a function slot — objectui#6182 rules the handler-string + // dialect out, so it is never mirrored as a string) and `open` (a boolean that + // only in-code hosts write). Their routes are open on objectui#6152, and the + // pair's `UnmirroredDeclared` entry keeps both. + formType: z.enum(['simple', 'tabbed', 'wizard', 'split', 'drawer', 'modal']).optional() + .describe('Form variant: simple (default), tabbed, wizard, split, drawer or modal'), + sections: z.array(ObjectFormSectionEntrySchema).optional() + .describe('Form sections: tabs of a tabbed form, steps of a wizard (array order is step order), groups of a simple form'), + defaultTab: z.string().optional().describe('Initially active tab (section name); tabbed forms only'), + tabPosition: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Tab strip position; tabbed forms only'), + allowSkip: z.boolean().optional().describe('Let the user enter any wizard step (navigation freedom, not a validation exemption); wizard forms only'), + showStepIndicator: z.boolean().optional().describe('Show the wizard step indicator; wizard forms only'), + nextText: stripImportedDefaults(SpecI18nLabelSchema).optional() + .describe('Next-step button label; wizard forms only. @objectstack/spec I18nLabel, a plain string or an inline locale map'), + prevText: stripImportedDefaults(SpecI18nLabelSchema).optional() + .describe('Previous-step button label; wizard forms only. @objectstack/spec I18nLabel, a plain string or an inline locale map'), + splitDirection: z.enum(['horizontal', 'vertical']).optional().describe('Split panel direction; split forms only'), + splitSize: z.number().optional().describe('Size of the first panel as a percentage; split forms only'), + splitResizable: z.boolean().optional().describe('Whether the split panels can be resized; split forms only'), + drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Drawer slide-in side; drawer forms only'), + drawerWidth: z.string().optional().describe('Drawer width as a CSS value; drawer forms only'), + modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Modal dialog size; modal forms only'), + modalCloseButton: z.boolean().optional().describe('Show the modal header close button; modal forms only'), + mobile: z.object({ + stickyActions: z.boolean().optional().describe('Pin Submit/Cancel to the bottom of the viewport on mobile'), + stepper: z.union([z.boolean(), z.literal('auto')]).optional().describe('One-field-at-a-time stepper on small screens: true, false or auto'), + stepperMinFields: z.number().optional().describe('Field-count threshold for stepper auto'), + stepperFieldsPerStep: z.number().optional().describe('Fields shown per stepper step'), + fullscreenLongText: z.boolean().optional().describe('Offer a fullscreen editor for textarea and rich-text fields'), + }).optional().describe('Mobile-only form behaviour; every option is opt-in'), + buttons: z.object({ + submit: ObjectFormButtonToggleSchema.optional(), + cancel: ObjectFormButtonToggleSchema.optional(), + reset: ObjectFormButtonToggleSchema.optional(), + }).optional().describe('Structured submit/cancel/reset visibility and labels; folded onto the flat keys, which win when set'), + defaults: z.record(z.string(), z.any()).optional() + .describe('Create-mode initial values keyed by field name; folded into initialValues, which wins when set'), + subforms: z.array(z.object({ + childObject: z.string().describe('Child object name'), + relationshipField: z.string().optional().describe('Foreign-key field on the child (derived from metadata when omitted)'), + columns: z.array(z.any()).optional().describe('Grid columns for the child rows (derived from metadata when omitted)'), + amountField: z.string().optional(), + totalField: z.string().optional(), + title: z.string().optional(), + addLabel: z.string().optional(), + minRows: z.number().optional(), + maxRows: z.number().optional(), + })).optional().describe('Inline child collections: renders a master-detail form persisted in one transaction'), // ⭐ `8d50bc2bf` — five keys the REGISTERED `object-form` renderer reads off // the authored document while this arm declared none of them. `BaseSchema` is // `.passthrough()`, so an undeclared key is NOT refused: it stops being From 78b4eee67ac0c48934fab29b6a98c0c816acfc3d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 16:04:48 +0000 Subject: [PATCH 2/5] test(app-shell): retire the object-form::formType@node block-config ledger row objectui#6152 round 1 closed The row pointed at objectui#6152's `UnmirroredDeclared` entry: the TS `ObjectFormSchema` and the spec both declared `formType`, PageBlockCanvas read it, and the zod mirror omitted it. The mirror now declares it, so the "every ledger row still applies" ratchet turned red as written; the row goes, and a live control re-measures the retired verdict so an empty node-face ledger stays a reading rather than a silence. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .../block-config-schema-parity-8216.test.ts | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts index c8cb98fa2a..2409576fc2 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-schema-parity-8216.test.ts @@ -200,9 +200,10 @@ const EXEMPT: Readonly> = { * that stops applying is as red as a violation that is missing one. * * `face` scopes the row to the oracle that reports it, and it is load-bearing - * rather than decoration: `object-form.formType` is a NODE-face declaration gap - * while the SPEC face declares the key perfectly well, and an unscoped row - * would absorb a future spec-face violation on the same name. + * rather than decoration: `object-form.formType` was a NODE-face declaration gap + * while the SPEC face declared the key perfectly well (until objectui#6152 round + * 1 closed it — see the note inside the ledger), and an unscoped row would have + * absorbed a future spec-face violation on the same name. */ const LEDGER: ReadonlyArray<{ block: string; path: string; face: OracleFace; card: string; why: string }> = [ // `object-kanban::limit@spec` stood here until @objectstack/spec 17.4.0. It was @@ -214,14 +215,13 @@ const LEDGER: ReadonlyArray<{ block: string; path: string; face: OracleFace; car // declared `limit` upstream, which is the maintainer's option-A ruling on // objectui#8172, and the row went stale. Deleted rather than kept: a row that // no longer describes a violation is as red here as a violation with no row. - { - block: 'object-form', - path: 'formType', - face: 'node', - card: 'objectui#6152', - why: - 'A declaration gap on the passthrough face only: the TS `ObjectFormSchema` declares `formType`, the spec declares it, PageBlockCanvas reads it, and the zod mirror omits it. objectui#6152 already carries this exact row in its UnmirroredDeclared table (objectql.zod.ts#ObjectFormSchema), so this ledger points there rather than opening a second card over the same debt.', - }, + // + // `object-form::formType@node` stood here too, pointing at objectui#6152: the + // TS `ObjectFormSchema` and the spec both declared `formType`, PageBlockCanvas + // read it, and the zod mirror omitted it. objectui#6152 round 1 declared it on + // the mirror (with the other members that pair's `UnmirroredDeclared` entry + // recorded), the "every ledger row still applies" pin below turned red as + // written, and the row went. ]; const ledgerId = (r: { block: string; path: string; face: OracleFace }) => `${r.block}::${r.path}@${r.face}`; @@ -454,4 +454,18 @@ describe('BLOCK_CONFIG ↔ node-schema parity — the ratchet (objectui#8216)', expect(bogus.success).toBe(false); expect(bogus.error.issues.flatMap((i: any) => i.keys ?? [])).toContain('notAKanbanKey'); }); + + it('the NODE-face ledger is empty too, and the row it last carried is re-measured', () => { + // Same non-vacuity problem as the spec face above, on the other oracle: an + // empty node-face ledger reads exactly like a node-oracle lookup that found + // nothing. The live control re-measures `object-form::formType@node`, the + // row objectui#6152 round 1 retired by declaring the key on the mirror. + expect(LEDGER.filter((r) => r.face === 'node').map(ledgerId)).toEqual([]); + const form = NODE_ORACLES['object-form']; + expect(form, 'the object-form node oracle must resolve, or the verdicts below prove nothing').toBeDefined(); + expect(judge(form, 'formType')).toBeUndefined(); + // …and the same oracle still reports an undeclared name, so the line above is + // not satisfied by an oracle that stopped judging. + expect(judge(form, 'notAnObjectFormKey')?.kind).toBe('MISSING'); + }); }); From a5884b4030042845564fc29e9e28d4a3adaf1330 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 03:51:50 +0000 Subject: [PATCH 3/5] docs(changeset): the pending objectui#10993 entry no longer says nextText / prevText stay unmirrored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That sentence publishes verbatim at the next release, and objectui#6152 round 1 (this branch) mirrors both members by the same `I18nLabelSchema` reference, so the two entries would have published contradicting each other. The sentence now says what #10993 did and what #6152 does — a prose correction to a pending entry, no package name or bump level touched. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .changeset/10993-object-form-i18nlabel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/10993-object-form-i18nlabel.md b/.changeset/10993-object-form-i18nlabel.md index 4e1fac2450..9dd3cc0229 100644 --- a/.changeset/10993-object-form-i18nlabel.md +++ b/.changeset/10993-object-form-i18nlabel.md @@ -16,6 +16,6 @@ An `object-form` whose `title`, `description`, `submitText`, `cancelText`, `next - `object-view`'s `form` slot takes `ObjectFormSchema`'s keys, so its `form.title` and `form.description` are `I18nLabel` too. `ObjectView` draws its own drawer and modal header around the form, and it now resolves both the same way, against the same UI language, where it used to read them raw. - The `object-form` registration declares both arms for the seven keys, `type: ['string', 'object']`, with descriptions that teach the per-locale map. The manifest built from `ComponentRegistry.getPublicConfigs()` therefore no longer makes `validateTree` report `type-mismatch` on a locale map for these keys. A value that matches neither arm, such as a number, is still reported. -**Types.** In `@object-ui/types`, `ObjectFormSchema.title`, `.description`, `.submitText`, `.cancelText`, `.nextText`, `.prevText` and `.successMessage` widen from `string` to `I18nLabel`, matching the spec row. The zod mirror's `title`, `description`, `submitText`, `cancelText` and `successMessage` widen from `z.string()` to the spec's `I18nLabelSchema`, by reference, so `safeValidateSchema` accepts a locale map on them; a number is still refused at the member. `nextText` and `prevText` stay unmirrored, as before. Code that writes these members compiles unchanged. Code that reads one of them off an `ObjectFormSchema` and uses it as a `string` no longer compiles: resolve it first, for example with `pickLocalized` from `@object-ui/i18n`. +**Types.** In `@object-ui/types`, `ObjectFormSchema.title`, `.description`, `.submitText`, `.cancelText`, `.nextText`, `.prevText` and `.successMessage` widen from `string` to `I18nLabel`, matching the spec row. The zod mirror's `title`, `description`, `submitText`, `cancelText` and `successMessage` widen from `z.string()` to the spec's `I18nLabelSchema`, by reference, so `safeValidateSchema` accepts a locale map on them; a number is still refused at the member. This change leaves `nextText` and `prevText` unmirrored; objectui#6152 (round 1) mirrors both, by the same reference. Code that writes these members compiles unchanged. Code that reads one of them off an `ObjectFormSchema` and uses it as a `string` no longer compiles: resolve it first, for example with `pickLocalized` from `@object-ui/i18n`. **Clause-②: yes** — seven members of the exported `ObjectFormSchema` type, and five members of its zod mirror, widen from a string to `I18nLabel`, and the registration's `inputs` for the seven keys widen from `'string'` to `['string', 'object']`. Nothing that was accepted before is refused now. From 4ed5adf6ff1c789643f7a06fd958c5271b748893 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 07:41:41 +0000 Subject: [PATCH 4/5] docs(changeset): append a dated correction to the pending objectui#10993 entry instead of rewriting its line The base-merge round rewrote one sentence of this pending changeset. The maintainer's ruling keeps pending changesets append-only: the merge commit put the line back to main's bytes, and this commit appends one dated note saying nextText / prevText are now mirrored (objectui#6152, PR #11125), by the same I18nLabelSchema reference. Frontmatter and every existing line are byte-identical to main. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .changeset/10993-object-form-i18nlabel.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/10993-object-form-i18nlabel.md b/.changeset/10993-object-form-i18nlabel.md index 4e1fac2450..05ce5c9cf6 100644 --- a/.changeset/10993-object-form-i18nlabel.md +++ b/.changeset/10993-object-form-i18nlabel.md @@ -19,3 +19,5 @@ An `object-form` whose `title`, `description`, `submitText`, `cancelText`, `next **Types.** In `@object-ui/types`, `ObjectFormSchema.title`, `.description`, `.submitText`, `.cancelText`, `.nextText`, `.prevText` and `.successMessage` widen from `string` to `I18nLabel`, matching the spec row. The zod mirror's `title`, `description`, `submitText`, `cancelText` and `successMessage` widen from `z.string()` to the spec's `I18nLabelSchema`, by reference, so `safeValidateSchema` accepts a locale map on them; a number is still refused at the member. `nextText` and `prevText` stay unmirrored, as before. Code that writes these members compiles unchanged. Code that reads one of them off an `ObjectFormSchema` and uses it as a `string` no longer compiles: resolve it first, for example with `pickLocalized` from `@object-ui/i18n`. **Clause-②: yes** — seven members of the exported `ObjectFormSchema` type, and five members of its zod mirror, widen from a string to `I18nLabel`, and the registration's `inputs` for the seven keys widen from `'string'` to `['string', 'object']`. Nothing that was accepted before is refused now. + +**Correction, 2026-09-30 (objectui#6152).** The **Types** paragraph above says `nextText` and `prevText` stay unmirrored. That was true when this change was written, and it no longer is: objectui#6152 (PR #11125) mirrors both, by the same reference to the spec's `I18nLabelSchema`, so `safeValidateSchema` now judges them as it judges the other five: a locale map is accepted, and a number is refused at the member. From 8f9bb76f6c5ceb93420fafbbf7c8041eae11e68c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 08:11:49 +0000 Subject: [PATCH 5/5] docs(changeset): append a dated correction to the pending objectui#7200 entry The pending objectui#7200 changeset gives, as its reason for not using a never-typed tombstone, that ObjectFormSchema's zod mirror does not declare `sections`. objectui#6152 round 1 (this PR) mirrors `sections` with a closed section entry, so that parenthetical goes false when this lands. The changeset-claim re-read on PR #11125 asked for exactly this read. Per the maintainer's append-only ruling for pending changesets, one dated note is appended; the frontmatter and every existing line are byte-identical to main. Measured with a scratch probe at 4ed5adf6f: the strict authoring face refuses a section `className` as unrecognized_keys at sections.0, and the tolerant face accepts the document and drops the key. Claude-Session: https://claude.ai/code/session_012UwY3ahMixEFkfTUxMVkYm Co-authored-by: Claude --- .changeset/7200-object-form-section-style-keys-undeclared.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/7200-object-form-section-style-keys-undeclared.md b/.changeset/7200-object-form-section-style-keys-undeclared.md index 5f3603b124..38e40604ba 100644 --- a/.changeset/7200-object-form-section-style-keys-undeclared.md +++ b/.changeset/7200-object-form-section-style-keys-undeclared.md @@ -32,3 +32,5 @@ carrying either key was already ignored. **Migration.** Remove the two keys from any `ObjectFormSection` literal; they did nothing. Style sections through the host application's own CSS or the form ROOT `className`. Section *layout* stays authorable through `columns`. + +**Correction, 2026-09-30 (objectui#6152).** The paragraph above gives, as the reason no `?: never` tombstone was used, that `ObjectFormSchema` in `zod/objectql.zod.ts` does not declare `sections`. That was true when this change was written, and it no longer is: objectui#6152 (PR #11125) mirrors `sections`, and each entry is judged member by member against `ObjectFormSection`. So a section now has a parse door. The strict authoring face (`StrictAnyComponentSchema`) refuses a section `className`, or any other key the entry does not declare, by name (`unrecognized_keys` at the section's path). The tolerant face (`AnyComponentSchema`) accepts the document and drops that key from its parsed output. The TypeScript change above is unaffected.