Skip to content

docs(spec): the span auto clause names the field types the form renderer really widens - #19253

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-18516-span-auto-describe
Sep 20, 2026
Merged

os-steve merged 4 commits into
mainfrom
claude/issue-18516-span-auto-describe

Conversation

@os-steve

@os-steve os-steve commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

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.

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.


Generated by Claude Code

…ally widens

`FormField.span`'s `'auto'` describe told authors that "wide widgets like
textarea/richtext/json/file/subform take the whole row". Re-measured against
objectui at the pin this repo builds against: `WIDE_FIELD_TYPES` is ten
entries (`textarea`/`markdown`/`html`/`grid`/`richtext`, bare and
`field:`-prefixed), so `json` and `file` are spec field types that are NOT
wide, `subform` is not a spec field type at all, and `markdown`/`html` — two
that ARE wide — went unnamed. Three of the five names were wrong.

The clause now names the long-form family the renderer actually widens and
says it resolves to the full COLUMN COUNT, leaving the tier sentence beside
it to state how far down the container-query tiers that span is emitted. The
pin citation moves from the historical spelling to the asserting one, so
`check:objectui-pin-citations` reds at the next pin bump and the sentence
cannot rot silently.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
`pnpm --filter @objectstack/spec check:generated` proved exactly one artifact
stale (`content/docs/references/**`); `gen:docs` moved the two `span` rows in
`content/docs/references/ui/view.mdx`. The string's full population re-derived
on this tree: src 1 · dist 30 · json-schema 12 · docs 2, and zero occurrences
of the retired wording remain.

`dist` and `json-schema` are gitignored but both sit in the package's `files[]`,
so published bytes move and a `patch` changeset is owed.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation protocol:ui tooling labels Sep 20, 2026
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7056ca50d1d94c596fc7d56a58e758b5339062e5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ed16912ba16318c966e5ce69b6f436aa536c63ce — the merge of head 6a34e6f67a22478036bbe674a7089a5b981192bf into base 7056ca50d1d94c596fc7d56a58e758b5339062e5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ed16912ba16318c966e5ce69b6f436aa536c63ce && git checkout ed16912ba16318c966e5ce69b6f436aa536c63ce
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7056ca50d1d94c596fc7d56a58e758b5339062e5 6a34e6f67a22478036bbe674a7089a5b981192bf && git checkout -B drift-repro 7056ca50d1d94c596fc7d56a58e758b5339062e5 && git merge --no-ff 6a34e6f67a22478036bbe674a7089a5b981192bf

node scripts/docs-audit/affected-docs.mjs --json 7056ca50d1d94c596fc7d56a58e758b5339062e5

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Copy link
Copy Markdown
Collaborator Author

Contract review

Reviewed at 2026-09-20T05:43Z by the isolated at-tier review subagent for the domain:spec lane, against primary sources only: the PR diff at its head, ../objectui at the .objectui-sha pin, and the repo's own gates. Nothing below is taken from the PR body or the implementer's report; where their numbers are mentioned it is to say whether mine reproduce them.

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cee5969dee6f732c910099282107d981f8e1f8a1

Trees the readings were taken against: objectstack head cee5969dee6f732c910099282107d981f8e1f8a1 (base 1b82c519df9db93891514b814b4d8e16e42d5d5c = origin/main), detached worktree; objectui pin 53ded82bf7a494f54e344e19099dbf00854b8694 (the exact content of .objectui-sha at the head), detached worktree. Every exit code below was captured before any pipe.

① Derived judgments

J1 — BLOCKING. The new 'auto' clause is false in the other direction at the pin: repeater also resolves to the full column count.

The sentence under review asserts that at the pin "only the long-form types (textarea, markdown, html, richtext) resolve to the full column count". I re-derived the wide set by executing the pin's own code over every member of the spec's FieldType enum, along the path the runtime form actually takes:

  • Population: FieldType.options executed from packages/spec/src/data/field.zod.ts at the head = 49 members (second instrument, token count of the enum literal in source text = 49, 49 unique). Membership: json yes, file yes, repeater yes, subform no, grid no.
  • Instruments, all read out of the objectui worktree at the pin and executed unmodified: plugin-form/src/autoLayout.ts (isWideFieldType, resolveColSpan, CONTAINER_GRID_COLS), plugin-detail/src/autoLayout.ts (isWideFieldType), fields/src/field-type-alias.ts (mapFieldTypeToFormType, with only its @object-ui/core tombstone-reporter import stubbed to false), and components/src/renderers/form/form.tsx:2472-2521 (pickSpanClass, extracted verbatim by line range, sha256 917a686a09818e3d…).
  • Bare path — isWideFieldType(specName) — wide set at grid 3: {html, markdown, richtext, textarea}. This is what objectui's own pin test (autoLayout.wideSpelling.test.ts, "its spec-facing surface is EXACTLY the long-form family") measures, and it is what the implementer measured. It reproduces.
  • Runtime path — isWideFieldType(mapFieldTypeToFormType(specName)) — wide set at grid 3: {html, markdown, repeater, richtext, textarea}. repeater maps to field:grid (field-type-alias.ts:102 at the pin), field:grid is a member of WIDE_FIELD_TYPES (autoLayout.ts:61), so resolveColSpan({type: 'field:grid'}, 3) = 3, at grid 4 = 4, class emitted @2xl:col-span-3. The detail twin agrees (plugin-detail isWideFieldType('field:grid') = true). With multiple: true the set is unchanged (the only arity move is select to field:multiselect).
  • The runtime path is the one the form walks, not a hypothetical: every non-test site that assigns FormField.type at the pin goes through the mapper first — plugin-form/src/ObjectForm.tsx:732, plugin-form/src/sectionFields.ts:122 (fromObjectSchema) and sectionFields.ts:207 (an authored form-row type override, rawType, is mapped too) — and the mapped list is what applyAutoColSpan receives (ObjectForm.tsx:1330, ModalForm.tsx:670/765, DrawerForm.tsx:665, SplitForm.tsx:398, TabbedForm.tsx:475, WizardForm.tsx:1045). field:grid is a registered, rendered form widget (fields/src/index.tsx:2794, GridField), and nothing in plugin-form filters it out of a form: the only form-side exclusions are formula/summary/autonumber and the system field names.
  • repeater is authorable on this key: FormFieldSchema.safeParse({ field: 'a', type: 'repeater' }) at the head is ACCEPTED (span defaults to auto). So an author who reads the new sentence and lays out { field: 'items', type: 'repeater' } expecting one cell gets the whole column count at the pin — the card's defect class with the sign flipped, which is exactly what this card exists to end.
  • The pin says this itself in prose, in a file the implementer counted: app-shell/src/views/studio-design/formFieldType.ts:36-40 at the pin — "a repeater (a spec FieldType that resolves to the WIDE field:grid widget) matched nothing: the canvas laid a repeater out at normal width while the runtime form spanned it full-row. Any future wide widget whose spec name differs from its widget id falls in the same hole." The PR's reasoning that grid "is unwritable on this key" is right for the literal grid (type: 'grid' is REFUSED at the head) and does not carry to repeater, which is the spec spelling that reaches the same widget.

Controls for the zeros in this section: json maps to field:code and file to field:file, both resolveColSpan = 1 at grid 3 and 4, no class emitted; subform is not a FieldType, maps through the field:text tail, = 1; text = 1; firing control textarea = 3. Re-taking the implementer's three-site literal counts at the pin: json/file/subform = 0/0/0 in plugin-form/src/autoLayout.ts, plugin-detail/src/autoLayout.ts, app-shell/.../formFieldType.ts; textarea = 2/2/0 in the same files — that shape reproduces — and repeater = 0/0/2, grid = 2/2/0 in the same three files, which is the hole: the third file names the type that the first two widen under another spelling. subform as a whole word across packages/*/src (ts/tsx, tests excluded) at the pin = 4 hits, all comments (MetadataProvider.tsx:489, data-objectstack/src/index.ts:3420, GridField.tsx:71, :1212); firing control richtext in the same radius = 35 files.

What this does and does not say about the fix: the sentence needs to name repeater beside the four long-form types (or to stop saying "only"), and the sweep behind it must run along the runtime path (mapFieldTypeToFormType first). It needs no change to WIDE_FIELD_TYPES or any renderer file — triage's fence holds either way. I did not repair it.

J2 — PASS. The tier sentence is true at the pin and does not over-claim. Executed: CONTAINER_GRID_COLS[3] = grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3; pickSpanClass(3) against it = @2xl:col-span-3 and nothing else; so at the @md tier the grid is two columns and a wide field carries no span class — one cell of two. Grid 2 emits @md:col-span-2, grid 4 emits @4xl:col-span-4 only (one of two at @md, one of three at @2xl), so "only the widest tier's class is emitted" is the general rule, not just the 3-column example. span: 'full' on a field:text at grid 3 = 3, @2xl:col-span-3 — same emission. The viewport numbers (390/720/1700, "one of two at the 720px modal width") are inherited text unchanged by this PR; I verified them against the code rather than a browser: the default modal is sm:max-w-lg (32rem = 512px, ModalForm.tsx:205), Tailwind is v4 (^4.2.1/^4.3.3) with no container-breakpoint override in the theme css, so the @2xl container tier (42rem = 672px) cannot engage inside the default modal and the @md tier (28rem = 448px) governs — one of two. This is the understatement direction at worst; nothing here over-claims. The forward-looking claim in the changeset about objectui bd09957380 matches that commit's own title at objectui: "fix(form): emit one col-span class per breakpoint tier, not one for the widest (#9253)".

J3 — PASS. The pin guard guards; the control leg proves the conversion bought something. check-objectui-pin-citations.ts --self-test exit 0. Baseline at the head: exit 0, "27 asserting objectui pin citation(s) match .objectui-sha (53ded82bf), 45 historical citation(s) recorded and not checked, across 1483 spec source(s)". Guard leg: the describe's asserting sha mutated to 1a2b3c4d5e6 (1 occurrence replaced, blob 44e35eb24abc…) — exit 1, "packages/spec/src/ui/view.zod.ts:2925 cites 1a2b3c4d5e6". Control leg: the same wrong sha in the historical spelling (blob 2f032b56dba4…) — exit 0, 26 asserting / 46 historical. Base-tree leg: the file as it is on main (65853f52f3…) — exit 0, 26 / 46. Each restore proven by blob hash cd5a238e193036044a5e07c7859ce09ff5fe3cce == HEAD:packages/spec/src/ui/view.zod.ts, git status --porcelain empty. The implementer's 27/45 (was 26/46) and both ablation readings reproduce exactly.

J4 — PASS. Scope held; Clause-②: no holds; the changeset is right. The diff touches three files and no objectui file (GitHub get_files: .changeset/18516-span-auto-wide-types.md +22, content/docs/references/ui/view.mdx +2/−2, packages/spec/src/ui/view.zod.ts +1/−1). view.zod.ts with every .describe(…) stripped is byte-identical between base and head (312871 bytes each), so the runtime schema cannot have moved. Executed on the same eleven authored rows before (base file swapped in, blob 65853f52f3…) and after (head): span omitted → accepted, auto; 'auto'/'full' → accepted; 'wide'/1 → refused invalid_value; type: 'repeater'/'textarea'/'json' → accepted; type: 'subform'/'grid' → refused; colSpan: 5 → refused too_big — the two tables are identical. check-clause2-carriers.mjs --pair 19253 reads the declaration no on both carriers and reports C6 only (no review of record on this head; exit 4 before this comment); the C5 widening tells are silent. On the changeset: @objectstack/spec files[] at the head is dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, api-surface-declarations, spec-changes.json — so the one string moves published bytes on three of them (measured after a fresh build: src 1, dist 30 occurrences in 30 lines, json-schema see note N1, docs 2). A patch changeset is owed; naming it as a third path beyond the claim's literal file surface, rather than treating it as a breach, is the right call. check-adr-0087-registration and check-empty-changeset readings are in N3.

