Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/19311-form-section-collapse-describes.md
Original file line number Diff line number Diff line change
@@ -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).
12 changes: 6 additions & 6 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ View filter rule
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) |
| **label** | `string \| Record<string, string>` | 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`) | |
Expand Down Expand Up @@ -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<string, string>` | 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`) | |
Expand All @@ -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<string, string>` | 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`) | |
Expand Down
52 changes: 50 additions & 2 deletions packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading