You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit c7448dc
Browse filesBrowse the repository at this point in the historyBrowse files
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>
`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.
|**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). |
203
203
|**widget**|`string`| optional | Custom widget/component name (overrides type-based inference) |
204
204
|**language**|`string`| optional | Code editor language (for type=code) |
|**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). |
368
368
|**widget**|`string`| optional | Custom widget/component name (overrides type-based inference) |
369
369
|**language**|`string`| optional | Code editor language (for type=code) |
* multi-column tier, making 'full' the whole row at every multi-column tier
2923
2923
* — re-read this block at the pin bump that absorbs it.
2924
2924
*/
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)."),
2926
2926
2927
2927
/** Custom widget override — only needed when auto-inference is insufficient */
2928
2928
widget: z.string().optional().describe('Custom widget/component name (overrides type-based inference)'),
0 commit comments