Skip to content

Commit c7448dc

Browse files
os-steveclaude
andauthored
docs(spec): the span auto clause names the field types the form renderer really widens (#19253)
Fixes #18516 Clause-②: no ## The defect `packages/spec/src/ui/view.zod.ts` — `FormField.span`, the `'auto'` clause — shipped this sentence to authors: > wide widgets **"like textarea/richtext/json/file/subform take the whole row"** Three of those five names are wrong, and two types that really are wide went unnamed. An author reads that sentence to decide how to lay out a form. ## Re-derived on this tree — not inherited from the card Both falsifications were re-measured here against objectui at the `.objectui-sha` pin `53ded82bf7`, by **executing** that tree's own code: `git show PIN:packages/plugin-form/src/autoLayout.ts` verbatim, plus lines 2472-2521 of `git show PIN:packages/components/src/renderers/form/form.tsx` (`pickSpanClass`, extracted verbatim by line range, 50 lines, sha256 recorded) wrapped in a driver that supplies the `containerClass` the pin's own `CONTAINER_GRID_COLS` produces. ### 1. `WIDE_FIELD_TYPES` is ten entries, and the three named types are in none of them `packages/plugin-form/src/autoLayout.ts:57` at the pin, exactly: ``` field:textarea field:markdown field:html field:grid field:richtext textarea markdown html grid richtext ``` The `plugin-detail` twin at `autoLayout.ts:53` carries the same ten in a different order. Executed — `span: 'auto'` (key omitted), 3-column grid, at the pin: **Swept along the RUNTIME path** — `mapFieldTypeToFormType` → `isWideFieldType` → `resolveColSpan` → `pickSpanClass`. ⚠️ The BARE path (`isWideFieldType(specName)`, which is what objectui's own pin test measures) answers four; every site assigning `FormField.type` at the pin maps first, and `repeater` reaches the wide widget only along the mapped path. That difference is what round 1 missed. | type | `isWideFieldType` | `resolveColSpan` | class emitted | |---|---|---|---| | `textarea` | true | 3 | `@2xl:col-span-3` | | `markdown` | true | 3 | `@2xl:col-span-3` | | `html` | true | 3 | `@2xl:col-span-3` | | `richtext` | true | 3 | `@2xl:col-span-3` | | `json` | **false** | **1** | *(none)* | | `file` | **false** | **1** | *(none)* | | `subform` | **false** | **1** | *(none)* | | `text` — narrow control | false | 1 | *(none)* | | `repeater` | **true** — maps to `field:grid` | **3** | `@2xl:col-span-3` | | `field:textarea` — widget-id control | true | 3 | `@2xl:col-span-3` | Swept across the whole `FieldType` vocabulary (**49** members, two instruments agreeing — the executed `FieldType.options` and the enum source tokens with comments stripped), the runtime-path wide set is **exactly five**: `textarea`, `markdown`, `html`, `richtext` and `repeater`. ⚠️ objectui's own pin test asserts the **bare-path four**, and that assertion is true of the bare path — it is not the path an authored field takes. **Every zero reading carries a firing control.** The three full-row decision sites at the pin are `plugin-form/src/autoLayout.ts`, `plugin-detail/src/autoLayout.ts` and `app-shell/.../studio-design/formFieldType.ts`. Searching them for `json` / `file` / `subform` as type literals: **0** in each. Control `textarea` in the same three files: **2 / 2 / 0** — the third is a mapper, not a decision site, which is why it reads zero on the control too. Wider control: `col-span-full` under `packages/*/src/*` at the pin is **15** hits (section dividers in `form.tsx:61`, dashboard wrappers, and the designer canvas) — the instrument fires, and none of the 15 is a field-type path for any of the three names. ### 2. The tier claim, and why the fix must not flip its sign `resolveColSpan` returns a **cell count**, not a class. The class comes from `pickSpanClass`, which picks the first breakpoint whose column count reaches the target: ``` CONTAINER_GRID_COLS[3] = 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2XL:grid-cols-3' matches = [ @md -> 2, @2XL -> 3 ] find(cols >= 3) -> @2XL => '@2XL:col-span-3' ``` So at the `@md` tier the grid is two columns and the wide field carries **no span class at all** — one cell of two, not the row. Probe row, executed: `textarea`, `span: 'auto'`, 3-column grid, at the pin, class `@2xl:col-span-3`, 1 of 2 at `@md`. A replacement sentence promising a whole row at every tier would be exactly as false as the one being replaced, so the clause states the resolved **column count** and leaves the tier sentence beside it to say how far down the tiers that span is emitted. ## What the clause says now Before: > 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) … at the `.objectui-sha` pin `53ded82bf7` only the widest tier's class is emitted … After: > Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count — at the pin this repo builds against (`.objectui-sha` = `53ded82bf7`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). '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 that same pin only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field takes a single cell, not the row (one of two at the 720px modal width; measured in Chromium at viewport widths 390, 720 and 1700). One `.describe()` string, one line of diff in `src`. ## The guard — #18508's asserting spelling, and why it applies The card notes this sentence goes stale at the bump absorbing objectui `bd09957380`, which emits one clamped class per multi-column tier. `check:objectui-pin-citations` (#18508) distinguishes two spellings: an **asserting** citation (`` `.objectui-sha` = `SHA` ``) claims *this is the pin we build against* and is compared against the pin file, while a **historical** one (`` `.objectui-sha` pin `SHA` ``) is a dated record and is never checked. The clause was carrying the historical spelling while making a claim about today — that is precisely the silent-rot state the gate exists to end — so the citation moves to the asserting form, and the two pin-sensitive halves (the wide-type list and the tier behaviour) now sit under one citation, the second referring to it as "that same pin" so there is one citation, not two. **Reverse-verified, both legs, fix committed first, mutation proven on disk by `scripts/ablation-replace.mjs`:** | leg | mutation | predicted | observed | |---|---|---|---| | guard | asserting citation's sha to `1a2b3c4d5e6` | RED | **RED**, exit 1, `packages/spec/src/ui/view.zod.ts:2925 cites 1a2b3c4d5e6` | | control | same wrong sha, in the **historical** spelling | GREEN — the old spelling buys no guard | **GREEN**, exit 0, 26 asserting / 46 historical | The control leg is the argument for the spelling change: with a plainly wrong sha, the spelling this clause used to carry passes. Both legs restored under a `trap`, each restore proven by blob hash (`cd5a238e1930` == HEAD) with `git diff HEAD` empty. The gate reads `packages/spec/src` directly, so `dist` is not on its path and no dist preflight applies. Gate after the change: **27 asserting** citations match the pin (was 26), **45 historical** (was 46) — exactly the one citation converted, nothing else moved. ## Population the one string moves — re-derived The card measured `src` 1 · `dist` 30 · `json-schema` 12 · docs 2 — ⚠️ that last figure is a **file** count. Re-derived on this tree for the **new** string, with the unit stated: `src` **1 file / 1 occurrence** · `dist` **30 / 30** · `json-schema` **12 files / 22 occurrences** · `content/docs/references/ui/view.mdx` **2 occurrences**. The retired wording is gone from the tree with one deliberate exception: the changeset **quotes the original sentence**, which is a historical record by construction. ⛔ The earlier phrasing of this line — that zero occurrences remain anywhere outside `.git` — is withdrawn as over-claimed. `check:generated` proved exactly one artifact stale (`content/docs/references/**`) and `--fix` regenerated only that one; `check:authorable-surface`, `check:api-surface` and `check:api-surface-declarations` all stayed green — the last because it excludes leading TSDoc by design, so a re-worded describe does not churn it. ## Changeset — owed, and why `@objectstack/spec` ships `dist`, `json-schema` **and** `src/**/*.zod.ts` in its `files[]`, so all three of the moved populations are published bytes. A `patch` changeset is owed and is in this PR; `skip-changeset` would be wrong here. ## Verification - `pnpm --filter @objectstack/spec build` — green (under the shared verify lock). - `pnpm --filter @objectstack/spec typecheck` — green. - `pnpm --filter @objectstack/spec test` — **500 files / 14632 tests passed**. - `pnpm --filter @objectstack/spec check:generated` — green after regenerating the one stale artifact. - Gate families from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: **100 derived, 94 run green, 6 NOT MEASURED, 0 unrun** — reconciled with `--ran` (exit 0), every line carrying its exit code captured before any pipe. - The 6 NOT MEASURED all refused with their own stated prerequisite — they read **built** output of packages outside this change's dependency closure (`@objectstack/formula`, `@objectstack/lint`, `@objectstack/client-react`, `@objectstack/objectql`, and in one case most of the monorepo). `packages/spec` has no workspace dependencies, so the closure build is an empty run and those builds are CI's farm: `pnpm --filter @objectstack/lint run check:doc-formula-expressions` (exit 3), `check:doc-security-posture` (exit 3), `pnpm --filter @objectstack/spec run check:skill-examples` (exit 1, needs `client-react` built), `pnpm check:docs-transcript-drift` (exit 3), `pnpm check:dual-build-cjs-loads` (exit 3), `pnpm check:lean-entry-closure` (exit 3). Exit 3 is this repo's "nothing was measured" code — neither a pass nor a finding. ## Scope Triage's fence, verbatim and binding: > ⚠️ 边界:改的是**那一句**。⛔ 不要顺手去改渲染器的 `WIDE_FIELD_TYPES` —— 那是扩大接受集,是另一类改动、另一张卡。 `WIDE_FIELD_TYPES` and every other objectui file were **read only** — no renderer behaviour is touched, and nothing here widens an accept set. No key, default, enum member or exported name moves: the same authored metadata is accepted and refused before and after, which is what `Clause-②: no` declares. ## Acceptance notes - `noted, not filed` — the docblock above `span` (`view.zod.ts` ~2914-2924) carries its own citation of the same pin in the historical spelling plus the prose reminder *"re-read this block at the pin bump that absorbs it"*. It states nothing false today and is not an authored-metadata trap, so it is not one of the three filing classes. It is left untouched deliberately: the describe's asserting citation now reds on the adjacent line at the next bump, and whoever re-reads it has the docblock in front of them. Successor: the pin-bump PR that absorbs objectui `bd09957380`. - `noted, not filed` — the two `WIDE_FIELD_TYPES` sets in objectui (`plugin-form` and `plugin-detail`) are maintained as byte-equal twins by hand, with no cross-repo pin holding them equal; `richtextSurfaceParity.test.tsx` compares the two exported predicates rather than the sets. Nothing is wrong today. Successor: none in this repo — it is an objectui-side observation, and this card's fence forbids touching those files. <sub>Body edited by the dispatching seat `domain:spec#4` (`session_01AmH9bKvGoLjiY86Q4Z3og2`) after the rework landed at `6a34e6f67a22478036bbe674a7089a5b981192bf`. The contract review of record on the previous head (`cee5969dee`) returned FAIL on one finding — the clause was false in the other direction, because `repeater` maps to the wide `field:grid` widget — and the sections above are updated to the runtime-path measurement. Two counts the review corrected are fixed here: the `FieldType` population is **49**, not 55, and the json-schema figure is **12 files / 22 occurrences**, not 12 occurrences. The round-1 claim that no occurrence of the retired wording remains anywhere is withdrawn: the changeset quotes the original sentence, by construction. The implementer named all of these edits in its report and correctly declined to patch this body itself — a dev writes a PR body once, at the POST that opens it.</sub> --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0f42d36 commit c7448dc

3 files changed

Lines changed: 25 additions & 3 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
`FormField.span`'s `'auto'` description now names the field types the form renderer actually widens, instead of three that it does not (#18516).
6+
7+
`Clause-②: no`
8+
9+
The clause told authors that wide widgets *"like textarea/richtext/json/file/subform take the whole row"*. Three of those five names were wrong and two real ones were missing. Re-measured against objectui at the `.objectui-sha` pin `53ded82bf7` by executing that tree's own `mapFieldTypeToFormType`, `isWideFieldType` and `resolveColSpan` over the whole `FieldType` population (**49** members, agreed by two instruments — the executed `.options` and the enum's source tokens with comments stripped):
10+
11+
- `WIDE_FIELD_TYPES` is **ten** entries — `textarea` / `markdown` / `html` / `grid` / `richtext`, each bare and `field:`-prefixed — in `plugin-form/src/autoLayout.ts` and again in its `plugin-detail` twin.
12+
- `json` and `file` **are** spec field types, and both resolve to **one cell**, not the row (`json` maps to `field:code`, `file` to `field:file`). `subform` is not a spec field type at all.
13+
- `markdown` and `html` **are** widened, and the sentence named neither.
14+
- The set that resolves to the full column count is **five**: `textarea`, `markdown`, `html`, `richtext` and **`repeater`**. The measurement has to follow the path the form actually walks — every site that assigns `FormField.type` maps the spec name through `mapFieldTypeToFormType` first — and on that path `repeater` becomes `field:grid`, which is a member of `WIDE_FIELD_TYPES`. Measured only on the bare spec name the set is the four long-form types, which is what objectui's own pin asserts at that sha ("its spec-facing surface is EXACTLY the long-form family"); that reading is true of the bare path and is not the one an author's field takes. The literal `grid` stays unnamed because it is an objectui-local metadata key rather than a `FieldType`, so `type: 'grid'` is refused — but `repeater` is the spec spelling that reaches the same widget, and it is accepted.
15+
16+
An author reads that sentence to decide a form layout, so the cost of a wrong name is a layout decided on a type that behaves the opposite way — in either direction.
17+
18+
What the clause says now: those five resolve to the full **column count** — the number `resolveColSpan` really returns — leaving the sentence beside it to state how far down the container-query tiers that span is emitted. At this pin only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid, whose container class is `grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3`), so a wide field is one cell of two at the `@md` tier. Promising a whole row at every tier would be the same defect with its sign flipped.
19+
20+
Both readings are of one pin, so the citation moves from the historical spelling to the **asserting** one (`` `.objectui-sha` = `<sha>` ``): `check:objectui-pin-citations` compares an asserting citation against the pin file, so the next pin bump reds on this sentence and it cannot rot silently. That matters here — objectui `bd09957380` is already ahead of the pin and emits one clamped class per multi-column tier, which makes this sentence's tier half wrong the moment a bump absorbs it.
21+
22+
No key, default, enum member or export moves: the same authored metadata is accepted and refused as before.

