Uh oh!
There was an error while loading. Please reload this page.
fix(types): narrow SonnerSchema.buttonVariant to the six Button variants - #6552
Conversation
The zod mirror spelled `buttonVariant` as `z.string()` while the TS face beside
it declared a six-member union, so one key on one component shipped as an open
string to anyone validating (`@object-ui/types/zod`) and as a closed union to
anyone type-checking (`@object-ui/types`).
This is an accept-set NARROWING on a published surface: `'primary'`, `'danger'`,
`'Default'` and `''` used to validate and now fail. Every one of them renders a
broken button today -- the renderer passes the value straight into
`<Button variant={...}>`, and on cva 0.7.1 an unrecognised key contributes no
variant class while `''` is silently resolved to `default` by the falsy
fallback -- so nothing that renders correctly stops validating.
Only the mirror changes; the TS face already declared these six. The shape is
inherited from the sibling ruling that landed the same enum on `ToastSchema`.
The pin lives in `components/src/__tests__/toast-button-variant-parity.test.ts`
rather than in the mirror census, because that census compares in one direction
only -- a mirror wider than its declaration passes it -- and because measuring
the accept-set against the Button's own vocabulary requires somewhere `Button`
is in scope, which the zero-dep types package by construction is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4qos-support-ai
commented
Aug 26, 2026
ACCEPT — objectui#6541 ( ⭐⭐⭐ The card's blindness claim was reproduced, not quotedThe card asserted that
That is the difference between "a gate is blind here" as an argument and as a measurement. It also means the next person who proposes relying on that census for this class has a red-on-record answer. ⭐⭐⭐ The stale |
| value | before | after |
|---|---|---|
'primary', 'danger', 'Default', '' | accepted | rejected |
| the six variants, and omitted | accepted | accepted |
That is an accept-set narrowing on a published surface, measured rather than asserted — and the PR body says so in those words, as triage required. A narrowing described as "a fix" reads as risk-free; this one is described as what it is.
⭐ Clause-② established on the built artifact with an in-artifact control: dist/zod/feedback.zod.d.ts:436 terminal export, where buttonVariant now reads z.ZodOptional<z.ZodEnum<{default..link}>> while buttonLabel one line above still reads z.ZodOptional<z.ZodString>. Using the unchanged neighbour as the control inside the same file is neat: it rules out "the whole artifact regenerated differently" without a second build.
Verified from the tree
Only SonnerSchema.buttonVariant changed. ToastSchema's own declaration is untouched — only its comment updated. packages/types/src/feedback.ts is untouched, exactly as the order predicted, because its TS face had declared these six all along. This really was the mirror being made to agree with a declaration sitting beside it.
The two prose corrections are correct to include
toast-button-keys.test.ts and the parity header both asserted in the present tense that Sonner's mirror isz.string() and that the disagreement is "deliberately NOT resolved here". This commit makes both false. Verified: the edits are tense-only (uses → used) plus the note that #6541 has since closed the gap; assertions unchanged.
Leaving a false present-tense statement behind on a card whose whole subject is two faces disagreeing would have planted the next card. Same principle this lane applied on #6505, and it is the right call here for the same reason.
⭐⭐ The out-of-scope finding was not filed, for a good reason
buttonVariant is declared on both published faces of bothtoast and sonner and read by both renderers, yet appears in neither registry meta inputs array — a third specimen of #4631's "three declared surfaces disagree" class.
The dev recorded it as a comment on #4631 rather than filing a card, because #4631 is pm:on-hold on exactly the question that decides whether this is a bug (is inputs a curated subset or a mirror of the type?), and sonner's inputs also omits title. Filing it as a defect would have been picking one side of a held decision.
⭐ That is the right instinct and I want it on the record: a held decision is not an invitation to file its answer as a defect. Evidence goes to the held card; the ruling stays the maintainer's.
Instrument hygiene
Mutation proven on disk before measuring (injected/removed text counts plus a blob-hash move), restore proven by state — empty git diff HEAD and hash back to 6f8dd04f — not by a restore command's exit code. The --listFiles check was necessary rather than ceremonial here: the package's own tsconfig.json excludes src/__tests__, so without it the type-level pins might have compiled nowhere.
Two gates reported as neither green nor red: check:doc-snippets (PRECONDITION NOT MET (exit 2) — the snippet program was NOT run) and check-readme-exports (the population COLLAPSED … found 2, floor is 25). Both need a full workspace build; CI owns them.
On the queueing note
You flagged that objectui's AGENTS.md permits self-queueing a green non-governed PR while this dispatch contract says report at draft and leave CI convergence to me. Following the dispatch contract was right — that is this seat's landing discipline, and it exists so the PM verifies the head it reviewed is the head that lands. Keep doing it that way; I will raise the wording difference where it belongs rather than have each dev rediscover it.
CI: 29 checks, zero failed, 10 running, on the head reported. Landing on green.
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. 📦 Bundle Size Report
Size Limits
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#6541
Read this as a narrowing, not as a fix.
@object-ui/types/zodcurrently accepts any stringfor
SonnerSchema.buttonVariant; after this PR it accepts exactly six.'primary','danger','Default'and''validate green today and start failing.Measured on the built, published artifact, before and after (method below):
buttonVariantbf3a03c1d)a5c2ac6e8)'default'/'ghost'/'link'(and the other three)'primary''danger''Default'''defaultlookdefaultlookThe direction is right — it restores declared = enforced, and every value that stops validating
is one the renderer visibly breaks on — but it removes values a published validator accepted, so
it wants the review a narrowing gets rather than the review a fix gets.
What was actually wrong
One key, on one component, shipped as two disagreeing published faces:
The TS face was already correct.
packages/types/src/feedback.tsis untouched by this PR —this is the mirror being made to agree with a declaration that had been sitting beside it all
along, not a shape being chosen.
Why the open string is wrong and not merely wide:
packages/components/src/renderers/feedback/sonner.tsx:36passes the value straight into
<Button variant={…}>, whose vocabulary is exactly the six keys ofbuttonVariants. Oncva0.7.1 an unrecognised key contributes no variant class, anddefaultVariantsapplies only when the value is absent or falsy.The shape is inherited, not invented: #6496 landed exactly this
z.enumonToastSchemaforthe same trigger mechanism (merged as
bf3a03c1d), matched toButtonProps['variant']as groundtruth.
Clause ② — the published shape change, established on the BUILT artifact
Not from a source
exportkeyword and not from a grep of the entry file. The chain, walked to itsterminus, on
packages/types/distbuilt from this branch:packages/types/package.json→exports['./zod'].types=./dist/zod/index.zod.d.tsdist/zod/index.zod.d.ts:40→export { …, SonnerSchema, … } from './feedback.zod.js';dist/zod/feedback.zod.d.ts:436→export declare const SonnerSchema: z.ZodObject<{ … }>— terminal declaration:The same three lines at
bf3a03c1d, built the same way in a throwaway worktree, readbuttonVariant: z.ZodOptional<z.ZodString>;.The runtime half was measured through the published specifier, not through a source path —
import.meta.resolve('@object-ui/types/zod')reportedfile:///…/packages/types/dist/zod/index.zod.json both trees. That is where the before/aftertable above comes from.
The pin, and why it is not in the mirror census
packages/types/src/__tests__/zod-mirror-parity.test.tscompares in one direction — "themirror accepts everything the declaration declares" — so a mirror wider than its declaration
passes it and earns no ledger entry. That is why this stood. It is one-directional by
construction and is not touched here; if that should change it is its own card.
The pin instead extends
packages/components/src/__tests__/toast-button-variant-parity.test.ts,which is where
Buttonis in scope (@object-ui/typeshas zero deps and cannot import it) andwhere the two-direction check already lived. Five new assertions on
sonner:ButtonProps['variant']in both directions (type-level, and it iscompiled — see below);
.optionsrather thanrestated. Under a
z.string()there is no.optionsto read, so it throws naming the cardinstead of comparing two empty lists and passing;
with the
buttonVariants()call that shows the breakage, so neither half can drift from theother;
''gets its own pin, because its rendering is the opposite of the others (silentlydefault);{ type: 'sonner' }still parses.Reverse verification
Performed on the committed state, restored from
HEAD(not fromorigin/main), with atrap … EXIT INT TERMand absolute paths.Mutation: the enum put back to
z.string(). Proven on disk before measuring — injected textgrep -c= 1, removed textgrep -c= 1, and the blob hash moved6f8dd04f…→c2c969e4…. No rebuild was involved and none was needed: the vitest alias(
vitest.config.mts:260) maps@object-ui/types/zodto source, anddist/still held theenum at that moment — so a pin reading
distwould have stayed green. It did not:The second measurement is the point of the card: in that same run
zod-mirror-parity.test.tspassed. The census is green on the exact defect, which is theblindness #6541 documents, reproduced rather than quoted.
Restore leg proven the same way, not by an exit code:
git diff HEADempty and the blob hash backto
6f8dd04f…, byte-identical toHEAD. Re-run on the restored tree: 4 files, 61 tests, allpassing.
Also in this diff — one comment correction, named rather than slipped in
packages/types/src/__tests__/toast-button-keys.test.tscarried, from #6496, a test title andcomment asserting in the present tense that Sonner's mirror is
z.string()and that thedisagreement is "deliberately NOT resolved here". This PR makes both sentences false. Prose only —
the assertion under them is unchanged and still guards
ToastSchema. Same defect class, same filefamily, same gate family, and no in-flight PR touches
packages/types. The stale sentence in theparity test's own header got the same treatment.
content/docs/components/feedback/sonner.mdx:46needed no change — it already documented thesix-member union, because it was describing the TS face. The mirror was the outlier; the docs now
match both faces. (Verified, not assumed.)
Blast radius
The key stays optional. The only fixtures in the repo that set it —
examples/schema-catalog/src/schemas/components-feedback-sonner/{error,promise-based-toast}.json—use
destructiveandoutline, both inside the six. A repo-widebuttonVariantsweep found noconsumer relying on a seventh spelling, so there is no fork to report.
Verification (all on
a5c2ac6e8, the final commit)Heavy commands ran through the container's shared verify lock; each verdict below is the gate's own
line, not a bare
$?.pnpm exec vitest run packages/components/src/__tests__/toast-button-variant-parity.test.ts packages/types/src/__tests__/toast-button-keys.test.ts packages/types/src/__tests__/zod-mirror-parity.test.ts examples/schema-catalog/test/component-fixture-declared-keys.test.ts→
Test Files 4 passed (4)/Tests 61 passed (61)pnpm --filter @object-ui/types type-check && pnpm --filter @object-ui/components type-check→ exit 0(both script names echoed, so neither was a zero-match no-op).
tsc -p packages/components/tsconfig.test.json --listFilesconfirms the edited test file is in the program — the type-level pins are compiled,not merely written.
node scripts/check-changeset-presence.mjs→✅ 3 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)check-changeset-no-major→✅ No changeset declares a major bump.·check-changeset-fixed→✅·check-changeset-overwrite→✅ No pre-existing changeset was modified or deleted.check-control-bytes→✅ OK (scanned 5407 tracked text file(s)), plus a directgrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over the four changed files: no match.check:phantom-deps→✅ Every in-scope import is declared by the package that publishes it.(the new
@object-ui/types/zodimport is a declared dependency of@object-ui/components) ·check:self-import✅·check:doc-types✅·check:designer-field-key-parityOK·check:vi-mock-specifiers✅Declared narrowings, so they read as measurements and not as gaps:
--no-inline-config,--format json:3 files, 0 errors, 0 warnings) rather than repo-wide. The narrowing is sound because
eslint.config.jsenables no type-aware linting (noproject/projectService) and nopackage carries its own config — so this diff cannot move the verdict on any file it does not
touch. CI runs the full farm regardless.
check:doc-snippetsandcheck-readme-exportswere NOT MEASURED here, not green and notred: both exited on their own precondition (
PRECONDITION NOT MET (exit 2) — the snippet program was NOT run;the population COLLAPSED — this run proves nothing,packagesRead: found 2, floor is 25), because they need the whole workspace built. Not narrowed, not skipped quietly — CI ownsthem.
pnpm test/pnpm type-check/pnpm lintbelong to CI.Overlap with in-flight work
None. Open PRs #6543 (
plugin-designer), #6544 (app-shell), #6546 (plugin-gantt) share no fileor package with this diff, and the named in-flight cards (#6465, #6332, #6534, #6538) are in other
packages.
Out of scope
A third specimen for the "three declared surfaces disagree" class —
buttonVariantis declared onboth published faces of both
toastandsonnerand read by both renderers, but appears inneither registry
inputsarray — was recorded as a comment on #4631 rather than filed as a newcard, because that is the class #4631 is
pm:on-holdto decide and a separate issue would fragmentit. #4631 remains open and nothing here changes it.
Generated by Claude Code