diff --git a/.changeset/19311-form-section-collapse-describes.md b/.changeset/19311-form-section-collapse-describes.md new file mode 100644 index 0000000000..eec9c7ea1f --- /dev/null +++ b/.changeset/19311-form-section-collapse-describes.md @@ -0,0 +1,22 @@ +--- +"@objectstack/spec": patch +--- + +`FormSection.collapsible` / `FormSection.collapsed` — both keys now carry a `.describe()`, so the published reference page no longer prints two empty Description cells for two authorable booleans (#19311). + +They were the only keys in `FormSectionSchema` with no contract text at all, sitting between neighbours that have it, and the dependency between them was published nowhere. **Both** are described rather than only `collapsed`: the sibling silence is what made the gap ambiguous in the first place, and describing one of a pair recreates it one key over. + +Measured against the built package, `FormSectionSchema.safeParse` on one section: + +| authored on the section | `collapsible` after parse | `collapsed` after parse | +| :--- | :--- | :--- | +| neither | `false` | `false` | +| `collapsible: true` | `true` | `false` | +| `collapsed: true` | `false` | `true` | +| `collapsed: true` + `collapsible: false` | `false` | `true` | +| both `true` | `true` | `true` | +| both `false` | `false` | `false` | + +- **Parse does NOT normalize the pair, in either direction.** `{ collapsed: true }` parses to `{ collapsible: false, collapsed: true }` verbatim, and `safeParseAsync` agrees. So the implication `collapsed` ⇒ `collapsible` — ruled 2026-09-18, letter A — is a **renderer** rule applied from the declaration, and the describes say exactly that rather than implying a fold the schema does not perform. A consumer reading the parsed `collapsible` is reading what the author typed, never whether a disclosure control renders. +- **This is the opposite of the `ObjectFieldGroup` pair**, where a parse-time mapping really does fold the old booleans onto the ADR-0085 `collapse` enum. The two surfaces share key names and share nothing else; the describes say so. +- **Text only.** No key is added, removed or re-typed, no accept set moves and no refinement changes: `check:authorable-surface` and `check:api-surface` are both green with no delta, and the wizard-step and `group` co-declaration refusals parse identically before and after (only `true` is refused in either place; `false` is accepted in both). diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index ea800bd3ae..b48d558ff2 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -331,8 +331,8 @@ View filter rule | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | @@ -463,8 +463,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | @@ -479,8 +479,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **description** | `string` | optional | Optional description rendered under the section header. | -| **collapsible** | `boolean` | optional (default: `false`) | | -| **collapsed** | `boolean` | optional (default: `false`) | | +| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. | +| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index ce2ae04cde..2ecea023f2 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -3312,8 +3312,56 @@ export const FormSectionSchema = lazySchema(() => strictObject({ name: z.string().optional().describe('Stable section identifier for i18n lookup (snake_case)'), label: I18nLabelSchema.optional(), description: z.string().optional().describe('Optional description rendered under the section header.'), - collapsible: z.boolean().default(false), - collapsed: z.boolean().default(false), + /** + * ## The collapse pair, declared once for both members + * + * Two independent booleans, both `.default(false)`, and the dependency + * between them is a RENDERER rule rather than anything parse does — so it + * has to be stated on the declaration or it reaches nobody. `collapsed` + * IMPLIES `collapsible`: a section that starts closed always carries the + * disclosure control that reopens it, `collapsed: true` needs no + * `collapsible` beside it, and it outranks an explicit `collapsible: false` + * (maintainer ruling 2026-09-18, letter A). Letter B — refusing the + * combination at the declaration — and letter C — a dev-only warning — were + * both REFUSED, so ⛔ neither this schema nor a lint rule may grow one: + * `collapsed: true` alone is a CORRECT spelling of "collapsed by default", + * which is why the ruling made the renderer widen instead. + * + * ⚠️ The pair is NOT normalized at parse, in either direction — measured + * against the built package: `{ collapsed: true }` parses to + * `{ collapsible: false, collapsed: true }`, verbatim, and + * `safeParseAsync` agrees. So a consumer reading the parsed `collapsible` + * is reading what the author typed, NOT whether a control renders; it + * applies the implication itself, from the declaration and never from live + * collapse state (deriving it from the latter deletes the control the + * moment the reader opens the section). That is the opposite of the + * `fieldGroups` pair on `ObjectSchema`, which a parse-time mapping folds + * onto the `collapse` enum — ⛔ do not carry a reading across. + * + * Only `true` is refused on a wizard step and beside `group`; `false` is + * accepted in both, because it declares exactly what those surfaces already + * deliver (see `trueOnlyDerivedKeys` below and the FormViewSchema wizard + * refinement). + */ + collapsible: z.boolean().default(false).describe( + 'Whether the section renders a disclosure control, so a reader can close it and open it again. ' + + 'Default `false`: a section declaring neither collapse key is always open and shows no control. ' + + '⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take ' + + 'the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never ' + + 'rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is ' + + 'refused on a wizard step and beside `group`; `false` is accepted in both, because it declares ' + + 'exactly what those surfaces already deliver.', + ), + collapsed: z.boolean().default(false).describe( + 'Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is ' + + 'sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that ' + + 'reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the ' + + 'combination at the declaration, and warning on it, were both rejected — nobody can depend on a ' + + 'section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: ' + + '`{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed ' + + '`collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not ' + + 'collapse) and beside `group`, whose field group declares the pair.', + ), /** * Conditional-visibility predicate (CEL) — the whole section is shown only * when TRUE (ADR-0089, canonical `*When` name). Same per-layer binding root as