…mn header
A grid/table repeater row has no `<label>` by design (objectui#5063): the
column name is written once in the `<th>` and every cell points at it by
IDREF, keeping the cell id on the control as a plain anchor. The nine
metadata-admin widgets declared `labelling: 'group'` answered that IDREF; the
eleven declared `'control'` read only the id and dropped it, so each rendered
as an unnamed edit box in every grid row.
`labelling` stays two-valued. It answers one question — can the host's
`<label for>` reach a labelable element? — and collapsing it re-opens the
dangling `for` objectui#3978/#4010 closed. What was wrong is a second meaning
read into it: that `'control'` also declares which naming channel the widget
consumes. `FieldRow`'s "exactly one of id / ariaLabelledBy" is a rule about a
host THAT HAS A LABEL, which `FieldControl`'s builtin branches already state
and act on; only the registry widgets were left behind.
Each `'control'` widget now puts both channels on the same labelable element
via one file-local `controlNaming()` helper, in every arm it can render. The
auxiliary affordances beside that control — a reveal toggle, the hex mirror
next to a colour swatch — are exempt and keep their own names; that exemption
is pinned, not just documented.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
Fixes #9889
⭐ First deliverable — the question, answered from the registry
Neither. It stays two-valued, and
'control'names nothing the IDREF cannot serve. The reading, fromWIDGET_LABELLINGand its two prose sections:labellinganswers exactly one question — can the host's LABEL element withforreach a labelable element?'control'says yes (the widget puts the hostidon the one labelable element that is the field's primary control);'group'says no (the field IS a container —foris inert on it). That single answer is whatFieldRowbranches on:htmlFor+iddown, versus anidpublished on the LABEL and handed down asariaLabelledBy. It is a real structural fact about the DOM each widget renders, and collapsing it re-opens the danglingforthat objectui#3978 and objectui#4010 ruled against — or strips click-to-focus from eleven real controls. ⛔ Do not collapse it.What was wrong is a second meaning read into it: that
'control'also declares which naming channel the widget consumes. It never did.FieldRow's "exactly one ofid/ariaLabelledBy" is a rule about a host that has a LABEL. A grid/table repeater cell has none (objectui#5063), names each cell from its TH column header by IDREF, keeps the cell id on the control as a plain anchor — and therefore hands down both.FieldControl's own prop doc already says this in as many words, and its builtin branches already emit both. Only the twenty registry widgets were left behind, and the nine'group'ones happened to be right by accident of which prop they read.'control'widgets genuinely cannot take an IDREF (a composite that renders several inputs, say)" — none of the eleven is such a composite. Each renders exactly one primary labelable element per arm, plus auxiliary affordances that already carry their own names, which is verbatim what the'control'contract says.aria-labelledbyis valid on a labelable element, so the same element takes both. ⇒ "make them all consume it" is the right shape here.⇒ The repair is not a collapse and not a widening of
labelling. It is one file-localcontrolNaming({ id, ariaLabelledBy })helper that every'control'widget spreads onto its primary control, in every arm.⭐ Re-derived: the bound that justified letting this grow — it has NOT moved
objectui#9859 enlarged this population and was accepted because no shipped metadata declares a grid repeater. Re-derived here, at objectstack
ed6c554545c01960579da643930e5177731d58a0, 2026-09-18T17:13Z, one command:widget: 'grid'orwidget: 'table', shipped.form.ts/ object / plugin sources, tests excluded⭐ Two corrections to the dispatch's framing, both in the direction of more confidence, not less:
widget: 'grid'.RepeaterFieldactually takes the grid layout onwidget === 'grid' || widget === 'table', so the instrument that justified the trade was narrower than the trigger. Re-derived on the wider one, the zero still holds. (objectui#9859's own changeset already used the wider spelling — the dispatch gloss did not.)objectstackcheckout, tests excluded. Same subject, same command, controls firing.⭐ How my census probe sees through a spread
The dispatch warns that a first probe matched the literal attribute
aria-labelledby={ariaLabelledBy}and missed a widget reaching the channel through a spread. Mine keys on the identifier reaching the component body, not on the attribute spelling: it slices each registered component's body by brace-matching from its declaration, then tests the body for theariaLabelledByidentifier (which catches destructuring, rename,props.ariaLabelledBy, and building it into an object that is later spread), and reports the literal-attribute answer as a second column so the two can be compared rather than conflated.It reproduces the difference in-band. On
origin/mainfc12bc8c6:ColorSwatchGroupWidgetbuilds{ ariaLabelledBy }into anamingobject and spreads it — a literal probe scores'group'at 8/9 and reports a widget as broken that is not. ⭐ And after this change the point sharpens: all eleven repaired widgets readident=YES literalAttr=no, so a literal probe would now report0/11repaired. The blind spot the probe still has is a component that spreads all of its props without ever naming this one; the probe reports that case as a labelled blind spot and found none.What changed
packages/app-shell/src/views/metadata-admin/widgets.tsxonly — ⛔ noSchemaForm.tsxedit is needed, becauseFieldControlalready hands both channels to a registered widget.controlNaming({ id, ariaLabelledBy }), spread at 21 sites across the eleven'control'widgets, replacing a bareid={id}, in every arm each can render;filter-builderforwards the IDREF one level intoFilterBuilderField, whose other call site (insideFilterModeWidget's tab editor) is a sub-control of a group and deliberately gets neither channel;color-inputandsecretcarry anaria-labelfallback for a caller with no host label; it was suppressed onidalone and is now suppressed on either channel, so the fallback cannot sit beside a live IDREF (the one-label-one-channel rule of objectui#3978);WidgetProps.ariaLabelledBy("undefinedfor'control'widgets"),WidgetProps.id(a'group'widget does receive a cell id in the grid path, as a plain anchor), and the'control'section ofWIDGET_LABELLING, which now states the exemption explicitly.Every edit was applied by a script that asserts each anchor matches exactly once and refuses otherwise — a zero-hit replacement exits non-zero rather than silently doing nothing.
What is pinned — both sides and the negative
packages/app-shell/src/views/metadata-admin/SchemaForm.controlWidgetGridNaming-9889.test.tsx, 38 assertions, all from a realSchemaFormrender reading the computed accessible name — ⛔ never a source-text or prop assertion.'control'widgets, driven into a grid cell, hastoHaveAccessibleNameequal to its column, and its IDREF is shown to land on an element whose tag is TH;WIDGET_LABELLINGitself, so a twelfth'control'widget cannot be added without a reading here;conditionandmultiselect('group') still answer that same IDREF. If the change had moved the channel rather than widened it, these go red;for, and carry noaria-labelledby;secretand the hex mirror insidecolor-inputkeep their own names, carry no IDREF, and are not renamed after the column.Ablation — a value the two implementations disagree about
Run at this branch's head, mutation proved by blob hash, restore proved by hash equality and an empty
git diff HEAD, trap on EXIT/INT/TERM with absolute paths. Anchors are asserted to match exactly once, so a no-op mutation fails loudly.⭐ The value chosen is the IDREF in a grid cell. In the card layout old and new produce byte-identical DOM (the host sends no IDREF there), so an ablation measured on a card row comes back green against both implementations and proves nothing. The signature below is what makes the pin discriminating rather than merely red:
38 passedcontrolNamingreturns the id alone — exactly the pre-change behaviour13 failed / 25 passed— all 11'control'grid cases + both negatives red; ⭐ both'group'cases and all 11 card-layout cases stay GREENcontrolNamingontosecret's reveal toggle — the wrong "completion"1 failed / 37 passed— only the exemption pin38 passed, hash back to baseNo build step is involved: these tests import the module through a relative source specifier, so there is no
distbetween the mutation and the assertion.Verification
pnpm exec vitest run packages/app-shell/ packages/core/src/utils/__tests__/column-identity.ratchet.test.tsTest Files 725 passed (725)· `Tests 7163 passedpnpm exec vitest run apps/console/Test Files 101 passed (101)·Tests 1158 passed (1158)—VERDICT command-exit 0packages/app-shell→tsc --noEmitthentsc -p tsconfig.test.json(the fulltype-checkscript)0 errors, exit 0 —turbo run build --filter=@object-ui/app-shell^...; a fresh worktree gives 670 TS2307 from unbuilt dependencies, a prerequisite and ⛔ not a red gatepackages/app-shell→eslint .0 errors, 3041 warnings— the repo-wide warning baseline;--max-warningsis deliberately unset here. ⛔ My diff adds and removes no export site, so thereact-refreshsubject set is unchanged (the helper is file-local for exactly this reason)node scripts/check-changeset-presence.mjsnode scripts/check-changeset-no-major.mjsnode scripts/check-changeset-claims.mjsnode scripts/check-pending-changeset-literals.mjsnode scripts/check-control-bytes.mjsnode scripts/check-new-cross-file-line-citations.mjs0 new citation(s), exit 0node scripts/check-test-path-roots.mjsnode scripts/check-governed-queue-guard.mjs --test(my three paths)NOT GOVERNED— the ordinary routeEvery heavy run went through
scripts/pm/os-verify-lock.shin the sibling checkout; each verdict above is that script'sVERDICTline, ⛔ not a bare exit status.The sweep. Tests naming the changed module were enumerated by concept, not by identifier — the labelling channel, the grid-cell naming channel, the registry table, accessible-name assertions — with a deliberately-absent token as the same-run control (zero hits, so the enumerator is not matching every file). It returns 20 files that reach the module and 19 that name the concept, two of them outside
packages/app-shell(packages/core/src/utils/__tests__/column-identity.ratchet.test.tsandpackages/app-shell/src/views/view-filter-fold.ratchet.test.ts). Since the package barrel re-exports this family andapps/consoleconsumes it through the src alias, a narrowing below the package is not provable ⇒ the whole package ran, plus that out-of-package ratchet test by path. ⭐ Andapps/consoleran too: the same concept-level enumeration finds 8 console tests reaching this family, so stopping at the package boundary would have been the same unprovable narrowing one level up.;, and the wrapper printedVERDICT batch-last-exit 0 — NOT A VERDICT ON THE WHOLE COMMAND. That is not a reading, so the console suite was re-run as a command of its own for the verdict quoted above.Pending-changeset sweep. 14 pending
.changeset/bodies mention this subject; each was read. ⛔ None is made false by this diff. The nearest,9859-repeater-grid-widget-detectors.md, states the cell "names each cell from its column header by IDREF" — still true, and now true for every widget rather than nine of twenty.select-empty-host-id-8803.md,3991-builtin-select-empty-state-label-for.mdandrecord-picker-label-association-5771.mdare all about@object-ui/components/@object-ui/fieldsregistrations, a different registry. ⛔ No other card's changeset was edited.Adjacency — re-derived, ⛔ not taken from the dispatch
PR objectui#9923 (card objectui#9912), read from its file list via the REST API at 2026-09-18T17:13Z, ⛔ not from its title and ⛔ not from its branch name:
Three files,
state: open,mergeable_state: behind. Zero overlap with this branch's three paths — same directory, disjoint files. ⇒ no serialization owed, and this branch deliberately does not touchSchemaForm.tsxeven for the stale comment noted below, so the near miss stays a miss.Acceptance notes — out of scope, ⛔ not fixed here
To file (measured, with dedupe words).
'control'widgets whose catalog-failure arm renders no labelable element at all:PickerLoadFailurereplaces the picker, so the host id lands nowhere andFieldRow's LABEL element withfordangles. Measured in a card-layout render, each widget's failure arm and loaded arm read in the same test, so the loaded arm is the control:field-selectoris NOT MEASURED, ⛔ not negative. It loads its catalog through its own fetch rather than throughwidgetContext.objectFields, so this probe never put it in its failure arm — it landed on the no-object-bound arm, which is healthy. By source reading it has the same shape, but that is a hypothesis here, not a reading. Three widgets are measured DANGLING. The'control'contract inWIDGET_LABELLINGsays verbatim: "The widget putsidon the ONE labelable element that is the field's primary control, in EVERY branch it can render" — the failure arm was added after that sentence and is not covered by it. Same class as objectui#4871 / objectui#4788, and the same shape objectui#8803 repaired forfield:selectby making its empty state an OUTPUT element. Unrelated to this card: in a grid cell that arm renders no control, so it is ⛔ not the unnamed-edit-box defect. Dedupe words:PickerLoadFailure arm has no labelable element·control widget failure arm dangling label for·object-selector load failure unnamed·labelling control contract EVERY branch·field-ref error arm no host id.Noted, not filed.
FieldControlcarries a comment above its registered-widget render reading "Exactly one of these is defined, decided by the widget's ownlabellingdeclaration" — false for the grid-cell path, and contradicted by that same component's ownariaLabelledByprop doc a few dozen lines above it. It is documentation drift, ⛔ not a runtime defect, and it lives inSchemaForm.tsx, which PR objectui#9923 is live in. Successor who will touch that file: objectui#9923's author, or the nextSchemaFormcard. ⛔ Not repaired here, to keep the directory-level near miss a miss.Landing
⛔ Draft on purpose. ⛔ Not marked ready, ⛔ no auto-merge, ⛔ not merged. No label was applied: the changeset declaration is this repository's real mechanism and
skip-changesetis inert here, so a label write would be theatre.⛔ No
Clause-②:line is copied from the claim comment because the claim carries none — this repository'sCheck Changesetreads the declared changeset, which is present.Generated by Claude Code