Skip to content
2 changes: 2 additions & 0 deletions .changeset/10993-object-form-i18nlabel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
46 changes: 46 additions & 0 deletions .changeset/6152-object-form-unmirrored-members.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .changeset/7200-object-form-section-style-keys-undeclared.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,10 @@ const EXEMPT: Readonly<Record<string, { reason: string; card: string }>> = {
* 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
Expand All @@ -208,14 +209,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}`;
Expand Down Expand Up @@ -448,4 +448,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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -48,8 +50,11 @@ export type assertionLabelMembersAreI18nLabel = [
Expect<Equal<ObjectFormSchema['successMessage'], I18nLabel | undefined>>,
];

/** 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;
Expand Down Expand Up @@ -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<string, unknown>;
expect('nextText' in shape).toBe(false);
expect('prevText' in shape).toBe(false);
for (const key of MIRRORED) expect(key in shape, key).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)).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<string, unknown>;
expect('open' in shape).toBe(false);
expect('submitHandler' in shape).toBe(false);
});
});
Loading
Loading