J5 — PASS. The generated page matches its source. Both span cells in content/docs/references/ui/view.mdx (lines 202 and 367) are byte-equal to the describe string in view.zod.ts:2925 (709 characters, compared after unescaping the table's \|). Independently of that text comparison: pnpm --filter @objectstack/spec build (exit 0) and then pnpm --filter @objectstack/spec check:generated (exit 0) in the detached worktree — "All 16 generated artifacts are up to date", check:docs (content/docs/references/**), check:authorable-surface, check:api-surface and check:api-surface-declarations among them. Retired wording at the head tree, grep -rnF excluding .git, node_modules, dist, .turbo: json/file/subform = 1 line, take the whole row = 1 line, both the changeset's own quotation of the old sentence (.changeset/18516-span-auto-wide-types.md:9); firing control, the new wording = 3 hits (source + the two mdx cells); the same sweep on the base tree finds the retired wording in view.zod.ts:2925 and view.mdx:202/367. In the freshly built dist and json-schema the retired wording = 0 with the new wording as the firing control (30 and 22 lines respectively).

② Semver level

patch on @objectstack/spec, Clause-②: no — consistent with the diff (one describe string; no key, default, enum member or export moves) and with the accept/refuse tables above. No ADR-0087 marker is owed because nothing breaks. The level would be unchanged by the J1 fix, which is also prose only.

③ Boundary flags

  • Implementer flag "changeset is a third path beyond the claim's literal surface" — answered in J4: right, and owed.
  • Implementer note "docblock above span keeps the historical spelling" — agreed, not a filing class; the asserting citation on the adjacent line now reds at the bump.
  • Implementer note "objectui's two WIDE_FIELD_TYPES twins are hand-kept" — an objectui observation outside this repo; nothing owed here.

Noted (not blocking):

  • N1 — the implementer reports the FieldType population as 55; it is 49 at this head by two instruments (executed .options; source token count), and field.zod.ts is identical between the head and the base. The bare-path set they derived is still right; the population number is not. The json-schema population reads 22 lines / 22 occurrences in 12 files here versus the reported 12 — a per-file versus per-line instrument difference; the retired wording is 0 there either way.
  • N2 — "zero occurrences of the retired wording remain anywhere outside .git" is over-claimed by the changeset's own quotation of it (line 9). A changeset is a historical record by construction (the pin-citations gate's header classifies .changeset/*.md that way), so this is not a live claim; but the card's enumeration instrument is a grep for that literal, and objectui's own pin test deliberately declines to quote it for that reason.
  • N3 — changeset gates, for the record: node scripts/check-adr-0087-registration.mjs exit 0 ("this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)"); node scripts/check-empty-changeset.mjs exit 0 ("1 declaring changeset(s) added", none from the merge base modified or deleted). Nothing owed.

Implemented-by: claude/issue-18516-span-auto-describe
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: FAIL

One BLOCKING finding (J1); J2 through J5 pass and are stated plainly as such. What unblocks: the 'auto' clause re-derived along the runtime path (mapFieldTypeToFormType then isWideFieldType) at the pin, naming repeater beside the four long-form types or dropping "only", with the generated page regenerated and the same asserting citation kept — no renderer file and no accept set touched. A fresh review of record is owed on the new head.


Generated by Claude Code

…ime path, not the bare one

The contract review on #19253 found the replacement clause false in the other
direction: it said ONLY textarea/markdown/html/richtext resolve to the full
column count, and `repeater` does too.

Re-swept along the path the form actually walks — `mapFieldTypeToFormType`
then `isWideFieldType` then `resolveColSpan`, every instrument read out of
objectui at the pin — over the whole `FieldType` population (49 members, two
instruments agreeing). The runtime-path set is five: textarea, markdown, html,
richtext and repeater. `repeater` maps to `field:grid`, which is a member of
`WIDE_FIELD_TYPES`, so it resolves to 3 cells at grid 3 and 4 at grid 4 and
emits `@2xl:col-span-3`. The first sweep took the BARE path
(`isWideFieldType(specName)`), which is also what objectui's own pin test
measures; that reading is true of the bare path and is not the one an author's
field takes.

Controls in the same run: json maps to `field:code` and file to `field:file`,
both one cell; composite/record map to `field:object`, one cell; `subform` is
not a `FieldType` member at all. Delta vs the bare path is exactly
`["repeater"]`.

No renderer file, no objectui file and no accept set is touched, and the
asserting pin citation is kept exactly as it was.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>
`check:generated` proved one artifact stale and `--fix` regenerated only it;
the re-check then reads "All 16 generated artifacts are up to date".

Population of the corrected string, both instruments: src 1 file / 1
occurrence, dist 30 / 30, json-schema 12 files / 22 occurrences, docs 2.
The round-1 wording is gone from the tree entirely; the only hit for the
ORIGINAL sentence is the changeset's own quotation of it, which is a
historical record by construction.

`check:objectui-pin-citations` still reads 27 asserting / 45 historical —
the asserting citation this rework was told to keep is untouched.

Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

Reviewed at 2026-09-20T06:27Z — round 2, the narrow re-review of the rework — by the isolated at-tier review subagent for the domain:spec lane, against primary sources only: the PR diff at its head, ../objectui read by git show at the .objectui-sha pin, and the repo's own gates. Nothing below is taken from the PR body or from either implementer report; where their numbers appear it is to say whether mine reproduce them. Items 2–5 of round 1 are closed by the seat and were not re-litigated; what follows is the delta plus a regression check on the new head.

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a34e6f67a22478036bbe674a7089a5b981192bf

Trees the readings were taken against: objectstack head 6a34e6f67a22478036bbe674a7089a5b981192bf (base 1b82c519df9db93891514b814b4d8e16e42d5d5c = merge-base with origin/main), detached checkout; objectui pin 53ded82bf7a494f54e344e19099dbf00854b8694 — the exact content of .objectui-sha at both base and head — read file-by-file with git show at that sha and every extracted file proven by blob id, never a checkout of a moving ref. Every exit code below was captured before any pipe.

① Derived judgments

J1 — PASS. The five-member set is complete and correct along the runtime path at the pin. The round-1 defect was one missed member under a closed-set framing; the rework kept the framing, so I re-derived the set over the whole population rather than checking the one name.

  • Population swept: all 49 FieldType members at the head (J3 says how the number is known). json, file, repeater are members; subform, grid, owner are not.
  • Instruments, each extracted verbatim from objectui at the pin and its blob id checked against git rev-parse PIN:path: packages/fields/src/field-type-alias.ts (3f413a8af929), packages/core/src/utils/retired-field-types.ts (bb80173b0807, the REAL @object-ui/core module — see J2), packages/plugin-form/src/autoLayout.ts (d5c39cadd420), packages/plugin-detail/src/autoLayout.ts (dcadff3bfbf4), and packages/components/src/renderers/form/form.tsx:2472-2521 (pickSpanClass, 50 lines, sha256 917a686a09818e3d…, wrapped only to supply containerClass). Executed with tsx, unmodified except the one import path.
  • Runtime path — mapFieldTypeToFormType(t) then isWideFieldType, then resolveColSpan, then pickSpanClass — over the 49: the wide set is exactly {html, markdown, repeater, richtext, textarea}, five, and it is the same five on every variant I could think of: the plugin-form set and the plugin-detail twin (10 entries each, equal as sets); multiple: true on every member (the only arity move is select → field:multiselect, narrow); resolveColSpan equal to the grid at grid 3 and at grid 4; and the prefix-stripped widget key (the resolveFormWidgetType shape the action-param dialogs consume — repeater → grid, which the set also holds). The other 44 members resolve to 1 at both grids and emit no class. Bare path (isWideFieldType(specName)) is the four long-form types; the runtime-minus-bare delta is exactly [repeater].
  • The parenthetical the clause added is true as written: repeater → field:grid (field-type-alias.ts typeMap at the pin), and field:grid is a member of both WIDE_FIELD_TYPES sets; resolveColSpan({type: 'field:grid'}, 3) = 3, at 4 = 4, class @2xl:col-span-3.
  • The runtime path is the one a spec FormField walks, re-read at the pin: sectionFields.ts branch (2) "spec FormFieldSchema object" builds from fromObjectSchema (:122, mapped) and re-maps an authored type override (:207, rawType); a string shorthand goes through ObjectForm.tsx:732 (mapped); the designer goes through toFormFieldType → mapFieldTypeToFormType. Inline customFields (ObjectForm.tsx:719) carry a runtime FormField in widget-id vocabulary, not a spec FieldType, so they are outside the population by construction. The widget override cannot add a member either: resolveColSpan reads span, colSpan and type only, and nothing in plugin-form derives type from widget. So the word 「only」 holds at the pin — no sixth member.
  • Controls, same instrument: json → field:code = 1 / file → field:file = 1 / text = 1 / subform (not a member) → field:text = 1 / grid (not a member) reads wide on the bare path and narrow on the mapped path (field:text); firing control textarea → 3, @2xl:col-span-3. Tier regression, unchanged text: CONTAINER_GRID_COLS[3] = grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3, pickSpanClass(3) = @2xl:col-span-3 only; grid 2 → @md:col-span-2; grid 4 → @4xl:col-span-4.

J2 — PASS, one imprecision noted. The stub is answer-preserving over the population — shown by execution, not by the argument.

  • I ran the mapper twice from the same blob: REAL, with the @object-ui/core re-export block and import (lines 81–87 — I confirmed those are exactly the seven lines the implementer named) rewired to the pin's own retired-field-types.ts; and STUB, with those lines replaced by a reportRetiredFieldType that returns false unconditionally. Over all 49 members the two mappers disagree on 0 rows. The only divergence sits outside the population and is the firing control for the comparison: owner → real field:owner (the tombstone; one console line, the retired-branch path really executing) vs stub field:text. RETIRED_FIELD_TYPES at the pin is exactly {owner}, and owner is not a FieldType member, so no live member can reach the retired branch at all — never mind arrive at a wide id.
  • The argument as the implementer wrote it is imprecise: "every widget id in WIDE_FIELD_TYPES has its spec spelling in typeMap" is false for field:grid — grid is not a typeMap key (mapFieldTypeToFormType('grid') with the stub is field:text, the tail, not a table hit). The conclusion survives on the correct premise: the retired branch returns field: + the name, which is wide only for a name in {textarea, markdown, html, grid, richtext}; the first four are live typeMap keys and never reach the branch, grid is not in the population, and the sole retired spelling is owner. Noted, not blocking: the instrument's answer is sound; its stated justification has a hole that happens to be empty.

J3 — PASS. The population is 49, by two instruments of mine, and the round-1 55 is explained.

  • Executed FieldType.options from packages/spec/src/data/field.zod.ts at the head: 49, 49 unique.
  • The enum literal read out of the source text with /* */ and // comments stripped: 49 (49 unique) at the head and at the base — field.zod.ts is blob e16ed2b9eff3 on both sides, untouched by the PR. The same instrument with the comments left in reads 55: the enum's own comments quote 'password', 'secret', 'lookup' and friends, which is exactly the round-1 number, so the earlier reading is a known bad instrument rather than an unexplained discrepancy. Firing control: a scratch copy with one member appended reads 50.

J4 — PASS. The delta is the clause, the changeset and the regenerated page; the guard survived; Clause-②: no holds.

  • git diff --stat cee5969dee..6a34e6f67a: 3 files, +8/−8 (view.zod.ts +1/−1, the changeset +5/−5, view.mdx +2/−2), in two commits (1fa83ebf03 clause + changeset, 6a34e6f67a page); base..head: the same 3 files, +25/−3. No objectui file: the PR is one repository, .objectui-sha is byte-identical base vs head, and objectui's remote carries no issue-18516 head (177 issue-* heads read as the control). WIDE_FIELD_TYPES is pinned by the sha — 10 entries in each twin, equal as sets, read fresh above.
  • The asserting citation is byte-untouched: word-diff of the round-1 and round-2 describe strings moves only the middle clause (the long-form types (textarea, → textarea,; richtext) → richtext and repeater; count. → count (repeater reaches it …)), and the substring (`.objectui-sha` = `53ded82bf7`) occurs once, byte-equal, in both. Gate at the head: check-objectui-pin-citations.ts --self-test exit 0; the gate exit 0 — "27 asserting objectui pin citation(s) match .objectui-sha (53ded82bf), 45 historical citation(s) recorded and not checked, across 1483 spec source(s)"; --list classifies view.zod.ts:2925 asserting … current. The round-1 ablation pair was not re-opened; the seat closed it.
  • Clause-②: no, verified in shape on this head: view.zod.ts with every .describe(…) call removed by my own string-aware stripper (344 calls on each side, 0 residual) is byte-identical base vs head and round-1 vs head (300041 bytes each), while the raw files differ; firing control — a scratch copy of the head with .default('auto') → .default('full') on the span line differs after stripping at exactly that line. A 13-row accept/refuse table executed against FormFieldSchema from the base tree (packages/spec/src archived at 1b82c519df, view.zod.ts blob 65853f52f3b7) and from the head: 13/13 identical — span omitted / 'auto' / 'full' accepted (default auto), 'wide' and 1 refused invalid_value, type: 'repeater' / 'textarea' / 'json' / 'file' accepted, 'subform' and 'grid' refused invalid_value, colSpan: 5 refused too_big, colSpan: 2 accepted.
  • No spec test pins the describe text: all three generations of the sentence grep to 0 across packages/spec/src/**/*.test.ts and packages/spec/scripts/**; the radius holds tests that do pin describe text off a schema shape (api/apis-publish-gates.test.ts:232 reads ApiEndpointSchema.shape.path.description, automation/approval.test.ts:339 reads ApprovalNodeConfigSchema.shape.minApprovals.description), and ui/view.test.ts, the one ui test touching span/colSpan, asserts colSpan behaviour only.
  • The regenerated page: both span cells (content/docs/references/ui/view.mdx:202 and :367) are byte-equal to the describe string (776 bytes, after unescaping the table's pipes), and those two lines are the only ones that moved in the file. Independently: pnpm --filter @objectstack/spec build exit 0, then check:generated exit 0 — "All 16 generated artifacts are up to date", check:docs (content/docs/references/**) among them. The tree read clean afterwards.

J5 — PASS. The PR body and the changeset tell the truth, with one stale sentence in the body's main text that its own footnote withdraws.

  • Body, read back through REST at head 6a34e6f67a: the sweep table is labelled as the RUNTIME path and carries a repeater row (field:grid, 3, @2xl:col-span-3); the closing sentence is the five-member set over 49 members; the After: quotation is byte-equal (776 bytes) to the shipped describe string, and the Before: quotation carries the original retired wording; the seat's footnote names 49-not-55 and 12 files / 22 occurrences and withdraws the "zero occurrences" claim. Against the freshly built tree: dist 30 files / 30 occurrences, json-schema 12 files / 22 occurrences (ui/FormField.json, ui/FormSection.json, ui/FormView.json, ui/View.json, ui/ViewItem.json, ui/ViewItemWire.json, objectstack.json, five api/*.json); the retired wording and the round-1 wording are 0 in both, the shipped wording being the firing control. In tracked files the retired wording survives on exactly one line, the changeset's own quotation (.changeset/18516-span-auto-wide-types.md:9); the round-1 wording is gone (git grep -qF exit 1; control on the shipped wording exit 0).
  • N1 — noted, not blocking. Line 92 of the body's main text still reads json-schema 12 with no unit and "Zero occurrences of the retired wording remain anywhere outside .git." The footnote corrects the first and withdraws the second, so the document as a whole is truthful, but a reader who stops at line 92 leaves with the withdrawn claim. A PR body is not a shipped artefact and the correction lives in the same document; an in-place edit of that one line is the tidier form.
  • Changeset at the head: every checkable sentence reproduces — 49 by two instruments; ten entries in both twins; json → field:code, file → field:file; subform not a member; the five-member set with repeater via field:grid; the bare-path four being what objectui's own pin test asserts (autoLayout.wideSpelling.test.ts, "its spec-facing surface is EXACTLY the long-form family", read at the pin); type: 'grid' refused and repeater accepted (table above); @2xl:col-span-3 at grid 3 with the container class quoted correctly; '@objectstack/spec': patch; Clause-②: no.

② Semver level

patch on @objectstack/spec, Clause-②: no — unchanged from round 1 and consistent with J4: the delta moves one describe string and the artefacts generated from it; no key, default, enum member or export moves.

③ Boundary flags

  • Round-1 items 2–5 are closed by the seat; the pin-guard, accept-set and page readings above are regression checks on the new head, not re-openings of those items.
  • Implementer flags carried forward unchanged (docblock above span in the historical spelling; the two hand-kept WIDE_FIELD_TYPES twins; nothing in this repo pins the runtime-path set): agreed, none is a filing class, nothing owed here.
  • Out of scope, noted, not filed (objectui-side, outside the card's fence): mapFieldTypeToFormType reads its typeMap by plain index, so a prototype key such as constructor returns a function rather than a widget id. It fired only in my controls; no FieldType member reaches it.

Nothing is blocking. The one BLOCKING finding of round 1 is closed by the measurement in J1, and no second missed member exists.

Implemented-by: claude/issue-18516-span-auto-describe
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 20, 2026 06:29
@os-steve
os-steve added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit c7448dc Sep 20, 2026
51 checks passed
@os-steve
os-steve deleted the claude/issue-18516-span-auto-describe branch September 20, 2026 07:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/s tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: the span: 'auto' describe names json/file/subform as wide widgets, and "take the whole row" is false at the intermediate tier at the pin

2 participants