From e773b44b3ffdede3e1e5e3f49629eeff4377c0b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 07:49:58 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20wizard=20view=20v1=20?= =?UTF-8?q?=E2=80=94=20step-key=20refusal,=20steps=20guidance,=20empty-ste?= =?UTF-8?q?ps=20refusal,=20ruled=20TSDoc=20(#13704)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- packages/spec/src/ui/view.test.ts | 192 +++++++++++++++++++++++++++++- packages/spec/src/ui/view.zod.ts | 139 ++++++++++++++++++++- 2 files changed, 326 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index b8e733188c..59e01832ec 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -43,6 +43,7 @@ import { defineForm, ViewItemSchema, ViewMetadataSchema, + VIEW_METADATA_MEMBERS, } from './view.zod'; import { @@ -551,12 +552,20 @@ describe('FormViewSchema', () => { }); it('should accept all form view types', () => { - const types = ['simple', 'tabbed', 'wizard'] as const; - + // [#13704] `wizard` left this bare-type loop: a wizard with no `sections` + // is REFUSED since the D7 tightening (it used to silently render as a + // plain simple form) — the bare spelling is pinned red in the wizard + // block below, and acceptance here carries the steps a wizard requires. + const types = ['simple', 'tabbed'] as const; + types.forEach(type => { const view: FormView = { type }; expect(() => FormViewSchema.parse(view)).not.toThrow(); }); + expect(() => FormViewSchema.parse({ + type: 'wizard', + sections: [{ label: 'Step 1', fields: ['name'] }], + })).not.toThrow(); }); it('should accept form view with sections', () => { @@ -715,6 +724,185 @@ describe('FormViewSchema', () => { }).success).toBe(false); }); }); + + // [#13704] Wizard v1 — the declaration-and-refusal tightening ruled on + // #13622 (maintainer 2026-08-31, director batch #12, 「同意」): wizard steps + // carry no predicate slot and do not collapse (objectui#6237's ruled split, + // upgraded from a renderer fact to a parse refusal), a wizard must declare + // its steps, and the `steps:` spelling gets a guidance-refusal — never an + // alias. Breadth is ruled NARROW: exactly this step-key family. + describe('wizard tightening (#13704)', () => { + const STEP = { label: 'Step', fields: ['name'] }; + const issueAt = (r: ReturnType, path: string) => + r.success ? undefined : r.error.issues.find((i) => i.path.join('.') === path); + + it('ACCEPT pin — the showcase author shape parses green, values preserved', () => { + // examples/app-showcase/src/ui/views/task.view.ts `wizard:` — the one + // real in-tree author, verbatim step shape (census of 2026-08-31: it and + // its lint-fixture mirror are the whole authored wizard corpus). + const parsed = FormViewSchema.parse({ + type: 'wizard', + sections: [ + { name: 'step_basics', label: 'Basics', columns: 1, fields: ['title', 'project'] }, + { name: 'step_assign', label: 'Assignment', columns: 1, fields: ['assignee', 'priority'] }, + { name: 'step_schedule', label: 'Schedule', columns: 2, fields: ['start_date', 'end_date', 'due_date'] }, + ], + }); + expect(parsed.type).toBe('wizard'); + expect(parsed.sections?.map((s) => s.name)) + .toEqual(['step_basics', 'step_assign', 'step_schedule']); + expect(parsed.sections?.[2]?.columns).toBe(2); + expect(parsed.sections?.[1]?.fields).toEqual(['assignee', 'priority']); + }); + + it('a ONE-step wizard is legal (no arbitrary step floor)', () => { + expect(FormViewSchema.safeParse({ type: 'wizard', sections: [STEP] }).success).toBe(true); + }); + + it('refuses `visibleWhen` on a wizard step, naming the path and the remedy', () => { + const r = FormViewSchema.safeParse({ + type: 'wizard', + sections: [STEP, { label: 'Later', visibleWhen: 'record.stage == "won"', fields: ['amount'] }], + }); + expect(r.success).toBe(false); + const issue = issueAt(r, 'sections.1.visibleWhen'); + expect(issue?.message).toMatch(/no predicate slot/); + expect(issue?.message).toMatch(/objectui#6237/); + expect(issue?.message).toMatch(/fields inside the step/); + }); + + it('refuses the deprecated `visibleOn` spelling the same way (folded to `visibleWhen` pre-refine)', () => { + const r = FormViewSchema.safeParse({ + type: 'wizard', + sections: [{ label: 'Later', visibleOn: 'record.stage == "won"', fields: ['amount'] }, STEP], + }); + expect(r.success).toBe(false); + // The fold runs at SECTION parse, so the reported path names the + // canonical key — the message says so out loud. + const issue = issueAt(r, 'sections.0.visibleWhen'); + expect(issue?.message).toMatch(/`visibleOn` alias folds into `visibleWhen`/); + }); + + it.each(['collapsible', 'collapsed'] as const)('refuses `%s: true` on a wizard step', (key) => { + const r = FormViewSchema.safeParse({ + type: 'wizard', + sections: [{ label: 'Step', [key]: true, fields: ['name'] }], + }); + expect(r.success).toBe(false); + const issue = issueAt(r, `sections.0.${key}`); + expect(issue?.message).toMatch(/wizard steps do not collapse/); + }); + + it('an authored `collapsible: false` stays ACCEPTED — the deliberate boundary', () => { + // `collapsible`/`collapsed` carry `.default(false)`, so post-parse an + // authored `false` is indistinguishable from the default — and needs no + // refusal: it declares exactly the behavior a wizard delivers. Only + // `true` declares behavior a wizard step does not have. + expect(FormViewSchema.safeParse({ + type: 'wizard', + sections: [{ label: 'Step', collapsible: false, collapsed: false, fields: ['name'] }], + }).success).toBe(true); + }); + + it('the refusal is wizard-scoped: the SAME step keys stay accepted on tabbed/simple (#6237\'s split)', () => { + for (const type of ['simple', 'tabbed'] as const) { + const r = FormViewSchema.safeParse({ + type, + sections: [ + { label: 'A', collapsible: true, collapsed: true, fields: ['name'] }, + { label: 'B', visibleWhen: 'record.stage == "won"', fields: ['amount'] }, + ], + }); + expect(r.success, `type: ${type}`).toBe(true); + } + }); + + it('covers the legacy `groups` bucket at the authored path', () => { + const r = FormViewSchema.safeParse({ + type: 'wizard', + groups: [{ label: 'Step', visibleWhen: 'record.x', fields: ['name'] }], + }); + expect(r.success).toBe(false); + expect(issueAt(r, 'groups.0.visibleWhen')?.message).toMatch(/no predicate slot/); + }); + + it('covers the runtime-overlay door for step KEYS (the pane precedent\'s reach)', () => { + const r = VIEW_METADATA_MEMBERS.formOverlay.safeParse({ + type: 'wizard', + object: 'crm_lead', + viewKind: 'form', + sections: [{ label: 'Step', collapsible: true, fields: ['name'] }], + }); + expect(r.success).toBe(false); + expect(r.success ? [] : r.error.issues.map((i) => i.path.join('.'))) + .toContain('sections.0.collapsible'); + }); + + it('refuses the `steps:` spelling with the sections prescription — never an alias', () => { + const r = FormViewSchema.safeParse({ + type: 'wizard', + steps: [STEP], + sections: [STEP], + }); + expect(r.success).toBe(false); + const messages = r.success ? [] : r.error.issues.map((i) => i.message); + expect(messages.join('\n')).toMatch(/there is no `steps` key/); + expect(messages.join('\n')).toMatch(/array order is step order/); + }); + + describe('a wizard must declare its steps (D7)', () => { + it('refuses ABSENT sections, at `sections`', () => { + const r = FormViewSchema.safeParse({ type: 'wizard' }); + expect(r.success).toBe(false); + expect(issueAt(r, 'sections')?.message).toMatch(/must declare its steps/); + expect(issueAt(r, 'sections')?.message).toMatch(/absent/); + }); + + it('refuses EMPTY sections', () => { + const r = FormViewSchema.safeParse({ type: 'wizard', sections: [] }); + expect(r.success).toBe(false); + expect(issueAt(r, 'sections')?.message).toMatch(/empty/); + }); + + it('a populated legacy `groups` bucket IS steps (the #6926 fold feeds the wizard)', () => { + expect(FormViewSchema.safeParse({ type: 'wizard', groups: [STEP] }).success).toBe(true); + }); + + it('an empty `groups` bucket is refused at `groups`', () => { + const r = FormViewSchema.safeParse({ type: 'wizard', groups: [] }); + expect(r.success).toBe(false); + expect(issueAt(r, 'groups')?.message).toMatch(/empty/); + }); + + it('empty `sections` beside populated `groups` is refused — mirrors the fold\'s `sections`-wins rule', () => { + const r = FormViewSchema.safeParse({ type: 'wizard', sections: [], groups: [STEP] }); + expect(r.success).toBe(false); + expect(issueAt(r, 'sections')?.message).toMatch(/empty/); + }); + + it('the flattened runtime overlay stays EXEMPT — a partial patch is its contract (#7025 membership)', () => { + // `{ type: 'wizard' }` beside the required binding, `sections` supplied + // by the shadowed base view — the `overlay.form.identity` shape the + // #7025 corpus pins as ACCEPTED. Moving that membership needs its own + // ruling (#7741's precedent); this pin keeps the D7 refusal off it. + expect(VIEW_METADATA_MEMBERS.formOverlay.safeParse({ + type: 'wizard', + object: 'crm_lead', + viewKind: 'form', + }).success).toBe(true); + }); + + it('…but the ViewItem record door carries a COMPLETE view, so D7 fires there', () => { + const r = ViewMetadataSchema.safeParse({ + name: 'crm_lead.wiz', + object: 'crm_lead', + viewKind: 'form', + config: { type: 'wizard' }, + }); + expect(r.success).toBe(false); + }); + }); + }); }); /** diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 668fb2d991..1d1fcc7ff2 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2899,6 +2899,16 @@ function refineFormFieldFeaturesRoot( export const FormViewSchema = lazySchema(() => strictObject({ surface: 'this form view', history: VIEW_HISTORY, + // [#13704] `steps` is the word the wizard's own vocabulary teaches (and the + // deleted fake tutorial taught) — a guidance-refusal with the ruled spelling + // (#13622 T1, 2026-08-31: ⛔ never an accepted alias; the `groups` alias is + // history being paid down, not a precedent). The #5099-family shape: the + // unknown-key rejection itself carries the prescription. + guidance: { + steps: 'A wizard\'s steps are its `sections` — there is no `steps` key. ' + + 'Write `type: \'wizard\'` with `sections: [{ label, fields: [...] }, …]`: ' + + 'each section renders as one gated step, and array order is step order.', + }, }, { type: z.enum([ 'simple', // Single column or sections @@ -2921,9 +2931,52 @@ export const FormViewSchema = lazySchema(() => strictObject({ /** Tabbed (`type: 'tabbed'`). */ defaultTab: z.string().optional().describe('Initially active tab (tabbed forms)'), tabPosition: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Tab strip position (tabbed forms)'), - /** Wizard (`type: 'wizard'`). */ - allowSkip: z.boolean().optional().describe('Allow skipping steps (wizard forms)'), - showStepIndicator: z.boolean().optional().describe('Show the step indicator (wizard forms)'), + /** + * Wizard (`type: 'wizard'`) — the step-sequenced form variant. Ruled contract + * (#13622 proposal D4–D8, maintainer 2026-08-31 「同意」; renderer semantics + * measured in that proposal against objectui's WizardForm): + * + * - **Sections ARE the steps, and array order IS step order (D8).** There is + * no `steps:` key (refused with a prescription — see this surface's + * `guidance`) and no `order` key: an integer beside the array would be a + * second source of truth that a reordering edit silently contradicts. + * - **The step gate is the DEFAULT semantics of the type (D4).** With + * `allowSkip` absent/false, step N+1 opens only after step N submits and + * passes validation; going back, and re-entering completed steps, is always + * free. `allowSkip: true` grants **navigation freedom, not a validation + * exemption** — the final submit still re-validates every step's declared + * fields and returns the author to the first failing step. The gate is a + * UI admission rule, **never authorization**: nothing server-side evaluates + * it, and the server re-validates the submitted record in both modes. + * - **Per-step validation binds ONLY the existing field-level vocabulary + * (D6)**: `required` / `requiredWhen` / `readonlyWhen` / per-option + * `visibleWhen`, evaluated by the same canonical rule engine the plain form + * renderer and the server share. There is no step-level `ValidationRule` + * binding — cross-field/script rules stay write-time vocabulary the server + * enforces at final submit. + * - **Progress is DERIVED state (D5).** Each step's completed / current / + * upcoming / invalid standing derives from the gate; `showStepIndicator` + * is the only authorable progress surface — no progress object, no + * percentages, no custom step-state text. + * - **Wizard steps carry no predicate slot and do not collapse (D2).** + * `visibleWhen` (and its deprecated `visibleOn` alias) and + * `collapsible` / `collapsed: true` on a wizard section are refused at + * parse — see the superRefine below; it is the spec-door upgrade of + * objectui#6237's ruled `FormSectionConfig` split ("WizardForm steps = + * No"). A wizard with absent/empty `sections` is refused too (D7): it used + * to silently render as a plain simple form. + */ + allowSkip: z.boolean().optional().describe( + 'Wizard step-gate opt-out: allow entering a later step without submitting the one before it. ' + + 'Navigation freedom, NOT a validation exemption — the final submit still re-validates every ' + + 'step and returns to the first failing one. Default (absent/false): steps unlock in array ' + + 'order as each prior step submits validly. UI admission only, never authorization.', + ), + showStepIndicator: z.boolean().optional().describe( + 'Show the wizard step indicator (renderer default: shown). Step progress ' + + '(completed/current/upcoming/invalid) is derived from the step gate — this boolean is the ' + + 'only authorable progress surface.', + ), /** Split (`type: 'split'`). */ splitDirection: z.enum(['horizontal', 'vertical']).optional().describe('Split orientation (split forms)'), splitSize: z.number().optional().describe('Primary split panel size, % (split forms)'), @@ -3165,8 +3218,88 @@ export const FormViewSchema = lazySchema(() => strictObject({ section?.fields?.forEach((field, fieldIndex) => { refineFormFieldFeaturesRoot(field, [key, index, 'fields', fieldIndex], ctx); }); + // [#13704] Wizard steps carry no predicate slot and do not collapse — + // objectui#6237's ruled `FormSectionConfig` split ("WizardForm steps = + // No"), upgraded from a renderer fact to a parse refusal (#13622 D2/T2, + // ruled 2026-08-31). Breadth is ruled NARROW: exactly this step-key + // family — no per-type presentation-key matrix. + // + // Post-parse observability decides the spellings checked here: + // `FormSectionSchema` folds `visibleOn` → `visibleWhen` (its + // `.transform(normalizeVisibleWhen)`) before this refinement runs, so + // one check answers both spellings (the message says so, since the + // reported path can only name the canonical key). `collapsible` / + // `collapsed` carry `.default(false)`, so an authored `false` is + // indistinguishable from the default here — and needs no refusal: it + // declares exactly the behavior a wizard delivers. Only `true` declares + // behavior a wizard step does not have. + if (view.type === 'wizard' && section != null) { + if (section.visibleWhen != null) { + ctx.addIssue({ + code: 'custom', + path: [key, index, 'visibleWhen'], + message: + '`visibleWhen` on a wizard step is refused: wizard steps carry no predicate slot ' + + '(ruled objectui#6237) — steps are entered in array order behind the step gate, ' + + 'never conditionally. Put the predicate on the fields inside the step, or use a ' + + "`simple`/`tabbed` form for section-level visibility. (The deprecated `visibleOn` " + + 'alias folds into `visibleWhen` and is refused the same way.)', + }); + } + for (const collapseKey of ['collapsible', 'collapsed'] as const) { + if (section[collapseKey] === true) { + ctx.addIssue({ + code: 'custom', + path: [key, index, collapseKey], + message: + `\`${collapseKey}\` on a wizard step is refused: wizard steps do not collapse — ` + + 'the wizard shows exactly the current step. Remove the key; collapsible ' + + 'grouping belongs to `simple`/`tabbed` sections.', + }); + } + } + } }); } + // [#13704] A wizard must declare its steps (#13622 D7, ruled 2026-08-31). + // Before this refusal, `type: 'wizard'` with absent/empty `sections` + // silently fell back to plain simple rendering (objectui RecordFormPage's + // conditional spread + ObjectForm's `sections?.length` guard) — the + // accepted-but-ignored shape. ⛔ No "at least 2 steps" floor: a one-step + // wizard is legal and renders. + // + // Scoped to the doors where the body claims to be a COMPLETE authored form + // view (`ViewSchema.form` / `formViews`, the ViewItem config arms). The + // flattened runtime-overlay member (`FormViewOverlayWireSchema` = + // `.extend(flattenedViewOverlayFields()).strip()`) is exempt: an overlay is + // a partial patch over a shadowed registry entry — `{ type: 'wizard' }` + // beside the required `object`+`viewKind` binding, with `sections` supplied + // by the base view, is its load-bearing round-trip shape (#7025 pins that + // membership; moving it needs its own ruling, per #7741's precedent). The + // extension's required `viewKind` is the structural marker of that door: on + // THIS strict shape the key can never be present (unknown keys are refused + // before acceptance), so reading it distinguishes the doors mechanically. + // The step-KEY refusals above run on every door on purpose — they fire only + // on a key the author wrote, which is never a partial-overlay artifact. + if (view.type === 'wizard' && typeof (view as { viewKind?: unknown }).viewKind !== 'string') { + // Mirror `foldFormGroupsIntoSections`' precedence exactly (`sections` + // wins even when empty), and report at the bucket the author wrote — + // canonical `sections` when neither bucket is present. + const steps = view.sections !== undefined ? view.sections : view.groups; + if (steps === undefined || steps.length === 0) { + const authoredKey = + view.sections !== undefined ? 'sections' : view.groups !== undefined ? 'groups' : 'sections'; + ctx.addIssue({ + code: 'custom', + path: [authoredKey], + message: + "A `type: 'wizard'` form view must declare its steps: `" + + authoredKey + '` is ' + (steps === undefined ? 'absent' : 'empty') + + ', and the wizard would silently render as a plain simple form. Declare at least one ' + + 'step — `sections: [{ label, fields: [...] }]`; sections ARE the steps, in array order.', + }); + } + } }).overwrite(foldFormGroupsIntoSections)); /** From cee7ddde998d2ebfe2649f8f7a843a1b572062d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:40:25 +0000 Subject: [PATCH 2/2] docs(spec): regenerate reference tables; changeset; strip issue id from customer-facing refusal text (#13704) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PBjwYLS6BciTQW3c9xQiD2 --- .changeset/wizard-view-v1-tightening.md | 48 ++++++++++++++++++++++++ content/docs/references/api/protocol.mdx | 8 ++-- content/docs/references/ui/view.mdx | 20 +++++----- packages/spec/src/ui/view.test.ts | 5 ++- packages/spec/src/ui/view.zod.ts | 2 +- 5 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 .changeset/wizard-view-v1-tightening.md diff --git a/.changeset/wizard-view-v1-tightening.md b/.changeset/wizard-view-v1-tightening.md new file mode 100644 index 0000000000..3c6b282176 --- /dev/null +++ b/.changeset/wizard-view-v1-tightening.md @@ -0,0 +1,48 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): wizard view v1 — declaration-and-refusal tightening of `FormViewSchema` `type: 'wizard'` (#13704) + +**BREAKING** accept-set narrowing on `FormViewSchema`, shipped as `minor` under the +repo's launch-window convention for breaking changes. Grade argued per the #13622 +ruling (T4 leaves the final call to this PR's review chain): the nearest +tightening precedents are the two `ActionSchema` accept-set narrowings #11519 +(doubled post-success navigation refused) and #11842 (`newTabUrl` / +`opensInNewTab` co-constraint), both shipped `minor` with the **BREAKING** header; +the mechanism precedent is the `section.pane` non-split parse refusal this change +extends. The measured population of affected authored sources is zero in every +in-tree corpus (the showcase task wizard and its lint-fixture mirror are the whole +authored wizard corpus; both already use only clean step keys). + +The wizard form view existed end to end (spec enum, gated renderer, one real +author) but its contract was silent: the gate semantics lived only in renderer +comments, wizard-inert section keys parsed clean, and a step-less wizard silently +rendered as a plain simple form. Ruled on #13622 (maintainer 2026-08-31, director +batch #12, 「同意」), v1 is a declaration-and-refusal tightening with **zero new +authorable keys**: + +- **Wizard steps carry no predicate slot and do not collapse** (upgrades + objectui#6237's ruled `FormSectionConfig` split to a parse refusal): on + `type: 'wizard'`, a section `visibleWhen` (or its deprecated `visibleOn` + alias), `collapsible: true`, or `collapsed: true` is now refused at parse with + a prescription. Fix: remove the key — put visibility predicates on the fields + inside the step, or use a `simple`/`tabbed` form for section-level + visibility/collapsing. The same keys stay accepted on every non-wizard form + type. +- **A wizard must declare its steps**: `type: 'wizard'` with absent or empty + `sections` is refused (it previously fell back to plain simple rendering, + silently). Fix: declare at least one step — `sections: [{ label, fields }]`. + The flattened runtime personalization overlay (a partial patch bound by + `object` + `viewKind`) is deliberately exempt. +- **`steps:` is refused with guidance, never accepted as an alias**: the + unknown-key rejection now teaches the ruled spelling — sections ARE the steps, + array order is step order. +- **The ruled semantics are declared in the schema** (TSDoc + generated + reference docs): the step gate is the default of `type: 'wizard'`; `allowSkip` + is navigation freedom, not a validation exemption; the gate is UI admission, + never authorization; progress is derived state with `showStepIndicator` the + only authorable knob; per-step validation binds only the existing field-level + vocabulary; array order is step order. + + diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 39e6fe605c..80db78c89a 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1674,8 +1674,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **description** | `string` | optional | Form description | | **defaultTab** | `string` | optional | Initially active tab (tabbed forms) | | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | -| **allowSkip** | `boolean` | optional | Allow skipping steps (wizard forms) | -| **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard forms) | +| **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | +| **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | | **splitSize** | `number` | optional | Primary split panel size, % (split forms) | | **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | @@ -1759,8 +1759,8 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **description** | `string` | optional | Form description | | **defaultTab** | `string` | optional | Initially active tab (tabbed forms) | | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | -| **allowSkip** | `boolean` | optional | Allow skipping steps (wizard forms) | -| **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard forms) | +| **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | +| **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | | **splitSize** | `number` | optional | Primary split panel size, % (split forms) | | **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 933cd798fe..d55a24dd28 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -403,8 +403,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **description** | `string` | optional | Form description | | **defaultTab** | `string` | optional | Initially active tab (tabbed forms) | | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | -| **allowSkip** | `boolean` | optional | Allow skipping steps (wizard forms) | -| **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard forms) | +| **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | +| **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | | **splitSize** | `number` | optional | Primary split panel size, % (split forms) | | **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | @@ -1795,8 +1795,8 @@ Tab configuration for multi-tab view interface | **description** | `string` | optional | Form description | | **defaultTab** | `string` | optional | Initially active tab (tabbed forms) | | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | -| **allowSkip** | `boolean` | optional | Allow skipping steps (wizard forms) | -| **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard forms) | +| **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | +| **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | | **splitSize** | `number` | optional | Primary split panel size, % (split forms) | | **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | @@ -1880,8 +1880,8 @@ Tab configuration for multi-tab view interface | **description** | `string` | optional | Form description | | **defaultTab** | `string` | optional | Initially active tab (tabbed forms) | | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | -| **allowSkip** | `boolean` | optional | Allow skipping steps (wizard forms) | -| **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard forms) | +| **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | +| **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | | **splitSize** | `number` | optional | Primary split panel size, % (split forms) | | **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | @@ -2155,8 +2155,8 @@ This schema accepts one of the following structures: | **description** | `string` | optional | Form description | | **defaultTab** | `string` | optional | Initially active tab (tabbed forms) | | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | -| **allowSkip** | `boolean` | optional | Allow skipping steps (wizard forms) | -| **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard forms) | +| **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | +| **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | | **splitSize** | `number` | optional | Primary split panel size, % (split forms) | | **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | @@ -2341,8 +2341,8 @@ This schema accepts one of the following structures: | **description** | `string` | optional | Form description | | **defaultTab** | `string` | optional | Initially active tab (tabbed forms) | | **tabPosition** | `Enum<'top' \| 'bottom' \| 'left' \| 'right'>` | optional | Tab strip position (tabbed forms) | -| **allowSkip** | `boolean` | optional | Allow skipping steps (wizard forms) | -| **showStepIndicator** | `boolean` | optional | Show the step indicator (wizard forms) | +| **allowSkip** | `boolean` | optional | Wizard step-gate opt-out: allow entering a later step without submitting the one before it. Navigation freedom, NOT a validation exemption — the final submit still re-validates every step and returns to the first failing one. Default (absent/false): steps unlock in array order as each prior step submits validly. UI admission only, never authorization. | +| **showStepIndicator** | `boolean` | optional | Show the wizard step indicator (renderer default: shown). Step progress (completed/current/upcoming/invalid) is derived from the step gate — this boolean is the only authorable progress surface. | | **splitDirection** | `Enum<'horizontal' \| 'vertical'>` | optional | Split orientation (split forms) | | **splitSize** | `number` | optional | Primary split panel size, % (split forms) | | **splitResizable** | `boolean` | optional | Whether the split is resizable (split forms) | diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index 59e01832ec..dcba4c6987 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -767,8 +767,11 @@ describe('FormViewSchema', () => { expect(r.success).toBe(false); const issue = issueAt(r, 'sections.1.visibleWhen'); expect(issue?.message).toMatch(/no predicate slot/); - expect(issue?.message).toMatch(/objectui#6237/); expect(issue?.message).toMatch(/fields inside the step/); + // Customer-facing sink text carries no internal issue ids (the + // doc-authoring gate's rule) — the #6237 provenance lives in the + // adjacent source comment, not in the message. + expect(issue?.message).not.toMatch(/#\d+/); }); it('refuses the deprecated `visibleOn` spelling the same way (folded to `visibleWhen` pre-refine)', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1d1fcc7ff2..75fb25f71f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3240,7 +3240,7 @@ export const FormViewSchema = lazySchema(() => strictObject({ path: [key, index, 'visibleWhen'], message: '`visibleWhen` on a wizard step is refused: wizard steps carry no predicate slot ' - + '(ruled objectui#6237) — steps are entered in array order behind the step gate, ' + + '— steps are entered in array order behind the step gate, ' + 'never conditionally. Put the predicate on the fields inside the step, or use a ' + "`simple`/`tabbed` form for section-level visibility. (The deprecated `visibleOn` " + 'alias folds into `visibleWhen` and is refused the same way.)',