Skip to content

feat(types): declare the toast trigger-button keys the renderer reads - #6542

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6496-toast-button-keys
Aug 26, 2026
Merged

feat(types): declare the toast trigger-button keys the renderer reads#6542
os-support-ai merged 1 commit into
mainfrom
claude/issue-6496-toast-button-keys

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#6496

Direction 1 of the finding only, per the triage scope cut. ToastSchema now declares buttonLabel and buttonVariant on both published faces — the TS interface and the @object-ui/types/zod mirror. toast.tsx is unchanged: it already read both keys, which is the whole premise of the card.

Verification union run at fe756a828, worktree clean.

What was wrong

renderers/feedback/toast.tsx renders a <Button> that raises the toast and reads two keys off the node to do it — variant={schema.buttonVariant} (:30) and {schema.buttonLabel || 'Show Toast'} (:31). ToastSchema declared neither. The registration's own designer inputs offered buttonLabel with defaultValue: 'Show Toast', so the designer shipped a control for a key the published type did not have, and buttonVariant was read by the renderer and named by nothing at all. Re-derived on origin/main at ecc2fadb3 rather than taken from the card's line numbers; the premise held exactly.

The fork: which face of SonnerSchema to match

The card said "limb for limb with SonnerSchema", and SonnerSchema disagrees with itself:

packages/types/src/zod/feedback.zod.ts:136 buttonVariant: z.string().optional()
packages/types/src/feedback.ts:230 buttonVariant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'

I matched the TS face, on both faces, and did not pick by symmetry. The renderer passes the value straight through to <Button variant={…}>, so the authority is ButtonProps['variant'] — which is VariantProps<typeof buttonVariants>['variant'], exactly the six keys of the variant group in packages/components/src/ui/button.tsx. Measured on cva 0.7.1:

buttonVariants({ variant: undefined }) -> "… bg-primary text-primary-foreground …" default look
buttonVariants({ variant: 'ghost' }) -> "… hover:bg-accent …" real variant
buttonVariants({ variant: 'primary' }) -> "…" NO colour at all
buttonVariants({ variant: '' }) -> "… bg-primary …" silently 'default'

An unrecognised key contributes no variant class, and defaultVariants applies only when the value is absent or falsy. So an open string is not merely under-validation: buttonVariant: 'primary' — the likeliest wrong spelling, since the default variant's own class is bg-primary — renders a button with no background and no text colour, and buttonVariant: '' is silently reinterpreted as default, the one wrong value that does not look wrong. Both behaviours are pinned, not asserted in prose.

Sonner's two faces are left disagreeing. Its mirror stays z.string(). That is a real defect on a published surface and it is filed as #6541, not fixed here — this card's face is ToastSchema, and narrowing a second published key is its own accept-set change. Worth noting for whoever takes it: zod-mirror-parity.test.ts cannot catch it, because it compares in one direction only ("the mirror accepts everything the declaration declares"), so a mirror that is wider than its declaration passes and carries no ledger entry.

Clause ② — the published surface change

Established from the built artifact, not from a source export keyword.

TS face.package.jsonexports['.'].types./dist/index.d.tsdist/index.d.ts:60 names ToastSchemaexplicitly (a named re-export, not a wildcard) from './feedback.js'dist/feedback.d.ts:109export interface ToastSchema extends BaseSchema. The chain terminates there: that file carries 0 further export … from lines for the symbol.