‎content/docs/references/ui/view.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ Column footer summary configuration
199199
| **required** | `boolean` | optional | Required override |
200200
| **hidden** | `boolean` | optional | Hidden override |
201201
| **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. |
202-
| **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). |
202+
| **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 — at the pin this repo builds against (`.objectui-sha` = `53ded82bf7`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). '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 that same pin only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field takes a single cell, not the row (one of two at the 720px modal width; measured in Chromium at viewport widths 390, 720 and 1700). |
203203
| **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) |
204204
| **language** | `string` | optional | Code editor language (for type=code) |
205205
| **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 |
@@ -364,7 +364,7 @@ View filter rule
364364
| **required** | `boolean` | optional | Required override |
365365
| **hidden** | `boolean` | optional | Hidden override |
366366
| **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. |
367-
| **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). |
367+
| **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 — at the pin this repo builds against (`.objectui-sha` = `53ded82bf7`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). '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 that same pin only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field takes a single cell, not the row (one of two at the 720px modal width; measured in Chromium at viewport widths 390, 720 and 1700). |
368368
| **widget** | `string` | optional | Custom widget/component name (overrides type-based inference) |
369369
| **language** | `string` | optional | Code editor language (for type=code) |
370370
| **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 |

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2922,7 +2922,7 @@ const FormFieldBaseSchema = lazySchema(() => {
29222922
* multi-column tier, making 'full' the whole row at every multi-column tier
29232923
* — re-read this block at the pin bump that absorbs it.
29242924
*/
2925-
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)."),
2925+
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 — at the pin this repo builds against (`.objectui-sha` = `53ded82bf7`), only textarea, markdown, html, richtext and repeater resolve to the full column count (repeater reaches it through the wide `field:grid` widget it maps to). '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 that same pin only the widest tier's class is emitted (`@2xl:col-span-3` for a 3-column grid), so at intermediate widths the field takes a single cell, not the row (one of two at the 720px modal width; measured in Chromium at viewport widths 390, 720 and 1700)."),
29262926

29272927
/** Custom widget override — only needed when auto-inference is insufficient */
29282928
widget: z.string().optional().describe('Custom widget/component name (overrides type-based inference)'),

0 commit comments

Comments
 (0)