`element:text` is the LATER registration in `renderers/basic/index.ts`, so
`Registry.register()`'s bare-name fallback would hand it the short `text` key
on load order alone; `skipFallback: true` is the one line that stops it and
`Registry.ts` only `console.warn`s on such an overwrite. Nothing pinned the
resolution, so deleting that flag re-pointed every authored `text` node at the
four-value renderer silently.
The load-order half is derived from the barrel at run time, and the mechanism
carries a live control on a fresh `Registry` instance: the same pair without the
flag hands the bare key to `element:text`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w
Part of #7450. #7450 remains open — the standing ruling is deliberately not implemented here, and the measurement below is why.⛔ The headline: the convergence cannot be declared in this repository
The ruling (
5565743592, director seat, decision batch #71, 2026-09-07, maintainer verbatim 「其他同意」) is sound and is not reopened. But the card, the census (5596105170) and the dispatch (5598675699) all rest on one premise, and that premise is false:It is declared — not in
@object-ui/types, but upstream in@objectstack/spec, which AGENTS.md #0 makes the binding contract for this repo.elements.tsx's own file docblock already says so ("Spec-aligned renderers for theelement:*component namespace defined by@objectstack/spec").Source of the declaration —
framework/packages/spec/src/ui/component.zod.ts:1819, insideElementTextPropsSchema:Measured against the installed pin (
@objectstack/spec@17.3.0), through the schema's own verdicts:element:textvariantElementTextPropsSchema.safeParseheadingsubheadingbodycaptionh1h2h3h4h5h6overlineinvalid_valueatvariantnonsense(control)invalid_valuevariant: 'body'The repo's own per-PR gate refuses the convergence, mechanically
apps/console/src/__tests__/registry-inputs-spec-parity.test.tsjudges anenumarm exactly, by its own header: "AnenumARM is judged differently and exactly, because it is the one arm whose admitted set is FINITE and written down:armAcceptsconsults the input's ownenumlist, so every declared member must be a value the spec accepts."So I ran the convergence as an ablation — swapped the registry
inputsenum inelements.tsxfor the published nine and nothing else:Baseline on the same file, unmutated:
198 passed.⇒ Declaring the nine here is a consumer-side widening past the contract, which is exactly AGENTS.md #0.1's ban, and the gate that exists to catch it does catch it. The convergence has to start in
@objectstack/spec; this repo follows on a released pin.The one-line in-repo migration is blocked by the same wall.
apps/console/src/preview-samples.tscarries the only authoredelement:textvariant,subheading. That value is spec-legal today; rewriting it toh3(the value that preserves today's rendered element) would make it spec-illegal. The migration is not a line I can write yet either.A second correction: "refused by nothing" is true only at the RENDER layer
Re-derived here, not quoted. Rendering
element:textthrough the real registry:element:textvariantvalidateTreediagnosticsheadingh2subheadingh3body/captionph1/h4/overlinep, classtext-sm text-foreground— byte-identical tobodyinvalid-enum/ errorpThe renderer's silent swallow is real and reproduces exactly. But
packages/sdui-parser'svalidateTree— the same validator behind the save gate and the JSX-page prop whitelist — already raisesinvalid-enumat severityerror:element:text prop "variant"="h1" is not one of ["heading","subheading","body","caption"]. So the accurate statement is "the renderer swallows what the authoring gate already refuses", not "nothing refuses it". Two authorities, and today they agree with each other; the objectui-only convergence would have made them disagree.The mapping question, answered out loud (to be carried upstream, not decided here)
The dispatch requires the
heading/subheadingmapping to be stated rather than chosen silently. My recommendation for the spec-side card:heading→h2,subheading→h3as the migration hint, not as an alias. Those are the elements the renderer hard-codes today (elements.tsx,variant === 'heading' ? 'h2' : variant === 'subheading' ? 'h3' : 'p'), so the hint preserves the rendered element byte-for-byte for anyone who follows it, which is the only property a migration hint owes.h1–h6.headingwas never a level, it was a role; pinning it permanently toh2would import the old vocabulary's one-size assumption into the new one and re-create the expressiveness gap the ruling exists to close. The hint namesh2because that is what the author's page renders today — it is a "here is where you were", not a "here is where you must stay".@objectstack/spechasretiredKey()(ADR-0087 D2) for retiring a key; I found no value-level equivalent underpackages/spec/src/shared/. "Named refusal carrying a migration hint" for two enum values needs either that mechanism or a per-value refinement, and it is a narrowing of a published, defaulted enum. That is upstream's call to score and sequence.⛔ #6942's constraint was never at risk, and is untouched
ui:textstill does not synthesisebodyfor an absentvariant;element:textstill does (props.variant ?? 'body'). Nothing in this PR moves either. The ruling itself already settles the asymmetry in the same direction ("element:textkeeps its?? 'body'because it is a different component"), so convergence never forced a decision about absence.✅ What this PR does land: the pin the card owes
The dispatch calls it out directly, and it is independent of everything blocked above.
Two production registrations claim the short name
text.renderers/basic/index.tsimports./text(which registersui:text) before./elements(which registerselement:text), andRegistry.register()claims the bare key for a namespaced registrationif (meta?.namespace && !meta?.skipFallback)— its own comment: "the last registration wins for non-namespaced lookups". So load order alone would hand the baretextkey toelement:text;skipFallback: trueis the single line that stops it, andRegistry.tsonlyconsole.warns on such an overwrite.The corpus authors the bare
textspelling, so that one flag decides how every authored text node renders. Nothing pinned it.packages/components/src/__tests__/text-bare-key-resolution-7450.test.tsxpins it, in six parts:textresolves toui:text, asserted by component identity and not only by the type string.renderers/basic/index.tsat run time, so re-ordering those imports moves the pin with it rather than leaving it stale.skipFallback: trueis present onelement:textand absent onui:text.Registryinstance (never the shared singleton): the same pair registered in the same order keeps the bare key onuiwith the flag and hands it toelementwithout it. Without this control, "the bare key isui:text" would also pass if the fallback path had stopped working orelement:texthad stopped registering.{ type: 'text', variant: 'h1' }node renders anh1element. This is what the flag protects:element:texthas noh1in its four-value vocabulary and falls through to ap.Ablation — the pin reds when the thing it describes is broken
skipFallback: truedeleted fromelement:text's registration and nothing else, mutation proven on disk:Restoration proved twice over: an empty
git diff HEADand blob-hash equality with theHEADblob. Both ablations ran from the committed state, under the shared verify lock, exit codes captured before any pipe.Verification
All at
12cc07f9d.vitest run packages/components apps/consoleVERDICT command-exit 0pnpm --filter @object-ui/components --filter @object-ui/console run type-checkDone,VERDICT command-exit 0tsc -p tsconfig.test.json --listFiles: 1 hit for the new file, controltext-variant-align-6942also 1eslint .inpackages/components(the whole package, not a narrowed slice)node scripts/check-changeset-presence.mjssrc/__tests__/changenode scripts/check-control-bytes.mjspnpm check(CLI built first)✓ All checks passedlint:coverage/type-check:coverage/check:lint-rule-coveragenode scripts/check-governed-queue-guard.mjs --teston both changed pathsNOT GOVERNEDapps/consoleis in the sweep deliberately (excluding it is what reddened PR #8763 in the queue).Changesets
One, with empty frontmatter: this is a test file plus a changeset, and
@object-ui/componentsships["dist","README.md","CHANGELOG.md","LICENSE"]— nothing published moves. The changesets owed for@object-ui/componentsand@object-ui/typesbelong to the convergence, which is not in this diff.Handling
⛔ Draft. ⛔ Not flipped ready, ⛔ not enqueued, ⛔ no auto-merge.
needs:contract-reviewis hung per the dispatch's 双载体 rule, though as landed this diff moves no contract: it is a test file and an empty-frontmatter changeset.Part of #7450rather than the closing form the dispatch asked for, and that is a deliberate departure I am reporting rather than making quietly: merging a PR that does not implement the ruling must not close the card that carries it. The half left open is the whole convergence; the half delivered is the resolution pin.Generated by Claude Code