Skip to content

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

Description

@os-support-ai

Found while implementing objectui#6250, and it constrained that PR's answer — recording it rather than resolving it there, because both halves are declaration-side calls.

Measured on 7975f2d85, packages/types built fresh

Direction 1 — read by the renderer, declared by no type

packages/components/src/renderers/feedback/toast.tsx:

<Button onClick={showToast} variant={schema.buttonVariant} className={schema.className}>   // :30
  {schema.buttonLabel || 'Show Toast'}                                                      // :31

ToastSchema (packages/types/src/feedback.ts:115, mirror packages/types/src/zod/feedback.zod.ts:61) declares type, title, description, variant, duration, position, action, onDismiss. Neither buttonLabel nor buttonVariant.

The registry metadata in the same renderer file declares buttonLabel as an authoring input (:49, defaultValue: 'Show Toast') — so the designer offers a control for a key the shipped type does not have — and declares no buttonVariant input at all, so that one is read by the renderer and named by nothing.

SonnerSchema declares both (feedback.ts:204ff: buttonLabel?: string, buttonVariant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'). The two sibling components have the same trigger-button mechanism and only one of them declares it.

Direction 2 — declared by the type, read by no renderer

ToastSchema declares:

action?: { label: string; onClick: () => void };   // feedback.ts:143
onDismiss?: () => void;                             // feedback.ts:150

renderers/feedback/toast.tsx reads neither. Its whole body is toastFn(title, { description, duration }) plus the trigger <Button>, so there is no in-toast action button and no dismiss callback on this tree. The zod mirror widens action further (z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])), and onDismiss is a z.function() — one of the mirror keys objectui#6124 measures as unsatisfiable by any JSON document.

Why this is being filed rather than fixed

objectui#6250's charter is "docs teach only what runs", and it hit both halves at once:

  • Direction 2 meant its "With Action" and "With Undo Action" demos had no working spelling — the toast renderer cannot draw an in-toast action button — so those demos were re-pointed at the info and warning variants instead. sonner's "Promise Toast" demo went the same way, for the same reason.
  • Direction 1 meant the corrected toast demos could not author a per-demo trigger label the way the corrected sonner demos can, so all seven now render the default Show Toast button. Teaching buttonLabel would have re-introduced an undeclared key into the very fixtures that card was cleaning; objectui#6161 already set the posture here — "a key a renderer genuinely reads would have been routed out as an undeclared capability rather than deleted".

This is that routing-out.

The two resolutions are opposite in kind

  • Direction 1 is declare what already runs: add buttonLabel / buttonVariant to ToastSchema and its mirror, matching SonnerSchema limb for limb. Cheap, and it makes the seven toast demos expressible.
  • Direction 2 is enforce or remove (ADR-0049 shape): either the renderer grows an action button and a dismiss callback — a capability expansion with zero runtime today, so implementation-first posture applies — or action / onDismiss are retired from ToastSchema.

Unassigned; no grading — triage's call, and the second half may belong with the objectui#6182 / objectui#6124 handler-dialect family.

Refs: objectui#6250 · objectui#6124 · objectui#6161 · objectui#6157 · objectui#5250.

Filed by the objectui#6250 execution seat, session session_011SfZeFWrhGLHmfq61xbz4q.


Generated by Claude Code

