fix(types)!: retire ToastSchema.action from both published faces (objectui#8338) - #8540
Conversation
…ectui#8338) `action` had NO satisfiable JSON inhabitant on the TypeScript face — `label` and `onClick` were both required and `onClick` is a function — while the zod mirror admitted `SchemaNode | SchemaNode[]`. Two published faces with disjoint accept sets, one of them empty: an author who wrote the key got a green `safeParse` and a `tsc` refusal, and no spelling satisfied both. The `toast` renderer read neither. ADR-0049 enforce-or-remove, retire route: `action?: never` on the declaration and `retirementTombstone()` on the mirror. Not `handlerKeyRefusal()` — `action` is a value key whose nested member was a function, which is why objectui#6124's top-level sweep walked past it and retired only `onDismiss`, four lines down. There is no replacement spelling and the capability was never fulfilled: objectui#6250 moved the toast demos off an in-toast action entirely. The parity ledgers drain with it: `KnownDrift` 42/64 -> 41/63 and `WiderThanDeclared` 23/36/47 (6/30/0/11) -> 22/35/45 (6/29/0/10), every figure re-derived by the file's own AST and mirror instruments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
…8338) Graded minor with the breaking-for-authored-metadata wording PR #7774 established: the mirror's accept set narrows (a node, and a list of nodes, no longer parse) and the TypeScript face's `{ label, onClick }` literal stops compiling. Both before/after columns are measured by ablating the tombstone back to the base tree's declarations and re-running the pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
❌ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. Which half objected:
📦 Bundle Size Report
Size Limits
|
|
| run | main head |
landed by | conclusion |
|---|---|---|---|
| 10702 | 639ca9dc |
#8495 | ✅ success |
| 10704 | f08d1a86 |
#8485 | ✅ success |
| 10706 | 40a7c538 |
#8503 | ✅ success |
| 10712 | 512c84b1 |
#8519 | cancelled |
| 10713 | f76f4362 |
#8512 | ❌ failure |
| 10718 | c3002671 |
#8528 | ❌ failure |
| 10719 | e76634cc |
#8529 | ❌ failure |
| 10721 | 90a9aab3 |
#8533 | ❌ failure |
⇒ main has been red on this check since f76f4362 (2026-09-08T05:44Z) — four consecutive merges. In run 10721 the ONLY failing step is "Check console performance budget" (step 11); every other step in that job concluded success, so the red is the budget itself and ⛔ not an infrastructure failure.
⭐ Where the bytes came from, from the tree's own record. #8519's commit message, landed minutes before, states it verbatim:
The zod mirror is deliberately left open and reported for its own card: closing it ships runtime bytes into the console
frameworkchunk, measured at 70,999 gzip bytes against a 71,000 ceiling.
⇒ main was at one byte of headroom. f76f4362 (#8512) then landed a set of per-operator refusal message strings into packages/core — which is inside the framework chunk — and took it over. f76f4362 to attribute the bytes exactly, and does not claim to have.
What that means for this PR, and what is ⛔ NOT being done
⛔ No re-run. A size verdict is a measurement, not a flake, and the base being red is not something a re-run changes.
⛔ PER_CHUNK_GZIP_CEILINGS and PER_CHUNK_BASELINE are untouched. Maintainer floor. The gate says so itself in its own failure text — "do not widen it just to get a green check" — and that decision is with the maintainer.
⛔ The deliverable was not trimmed to chase bytes. This PR ships one runtime string, the tombstone's guidance, and its content is asserted by the pins (RETIRED (objectui#8338, NO replacement spelling, buttonLabel) and quoted by the changeset. framework to go green, which is roughly three times everything it adds. No change inside this card's appetite can make this check pass while the base is over.
⇒ No fix exists to port yet. Filed as #8541 so the base-branch failure has an owner; this PR stays parked behind it, exactly as PR #8501 already is.
⚠️ This is now a lane-wide blocker, not one PR's problem
Two PRs are held by it — this one and #8501 (card #8344) — and every future PR touching packages/core, packages/react or packages/types inherits it until the base is green. The domain:spec queue is almost entirely packages/types.
⛔ This PR is not enqueued regardless: SKILL.md:643 puts *.zod.ts on the contract path limb, so the pre-enqueue in-seat contract review at the contract-review tier is owed first and is running. It stays draft.
Generated by Claude Code
|
Generated by Claude Code |
…s firing, and pin the primitive arms it hid (objectui#8338) Three changes, no behaviour delta on the first, a strictly larger pin on the rest. 1. The tombstone's first message fragment moves onto the `retirementTombstone(` call line. An indented bare quoted string that ENDS a line is regex-identical to a closed-set member for `check-widening-tells.mjs`'s `BARE_STRING_ELEMENT`, so a NARROWING was reported as one widening tell (exactly one, because every continuation carries a leading `+ ` — which is the proof of the diagnosis). The emitted string is asserted byte-identical: sha256 10b0b510c8b4ece4ab594373f870644dcefb9816b9bc92608b0791a060190fcc, len 732, read off a rebuilt dist before and after, and the parse-time issue message still equals `.describe()`. 2. `RETIRED_MIRROR_SHAPES` gains the primitive arms. Re-derived by re-forming `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` around the SHIPPED `SchemaNodeSchema` (which is `z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), z.null(), z.undefined()])`): the old mirror ALSO accepted `'Undo'`, `1`, `true`, `null`, `[]` and `['a', 1]`. That is the larger half of this narrowing and it was unpinned. 3. The shape list's MIXEDness is now pinned. vitest spreads an `it.each` case only when `cases.every(Array.isArray)`, so the list arm arriving as one argument depended on an undeclared property of the data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
…ured (objectui#8338)
The note claimed no `.tsx` / `.mdx` / `.ts` source authors an `action` object.
Literally false as written: `app-shell/src/chrome/notificationToast.tsx:76` and
`chrome/toast-helpers.ts:71` both write `action: { label, onClick }` — for
SONNER's runtime API (`import { toast } from 'sonner'`), a different interface
that never fed this key. The conclusion is unchanged (0 of 7 authored `toast`
nodes carry `action`); only the sentence was wider than the measurement, and a
measured zero must not be carried forward wider than what was counted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
❌ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. Which half objected:
📦 Bundle Size Report
Size Limits
|
|
#8541 is the same finding, filed by the
I have posted #8542's readings onto #8541 so the measurement lives on the earlier card. ⛔ I am not closing either — two seats' cards, and the merge is triage's call. ⇒ Follow #8541 first. Landing check ② on this PR is now EXIT 0 (re-run on head Generated by Claude Code |
❌ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. Which half objected:
📦 Bundle Size Report
Size Limits
|
Fixes #8338
Retires
ToastSchema.actionfrom BOTH published faces under ADR-0049 enforce-or-remove, the direction ruled at comment 5571798211.action?: neveron the declaration,retirementTombstone()on the mirror.The spine: it sits four lines above a tombstone that gives its own reason
ToastSchema.onDismisshas been an ADR-0049 tombstone since objectui#6124, and its prose reads "JSON has no function value, and thetoastrenderer takes({ schema })and never reads it." Every clause of that sentence is true ofactionas well. It survived only because that sweep was over TOP-LEVEL function-valued keys and this key's function is one level down — inside a required object, which is strictly worse: not "a function key is inert", but no JSON value satisfies the whole member.The two faces shared nothing. The declaration required
labelandonClick; the mirror admittedSchemaNode | SchemaNode[]. One document, a greensafeParseand atscrefusal, and no spelling that satisfied both. The renderer read neither (it readsvariant,title,description,duration,buttonVariant,className,buttonLabel; the file has exactly oneComponentRegistry.registercall, so the zero is a reading and not a failed scan).The parity-operator question triage ordered an answer to
Answered at comment 5572120924, and re-derived here on
3bc187b7frather than quoted forward. The card offered two readings — the operator judgedactionclean, or it excused the key into the SCHEMA-NODE bucket. Neither. A third mechanism: with the mirror's input face readingunknown(the objectui#7760 defect), the wider-direction comparison could not form a reading at all, so no row existed to be right or wrong about. PR #8354 gave the recursive mirrors their declaration as a type argument, and on today's tree the key is ENTERED inKnownDrift, inWiderThanDeclared, and inWIDER_ARMS.So the card body's section "The parity ledgers do not record it" is historical and is not quoted forward. What the retirement owed was ordinary ledger work: drain those rows and move the header's pinned figures.
The header figures, derived and not stepped by hand
Read by
zod-mirror-parity.test.ts's own AST and mirror instruments — the ledger rows were removed first, the pins were run, and the numbers below are the ones those pins printed:KnownDriftKnownDriftrestatement (~120 lines down)WiderThanDeclaredEXPECTED_MIRROR_PAIRSCONCRETE fell by 1 while
armsfell by 2 — the split counts KEYS and the arm total counts ARMS, through different reducers. A hand-stepped CONCRETE was wrong by one when this was written, which is why the header now says so in as many words.The removal is not merely allowed, it is required by the compiler: the ablation below shows
tsc -p tsconfig.test.jsonreporting TS2322 at three sites in the ledger (Type '"feedback.zod.ts#ToastSchema"' is not assignable to type 'never') the moment the divergence goes away and the rows stay. The rows drain because the divergence is gone, and the type-level reconciliation is what says so.Two-direction ablation, with the restore proved
One run, both legs, under a
trap ... EXIT INT TERM, mutating by checking the base tree's two declarations back in and restoring withgit checkout HEAD --:Ablated, the runtime pins go red on exactly the shapes that used to parse:
Ablated,
tsc -p tsconfig.test.jsonreports:Only ONE of the two
@ts-expect-errordirectives goes unused under ablation, and that is correct: the old declaration accepted the{ label, onClick }object but still refused a node, so the second directive keeps firing. Stated because a report that claimed both would be claiming more than was measured.The mirror also admitted BARE PRIMITIVES — re-derived, and larger than first read
SchemaNodeSchemaisz.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), z.null(), z.undefined()])(zod/base.zod.ts:84), so the oldz.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])accepted far more than two node shapes. Re-derived by re-forming the old spelling around the SHIPPEDSchemaNodeSchemarather than a hand-rebuilt one, with a non-vacuity control (the two envelopes must disagree on the node case, else the probe compares a schema with itself — measuredold=true new=false):action=undefined{ "type": "button", "label": "Undo" },[{ "type": "button" }]invalid_type"Undo",1,true,null,[],["a", 1]invalid_type{ "label": "Undo", "onClick": … },{}invalid_unioninvalid_type[[{ "type": "button" }]]— a nested listinvalid_unioninvalid_typeThe primitive row is the LARGER half of this narrowing and the easy one to miss; the changeset's table now carries it.
RETIRED_MIRROR_SHAPESgained'Undo',1andnull, and the list's MIXEDness is now pinned in its own right: vitest 4.1.10 spreads anit.eachcase only whencases.every(Array.isArray), so the list arm arriving as ONE argument had been resting on an undeclared property of the data. The three added cases show up as three added tests (2795 -> 2799 with the mixedness pin), which is the reading that they were passed whole rather than spread.The tombstone message was respelled, and the emitted string is asserted unchanged
scripts/check-widening-tells.mjs's T2 arm matchesBARE_STRING_ELEMENT— an indented bare quoted string that ENDS a line — so the tombstone's first message fragment, sitting alone on its own line, read as a closed set gaining a member and this NARROWING reported one widening tell. Exactly one, because every continuation carries a leading+, which is itself the proof of the diagnosis. The fragment moved onto theretirementTombstone(call line; no message TEXT changed.Asserted, not eyeballed: the emitted
.describe()string is read off a rebuiltdistbefore and after and hashed — len 732, sha25610b0b510c8b4ece4ab594373f870644dcefb9816b9bc92608b0791a060190fcc, identical, with the probe exiting non-zero on any drift; the parse-time issue message still equals.describe()and the code is stillinvalid_type. Self-scan: the sameBARE_STRING_ELEMENTregex over every ADDED line of the whole diff now finds 0 hits, with a synthetic'destructive',as the control that the regex fires.retirementTombstone(), and why NOT the neighbour one line downTriage's model was "照
onDismiss一模一样的处理,四行之外就是范本". Measured, the neighbour ishandlerKeyRefusal('onDismiss', 'retired', 'Dismiss handler')— the handler-key variant, which reportscustomand whose remedy sentence is fixed at "author behaviour as a NODE TYPE".actionis not a handler key: it is a value key whose nested member was a function. It takesretirementTombstone()(invalid_type, and a guidance string this site writes for itself), which is what triage actually named. Copying line 77 verbatim is the obvious move and it is the wrong one.tombstone.zod.tsis called, not edited.What replaces it: nothing, and it was never fulfilled
Not "not yet supported" and not a pointer at a future shape. objectui#6250's close moved all seven toast demos off an in-toast action entirely; an in-toast action button is a capability expansion with zero runtime today and would need its own card. The tombstone points at the keys that DO run: raise the toast from the node itself and label its trigger with
buttonLabel/buttonVariant.Repo census: of the 7 authored
toastnodes in tracked JSON, 0 carryaction(control: the same walk finds all 7), and no source file authors one ON AtoastNODE. Stated that narrowly on purpose —app-shell/src/chrome/notificationToast.tsx:76andchrome/toast-helpers.ts:71DO writeaction: { label, onClick }, for sonner's runtime API, a different interface this key never fed. The changeset carries the same correction: a measured zero must not be quoted wider than what was counted.Not
EmptySchema.actionThe same word lives 69 lines below in the same file (
feedback.zod.ts:152), on a sibling schema, with the deliberately opposite disposition (objectui#7105, PR #8330) and its own comment block explaining that it refuses the{ label, onClick }shape this key spelled. It is untouched. Two different interfaces, on purpose.Verification
Every exit code captured by redirect BEFORE any pipe; every verdict quoted from the gate's own line, never from a bare
$?.vitest run --project unit packages/typespnpm --filter @object-ui/types type-checktsc --noEmit,tsconfig.examples.json,tsconfig.test.jsonpnpm --filter @object-ui/components type-checkToastSchema, against a rebuiltdistpnpm --filter '@object-ui/components^...' buildpnpm --filter @object-ui/types lintnode scripts/check-changeset-presence.mjspnpm changeset:checkmajornode scripts/check-control-bytes.mjsnode scripts/check-governed-queue-guard.mjs --testpnpm check:readme-exportspnpm check:published-distpnpm check:esm-specifiersvitestalone would be a FALSE GREEN for this ledger: its type-level reconciliation is judged bytsc -p tsconfig.test.json, and--listFilesconfirms that project compiles bothzod-mirror-parity.test.tsandtoast-button-keys.test.ts— so the green is a measurement and not an absence.All figures re-derived on
e8d984461, the final commit; the ratchet family (packages/types tests + all three tsc projects) was re-run on that exact head and is exit 0.验收备注
content/docs/components/feedback/toast.mdx:40-44still teachesaction?: { label, onClick }in itsplaintextschema fence. Outside this PR's declared file surface, and measured to be read by NO gate (check-doc-component-types.mjsstates in its own header that it judges thetypestring only). The snippet was already uncopyable before this PR — no JSON document satisfied it — so this is a pre-existing teaching defect whose failure mode this PR changes. Same shape as objectui#7340 and objectui#7239, both of which were filed as their own docs cards rather than ridden into the tombstone PR.packages/components/src/__tests__/empty-action-declared-7105.test.tsx:149says in a comment that{ label, onClick }"isToastSchema.action's shape" — present tense, now historical. Cosmetic prose inside another card's pin file (objectui#7105 / PR feat(types,components): declare EmptySchema.action as SchemaNode and enforce it #8330), which this PR is ruled not to touch. Whoever next edits that pin will meet it.position,buttonLabel,buttonVariantand theonDismisstombstone. A hand-maintained subset; recorded on objectui#8539 as an observation and deliberately not folded into its ask. Successor: whoever takes objectui#8539.SKILL.md:643puts*.zod.tson the contract path limb, so the pre-enqueue in-seat contract review is the PM's to run. This seat does not enqueue, does not flip to ready, and hangs no label the dispatch did not name.Bundle Analysisis red here and is ⛔ NOT this PR's:mainitself has been red on that check sincef76f4362, four consecutive merges, with theframeworkchunk over its per-chunk ceiling. Tracked at objectui#8542. Nothing was trimmed for bytes andPER_CHUNK_GZIP_CEILINGSis untouched.Clause-②: no— the retirement NARROWS both faces. Re-checked against the landing: the mirror's accept set loses the node union and gains nothing, and the declaration goes from an object type tonever. Nothing widens.🤖 Generated with Claude Code
https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
Generated by Claude Code