Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .changeset/6496-toast-button-keys.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
---
'@object-ui/types': minor
---

`ToastSchema` now declares the two trigger-button keys the `toast` renderer actually reads
(objectui#6496, triage scope cut 2026-08-26 — the same declare-what-runs family as
objectui#6170).

`renderers/feedback/toast.tsx` renders a `<Button>` that raises the toast, and reads two
keys off the node to do it: `variant={schema.buttonVariant}` and
`{schema.buttonLabel || 'Show Toast'}`. `ToastSchema` declared **neither**, on the TS face
or in the `@object-ui/types/zod` mirror. 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; `buttonVariant` was read by the renderer and named by
nothing at all. `SonnerSchema` — the sibling with the identical trigger mechanism —
declared both all along, so only one of the two components was expressible.

The visible cost was on objectui#6250: with `buttonLabel` undeclared, its seven corrected
toast demos could not author a per-demo trigger label the way the corrected sonner demos
could, and all seven render the default `Show Toast`. Those demos are unblocked by this.

**`buttonVariant` is declared as the six Button variants, on both faces.** The model this
card was told to copy disagrees with itself: `SonnerSchema` spells the key `z.string()` in
the zod mirror and `'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'`
in TS. Matching by symmetry picks nothing, so the shape was taken from what the value
*reaches* — the renderer passes it straight into `<Button variant={…}>`, whose prop type is
`VariantProps<typeof buttonVariants>['variant']`, exactly those six. The TS face is the
correct one and both faces here carry it.

An open `string` is not merely under-validation. Measured on `cva` 0.7.1: an unrecognised
variant key contributes **no** variant class, and `defaultVariants` applies only when the
value is *absent* — so `buttonVariant: 'primary'` renders a button with no background and
no text colour, silently, while `buttonVariant: undefined` renders the default look
correctly. `primary` is the likeliest wrong spelling precisely because the default
variant's own class is `bg-primary`. And `buttonVariant: ''` is silently resolved to
`default` by the same falsy fallback — the one wrong value that does not *look* wrong,
which an open `string` would accept and never signal. That the declared six are the Button's own vocabulary
is pinned in both directions in `components/src/__tests__/toast-button-variant-parity.test.ts`
— `@object-ui/types` has zero deps and cannot import the Button, so the list there is
necessarily hand-copied, and that file is what stops it being a copy that can drift.

**`SonnerSchema`'s own two faces are left disagreeing.** Its mirror stays `z.string()`.
That is a real defect on a published surface, and it is filed as objectui#6541 rather than
fixed here —
this card's face is `ToastSchema`, and narrowing a second published key is its own
accept-set change with its own consumers to measure.

**Direction 2 of the finding is untouched.** `action` and `onDismiss` are declared on
`ToastSchema` and read by no renderer; they sit immediately adjacent to this edit and are
byte-identical after it. They are enforce-or-remove on a published type and belong to the
objectui#6124 unsatisfiable-mirror census feeding the objectui#6182 handler-dialect
decision; they are deliberately not pinned here either, so that family's ruling lands
without a test of this card's to negotiate with.

Accept-set note for consumers: both keys are **optional** and materialise no default, so
nothing that renders today stops rendering and no stored toast JSON becomes invalid. Two
keys that previously resolved as `any` through `BaseSchema`'s index signature are now
typed, so `buttonLabel: 42` and a `buttonVariant` outside the six are a type error and a
Zod rejection where they used to pass silently — values that never rendered correctly in
the first place. `BaseSchema` is untouched, so an *undeclared* key is still accepted by
both halves (objectui#5155 / objectui#6269 own that ceiling); declaring these two bought
validation of the declared keys, not rejection of misspellings.
113 changes: 113 additions & 0 deletions packages/components/src/__tests__/toast-button-variant-parity.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `ToastSchema['buttonVariant']` ↔ Button vocabulary parity (objectui#6496).
*
* objectui#6496 declared `buttonLabel` / `buttonVariant` on `ToastSchema`, and
* the shape of the second one was the card's whole judgement call. The sibling
* it was told to copy disagrees with ITSELF — `SonnerSchema` spells
* `buttonVariant` as `z.string()` in the zod mirror and as a six-member union
* in TS — so "match the sibling" picks nothing. The ground truth is what the
* value REACHES: `renderers/feedback/toast.tsx:30` passes it straight into
* `<Button variant={…}>`, so `ButtonProps['variant']` is the authority.
*
* `@object-ui/types` has zero deps and cannot import the Button, so the
* declaration there is necessarily a hand-copied list. THIS file is what stops
* that list being a copy: it lives where both are visible and compares them.
* Add a seventh variant to `buttonVariants` and the type-level half below goes
* red naming the gap, instead of the schema quietly refusing a real look.
*
* The runtime half measures the thing that makes an open `string` wrong. `cva`
* contributes NO variant class for an unrecognised key, and falls back to
* `defaultVariants` only when the value is ABSENT — so a string outside the six
* does not render "some other style", it renders a button with no background
* and no text colour. That is the concrete cost the enum buys out, and it is
* measured here rather than asserted in prose.
*/

import { describe, it, expect } from 'vitest';
import type { ToastSchema } from '@object-ui/types';
import { buttonVariants, type ButtonProps } from '../ui/button';

/** The six as `ToastSchema` declares them. */
const DECLARED = ['default', 'secondary', 'destructive', 'outline', 'ghost', 'link'] as const;

/**
* What `buttonVariants` emits for a value it does not recognise: the base
* classes and nothing else. Every "this is not a real variant" assertion below
* is measured against this string rather than against a hand-written class list.
*/
const NO_VARIANT_CLASSES = buttonVariants({ variant: '__not-a-variant__' as never });

describe('the declared `buttonVariant` vocabulary is the Button’s own', () => {
it('every declared value is a real Button variant (it contributes a class)', () => {
const inert = DECLARED.filter((v) => buttonVariants({ variant: v }) === NO_VARIANT_CLASSES);
expect(inert, 'declared, but the Button draws nothing for them').toEqual([]);
});

it('every declared value is DISTINCT — no two collapse to the same look', () => {
const rendered = DECLARED.map((v) => buttonVariants({ variant: v }));
expect(new Set(rendered).size).toBe(DECLARED.length);
});

it('the declared list matches `ButtonProps["variant"]` in BOTH directions', () => {
// The exhaustiveness pin, and the one that cannot be satisfied by copying.
// `NonNullable` strips the `null | undefined` that `VariantProps` adds.
// - `declaredIsAccepted` fails if the schema declares a value the Button
// does not accept (the schema would invite a look that renders blank);
// - `acceptedIsDeclared` fails if the Button accepts a value the schema
// does not declare (a real look the schema refuses — what a seventh
// variant would cause).
type Accepted = NonNullable<ButtonProps['variant']>;
type Declared = NonNullable<ToastSchema['buttonVariant']>;

const declaredIsAccepted: Accepted = null as unknown as Declared;
const acceptedIsDeclared: Declared = null as unknown as Accepted;

expect([declaredIsAccepted, acceptedIsDeclared]).toHaveLength(2);
});

it('the runtime list and the declared TYPE are the same six', () => {
// Guards the array literal above against drifting from the type it claims
// to enumerate — the array is what the runtime assertions iterate.
const typed: NonNullable<ToastSchema['buttonVariant']>[] = [...DECLARED];
expect(typed).toHaveLength(6);
});
});

describe('why the mirror is an enum and not `z.string()`', () => {
it('an unrecognised variant renders NO colour — it is not merely unusual', () => {
// `primary` is the likeliest wrong spelling: the DEFAULT variant's own class
// is `bg-primary`, so the name reads correct and produces a blank button.
for (const bogus of ['primary', 'danger', 'warning', 'Default']) {
expect(buttonVariants({ variant: bogus as never }), bogus).toBe(NO_VARIANT_CLASSES);
}
expect(NO_VARIANT_CLASSES).not.toContain('bg-primary');
});

it('an EMPTY variant is silently reinterpreted as `default` — cva’s falsy fallback', () => {
// Measured, and worth its own pin because it is the one wrong value that
// does NOT look wrong. `cva` resolves
// `falsyToString(props.variant) || falsyToString(defaultVariants.variant)`,
// so `''` never reaches the variant table: it takes the default look. Under
// an open `z.string()` an author could write `buttonVariant: ''`, see a
// correctly styled button, and never learn the value meant nothing. The
// enum refuses it instead — pinned on the mirror side in
// `types/src/__tests__/toast-button-keys.test.ts`.
expect(buttonVariants({ variant: '' as never })).toBe(buttonVariants({ variant: 'default' }));
expect(buttonVariants({ variant: '' as never })).not.toBe(NO_VARIANT_CLASSES);
});

it('an ABSENT variant still gets the default look — so optional stays safe', () => {
// This is why both keys are declared OPTIONAL rather than defaulted in the
// schema: omission already has a defined, styled outcome downstream.
expect(buttonVariants({ variant: undefined })).toBe(buttonVariants({ variant: 'default' }));
expect(buttonVariants({ variant: undefined })).not.toBe(NO_VARIANT_CLASSES);
});
});
Loading
Loading