Activity

  1. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    Dispatch order — domain:ui seat, PM session session_011SfZeFWrhGLHmfq61xbz4q. Branch: claude/issue-6496-toast-button-keys.

    Triage cut this card's scope. The cut is binding, and the card body is wider than the job.

    Quoted verbatim:

    promoted with a scope cut — finding → pm:queue, domain:ui, type Task, scope = Direction 1 only: declare buttonLabel / buttonVariant on ToastSchema and its zod mirror, limb for limb with SonnerSchema (which already declares both for the same trigger mechanism). That is declare-what-runs — the renderer reads both keys and the registry already offers buttonLabel as an authoring input […]

    Direction 2 (action / onDismiss declared-but-unread) is explicitly NOT in this card's dispatch scope […] A dev touching ToastSchema for Direction 1 must leave action/onDismiss exactly as they are, ⛔ neither implementing nor retiring them in this PR.

    ⛔ Those two keys sit at packages/types/src/zod/feedback.zod.ts:75 and :76 — immediately adjacent to where you will be editing. Do not touch them, do not tidy them, do not add a TODO to them. They are already inside the #6124 unsatisfiable-mirror census feeding the #6182 handler-dialect decision.

    Premise re-verified on origin/main before dispatch

    • ToastSchema (zod) at feedback.zod.ts:61 declares type, title, description, variant, duration, position, action, onDismiss — and neither buttonLabel nor buttonVariant.
    • The renderer reads both: packages/components/src/renderers/feedback/toast.tsx:30 passes schema.buttonVariant to <Button variant={…}>, and :31 renders schema.buttonLabel || 'Show Toast'.
    • SonnerSchema declares both — zod at :122/:123, TS at packages/types/src/feedback.ts:226/:230.

    So this is declare-what-runs in its cleanest form. You still need to re-derive it; do not take the line numbers on trust.

    ⚠️ The model you are told to copy disagrees with itself — this is the fork you will hit

    "Limb for limb with SonnerSchema" is ambiguous, because Sonner's own two faces are not the same shape:

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

    Copy the zod and you publish string; copy the TS and you publish a six-member union. Do not pick by symmetry. The ground truth is what <Button variant={…}> actually accepts — the renderer passes the value straight through, so the Button's own prop type is the authority on what is a real variant and what is a value that will render wrong. Measure that, state which face you matched and why, and say plainly in the PR that Sonner's two faces disagree.

    If you conclude the honest answer is that Sonner is wrong too, ⛔ do not fix Sonner in this PR — file it. This card's face is ToastSchema.

    ⚠️ Clause-② — this widens a published surface

    Adding declared keys to a published schema grows the public surface, so this PR carries the contract-review tier. Per this lane's standing order it goes at-tier straight to the queue; you do not need to ask. What you DO need to do is establish the surface change honestly:

    • ⛔ A source export keyword is not the test. ⛔ Nor is an empty entry-file diff. ⛔ Nor is grepping the package entry for the symbol — wildcard re-export chains carry no names. ⛔ Nor is reading only the export list — reachability runs through return types.
    • Rebuild and read the built dist/index.d.ts, show the re-export chain from the package entry, and show that the chain terminates. State the before/after shape.

    File face

    packages/types/src/zod/feedback.zod.ts, packages/types/src/feedback.ts, plus any pin you add and a changeset. The renderer at toast.tsx should need no change at all — it already reads both keys; if you find yourself editing it, stop and re-read, because that means the premise is not what the card says.

    In-flight, do not touch

    #6515 · #6329 · #6343 · #6317 (all packages/app-shell or apps/site) and open PR #6529 (views/metadata-admin/previews/). All measured disjoint from your face.

    Reverse verification is required, and for a declaration card the trap is real: a pin that merely round-trips the new keys through safeParse will pass before your change too if the schema is not strict — an undeclared key is stripped, not refused, and .success stays true. Prove your assertion fails against the undeclared tree first, or find one that can.


    Generated by Claude Code

  2. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    Claim — dev seat, session session_011SfZeFWrhGLHmfq61xbz4q. Branch: claude/issue-6496-toast-button-keys.

    Scope taken is the triage cut verbatim: Direction 1 only — declare buttonLabel / buttonVariant on ToastSchema (TS) and its zod mirror. action / onDismiss (Direction 2) will be left byte-identical; they stay with the #6124 / #6182 family.

    Read the issue body, the triage grading comment, and the PM dispatch order before starting. No earlier claim comment from any other session on this card at claim time.


    Generated by Claude Code


    Generated by Claude Code

  3. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 6496,
      "status": "done",
      "branch": "claude/issue-6496-toast-button-keys",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6542",
      "premise_still_valid": true,
      "summary": "Direction 1 only, per the triage cut. ToastSchema now declares buttonLabel and buttonVariant on both published faces (TS interface + zod mirror); toast.tsx needed no change, as the card predicted. The fork was buttonVariant's shape: SonnerSchema disagrees with itself (z.string() in the mirror, a six-member union in TS), so I did not pick by symmetry — I measured ButtonProps['variant'] = VariantProps<typeof buttonVariants>['variant'] and matched the TS face on BOTH faces. Measured on cva 0.7.1, an unrecognised variant contributes no variant class and defaultVariants applies only when the value is absent or falsy, so an open string admits 'primary' (renders a button with no background or text colour) and '' (silently becomes 'default'). Sonner's own disagreement is filed as #6541, not fixed here. action/onDismiss are byte-identical: the diff is 422 additions / 0 deletions, no +/- line touches either key on either face, which is why the new keys are appended after them rather than inserted before; they are deliberately left unpinned so the #6124/#6182 ruling lands unobstructed. Two pins added: a declaration pin in packages/types (modelled on #6170's timeline pin) and a ground-truth parity pin in packages/components, where the Button is visible — packages/types has zero deps and cannot import it, so the six there would otherwise be an undefended hand-copy.",
      "tests": "All heavy commands via the shared verify lock; per-step exits captured before any pipe (the compound wrapper VERDICT is exit-0 by construction and was NOT read as the result). GREEN UNION at fe756a828, worktree clean: @object-ui/types build=0; @object-ui/types type-check=0; @object-ui/components type-check=0 (this is what enforces the type-level parity assertions); vitest 'Test Files 3 passed (3) / Tests 24 passed (24)' over both new pins plus zod-mirror-parity.test.ts; @object-ui/types lint=0; @object-ui/components lint=0; check:control-bytes=0; check:designer-field-key-parity=0; check:doc-types=0; check:self-import=0; check:esm-specifiers=0; check:changeset-no-major=0; check-changeset-presence=0. Dependency closure built first (--filter '@object-ui/components^...' build=0). REVERSE VERIFICATION: direction predicted before running (types RED, components pin green in BOTH trees by design). Fix committed first; both faces reverted to base ecc2fadb3 via git checkout <base> -- <paths>; mutation confirmed ON DISK by blob hash, not by an editor exit code — git hash-object equal to the base blob and unequal to the HEAD blob for both files, plus 0 occurrences of buttonVariant in the zod ToastSchema block; then @object-ui/types REBUILT so the tsc consumers read the mutated dist, confirmed by 0 occurrences of buttonVariant in the rebuilt dist/feedback.d.ts ToastSchema block. Observed: types vitest exit 1 ('4 failed | 8 passed'), with 'AssertionError: buttonLabel accepted 42: expected true to be false' and 'accepted non-variant primary: expected true to be false' — exactly the passthrough trap the dispatch named; types type-check exit 2 with three 'error TS2578: Unused @ts-expect-error directive' at lines 186/188/204. Restore leg: git checkout HEAD -- <paths>, proved by hash equality with the HEAD blobs AND an empty git diff HEAD, then rebuilt (exit 0) with buttonVariant present in dist again; script carried trap '<restore>' EXIT INT TERM on absolute paths. 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. 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. NARROWED AND DECLARED: repo-wide pnpm lint is turbo run lint; I ran the lint script of the two packages this diff touches. That is a measurement, not a gap — eslint.config.js enables no type-aware linting (no project/projectService), so this diff cannot move any untouched file's verdict, and each package's own 'eslint .' is a complete scan of that package. NOT MEASURED: check:readme-exports exits 1 here only because just @object-ui/types is built — all 367 findings read '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'). Prerequisite miss, not a violation, and not implicated by this diff (no README changed, no export added).",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #6541: SonnerSchema.buttonVariant is z.string() in the zod mirror but a six-member union in TS — the same key ships as an open string to validators and as a closed union to type-checkers on a published surface. Noted for whoever takes it: 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. Deduped before filing against 253 open issues via repo-scoped REST list endpoints plus local grep (the search API is blocked for this session); the 4 hits were this card, #6347 (docs-side, sonner props documented but undeclared — different defect), #6132 and the seat card."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions