From da108e96e069b0aca42a38327c7fff287b4b7090 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 13:51:58 +0000 Subject: [PATCH 1/2] fix(plugin-form): declare WizardStepConfig independently of FormSectionConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard typed its steps as `Omit`, a subtraction from the TabbedForm section type — the predicate-CARRYING type. That defended the one key it named while every future key added to `FormSectionConfig` still reached a wizard step by default, reproducing the silent unimplemented slot the ruling split the types to stop, one key later. `WizardStepConfig` is now declared on its own, as `SplitFormSectionConfig`, `ModalFormSectionConfig` and `DrawerFormSectionConfig` already are. Same key set, no behaviour change; the derivation flips from subtractive to additive, and a family-level type pin fails the build if any `*When` key ever appears on the step type. Also repairs the support table in the README and the docs mirror: the `formType: 'tabbed'` row still said the predicate was dropped, which stopped being true when that arm landed. The wizard row stays No, which is still exact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .changeset/6237-wizard-step-config-split.md | 35 ++++++ content/docs/plugins/plugin-form.mdx | 44 ++++---- packages/plugin-form/README.md | 38 ++++--- packages/plugin-form/src/TabbedForm.tsx | 15 ++- packages/plugin-form/src/WizardForm.tsx | 102 +++++++++++++++--- .../tabbedFormSectionPredicate-6237.test.tsx | 40 +++++++ 6 files changed, 219 insertions(+), 55 deletions(-) create mode 100644 .changeset/6237-wizard-step-config-split.md diff --git a/.changeset/6237-wizard-step-config-split.md b/.changeset/6237-wizard-step-config-split.md new file mode 100644 index 000000000..4e732a033 --- /dev/null +++ b/.changeset/6237-wizard-step-config-split.md @@ -0,0 +1,35 @@ +--- +'@object-ui/plugin-form': patch +--- + +Split `WizardStepConfig` off `FormSectionConfig`, and correct the section-predicate +support table (objectui#6237, maintainer ruling 2026-08-30). + +`WizardForm` typed its steps as `Omit` — a +subtraction from the TabbedForm section type, which is the predicate-CARRYING +type. That defended the one key it named and left the mechanism open: every key +added to `FormSectionConfig` reached a wizard step by default, so the next +predicate in the same family (`readonlyWhen` / `requiredWhen`, already this +package's field-level vocabulary) would have handed the wizard a silent slot its +renderer does not read — the declared-but-unenforced shape the ruling split the +types to stop. + +`WizardStepConfig` is now declared independently in `WizardForm.tsx`, which is +simply what `SplitFormSectionConfig`, `ModalFormSectionConfig` and +`DrawerFormSectionConfig` already do: each layout owns its group shape, documents +`className` / `gridClassName` in its own terms, and declares `visibleWhen` only +where its renderer honours it. The derivation flips from subtractive to additive +— a key is authorable on a wizard step only if someone writes it there. + +No behaviour change and no key added or removed: `WizardStepConfig` exports the +same key set it already had, and `visibleWhen` on a wizard step literal was, and +remains, a compile error. What is new is that it stays one for the whole +predicate family, pinned by a type-level assertion that fails the build if any +`*When` key ever appears on the step type. + +Documentation repair in the same stroke: the support table in the README and in +`content/docs/plugins/plugin-form.mdx` still said `formType: 'tabbed'` sections +drop the predicate. That stopped being true when the tabbed arm landed — the row +now reads **Yes**, the surrounding prose no longer claims two inert arms or a +diagnostic that fires for `tabbed`, and the wizard row stays **No**, which is +still exactly true. diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 5c5467703..304f83b9d 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -184,9 +184,9 @@ inactive tab unmount along with its values). - Fields no tab claims render above the tab strip rather than disappearing. - Needs at least two tabs; ignored when the form uses `children`. -`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` build on this. Of the -two, only `ModalForm` forwards a section's `visibleWhen` onto its tab — see the -support table at the end of the next section. +`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` build on this, and both +forward a section's `visibleWhen` onto the tab they synthesise — see the support +table at the end of the next section. #### Conditional tabs (`FormFieldTab.visibleWhen`) @@ -243,28 +243,36 @@ and only where the renderer actually evaluates it: | --- | --- | | `type: 'form'` with `fieldTabs[].visibleWhen` (as above) | **Yes** — evaluated by the form renderer | | `ModalForm` / `formType: 'modal'` with `contentLayout: 'tabbed'` | **Yes** — the section's `visibleWhen` is copied onto its tab | -| `TabbedForm` / `formType: 'tabbed'` sections | **No** — a section `visibleWhen` is dropped before the renderer sees it; the tab always renders | +| `TabbedForm` / `formType: 'tabbed'` sections | **Yes** — the section's `visibleWhen` is copied onto the tab `TabbedForm` synthesises; a single-section form, which draws no tab strip, gates the group through the flat layout's own section-divider mechanism instead | | `WizardForm` / `formType: 'wizard'` steps | **No** — steps are not tabs; nothing evaluates a step-level predicate | -The two **No** rows are deliberate, not oversights: declaring the key on a +The remaining **No** row is deliberate, not an oversight: declaring the key on a surface whose renderer ignores it would make the metadata lie (objectui#6111), -so the key stops at the boundary until each surface enforces it. Track -objectui#6237 for both. - -Both **No** rows now **report themselves** rather than failing silently. Authoring -a section `visibleWhen` on `formType: 'tabbed'` or `formType: 'wizard'` logs a -console warning naming the layout and the sections whose predicate is being -dropped: +so the key stops at the boundary until each surface enforces it. + +⛔ **A wizard step has no predicate slot at all, and that is enforced by the +type.** A step predicate is not the tab predicate under another name — steps are +mounted one at a time and keyed by index, so a step predicate would be +step-boundary reactive where a tab's is live-record reactive, and it would need +navigation, indicator, `isLastStep`, final-gate and re-selection semantics that +none of the tab machinery supplies. So `WizardStepConfig` is declared +independently of `FormSectionConfig` (maintainer ruling 2026-08-30, +objectui#6237) and simply does not carry the key: `visibleWhen` on a wizard step +literal is a **compile error**, not a silently ignored key. Track objectui#6237 +for the step contract. + +That row also **reports itself** rather than failing silently — untyped JSON +reaches the renderer too, so the compile-time half cannot be the only one. +Authoring a section `visibleWhen` on `formType: 'wizard'` logs a console warning +naming the layout and the sections whose predicate is being dropped: > `[ObjectForm] Section \`visibleWhen\` is not yet supported on this layout: the -> \`tabbed\` layout's tabs drop the predicate, so section(s) pay render +> \`wizard\` layout's steps drop the predicate, so section(s) pay render > unconditionally. …` -This is an interim diagnostic, ruled 2026-08-29 alongside the decision to design -the real repair as **one** section/group predicate contract shared by every layout -arm instead of patching them one at a time. It changes no behaviour — the -predicate is still dropped on those two arms — it only stops the drop from being -invisible. Nothing warns on the four arms that honour the key. +It changes no behaviour — the predicate is still dropped on that one arm — it +only stops the drop from being invisible. Nothing warns on the five arms that +honour the key. ### Wizard steps and `allowSkip` diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index bc04b29a8..a6d9d286f 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -406,9 +406,9 @@ the renderer distribute the fields: - Fields no tab claims render above the tab strip rather than disappearing. - Needs at least two tabs, and is ignored when the form uses `children`. -`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this. Of -the two, only `ModalForm` forwards a section's `visibleWhen` onto its tab — see -the support table below. +`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this, and +both forward a section's `visibleWhen` onto the tab they synthesise — see the +support table below. #### Conditional tabs (`FormFieldTab.visibleWhen`) @@ -465,21 +465,29 @@ and only where the renderer actually evaluates it: | --- | --- | | `type: 'form'` with `fieldTabs[].visibleWhen` (as above) | **Yes** — evaluated by the form renderer | | `ModalForm` / `formType: 'modal'` with `contentLayout: 'tabbed'` | **Yes** — the section's `visibleWhen` is copied onto its tab | -| `TabbedForm` / `formType: 'tabbed'` sections | **No** — a section `visibleWhen` is dropped before the renderer sees it; the tab always renders | +| `TabbedForm` / `formType: 'tabbed'` sections | **Yes** — the section's `visibleWhen` is copied onto the tab `TabbedForm` synthesises; a single-section form, which draws no tab strip, gates the group through the flat layout's own section-divider mechanism instead | | `WizardForm` / `formType: 'wizard'` steps | **No** — steps are not tabs; nothing evaluates a step-level predicate | -The two **No** rows are deliberate, not oversights: declaring the key on a +The remaining **No** row is deliberate, not an oversight: declaring the key on a surface whose renderer ignores it would make the metadata lie (#6111), so the -key stops at the boundary until each surface enforces it. Track #6237 for -both. - -Both **No** rows now **report themselves** rather than failing silently. Authoring -a section `visibleWhen` on `formType: 'tabbed'` or `formType: 'wizard'` logs a -console warning naming the layout and the sections whose predicate is being -dropped. This is an interim diagnostic (ruled 2026-08-29, alongside the decision -to design the real repair as **one** section/group predicate contract shared by -every layout arm): it changes no behaviour, it only stops the drop from being -invisible. Nothing warns on the four arms that honour the key. +key stops at the boundary until each surface enforces it. + +⛔ **A wizard step has no predicate slot at all, and that is enforced by the +type.** A step predicate is not the tab predicate under another name — steps are +mounted one at a time and keyed by index, so a step predicate would be +step-boundary reactive where a tab's is live-record reactive, and it would need +navigation, indicator, `isLastStep`, final-gate and re-selection semantics that +none of the tab machinery supplies. So `WizardStepConfig` is declared +independently of `FormSectionConfig` (maintainer ruling 2026-08-30, #6237) and +simply does not carry the key: `visibleWhen` on a wizard step literal is a +**compile error**, not a silently ignored key. Track #6237 for the step +contract. + +That row also **reports itself** rather than failing silently. Authoring a +section `visibleWhen` on `formType: 'wizard'` logs a console warning naming the +layout and the sections whose predicate is being dropped — untyped JSON reaches +the renderer too, so the compile-time half cannot be the only one. Nothing warns +on the five arms that honour the key. ### Wizard steps and `allowSkip` diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 3628c3cf2..371c5757c 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -70,11 +70,16 @@ export interface FormSectionConfig { * see the boundary note on `WizardStepConfig` and objectui#6237 for the * measured reason the server cannot read this predicate. * - * ⛔ Deliberately NOT on the wizard's step type. `WizardForm` used to borrow - * this very interface for its steps, and declaring the key on a type the - * wizard renderer never reads would manufacture the declared-but-unenforced - * shape this card family exists to close. `WizardStepConfig` omits it, so the - * key is writable exactly where it is honoured. + * ⛔ Deliberately NOT on the wizard's step type, and that boundary is now + * structural rather than subtractive (maintainer ruling 2026-08-30, + * objectui#6237). `WizardForm` used to type its steps as + * `Omit` — a derivation that defended the + * one key it named while every FUTURE key added here still reached a wizard + * step by default. `WizardStepConfig` is now declared independently, exactly + * as `SplitFormSectionConfig` / `ModalFormSectionConfig` / + * `DrawerFormSectionConfig` already are, so this type and the wizard's share + * nothing: a key is authorable on a wizard step only if someone writes it + * there, on the type whose renderer has to honour it. */ visibleWhen?: string | { dialect?: string; source: string }; diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index 1d853d2d9..54c9df9c5 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -33,15 +33,37 @@ import { } from './submitRedirectNavigation'; import { useOccSave } from './occSave'; import { hasInlineFieldSource, noSubmitTargetError } from './submitTarget'; -import type { FormSectionConfig } from './TabbedForm'; /** - * A wizard STEP — the tabbed layout's section config minus its predicate slot - * (objectui#6237). + * A wizard STEP — the wizard's OWN authored group shape (objectui#6237). * - * The wizard borrows the tabbed arm's section shape because the two describe the - * same authored thing: a named group of fields. It must NOT borrow the predicate - * slot, and the omission is the enforcement, not a comment: + * ## Why this is declared here rather than derived from the tabbed layout + * + * It used to be `Omit`, which subtracted the + * predicate slot from `TabbedForm`'s section type. That closed the hole for the + * ONE key it named and left the mechanism wide open: `FormSectionConfig` is the + * predicate-CARRYING type, so every key added to it lands here by default and + * the author has to remember to widen the `Omit`. The defect the 2026-08-30 + * ruling closes is the SILENCE — "在共享 `FormSectionConfig` 上声明 `visibleWhen` + * 会**静默**给 WizardForm 的 step 也发一个谓词槽而那侧无实现" — and a subtractive + * derivation reproduces that silence for the next key in the same family + * (`readonlyWhen` / `requiredWhen`, both already this repo's field-level + * predicate vocabulary). Declaring the step shape independently flips the + * derivation from subtractive to additive: a key reaches a wizard step only + * because someone wrote it here, on the type whose renderer has to honour it. + * + * This is also simply the house pattern. `SplitFormSectionConfig`, + * `ModalFormSectionConfig` and `DrawerFormSectionConfig` each declare their own + * group shape, each documents `className` / `gridClassName` in ITS layout's + * terms, and each declares `visibleWhen` only because its layout honours it + * (`SplitFormSectionConfig` even carries a key — `pane` — that exists nowhere + * else). The wizard borrowing the tabbed layout's interface was the exception, + * and it cost real accuracy: a wizard author reading the shared type was told + * `name` is "used as tab value", `label` is "used as tab trigger text", and + * `className` is "Unused in the tabbed layout" — while this renderer uses all + * three, `className` at the step container (`FormSectionContainer` below). + * + * ## ⛔ Why there is no predicate slot, and why that is not an oversight * * A step predicate is not a port of the tab predicate, it is a different * contract. Steps are wizard component state keyed by section INDEX, only the @@ -55,19 +77,65 @@ import type { FormSectionConfig } from './TabbedForm'; * stability while hiding is live, re-selection when the CURRENT step hides, and * a final-gate exclusion for a hidden step's required fields. * - * Declaring the key on the shared type would make it WRITABLE on a step while - * this renderer ignores it — precisely the declared-but-unenforced shape this - * card family exists to close. Omitting it means TypeScript rejects the key on a - * wizard step literal, which is where an author (or an agent authoring metadata) - * finds out. `ObjectForm` additionally reports the gap at runtime for a section - * predicate arriving on the wizard route, since untyped JSON reaches it too. + * Declaring the key on a type this renderer ignores would make it WRITABLE on a + * step — precisely the declared-but-unenforced shape this card family exists to + * close. Its absence means TypeScript rejects the key on a wizard step literal, + * which is where an author (or an agent authoring metadata) finds out. + * `ObjectForm` additionally reports the gap at runtime for a section predicate + * arriving on the wizard route, since untyped JSON reaches it too. Both halves + * are pinned in `__tests__/tabbedFormSectionPredicate-6237.test.tsx`, including + * a FAMILY-level pin that fails the build if any `*When` key ever appears here, + * not just the one this card was about. * - * ⚠️ This omission changes nothing that used to work: `FormSectionConfig` did - * not declare `visibleWhen` before objectui#6237 either, so a wizard step - * literal carrying it was already a type error. The wizard's surface is exactly - * what it was; only the tabbed arm widened. + * ⚠️ This changes nothing that used to work. The published surface is exactly + * the key set `WizardStepConfig` already had — the derivation changed, not the + * type — and `visibleWhen` was already a type error on a wizard step literal. */ -export type WizardStepConfig = Omit; +export interface WizardStepConfig { + /** + * Step identifier. Used as the step's test handle + * (`data-testid="wizard-step:"`) and, with `label`, as what the + * indicator names; falls back to the step INDEX when absent. + */ + name?: string; + + /** + * Step label — the text the step indicator shows. + * Falls back to `Step `. + */ + label?: string; + + /** + * Step description, rendered under the step heading. + */ + description?: string; + + /** + * How densely this step fills the grid (1–4). Outranked by the form view's + * own `columns` (spec `FormView.columns`), which sets the grid WIDTH. + * @default 1 + */ + columns?: 1 | 2 | 3 | 4; + + /** + * Field names or configurations in this step. + */ + fields: (string | FormField)[]; + + /** + * Custom CSS class for this step's container. + * + * Honoured, unlike its tabbed counterpart: a wizard mounts ONE step at a + * time inside its own `FormSectionContainer`, so the step has a container to + * carry it. + */ + className?: string; + + /** + * Custom CSS class for this step's field grid (overrides the column classes). + */ + gridClassName?: string; +} /** * What the submitter is told when a DECLARED `navigateOnSuccess` produced no diff --git a/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx b/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx index d18e663b4..6382196f3 100644 --- a/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx +++ b/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx @@ -343,4 +343,44 @@ describe('#6237 — the wizard boundary is enforced by the TYPE, not by a commen }; expect(step.name).toBe('pay'); }); + + it('⛔ NO predicate-family key may appear on a wizard step — the family pin', () => { + // The pin above names ONE key. That was the whole weakness of the shape + // this replaced: `WizardStepConfig` was `Omit`, so it defended `visibleWhen` and let every FUTURE key + // added to the tabbed section type reach a wizard step by default — the + // same SILENT slot the 2026-08-30 ruling split the types to stop, one key + // later. `readonlyWhen` and `requiredWhen` are already this repo's + // field-level predicate vocabulary (see the plugin-form README's rule + // table), so the next key in the family is a named possibility, not a + // hypothetical. + // + // These are TYPE assertions: they are erased at runtime, so vitest proves + // nothing about them and `tsc -p tsconfig.test.json` is the only thing that + // can. `Expect` fails its own constraint when the argument is not `true`. + type Expect = T; + // `-?` strips optionality, or a mapped type over optional keys yields + // `K | undefined` and the `never` comparison below could never be true. + type PredicateKeysOf = { + [K in keyof T]-?: K extends `${string}When` ? K : never; + }[keyof T]; + + // ⭐ The CONTROL, and it is not decoration: a `PredicateKeysOf` that + // silently resolved to `never` for everything would make the wizard row + // below pass vacuously — a phantom check that can never fail. This row + // proves the machinery detects a predicate key when one is really there, + // on the sibling type that really has one. + type _TabbedSectionHasThePredicateKey = Expect< + PredicateKeysOf extends 'visibleWhen' ? true : false + >; + + // The pin itself: the wizard's step type carries no `*When` key at all. + type _WizardStepHasNoPredicateKey = Expect< + PredicateKeysOf extends never ? true : false + >; + + // Keep both aliases used, so `noUnusedLocals` cannot delete the pin. + const pinned: [_TabbedSectionHasThePredicateKey, _WizardStepHasNoPredicateKey] = [true, true]; + expect(pinned).toEqual([true, true]); + }); }); From ec4d5fdd7c2818dcfc6e4aa32e0bdde0fe85fc8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 13:53:41 +0000 Subject: [PATCH 2/2] test(plugin-form): make the family pin's control non-vacuous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `never` is assignable to everything, so asserting the helper's result extends `'visibleWhen'` would have been satisfied by a helper that returned `never` for everything — the exact breakage the control exists to catch, passing both rows while measuring nothing. Asking whether `'visibleWhen'` is assignable TO the result is the direction a `never` result fails. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB --- .../__tests__/tabbedFormSectionPredicate-6237.test.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx b/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx index 6382196f3..f1c923e39 100644 --- a/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx +++ b/packages/plugin-form/src/__tests__/tabbedFormSectionPredicate-6237.test.tsx @@ -370,8 +370,16 @@ describe('#6237 — the wizard boundary is enforced by the TYPE, not by a commen // below pass vacuously — a phantom check that can never fail. This row // proves the machinery detects a predicate key when one is really there, // on the sibling type that really has one. + // + // ⚠️ The DIRECTION is the whole control. Written the other way round + // (`PredicateKeysOf extends 'visibleWhen'`) it would be + // vacuous for exactly the failure it is meant to catch: `never` is + // assignable to everything, so a broken helper returning `never` would + // satisfy it too — and then BOTH rows would pass while measuring nothing. + // Asking whether `'visibleWhen'` is assignable TO the helper's result is + // the assertion a `never` result fails. type _TabbedSectionHasThePredicateKey = Expect< - PredicateKeysOf extends 'visibleWhen' ? true : false + 'visibleWhen' extends PredicateKeysOf ? true : false >; // The pin itself: the wizard's step type carries no `*When` key at all.