From 3616e5c231fd73c5cdc29ca86ccd2790ce734490 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:07:31 +0000 Subject: [PATCH 1/6] docs(spec,ui): state the measured colSpan / span behaviour instead of the falsified claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser measurement at all three surface widths falsified two sentences that were on the tree in four hand-written carriers and one generated one: - an absolute `colSpan` "only lines up at the width the author imagined" — it does not. The renderer clamps the span to the section's column count and emits one container-query-scoped class, so the cell starts at a real column boundary at every width; rendered overflow was 0px in every configuration, including `colSpan: 4` in a 3-column section. - `span: 'full'` gives the "whole row at any column count" — it does not. It compiles to a span of the section's declared column count gated at the top breakpoint only, and at the 2-column modal width it renders pixel-identical to authoring nothing at all. Each carrier now states the measured behaviour of its key. The preference clauses ("[legacy — prefer `span`]", "Prefer `span`.", "Prefer this over the absolute `colSpan`", "(preferred)", "Legacy absolute column span") are removed rather than reversed: they rest on the falsified claim, and which spelling an author should reach for is a renderer question this repository does not answer today. Both keys stay, each described by what it actually does. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- content/docs/protocol/objectui/layout-dsl.mdx | 10 ++++++---- content/docs/ui/views.mdx | 6 +++--- packages/spec/src/ui/view.zod.ts | 10 ++++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 777c136434c..c6ebb97913b 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -220,7 +220,7 @@ regions: **"12 columns" describes the renderer's internal grid, not an authorable span vocabulary.** What an author declares is much narrower, and both keys are strict: a section's `columns` is a scalar `1`–`4`, and a field's width is - `span: auto | full` (or the legacy absolute `colSpan`, also `1`–`4`). There is + `span: auto | full` (or the absolute `colSpan`, also `1`–`4`). There is **no** `span: 6` and no twelfth of anything in the metadata. The examples below are written in the vocabulary the schemas accept. @@ -254,9 +254,11 @@ section: ### Custom Span Widths A field's width is set on the field itself, inside the section's own `fields` -list — there is no section-level `layout:` block. `span: full` takes the whole -row at any column count; omitting `span` (`auto`) lets the renderer size the -field from its widget type and the current column count. +list — there is no section-level `layout:` block. `span: full` compiles to a +span of the section's declared column count, gated at the top breakpoint only — +the whole row at that breakpoint, and identical to omitting the key at every +narrower width. Omitting `span` (`auto`) lets the renderer size the field from +its widget type and the current column count. {/* os:check-yaml FormSectionSchema key=section */} ```yaml diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index aea74eb38c1..4965b4f250c 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -431,7 +431,7 @@ fields: [ placeholder: 'Enter title', helpText: 'A brief description of the task', required: true, // Override required - span: 'full', // Take the whole row at any column count + span: 'full', // Whole row at the top breakpoint; below it, same as omitting visibleWhen: "record.status != 'cancelled'", }, ] @@ -446,8 +446,8 @@ fields: [ | `readonly` | `boolean` | Read-only override | | `required` | `boolean` | Required override | | `hidden` | `boolean` | Hidden override | -| `span` | `'auto' \| 'full'` | Relative width — `'full'` takes the whole row at any column count (preferred) | -| `colSpan` | `1-4` | Legacy absolute column span — prefer `span` | +| `span` | `'auto' \| 'full'` | Relative width — `'full'` compiles to a span of the section's declared column count, gated at the top breakpoint only: the whole row there, identical to omitting the key at narrower widths | +| `colSpan` | `1-4` | Absolute column span, clamped by the renderer to the current column count — grid-aligned at every surface width | | `widget` | `string` | Custom widget/component name | | `dependsOn` | `string` | Parent field for cascading | | `visibleWhen` | `string` | Visibility predicate (CEL); runtime form fields bind `record` (+ `previous`, `parent`) and, since objectui#6010, `current_user` — the identity scope page components and per-option predicates already bound (ADR-0089 D1). Form **sections** bind the same scope since objectui#6110 + objectui#6111. One surface still evaluates it unbound, where the predicate faults open: the public `/f/:slug` route, mounted outside any provider on purpose because an anonymous visitor has no principal — the authed `/forms/:name` publishes the session principal and binds normally. ⚠️ Either way the binding is **client-side only**: nothing on the write path evaluates a form-view field or section `visibleWhen`, so a `current_user.positions` test here hides controls and protects no data — declare permission-set [field-level security](/docs/permissions/field-level-security) for that. No `features.*` on any form-view predicate — refused at parse, see the boundary note below. (was `visibleOn`, ADR-0089) | diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 1d1cfd0bef6..4e32de52ab4 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2607,12 +2607,14 @@ const FormFieldBaseSchema = lazySchema(() => { immutable: z.boolean().optional().describe('Editable on create, locked once the record exists (e.g. machine names).'), required: z.boolean().optional().describe('Required override'), hidden: z.boolean().optional().describe('Hidden override'), - colSpan: z.number().int().min(1).max(4).optional().describe('[legacy — prefer `span`] Absolute column span (1-4). Fragile when the column count is derived per surface (mobile 1 / modal 2 / page 3-4): a fixed span only lines up at the width the author imagined. The renderer clamps it to the current column count. Prefer `span`.'), + colSpan: z.number().int().min(1).max(4).optional().describe('Absolute column span (1-4). The renderer clamps it to the current column count and emits it as one container-query-scoped span class, so the cell starts at a real column boundary at every surface width; that class is inert below the breakpoint at which that many columns exist, and `colSpan: 1` emits no class at all.'), /** - * [#2578] Relative field width — decoupled from the (often auto-derived) - * column count, so it stays correct at 1/2/3/4 columns. + * [#2578] Relative field width, authored instead of an absolute column span. + * Browser measurement at three surface widths found that 'full' compiles to + * a span of the section's declared column count, gated at the top breakpoint + * only — it is not a whole row at every column count. */ - span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': whole row at any column count. Prefer this over the absolute `colSpan`."), + span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': compiles to a span of the section's declared column count, gated at the top breakpoint only — the whole row at that breakpoint, and identical to omitting the key at every narrower width."), /** Custom widget override — only needed when auto-inference is insufficient */ widget: z.string().optional().describe('Custom widget/component name (overrides type-based inference)'), From 419e3173cf283095184eb54098ce2bb5961dae35 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:14:37 +0000 Subject: [PATCH 2/6] docs(spec): regenerate the reference tables and add the changeset `content/docs/references/ui/view.mdx` is generated from the `.describe()` on `FormField.colSpan` / `FormField.span`; regenerated with `pnpm --filter @objectstack/spec check:generated --fix`, which proved exactly one artifact stale and touched only that one. Both FormField tables in the file (two occurrences each) now carry the measured text. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../17670-colspan-span-measured-behaviour.md | 14 ++++++++++++++ content/docs/references/ui/view.mdx | 8 ++++---- 2 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 .changeset/17670-colspan-span-measured-behaviour.md diff --git a/.changeset/17670-colspan-span-measured-behaviour.md b/.changeset/17670-colspan-span-measured-behaviour.md new file mode 100644 index 00000000000..787d3aebda0 --- /dev/null +++ b/.changeset/17670-colspan-span-measured-behaviour.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +--- + +docs(spec): `FormField.colSpan` and `FormField.span` describe their measured behaviour — the two claims browser measurement falsified are gone (#17670) + +Both `.describe()` strings ship inside the published package (`src/**/*.zod.ts`, `dist`, `json-schema`) and they generate the public `content/docs/references/ui/view.mdx` tables, so what they assert is what every reader of the API reference — human or AI — is told the renderer does. Two of those assertions were measured false in Chromium at all three surface widths (#17328, `absolute-colspan-discouraged` withdrawn on the same evidence): + +- `colSpan` was described as "fragile … a fixed span only lines up at the width the author imagined". It is not. The renderer clamps the span to the section's column count and emits one container-query-scoped class, so the cell starts at a real column boundary at every width; rendered overflow was 0px in every configuration measured, including `colSpan: 4` in a 3-column section — the case that would overflow if the clamp did not work. The old text contradicted its own next sentence, which already stated the clamp. +- `span: 'full'` was described as "whole row at any column count". It is not. It compiles to a span of the section's declared column count gated at the top breakpoint only (`@2xl:col-span-3` for a 3-column section — the identical class `colSpan: 4` emits), so at the 2-column modal width it renders pixel-identical to authoring nothing at all. + +Each key now states what it actually does. **The preference between the two keys is removed, not reversed** — `[legacy — prefer `span`]`, `Prefer `span`.` and `Prefer this over the absolute `colSpan`.` are gone, and nothing replaces them. Both spellings rest on the falsified claim, and the measurement puts the recommended one on the wrong side of it; which key an author should reach for depends on a renderer question (`span: 'full'` not spanning the row at intermediate container widths) that this repository does not answer today and that is deliberately not answered here. + +Nothing an author writes moves. Both keys are unchanged, both still parse, every stored form view keeps its shape and its rendering, and no validation, default or emitted class changes. This is a correction to what the package says about itself. diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 3bf385bc51c..4eaa66b5508 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -198,8 +198,8 @@ Column footer summary configuration | **immutable** | `boolean` | optional | Editable on create, locked once the record exists (e.g. machine names). | | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | -| **colSpan** | `integer` | optional | [legacy — prefer `span`] Absolute column span (1-4). Fragile when the column count is derived per surface (mobile 1 / modal 2 / page 3-4): a fixed span only lines up at the width the author imagined. The renderer clamps it to the current column count. Prefer `span`. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': whole row at any column count. Prefer this over the absolute `colSpan`. | +| **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the current column count and emits it as one container-query-scoped span class, so the cell starts at a real column boundary at every surface width; that class is inert below the breakpoint at which that many columns exist, and `colSpan: 1` emits no class at all. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': compiles to a span of the section's declared column count, gated at the top breakpoint only — the whole row at that breakpoint, and identical to omitting the key at every narrower width. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | @@ -363,8 +363,8 @@ View filter rule | **immutable** | `boolean` | optional | Editable on create, locked once the record exists (e.g. machine names). | | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | -| **colSpan** | `integer` | optional | [legacy — prefer `span`] Absolute column span (1-4). Fragile when the column count is derived per surface (mobile 1 / modal 2 / page 3-4): a fixed span only lines up at the width the author imagined. The renderer clamps it to the current column count. Prefer `span`. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': whole row at any column count. Prefer this over the absolute `colSpan`. | +| **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the current column count and emits it as one container-query-scoped span class, so the cell starts at a real column boundary at every surface width; that class is inert below the breakpoint at which that many columns exist, and `colSpan: 1` emits no class at all. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': compiles to a span of the section's declared column count, gated at the top breakpoint only — the whole row at that breakpoint, and identical to omitting the key at every narrower width. | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | From f0dc9f006cf50925c5a2afab26b03a41fc4e60b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:44:13 +0000 Subject: [PATCH 3/6] docs(spec,ui): anchor the colSpan / span tier behaviour to a named objectui commit The replacement text this branch landed was measured at the `.objectui-sha` pin only, and asserted timelessly. It is false on objectui's `main`: in a 3-column grid both `span: 'full'` and a clamped `colSpan: 4` emit `@md:col-span-2 @2xl:col-span-3` there, not the single `@2xl:col-span-3` the pin emits. Worse, the sentence the branch DELETED - "whole row at any column count" - is quoted by the objectui fix's own docblock, its two pins and its changeset as the published declaration they implement, so the text as written would have codified the behaviour that fix had just corrected against it. Each carrier now states what the key RESOLVES to - the form grid's full column count, true on both renderer states - and anchors the tier behaviour to a commit instead of asserting it timelessly. The span is the form grid's column count (explicit form `columns`, else the widest section's `columns`, else inferred), not "the section's declared column count"; those coincide only in the single-section form #17328 measured. The renderer-side fix is objectui#9253, commit `bd09957380` (2026-09-12), part of objectui#9244 - re-derived here by executing both extracted implementations. `.objectui-sha` is deliberately NOT moved: the ladder is unreleased (`@object-ui/components` 17.6.0 at both the pin and objectui HEAD, 0 tags contain the commit), and a pin bump is not a rider. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .../17670-colspan-span-measured-behaviour.md | 6 +++--- content/docs/protocol/objectui/layout-dsl.mdx | 10 ++++++---- content/docs/references/ui/view.mdx | 8 ++++---- content/docs/ui/views.mdx | 4 ++-- packages/spec/src/ui/view.zod.ts | 17 +++++++++++------ 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/.changeset/17670-colspan-span-measured-behaviour.md b/.changeset/17670-colspan-span-measured-behaviour.md index 787d3aebda0..961e3e99ce9 100644 --- a/.changeset/17670-colspan-span-measured-behaviour.md +++ b/.changeset/17670-colspan-span-measured-behaviour.md @@ -6,9 +6,9 @@ docs(spec): `FormField.colSpan` and `FormField.span` describe their measured beh Both `.describe()` strings ship inside the published package (`src/**/*.zod.ts`, `dist`, `json-schema`) and they generate the public `content/docs/references/ui/view.mdx` tables, so what they assert is what every reader of the API reference — human or AI — is told the renderer does. Two of those assertions were measured false in Chromium at all three surface widths (#17328, `absolute-colspan-discouraged` withdrawn on the same evidence): -- `colSpan` was described as "fragile … a fixed span only lines up at the width the author imagined". It is not. The renderer clamps the span to the section's column count and emits one container-query-scoped class, so the cell starts at a real column boundary at every width; rendered overflow was 0px in every configuration measured, including `colSpan: 4` in a 3-column section — the case that would overflow if the clamp did not work. The old text contradicted its own next sentence, which already stated the clamp. -- `span: 'full'` was described as "whole row at any column count". It is not. It compiles to a span of the section's declared column count gated at the top breakpoint only (`@2xl:col-span-3` for a 3-column section — the identical class `colSpan: 4` emits), so at the 2-column modal width it renders pixel-identical to authoring nothing at all. +- `colSpan` was described as "fragile … a fixed span only lines up at the width the author imagined". It is not. The renderer clamps the span to the form grid's column count, so the cell starts at a real column boundary at every width; rendered overflow was 0px in every configuration measured, including `colSpan: 4` in a 3-column section — the case that would overflow if the clamp did not work. The old text contradicted its own next sentence, which already stated the clamp. +- `span: 'full'` was described as "whole row at any column count". It is not. It resolves to the form grid's full column count, and at the `.objectui-sha` pin `53ded82bf7` the renderer emitted only the widest tier's class (`@2xl:col-span-3` for a 3-column grid — the identical class `colSpan: 4` emits), so at the 2-column modal width it rendered pixel-identical to authoring nothing at all. -Each key now states what it actually does. **The preference between the two keys is removed, not reversed** — `[legacy — prefer `span`]`, `Prefer `span`.` and `Prefer this over the absolute `colSpan`.` are gone, and nothing replaces them. Both spellings rest on the falsified claim, and the measurement puts the recommended one on the wrong side of it; which key an author should reach for depends on a renderer question (`span: 'full'` not spanning the row at intermediate container widths) that this repository does not answer today and that is deliberately not answered here. +Each key now states what it actually does. **The preference between the two keys is removed, not reversed** — `[legacy — prefer `span`]`, `Prefer `span`.` and `Prefer this over the absolute `colSpan`.` are gone, and nothing replaces them. Both spellings rest on the falsified claim, and the measurement puts the recommended one on the wrong side of it; the renderer question behind it — `span: 'full'` not spanning the row at intermediate container widths — was answered on the objectui side by objectui#9253 (commit `bd09957380`, 2026-09-12, part of objectui#9244), which emits one clamped col-span class per multi-column tier. That fix is unreleased at this repo's pin (`@object-ui/components` 17.6.0 at both, 0 tags contain the commit), so the text above anchors the pin state and this PR does not move `.objectui-sha`. Nothing an author writes moves. Both keys are unchanged, both still parse, every stored form view keeps its shape and its rendering, and no validation, default or emitted class changes. This is a correction to what the package says about itself. diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index c6ebb97913b..8b6e25076e7 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -254,10 +254,12 @@ section: ### Custom Span Widths A field's width is set on the field itself, inside the section's own `fields` -list — there is no section-level `layout:` block. `span: full` compiles to a -span of the section's declared column count, gated at the top breakpoint only — -the whole row at that breakpoint, and identical to omitting the key at every -narrower width. Omitting `span` (`auto`) lets the renderer size the field from +list — there is no section-level `layout:` block. `span: full` resolves to the +form grid's full column count (the explicit form `columns`, else the widest +section's `columns`, else inferred from field count). Which container-query +tiers emit that span is the renderer's: widest tier only at objectui +`53ded82bf7` (objectstack#17328), one clamped class per tier since +objectui#9253. Omitting `span` (`auto`) lets the renderer size the field from its widget type and the current column count. {/* os:check-yaml FormSectionSchema key=section */} diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 4eaa66b5508..72822507745 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -198,8 +198,8 @@ Column footer summary configuration | **immutable** | `boolean` | optional | Editable on create, locked once the record exists (e.g. machine names). | | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | -| **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the current column count and emits it as one container-query-scoped span class, so the cell starts at a real column boundary at every surface width; that class is inert below the breakpoint at which that many columns exist, and `colSpan: 1` emits no class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': compiles to a span of the section's declared column count, gated at the top breakpoint only — the whole row at that breakpoint, and identical to omitting the key at every narrower width. | +| **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (objectstack#17328, Chromium at 390/720/1700). | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | @@ -363,8 +363,8 @@ View filter rule | **immutable** | `boolean` | optional | Editable on create, locked once the record exists (e.g. machine names). | | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | -| **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the current column count and emits it as one container-query-scoped span class, so the cell starts at a real column boundary at every surface width; that class is inert below the breakpoint at which that many columns exist, and `colSpan: 1` emits no class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': compiles to a span of the section's declared column count, gated at the top breakpoint only — the whole row at that breakpoint, and identical to omitting the key at every narrower width. | +| **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (objectstack#17328, Chromium at 390/720/1700). | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 4965b4f250c..70e6f68ad63 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -431,7 +431,7 @@ fields: [ placeholder: 'Enter title', helpText: 'A brief description of the task', required: true, // Override required - span: 'full', // Whole row at the top breakpoint; below it, same as omitting + span: 'full', // Resolves to the form grid's full column count visibleWhen: "record.status != 'cancelled'", }, ] @@ -446,7 +446,7 @@ fields: [ | `readonly` | `boolean` | Read-only override | | `required` | `boolean` | Required override | | `hidden` | `boolean` | Hidden override | -| `span` | `'auto' \| 'full'` | Relative width — `'full'` compiles to a span of the section's declared column count, gated at the top breakpoint only: the whole row there, identical to omitting the key at narrower widths | +| `span` | `'auto' \| 'full'` | Relative width — `'full'` resolves to the form grid's full column count; which container-query tiers emit the span is the renderer's (widest tier only at objectui `53ded82bf7`, per objectstack#17328; one clamped class per tier since objectui#9253) | | `colSpan` | `1-4` | Absolute column span, clamped by the renderer to the current column count — grid-aligned at every surface width | | `widget` | `string` | Custom widget/component name | | `dependsOn` | `string` | Parent field for cascading | diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 4e32de52ab4..05c97cfd8e6 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2607,14 +2607,19 @@ const FormFieldBaseSchema = lazySchema(() => { immutable: z.boolean().optional().describe('Editable on create, locked once the record exists (e.g. machine names).'), required: z.boolean().optional().describe('Required override'), hidden: z.boolean().optional().describe('Hidden override'), - colSpan: z.number().int().min(1).max(4).optional().describe('Absolute column span (1-4). The renderer clamps it to the current column count and emits it as one container-query-scoped span class, so the cell starts at a real column boundary at every surface width; that class is inert below the breakpoint at which that many columns exist, and `colSpan: 1` emits no class at all.'), + colSpan: z.number().int().min(1).max(4).optional().describe("Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all."), /** - * [#2578] Relative field width, authored instead of an absolute column span. - * Browser measurement at three surface widths found that 'full' compiles to - * a span of the section's declared column count, gated at the top breakpoint - * only — it is not a whole row at every column count. + * [#2578] Relative field width. 'full' resolves to the form grid's full + * column count (`plugin-form` `resolveColSpan`); which container-query tiers + * receive the span class is the form renderer's, not this key's. + * At the `.objectui-sha` pin `53ded82bf7` the renderer emits the widest + * tier's class only, so at intermediate widths the field renders identically + * to omitting the key (objectstack#17328). objectui#9253 (objectui + * `bd09957380`, 2026-09-12, ahead of that pin) emits one clamped class per + * multi-column tier, making 'full' the whole row at every multi-column tier + * — re-read this block at the pin bump that absorbs it. */ - span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': compiles to a span of the section's declared column count, gated at the top breakpoint only — the whole row at that breakpoint, and identical to omitting the key at every narrower width."), + span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (objectstack#17328, Chromium at 390/720/1700)."), /** Custom widget override — only needed when auto-inference is insufficient */ widget: z.string().optional().describe('Custom widget/component name (overrides type-based inference)'), From a3dd4bbd4a66186d1200f08ae7de06c3ce7e8bb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 16:57:03 +0000 Subject: [PATCH 4/6] docs(spec): keep the browser measurement, drop the issue id from the span describe `check:doc-authoring` reds an internal issue id in customer-facing `.describe()` prose: it projects into content/docs/references/** and the generated skill artifacts, whose readers have no tracker to resolve `#NNNN` against. The parenthetical now names the measurement (Chromium at 390, 720 and 1700) without the citation-shaped token; the anchor itself stays one line above, in the TSDoc, where the gate's own guidance puts an internal reference. Regenerated content/docs/references/ui/view.mdx to match. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- content/docs/references/ui/view.mdx | 4 ++-- packages/spec/src/ui/view.zod.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 72822507745..61152642de4 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -199,7 +199,7 @@ Column footer summary configuration | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (objectstack#17328, Chromium at 390/720/1700). | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (measured in Chromium at viewport widths 390, 720 and 1700). | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | @@ -364,7 +364,7 @@ View filter rule | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (objectstack#17328, Chromium at 390/720/1700). | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (measured in Chromium at viewport widths 390, 720 and 1700). | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 05c97cfd8e6..d10af15c98d 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2619,7 +2619,7 @@ const FormFieldBaseSchema = lazySchema(() => { * multi-column tier, making 'full' the whole row at every multi-column tier * — re-read this block at the pin bump that absorbs it. */ - span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (objectstack#17328, Chromium at 390/720/1700)."), + span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (measured in Chromium at viewport widths 390, 720 and 1700)."), /** Custom widget override — only needed when auto-inference is insufficient */ widget: z.string().optional().describe('Custom widget/component name (overrides type-based inference)'), From d3419679951c4e5e4528dff650548fd9b74c6a9f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 18:47:00 +0000 Subject: [PATCH 5/6] docs(spec): scope the span tier claim to one cell, not identity with omitting the key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clause "at intermediate widths the field rendered identically to omitting the key" is false at the `.objectui-sha` pin `53ded82bf7` in a reachable configuration. With a form grid of 3 and a section `columns: 2` (ObjectForm passes the per-section count), a plain field with `span` omitted resolves to an effective 2 and emits `@md:col-span-2` — two of two cells at the 720px modal width — while `span: 'full'` at the pin emits `@2xl:col-span-3` only, which is one of two cells at that width. Not identical, and 'full' is the narrower of the two. The identity holds only where 'auto' resolves to a single cell, which is the one 3-column section the original measurement covered; the parenthetical scoped the measurement widths, not the form configuration. Both carriers now state what is true at every reachable configuration — the field takes a single cell, not the row — and the changeset keeps the pixel-identity reading where it was actually measured. The prose ships inside the published package and generates the public reference table, so a rendering claim that is false anywhere reachable is the defect class this card removes. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- .changeset/17670-colspan-span-measured-behaviour.md | 2 +- packages/spec/src/ui/view.zod.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/17670-colspan-span-measured-behaviour.md b/.changeset/17670-colspan-span-measured-behaviour.md index 961e3e99ce9..8dccfd06431 100644 --- a/.changeset/17670-colspan-span-measured-behaviour.md +++ b/.changeset/17670-colspan-span-measured-behaviour.md @@ -7,7 +7,7 @@ docs(spec): `FormField.colSpan` and `FormField.span` describe their measured beh Both `.describe()` strings ship inside the published package (`src/**/*.zod.ts`, `dist`, `json-schema`) and they generate the public `content/docs/references/ui/view.mdx` tables, so what they assert is what every reader of the API reference — human or AI — is told the renderer does. Two of those assertions were measured false in Chromium at all three surface widths (#17328, `absolute-colspan-discouraged` withdrawn on the same evidence): - `colSpan` was described as "fragile … a fixed span only lines up at the width the author imagined". It is not. The renderer clamps the span to the form grid's column count, so the cell starts at a real column boundary at every width; rendered overflow was 0px in every configuration measured, including `colSpan: 4` in a 3-column section — the case that would overflow if the clamp did not work. The old text contradicted its own next sentence, which already stated the clamp. -- `span: 'full'` was described as "whole row at any column count". It is not. It resolves to the form grid's full column count, and at the `.objectui-sha` pin `53ded82bf7` the renderer emitted only the widest tier's class (`@2xl:col-span-3` for a 3-column grid — the identical class `colSpan: 4` emits), so at the 2-column modal width it rendered pixel-identical to authoring nothing at all. +- `span: 'full'` was described as "whole row at any column count". It is not. It resolves to the form grid's full column count, and at the `.objectui-sha` pin `53ded82bf7` the renderer emitted only the widest tier's class (`@2xl:col-span-3` for a 3-column grid — the identical class `colSpan: 4` emits), so at the 2-column modal width it took one cell of two, not the row — in the single 3-column section #17328 measured, pixel-identical to authoring nothing at all. Each key now states what it actually does. **The preference between the two keys is removed, not reversed** — `[legacy — prefer `span`]`, `Prefer `span`.` and `Prefer this over the absolute `colSpan`.` are gone, and nothing replaces them. Both spellings rest on the falsified claim, and the measurement puts the recommended one on the wrong side of it; the renderer question behind it — `span: 'full'` not spanning the row at intermediate container widths — was answered on the objectui side by objectui#9253 (commit `bd09957380`, 2026-09-12, part of objectui#9244), which emits one clamped col-span class per multi-column tier. That fix is unreleased at this repo's pin (`@object-ui/components` 17.6.0 at both, 0 tags contain the commit), so the text above anchors the pin state and this PR does not move `.objectui-sha`. diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index d10af15c98d..73b79894f1f 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2613,13 +2613,13 @@ const FormFieldBaseSchema = lazySchema(() => { * column count (`plugin-form` `resolveColSpan`); which container-query tiers * receive the span class is the form renderer's, not this key's. * At the `.objectui-sha` pin `53ded82bf7` the renderer emits the widest - * tier's class only, so at intermediate widths the field renders identically - * to omitting the key (objectstack#17328). objectui#9253 (objectui + * tier's class only, so at intermediate widths the field takes a single + * cell, not the row (objectstack#17328: one cell of two at 720px). objectui#9253 (objectui * `bd09957380`, 2026-09-12, ahead of that pin) emits one clamped class per * multi-column tier, making 'full' the whole row at every multi-column tier * — re-read this block at the pin bump that absorbs it. */ - span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (measured in Chromium at viewport widths 390, 720 and 1700)."), + span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field took a single cell, not the row (one of two at the 720px modal width; measured in Chromium at viewport widths 390, 720 and 1700)."), /** Custom widget override — only needed when auto-inference is insufficient */ widget: z.string().optional().describe('Custom widget/component name (overrides type-based inference)'), From 38c1c6ccfb7af6929d0fa2171880f4e178fc62f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 18:51:19 +0000 Subject: [PATCH 6/6] docs(spec): regenerate the ui/view reference for the corrected span clause `gen:docs` projection of the `span` describe, both FormField tables. No other generated artifact moved: `check:generated --fix` reported 1 of 15 stale and regenerated only content/docs/references/**. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude --- content/docs/references/ui/view.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 61152642de4..f56a40122a0 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -199,7 +199,7 @@ Column footer summary configuration | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (measured in Chromium at viewport widths 390, 720 and 1700). | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field took a single cell, not the row (one of two at the 720px modal width; measured in Chromium at viewport widths 390, 720 and 1700). | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields | @@ -364,7 +364,7 @@ View filter rule | **required** | `boolean` | optional | Required override | | **hidden** | `boolean` | optional | Hidden override | | **colSpan** | `integer` | optional | Absolute column span (1-4). The renderer clamps it to the form grid's current column count, so the cell starts at a real column boundary at every surface width and never overflows (`colSpan: 4` in a 3-column grid renders as 3); a `colSpan` within the column count renders as authored, and `colSpan: 1` emits no span class at all. | -| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field rendered identically to omitting the key (measured in Chromium at viewport widths 390, 720 and 1700). | +| **span** | `Enum<'auto' \| 'full'>` | optional (default: `"auto"`) | Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': resolves to the form grid's full column count. How far down the container-query tiers that span is emitted is the renderer's, not this key's: at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field took a single cell, not the row (one of two at the 720px modal width; measured in Chromium at viewport widths 390, 720 and 1700). | | **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) | | **language** | `string` | optional | Code editor language (for type=code) | | **keyField** | `{ field?: string; label?: string \| Record; placeholder?: string \| Record; helpText?: string \| Record; … }` | optional | Key column config for record-typed fields |