You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0a168a3
Browse filesBrowse the repository at this point in the historyBrowse files
feat(spec)!: FormSectionSchema.columns drops default(1) — undeclared section density is the renderer's, declared is the author's ceiling (#23771)
Fixes#23740
Clause-②: no
## What
`FormSectionSchema.columns` (`packages/spec/src/ui/view.zod.ts`) drops
`.default(1)`, per the maintainer ruling recorded on
objectstack-ai/objectui#12521 (comment 6204264665): 「不用决策卡,我同意 b)
改协议(推荐): 去掉分节 columns 的 default(1)」.
- The key stays optional with values `1`–`4`; the string spellings
`'1'`–`'4'` keep their transform (now `parseInt(val, 10)`), and
`undefined` passes through. Measured with zod 4.6.5: an absent key
parses to an object with no `columns` key at all (not `columns:
undefined`).
- The schema gains a docblock and a `.describe()` stating the two
meanings: **declared** = the author's ceiling (at most that many fields
per row where the container is wide enough; collapsed by the renderer on
narrow containers); **undeclared** = the renderer's choice by available
width. No pixel or breakpoint value enters the protocol.
- `FormViewSchema.columns` is untouched: its describe does not speak to
sections.
## Files
- `packages/spec/src/ui/view.zod.ts`: the schema change.
- `packages/spec/scripts/lib/default-changes.ts`: a new `18: [...]` row
declaring `ui/FormSection:columns: 1 → (none)`. The spec build's default
ratchet refused the change until it was declared; its `reason` explains
what moves for a consumer and why no `semantic` migration entry comes
with it. A conversion that writes `columns: 1` onto stored sections
would bring back the reading the ruling removed.
- Generated: `packages/spec/authorable-defaults/ui.json` (the
`ui/FormSection:columns = 1` line leaves) and
`content/docs/references/ui/view.mdx` (three tables lose `default: 1`
and gain the describe). `check:generated` named only `check:docs` as
stale; `--fix` regenerated it.
- Docs: `content/docs/protocol/objectui/layout-dsl.mdx` gets one
paragraph on the two meanings.
- Tests: the pins that asserted a parsed section's `columns: 1` now
assert the key is absent. None was deleted.
- `packages/spec/src/ui/view.test.ts` (plus a new case: declared
`1`/`'2'`/`4` survive, and `5`/`'0'` are refused)
- `packages/spec/src/ui/view-union-diagnostics.test.ts` (the `SECTION`
parse output)
- `packages/spec/src/ui/view-overlay-viewkind-arm.test.ts`
(byte-identical form overlay output)
-
`packages/metadata-protocol/src/protocol.graft-folded-form-sections.test.ts`
- `.changeset/23740-form-section-columns-no-default.md`:
`@objectstack/spec` `minor`, with a BREAKING line, a before/after table
of parse outputs, and the ADR-0087 disposition `not-required
(no-migration-prescription)`. `check:adr-0087-registration` accepts that
spelling (measured, exit 0).
## Evidence
- Readers of a form section's `columns` in `packages/*/src` outside
`view.zod.ts`: 0, re-measured at `2906cd8d2`. `protocol.ts` hand-builds
a section with an explicit `columns: 2`, so it is unaffected.
- `pnpm --filter @objectstack/spec build`: exit 0, after the default row
was declared.
- `pnpm --filter @objectstack/spec test`: 678 files passed, 1 skipped;
20755 tests passed.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
- `@objectstack/metadata-protocol`: its dependency closure was built,
`protocol.graft-folded-form-sections.test.ts` passed 46 of 46, and
`typecheck` exited 0.
- Ablation, run once: `node scripts/ablation-replace.mjs` put
`.default(1)` back on disk (anchor 1 → 0, blob `3dba72ea` → `f04fd827`).
`view.test.ts` then failed 1 test, `FormSectionSchema > should accept
minimal form section`. The tool restored the file: blob back to the HEAD
blob `3dba72ea`, and `git diff HEAD` empty.
- Derived gates (`dispatch-gates --commands`, at head `741552c0`): 110
derived, 109 ran with exit 0. `--ran` reconciles 110 of 110.
- Two gates first exited 3 on prerequisites and passed after the
prerequisite was fixed: `check-plugin-teardown-shape --self-test` (the
shallow clone lacked its pinned fixture commit, which was fetched) and
`check:skill-examples` (the client-react closure was built first).
- **NOT MEASURED:** `check:dual-build-cjs-loads`, exit 3, PREREQUISITE
NOT MET. It needs every package's `dist/`, which takes a repo-wide
build. Left to CI.
## Acceptance notes
- objectui consumers named on the card (`metadata-admin/SchemaForm.tsx`
reads `s.columns ?? 1`; plugin-form's sectioned layout) are
objectui#12521 / objectui PR #12546's side. No objectui file is touched
here.
- `main` gained 3 commits since the branch point (pm-dispatch,
plugin-security tests, objectui pin). None touches `packages/spec` or
`metadata-protocol`, so no merge was made before opening.
- This PR is meant to ride the same spec release as #23747.
---
_Generated by [Claude
Code](https://claude.ai/code/session_012vwv8sA1ZBBjmphdHWo9i5)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(spec)!: a form section's `columns` has no default — undeclared, the density is the renderer's (by available width); declared, it is the author's ceiling
6
+
7
+
Clause-②: no
8
+
9
+
**BREAKING:** a parsed form section without `columns` no longer carries `columns: 1`. `FormSectionSchema.columns` drops `.default(1)`: the key stays optional with values `1`–`4`, and the string spellings `'1'`–`'4'` still parse to the number.
10
+
11
+
| A form section authored as | Parsed before | Parsed now |
12
+
|:---|:---|:---|
13
+
|`{ fields: [...] }` (no `columns`) |`columns: 1`| no `columns` key |
The two meanings, stated on the schema's describe and docblock:
18
+
19
+
-**Declared** — the author's ceiling: at most that many fields per row where the container is wide enough. A renderer collapses it on narrow containers.
20
+
-**Undeclared** — the renderer's choice, by available width. After parse a renderer can now tell "the author said nothing" apart from "the author asked for one column", which the old default erased. No pixel or breakpoint value enters the protocol.
21
+
22
+
Nothing to rewrite: every input accepted before is accepted now with the same declared value, and none refused before is accepted. A section that must stay single-column on a wide container declares `columns: 1`. A consumer that reads a parsed section's `columns` treats an absent key as "the renderer decides" rather than as `1`. `FormViewSchema.columns` (the form body) was already optional with no default and is unchanged.
23
+
24
+
<!-- adr-0087: not-required (no-migration-prescription) no key is removed, renamed or moved and no stored or authored value changes meaning when declared; only the parse-added default for an undeclared key goes away, which no conversion can restore without defeating the change -->
Copy file name to clipboardExpand all lines: content/docs/references/ui/view.mdx
+3-3Lines changed: 3 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -322,7 +322,7 @@ Form-view select option — the object-field option shape minus the per-option `
322
322
|**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`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". 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. |
323
323
|**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: unbound on the standalone form routes, where the predicate would fault open. |
324
324
|**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 | Fields per row, 1–4 ('1'–'4' parse to the number). Declared: the author's ceiling — at most that many per row where the container is wide enough; a renderer collapses it on narrow containers. Undeclared: the renderer's choice by available width — the parse adds no default, so declare `columns: 1` to keep a section single-column.|
326
326
|**pane**|`Enum<'primary' \| 'secondary'>`| optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
327
327
|**group**|`string`| optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
@@ -446,7 +446,7 @@ Form-view select option — the object-field option shape minus the per-option `
446
446
|**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`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". 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. |
447
447
|**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: unbound on the standalone form routes, where the predicate would fault open. |
448
448
|**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 | Fields per row, 1–4 ('1'–'4' parse to the number). Declared: the author's ceiling — at most that many per row where the container is wide enough; a renderer collapses it on narrow containers. Undeclared: the renderer's choice by available width — the parse adds no default, so declare `columns: 1` to keep a section single-column.|
450
450
|**pane**|`Enum<'primary' \| 'secondary'>`| optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
451
451
|**group**|`string`| optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
@@ -462,7 +462,7 @@ Form-view select option — the object-field option shape minus the per-option `
462
462
|**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`, so writing `collapsed: true` alone is a correct way to say "collapsed by default". 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. |
463
463
|**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: unbound on the standalone form routes, where the predicate would fault open. |
464
464
|**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 | Fields per row, 1–4 ('1'–'4' parse to the number). Declared: the author's ceiling — at most that many per row where the container is wide enough; a renderer collapses it on narrow containers. Undeclared: the renderer's choice by available width — the parse adds no default, so declare `columns: 1` to keep a section single-column.|
466
466
|**pane**|`Enum<'primary' \| 'secondary'>`| optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
467
467
|**group**|`string`| optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
0 commit comments