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
14 changes: 14 additions & 0 deletions .changeset/17670-colspan-span-measured-behaviour.md
Original file line number Diff line number Diff line change
@@ -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 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 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`.

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.
12 changes: 8 additions & 4 deletions content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</Callout>
Expand Down Expand Up @@ -254,9 +254,13 @@ 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` 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 */}
```yaml
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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<string, string>; placeholder?: string \| Record<string, string>; helpText?: string \| Record<string, string>; … }` | optional | Key column config for record-typed fields |
Expand Down Expand Up @@ -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 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 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<string, string>; placeholder?: string \| Record<string, string>; helpText?: string \| Record<string, string>; … }` | optional | Key column config for record-typed fields |
Expand Down
6 changes: 3 additions & 3 deletions content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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', // Resolves to the form grid's full column count
visibleWhen: "record.status != 'cancelled'",
},
]
Expand All @@ -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'` 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 |
| `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) |
Expand Down
15 changes: 11 additions & 4 deletions packages/spec/src/ui/view.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2607,12 +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('[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 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 — decoupled from the (often auto-derived)
* column count, so it stays correct at 1/2/3/4 columns.
* [#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 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': 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': 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)'),
Expand Down
Loading