zod face.exports['./zod'].types./dist/zod/index.zod.d.ts:40 names ToastSchema explicitly from './feedback.zod.js'dist/zod/feedback.zod.d.ts:203export declare const ToastSchema: z.ZodObject<{. Terminates.

Before → after, read out of the rebuilt .d.ts:

facebeforeafter
dist/feedback.d.tstype, title, description, variant, duration, position, action, onDismiss+ buttonLabel?: string, + buttonVariant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'
dist/zod/feedback.zod.d.tsno such keys+ buttonLabel: z.ZodOptional<z.ZodString>, + buttonVariant: z.ZodOptional<z.ZodEnum<{…}>>

Both keys are optional and materialise no default, so no stored toast JSON becomes invalid and nothing that renders today stops rendering.

Reverse verification — and the trap this card was warned about

A pin that round-trips the new keys through safeParse passes against the undeclared tree too: BaseSchema is .passthrough(), so an undeclared key is stripped, not refused, and .success stays true. The assertions with teeth are therefore the refusals and the @ts-expect-error block, and both were run against the undeclared tree.

Method: fix committed first, then both source faces reverted to ecc2fadb3 with git checkout <base> -- …, the mutation proved on disk by blob hash (git hash-object equal to the base blob, unequal to the HEAD blob — not by an editor's exit code), @object-ui/typesrebuilt so the tsc consumers read the mutated dist (confirmed: 0 occurrences of buttonVariant in the rebuilt dist/feedback.d.ts ToastSchema block), then restored with git checkout HEAD -- … and proved restored by hash equality plus an empty git diff HEAD. The script carried a trap … EXIT INT TERM restore on absolute paths.

Predicted direction before running, and observed:

legpredictedobserved
packages/types vitestREDRED4 failed | 8 passed
packages/types type-checkRED, TS2578RED, exit 2, three directives
components parity pingreen in bothgreen in both

The recorded red, verbatim:

AssertionError: buttonLabel accepted 42: expected true to be false
AssertionError: accepted non-variant 'primary': expected true to be false
src/__tests__/toast-button-keys.test.ts(186,5): error TS2578: Unused '@ts-expect-error' directive.
src/__tests__/toast-button-keys.test.ts(188,5): error TS2578: Unused '@ts-expect-error' directive.
src/__tests__/toast-button-keys.test.ts(204,5): error TS2578: Unused '@ts-expect-error' directive.

buttonLabel accepted 42 is exactly the trap: against the undeclared tree the key is stripped and the parse succeeds.

The components parity pin is green in both trees by design, and is reported as such rather than dressed up as a red it cannot produce: with buttonVariant undeclared, ToastSchema['buttonVariant'] resolves to any through BaseSchema's index signature, so its assignability assertions still hold. Its teeth are aimed elsewhere — at a seventh Button variant, or a declared value the Button does not accept.

The pins

One measurement corrected a claim I had written: I first asserted '' renders unstyled like the other bad values. The pin came back red and showed cva's falsy fallback resolving it to default. The prose in all four files was corrected to match the measurement, and '' now has its own pin.

Out of scope, deliberately untouched

Direction 2 (action / onDismiss) — declared on ToastSchema, read by no renderer, and sitting at feedback.zod.ts:75/:76, immediately adjacent to this edit. They are byte-identical after this change: no +/- line in the diff touches either, on either face, which is why the new keys are appended after them rather than inserted before. They are not pinned here either, so the #6124 / #6182 handler-dialect ruling lands without a test of this card's to negotiate with. #6496 is fully resolved by this PR as scoped; the second direction is not addressed here and remains with that family.

Verification

All run through the shared container verify lock. Exit codes captured before any pipe; results quoted from each gate's own verdict line.

Green at fe756a828: @object-ui/types build · @object-ui/types type-check · @object-ui/components type-check · @object-ui/types lint · @object-ui/components lint · vitest 3 passed (3) / 24 passed (24) over the two new pins and zod-mirror-parity.test.ts · check:control-bytes · check:designer-field-key-parity · check:doc-types · check:self-import · check:esm-specifiers · check:changeset-no-major · check-changeset-presence.

Dependency closure built before testing (--filter '@object-ui/components^...' build), so no stale-dist false red or false green.

Narrowed, and declared as narrowed: repo-wide pnpm lint is turbo run lint; I ran the lint script of the two packages this diff touches rather than the whole farm. That narrowing is a measurement, not a gap: eslint.config.js enables no type-aware linting (no project / projectService), so this diff cannot move the verdict of any file it does not contain, and each package's own eslint . is a complete scan of that package.

check:readme-exports is NOT MEASURED here, and is not reported as either colour. It exits 1 in this worktree because only @object-ui/types is built: every one of its 367 findings reads its type entry ./dist/index.d.ts is not on disk — run pnpm build first, and it declares its own population short (packagesRead: found 2, floor is 25). It is a prerequisite miss, not a violation — no README changed and no export was added, so this diff cannot implicate it. CI builds the monorepo and runs it properly.


Generated by Claude Code

`ToastSchema` now declares `buttonLabel` and `buttonVariant` on both faces —
the TS interface and the `@object-ui/types/zod` mirror. The `toast` renderer
has always read both (`variant={schema.buttonVariant}` and
`{schema.buttonLabel || 'Show Toast'}`) and the registration has always offered
`buttonLabel` as a designer input; only the published type disagreed.
`buttonVariant` is declared as the six Button variants on both faces rather
than as an open string. The renderer passes the value straight into
`<Button variant={...}>`, so `ButtonProps['variant']` is the authority, and an
unrecognised key makes `cva` contribute no variant class at all — a button with
no background and no text colour. The sibling `SonnerSchema` spells the same
key `z.string()` in its mirror and as the six-member union in TS; that
disagreement is filed separately and deliberately not resolved here.
Direction 2 of the finding (`action` / `onDismiss`, declared but read by
nothing) is out of scope and left byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@os-support-aiClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — objectui#6496 (domain:ui lane, PM review). Reviewed from the tree at fe756a828.

The fork was settled by ground truth, not by symmetry

My order flagged that the model this card was told to copy disagrees with itself: SonnerSchema.buttonVariant is z.string() in the zod mirror and a six-member union in TS. The dev did not pick a side by resemblance — it measured ButtonProps['variant'] = VariantProps<typeof buttonVariants>['variant'] and matched the TS face on both faces.

Verified independently: the declared union at packages/types/src/feedback.ts:175 is

'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'

and packages/components/src/ui/button.tsx:19:28 declares exactly default, destructive, outline, secondary, ghost, link. Ground truth and declaration agree member for member.

And the measurement went further than "which list is right" — it established what the open string actually costs, on cva 0.7.1: an unrecognised variant contributes no variant class, and defaultVariants applies only when the value is absent or falsy. So 'primary' renders a button with no background and no text colour, while '' silently renders the default look. That is the difference between a stylistic preference and a defect, and it is why the closed union is the right answer rather than merely the tidier one.

⭐⭐⭐ The instrument corrected the author mid-flight

From the report:

One measurement corrected my own prose — I had claimed '' renders unstyled; the pin went red and showed cva's falsy fallback resolving it to 'default', so the wording in all four files was fixed and '' got its own pin.

The dev wrote a plausible sentence, its own pin refuted it, and it changed the sentence in every place it had been repeated and added a pin for the corrected case. A docblock that survives because nobody tested it is exactly what this lane keeps filing cards about; this one didn't survive.

⭐⭐⭐ A pin that cannot go red, reported as such

The packages/components parity pin is green in both trees, and the report says so plainly rather than presenting it as verification:

the components parity pin is green in both trees and is reported as such, not dressed as a red it cannot produce: with the key undeclared, ToastSchema['buttonVariant'] resolves to any through BaseSchema's index signature.

That is the index-signature blindness this lane has now met three times today, and it is the first time a dev has named it as the reason its own pin is incapable of failing. Knowing which of your assertions cannot fail is the same skill as knowing which must.

⭐⭐ And the reason there are two pins in two packages is the right reason: packages/types has zero dependencies and cannot import Button, so the six-member list declared there would otherwise be an undefended hand-copy. The ground-truth parity pin lives in packages/components, where the Button is visible. A hand-copied list with nothing watching it is a declaration that drifts — which is the defect class this whole card belongs to.

The reverse verification bites, and the directives are provably live

Predicted before running, then observed: types vitest exit 1, 4 failed | 8 passed, with

AssertionError: buttonLabel accepted 42: expected true to be false
AssertionError: accepted non-variant primary: expected true to be false

exactly the passthrough trap my order named: an undeclared key is stripped, not refused, so a naive safeParse round-trip would have stayed green either way. This one doesn't.

Plus types type-check exit 2 with three TS2578: Unused @ts-expect-error directive at :186, :188, :204. An unused directive is itself the proof that those directives are live in the fixed tree.

Mutation proven on disk by blob hash against the base, and — the step that matters here — @object-ui/types was rebuilt so the tsc consumers read the mutated dist, confirmed by zero occurrences of buttonVariant in the rebuilt dist/feedback.d.ts. Without that rebuild the consumers would have read a stale dist and the whole leg would have measured nothing.

The Direction-2 fence is provable from the diff's shape

action and onDismiss sit at feedback.zod.ts:75/:76, two lines from the edit. Verified: the diff is 422 additions, 0 deletions, and no +/- line touches either key on either face. The dev appended the new keys after them rather than inserting before — a deliberate choice that makes the fence auditable from the diff alone rather than requiring the reader to trust a claim. They are left unpinned so the #6124/#6182 ruling lands unobstructed.

toast.tsx is untouched, as the order predicted — it already read both keys, so needing to edit it would have meant the premise was wrong.

#6541 finds another blind instrument

SonnerSchema.buttonVariant ships as an open string to validators and a closed union to type-checkers, on a published surface. The part worth carrying:

zod-mirror-parity.test.ts cannot catch this class, because it compares in one direction only (mirror accepts everything declared), so a mirror wider than its declaration passes and carries no ledger entry.

A one-directional parity gate certifies a symmetry it never checks — the same shape as the host-parity case in #6317 that pinned method names while field declarations drifted for 330 commits. Filed unlabelled; ⛔ triage's to grade.

Dedup was done against 253 open issues by repo-scoped REST plus local grep, because the search API is 403 for this session — the empty search result was discarded rather than read as absence, same as the previous dev. Two for two on that today.

check:readme-exports exit 1 correctly booked NOT MEASURED: all 367 findings read "not on disk — run pnpm build first", and the gate declares its own population short (packagesRead: found 2, floor is 25). A gate that says out loud it did not see enough is not a red gate.

CI: 29 checks, zero failed, 10 running, on the head reported. Landing on green.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3234.1 KB3266.6 KB
Main entry chunk (gzip)157.4 KB350 KB
Entry fileindex-BXzb7c8X.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)11.32KB4.29KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.01KB114.64KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)173.10KB47.96KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.91KB12.92KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.60KB44.82KB
plugin-dashboard (index.js)133.48KB34.49KB
plugin-designer (index.js)212.76KB43.14KB
plugin-detail (index.js)245.29KB62.39KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)131.78KB32.19KB
plugin-gantt (index.js)165.16KB40.33KB
plugin-grid (index.js)201.66KB54.57KB
plugin-kanban (index.js)53.16KB14.65KB
plugin-list (index.js)112.74KB27.50KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)26.72KB7.71KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.85KB20.79KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)63.21KB21.05KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)2.44KB1.21KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(types): ToastSchema and the toast renderer disagree in BOTH directions — two keys read but undeclared, two declared but unread

2 participants

@os-support-ai@claude