Skip to content

docs(guide): author the button keys the schema actually declares — six text to label, three top-level onClick to action:button - #7931

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7898-guide-button-phantom-keys
Sep 6, 2026
Merged

docs(guide): author the button keys the schema actually declares — six text to label, three top-level onClick to action:button#7931
os-sam merged 1 commit into
mainfrom
claude/issue-7898-guide-button-phantom-keys

Conversation

@os-sam

@os-sam os-sam commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7898

Nine JSON fences under content/docs/guide/ authored button keys that ButtonSchema does not declare. All nine are now the keys the renderer actually reads. Every reading below is measured on this branch; the union at the bottom was re-run on the final commit 9cd6c894f with git diff HEAD empty.

⭐ Why no instrument saw these nine — the blind spot is the most valuable sentence here

They all hold LITERALS. That places them outside every gate this repo owns:

instrument what it judges why it cannot see these nine
check-doc-expression-carriage (#7851 / PR #7868) keys that hold ${…} these keys hold "Submit", "Edit", "editRecord" — not in its population at all
check:doc-types (check-doc-component-types) the type literal its own header says so deliberately: "NOT in scope, deliberately: whether the snippet's OTHER keys are read by the renderer the type resolves to"
check:doc-snippets ts / tsx blocks these are json fences

And nothing catches them at parse time either: BaseSchema is .passthrough(), so an undeclared key is not refused, it is KEPT. Measured from the built @object-ui/types dist:

ButtonSchema.safeParse({ type: 'button', text: 'Submit' })
  -> success: true
  -> parsed output: {"type":"button","variant":"default","size":"default",
                     "iconPosition":"left","buttonType":"button","text":"Submit"}

A green parse, the phantom key carried through, and then dropped by the renderer. This is the same geometry as #7896 (a package README's type literals judged by nothing): the defect falls between all the instruments. Per the dispatch, this PR builds no gate — that would introduce a new scan population and trigger this lane's report-only-first rule. #5250 is the instrument-level card for the class, and it carries the .passthrough() half that makes a naive gate vacuous.

Finding 1 — six sites spelled the button's text as text

Measured from the built artifact, not the card:

ButtonSchema declared keys (packages/types/dist/zod/form.zod.js):
["type","id","name","label","description","placeholder","className","style","data",
 "bind","body","children","visible","visibleWhen","visibleOn","hidden","hiddenOn",
 "disabled","disabledOn","testId","ariaLabel","variant","size","loading","icon",
 "iconPosition","onClick","buttonType"]
has label: true
has text : false

packages/components/src/renderers/form/button.tsx renders schema.label || renderChildren(schema.body || schema.children). text is on no read path and is not on SDUI_DOM_PASS_THROUGH_KEYS, so it is not even leaked — it is silently dropped.

Render evidence (#6318's standard: it renders differently)

Driven through the real SchemaRenderer and the real registered renderers:

=== BEFORE  {"type":"button","text":"Submit"} ===
<button class="inline-flex items-center justify-center … h-10 px-4 py-2"
        type="button" data-obj-type="button"></button>
textContent: ""

=== AFTER   {"type":"button","label":"Submit"} ===
<button class="inline-flex items-center justify-center … h-10 px-4 py-2"
        type="button" data-obj-type="button">Submit</button>
textContent: "Submit"

Same for layout.md's "New Order": before, textContent: ""; after, "New Order". The button had no text at all — it rendered as a blank rectangle.

Finding 2 — three sites authored a top-level onClick

ButtonSchema.onClick is handlerKeyRefusal('onClick', 'runtime-slot', 'Click handler') (packages/types/src/zod/tombstone.zod.ts) — a z.custom(() => false) that refuses every authored value. Measured:

ButtonSchema.safeParse({ type: 'button', label: 'Edit', onClick: 'editRecord' })
  success: false
  code=custom path=["onClick"]
  msg=Click handler — `onClick` is a RUNTIME SLOT for a host-supplied function, not
      authorable metadata (objectui#6124) … Author behaviour as a NODE TYPE instead —
      e.g. { "type": "toast", … } or an action:button node with a declared action, the
      spelling PR #6498 established.

The refusal is not the whole cost. onClick is on SDUI_DOM_PASS_THROUGH_KEYS (React synthetic handlers never become attributes, so the whitelist admits it), which means an authored string or object is forwarded by toFormControlDomProps into the real DOM listener slot. Measured — this throws when a user clicks, it is not merely inert:

{"type":"button","label":"Click Me","onClick":{"actionType":"ajax","api":"/api/action"}}
  click threw: "Expected `onClick` listener to be a function, instead got a value of
                `object` type."
  runner handler calls after click: 0

Which channel is actually read — measured, not assumed

The dispatch asked me to measure whether the remedy is the events bag or something else. It is not the events bag. events is declared on EventableSchema (packages/types/src/api-types.ts) and re-exported, but a repo-wide search for .events / ?.events across packages/**/src and apps/**/src finds zero SDUI read points — every hit is audit events, validation-rule events, or calendar/timeline events. EventableSchema has no mirror, no extender and no reader; that is #6497, whose status note records the 2026-08-25 ruling on #6182 (Option A): the authorable form for actions is the declarative action object, and EventableSchema re-prices to an ADR-0049 retirement rather than becoming the seat of any wiring.

So the repair uses the channel the refusal message itself names and that I measured to dispatch — an action:button node carrying actionType:

{"type":"action:button","name":"edit_record","label":"Edit","icon":"pencil",
 "actionType":"editRecord"}
  rendered: "Edit"
  handler `editRecord` invoked with: {"type":"editRecord","name":"edit_record","label":"Edit"}

{"type":"action:button","name":"call_api","label":"Click Me","actionType":"api",
 "endpoint":"/api/action","method":"POST"}
  handler `api` invoked with: {"type":"api","name":"call_api","label":"Click Me",
                               "endpoint":"/api/action","method":"POST"}

This is the spelling content/docs/guide/record-edit-modes.md already teaches ("The handler name goes in actionType") and that content/docs/core/app-schema.mdx already points at ("the handler-expression string is refused by name — author an action:button node instead").

Repaired per passage, never one blanket rule

page passage what it teaches repair
architecture.md Conditional Rendering the predicate keys; the text is incidental text to label
layout.md With Action Buttons (x2) how a page declares action buttons text to label
layout.md Detail Page with Actions (x2) how a detail page binds Edit / Delete button to action:button, text to label, onClick to actionType
layout.md Best practice 3 placement of a primary action text to label
schema-rendering.md Reference actions in schemas how to reference an action button to action:button; onClick object to actionType + endpoint + method

The schema-rendering.md site had three defects in one node, all now stated in prose: the refused top-level onClick; ajax, which is not an action type (the built-in vocabulary is script / url / modal / flow / api / form, plus objectui's navigation alias, plus host-registered handler names); and api, which is not a key — the endpoint key is endpoint, with method.

✅ Deliberately untouched

content/docs/guide/quick-start.md:149 — recorded by #7872's seat as measured-and-NOT-a-defect, and out of this PR's surface. Not touched. (My own measurement of the events bag disagrees with that note; see the paragraph above and #6497. That page's repair belongs to #6497's retirement chain, not to this card — reported, not acted on.)

⚠️ Reported, not routed around — a second defect on the same three passages

Seven of the nine sites sit inside page.actions, and page.actions has no reader on the page node. PageNodeSchema does not declare it (it survives a parse only via .passthrough()), PageRenderer has zero read points for it, and the array is stringified onto the wrapper element as actions="[object Object],[object Object]". Measured: that page renders 0 buttons; the same two buttons moved into page.body render 2.

That is a capability decision (should page grow an actions reader, or should the pages move onto body / regions + page:header?), not a documentation fix, so this PR repairs the button keys and stops. Filed as #7926. The button-key repair is correct independently of how #7926 is ruled — label is the button's text key either way.

The census is unchanged, as predicted

check-doc-expression-carriage before and after this PR is byte-identical:

Scanned 184 file(s) under content/docs: 204 json/jsonc fence(s), 204 parsed, 0 UNPARSED
361 node(s) with a string `type`; 65 ${…} site(s) on those nodes, 61 of them carried.
⚠️  4 site(s) in 3 page(s) author ${…} on a key nothing evaluates

Confirming the blind-spot claim: these nine hold literals and are entirely outside its population.

Gate union — re-run on 9cd6c894f, git diff HEAD empty

Exit codes captured by redirecting first, never through a pipe. Verdict lines are the gates' own:

gate exit its verdict line
check-doc-expression-carriage 0 ✅ Blind spot: none — every fence above was parsed and judged. (4 pre-existing uncarried sites, unchanged)
check:doc-types 0 ✅ Every documented component type is registered. (889 type literals, 772 registered, 117 exempted)
check:doc-fences 0 ✅ check:doc-fences — every TypeScript block in 227 document(s) is fenced ts/tsx/typescript…
check-doc-links 0 Links are valid across 17 scan roots.
check:doc-example-readers 0 OK 80 documented symbol(s), 3946 call site(s)… no @example hand-spells one.
check:doc-snippets 0 Every covered documentation snippet compiles against the built types. (456/456 blocks judged, 0 failed — after building the gate's own 26-package scoped closure; the first run was EXIT=2 PRECONDITION NOT MET, not a red gate)
check:control-bytes 0 ✅ check-control-bytes: OK (scanned 6400 tracked text file(s); skipped 85 binary).
check:handler-key-reads 0 OK 106 arm(s)… every judged read is a declared member of its arm.
check-governed-queue-guard --test 0 ✅ NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched.

Changeset — the checker's verdict, quoted verbatim, not guessed:

Compared the working tree with f5d2acc35 (merge-base with origin/main): 3 file(s)
changed, 0 of them published source of a package the release covers, 0 of them a
manifest whose published contract moved, 0 under a package changesets ignores,
0 changeset(s) added.
✅  No source or published contract of a released package changed in this range,
    so no changeset is owed.

Clause-② is no as dispatched: doc examples only — no key added, no zod touched, no gate predicate or scan population moved. Nothing outside content/docs/guide/.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3


Generated by Claude Code

…ectui#7898)

Nine JSON fences under content/docs/guide/ authored `button` keys that do not
exist on `ButtonSchema`, and no instrument saw them because they all hold
LITERALS: the expression-carriage census only judges sites that author `${...}`,
and check:doc-types only judges the `type` literal.

Six sites spelled the button's text as `text`. Measured from the built
`@object-ui/types` dist: `ButtonSchema.shape` has `label` and has no `text`, and
`button.tsx` renders `schema.label || renderChildren(...)`. Nothing refuses the
misspelling either — `BaseSchema` is `.passthrough()`, so the validator KEEPS the
unknown key. Rendered through the real SchemaRenderer, the node with `text`
produces a button whose textContent is the empty string.

Three sites authored a top-level `onClick`. `ButtonSchema.onClick` is
`handlerKeyRefusal('onClick', 'runtime-slot', ...)`, a `z.custom(() => false)`
that refuses every authored value by name — and because `onClick` is on
SDUI_DOM_PASS_THROUGH_KEYS, an authored string or object is forwarded to the real
DOM listener slot, where React throws on the first click. Repaired onto the
channel the refusal message itself names and the channel measured to dispatch:
an `action:button` node carrying `actionType`.

Judged per passage, never one blanket rule: five sites are teaching how a button
shows text and take `label`; four are teaching how a button binds an action and
become `action:button`. `quick-start.md`'s `events`-bag site is deliberately
untouched.

The census figure is unchanged by this PR, as predicted — 65 expression sites, 61
carried, 4 uncarried in 3 pages, byte-identical before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@os-sam
os-sam marked this pull request as ready for review September 6, 2026 03:40
@os-sam
os-sam added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 0adb329 Sep 6, 2026
30 checks passed
@os-sam
os-sam deleted the claude/issue-7898-guide-button-phantom-keys branch September 6, 2026 03:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants