From 390f5a5f7839ed57039f9bfa1df703fcc82962a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 16:57:52 +0000 Subject: [PATCH 1/2] feat(components): give `alert-dialog`'s confirm button a destructive variant (objectui#8978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AlertDialogSchema` declares `actionVariant?: 'default' | 'destructive'` and the renderer reads it onto the confirm button, so the red destructive confirm decision batch #70 granted on 2026-09-07 is finally authorable. ⛔ NOT under the spelling that ruling named. `confirmVariant` is retired with a tombstone (objectui#7963) because it was measured inert — it reached no DOM node at all — and re-adding a published spelling that reds today would be exactly the churn the maintainer flagged on 2026-09-10. It still reds, with the same code; only its MESSAGE moved, from "no replacement" to naming `actionVariant`. `actionVariant` is the `action*` dialect this node already uses for that button (`actionText`, `onAction`, `AlertDialogAction`), and the dialect the retirement itself pointed `confirmLabel` at. Two values and not `ButtonSchema.variant`'s six, by measurement rather than by deference: `packages/components/src/ui/**` is a No-Touch zone (AGENTS.md #7) and `AlertDialogAction` bakes in `cn(buttonVariants(), className)` with no variant prop, so the variant arrives as a className OVERRIDE. An override displaces only a baked-in class in its own tailwind-merge group, and rendered through the real renderer `outline` / `ghost` / `link` leave the primitive's background and/or text colour showing underneath while `default` / `destructive` land clean. Declaring a value the node cannot render is the retired key's disease one level down. The pin reads the confirm button's own `class` off the rendered DOM, never that a prop was passed, with the expected tokens computed FROM `buttonVariants` instead of typed in. Two legs so a probe on the wrong node cannot read clean — the destructive dialog carries the tokens and loses the ones they displace, the default dialog carries neither — plus an `UNTOUCHED` control proving a document without the key renders a byte-identical dialog, which is the blast-radius answer for every `AlertDialogAction` call site in the tree. The two schema-catalog fixtures whose confirm button authored `variant: "destructive"` before PR #7962 stripped it get it back. Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ Co-Authored-By: Claude --- .../8978-alert-dialog-action-variant.md | 50 +++ .../docs/components/overlay/alert-dialog.mdx | 3 + .../basic-alert-dialog.json | 3 +- .../destructive-action.json | 3 +- .../alert-dialog-action-variant-8978.test.tsx | 290 ++++++++++++++++++ .../src/renderers/overlay/alert-dialog.tsx | 27 +- ...rt-dialog-footer-keys-refusal-7963.test.ts | 51 ++- .../alert-dialog-read-dialect-7104.test.ts | 24 +- packages/types/src/overlay.ts | 51 ++- packages/types/src/zod/overlay.zod.ts | 39 ++- 10 files changed, 509 insertions(+), 32 deletions(-) create mode 100644 .changeset/8978-alert-dialog-action-variant.md create mode 100644 packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx diff --git a/.changeset/8978-alert-dialog-action-variant.md b/.changeset/8978-alert-dialog-action-variant.md new file mode 100644 index 0000000000..ea0bbc29e7 --- /dev/null +++ b/.changeset/8978-alert-dialog-action-variant.md @@ -0,0 +1,50 @@ +--- +'@object-ui/types': minor +'@object-ui/components': minor +--- + +`alert-dialog` can finally paint the red destructive confirm: `AlertDialogSchema` +declares `actionVariant?: 'default' | 'destructive'` and the renderer reads it +onto the confirm button (objectui#8978, the capability decision batch #70 +granted on 2026-09-07). + +**Additive, and nothing that renders today moves.** No default changes, no key +narrows, no existing document changes verdict. A document that does not author +`actionVariant` renders a BYTE-IDENTICAL dialog — asserted as the `UNTOUCHED` +control of the pin, not argued — which is also the whole blast-radius answer for +the `AlertDialogAction` call sites across the repo: they pass no variant, so they +get exactly what they got before. + +**Why the spelling is not `confirmVariant`.** That key is retired with a +tombstone (objectui#7963) because it was measured inert: it reached no DOM node +at all. Re-adding the same published spelling would be a retire-then-re-add cycle +on the authoring surface — 「协议不应该改来改去啊,否则元数据应用怎么办」 +(maintainer, 2026-09-10) — so a document that reds on `confirmVariant` today +still reds on it, with the same code. What moved is one published string: that +tombstone's MESSAGE used to say the key had no replacement and now names +`actionVariant`. `actionVariant` is the `action*` dialect this node already uses +for that button (`actionText`, `onAction`, `AlertDialogAction`), and it is the +same dialect the retirement itself pointed `confirmLabel` at. + +**Why two values and not `ButtonSchema.variant`'s six.** ⛔ Not deference — a +measurement. `packages/components/src/ui/**` is a No-Touch zone (AGENTS.md +Commandment #7) and `AlertDialogAction` bakes in `cn(buttonVariants(), className)` +with no variant prop, so the renderer applies the variant as a className +OVERRIDE, and an override can only displace a baked-in class that shares its +tailwind-merge group. Rendered through the real renderer, `default` and +`destructive` land clean; `outline`, `ghost` and `link` leave the primitive's own +background and/or text colour visible underneath. Declaring a value this node +cannot render is the `confirmVariant` disease one level down, at the value +instead of the key — so the union is exactly what the channel carries, and the +pin measures the admitted values AND the refused ones, so widening the union +without widening the mechanism reds. + +**The pin has a firing control**, which is the point of the exercise: +`packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx` +renders the dialog and reads the confirm button's own `class` off the DOM — never +that a prop was passed — with the expected tokens computed FROM `buttonVariants` +rather than typed in, so an upstream rename follows instead of going stale. + +The two schema-catalog fixtures whose confirm button authored `variant: +"destructive"` before PR #7962 had to strip it (`basic-alert-dialog`, +`destructive-action`) get it back, in the dialect the renderer reads. diff --git a/content/docs/components/overlay/alert-dialog.mdx b/content/docs/components/overlay/alert-dialog.mdx index b22a57d5fe..6cfa0bc345 100644 --- a/content/docs/components/overlay/alert-dialog.mdx +++ b/content/docs/components/overlay/alert-dialog.mdx @@ -33,6 +33,7 @@ interface AlertDialogSchema { content?: SchemaNode | SchemaNode[]; // Rendered between the header and the footer cancelText?: string; // Cancel button label; no cancel button when omitted actionText?: string; // Confirm button label; no confirm button when omitted + actionVariant?: 'default' | 'destructive'; // Confirm button variant; red confirm when 'destructive' // Open state defaultOpen?: boolean; // Initial state when uncontrolled (default: false) @@ -45,6 +46,8 @@ interface AlertDialogSchema { The footer is driven by the two label keys: the cancel button renders only when `cancelText` is set and the confirm button only when `actionText` is set — neither has a renderer default (the designer palette seeds `Cancel` / `Continue`). The confirm button's click handler, `onAction`, is a runtime slot a React host supplies through the TypeScript interface; it has no JSON spelling, and the validator refuses it by name. +The confirm button's look is `actionVariant`. Omit it and the button stays the default primary; write `'destructive'` for the red confirm a delete dialog wants. Those are the two values it takes — not the six a `button` node takes — because the renderer applies the variant as a class override on top of the primitive's built-in styling, and the remaining upstream variants set no background and/or no text colour, so the default's would show through. The cancel button is always `outline` and has no variant key. (The older spelling `confirmVariant` is retired and refused by name — it never reached the DOM.) + ## Examples ### With Custom Actions diff --git a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/basic-alert-dialog.json b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/basic-alert-dialog.json index ebc687d7da..eee8ac99d7 100644 --- a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/basic-alert-dialog.json +++ b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/basic-alert-dialog.json @@ -7,5 +7,6 @@ "label": "Delete Account" }, "cancelText": "Cancel", - "actionText": "Continue" + "actionText": "Continue", + "actionVariant": "destructive" } diff --git a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/destructive-action.json b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/destructive-action.json index cfd178b206..41f980bc76 100644 --- a/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/destructive-action.json +++ b/examples/schema-catalog/src/schemas/components-overlay-alert-dialog/destructive-action.json @@ -8,5 +8,6 @@ "variant": "destructive" }, "cancelText": "Cancel", - "actionText": "Delete" + "actionText": "Delete", + "actionVariant": "destructive" } diff --git a/packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx b/packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx new file mode 100644 index 0000000000..7f9744cd10 --- /dev/null +++ b/packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx @@ -0,0 +1,290 @@ +/** + * 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. + */ + +/** + * objectui#8978 — `AlertDialogSchema.actionVariant` MOVES THE CONFIRM BUTTON'S + * CLASS. Measured here, through the real `SchemaRenderer` and the real + * registry, by reading the `class` attribute off the rendered confirm button. + * + * ## Why this file exists at all, and why it reads the DOM + * + * The key this one replaces, `confirmVariant`, survived DECLARED AND DEAD for + * months. Nothing ever asserted that it moved anything: it was declared, it + * parsed green under `.passthrough()`, it rode the renderer's rest-spread into + * the Radix root — and died there, because that root renders a context + * provider rather than an element. Every reading that would have caught it is a + * DOM reading, and there was none. (Its post-mortem is + * `alert-dialog-footer-keys-liveness-7963.test.tsx`, kept standing next to this + * file; that one asserts the retired key still moves NOTHING, this one asserts + * the new key moves the one thing its name promises.) + * + * ⛔ So: no leg here asserts that a prop was passed, that a component received a + * value, or that a source file contains a spelling. Every leg renders a dialog + * and reads an attribute off a node. + * + * ## The oracle is READ, never typed in + * + * ⭐ `bg-destructive` is nowhere in this file as an expectation. The classes a + * variant emits belong to `buttonVariants` (`../ui/button`), which is a synced + * upstream file that can change under us, so the expected token set is computed + * FROM `buttonVariants` at run time and diffed against the default's. If + * upstream renames the token, this file follows it; if upstream ever makes the + * two variants emit the same classes, `ORACLE` below reds rather than letting + * every reading pass vacuously. + * + * ## The controls, and what each one buys + * + * - `WIRED` — the fixture, the registry and the renderer draw both footer + * buttons at all, so a class reading has something to read. + * - `ORACLE` — the computed expectation is non-empty: `destructive` really + * does emit tokens `default` does not. Without it, "the button carries every + * destructive token" is satisfied by the empty set. + * - `INSTRUMENT` — inside ONE dialog the cancel button's class already differs + * from the confirm button's, so this reading demonstrably separates one + * button variant from another on this very DOM. + * - `UNTOUCHED` — a document that does NOT author the key renders a dialog + * BYTE-IDENTICAL to one that never could. This is the blast-radius reading: + * every `AlertDialogAction` call site in the tree passes no variant, and this + * says what they now get, rather than arguing it. + * + * ## The mechanism leg — why the union is two values and not six + * + * The renderer cannot pass a variant PROP: `packages/components/src/ui/**` is a + * No-Touch zone (AGENTS.md Commandment #7) and `AlertDialogAction` bakes in + * `cn(buttonVariants(), className)`. So the variant arrives as a className + * OVERRIDE, and an override can only displace a baked-in class that shares its + * tailwind-merge group. `MECHANISM` below measures that boundary live, over the + * variants the union DOES admit and the ones it does NOT, so the narrowness of + * the declared union is re-derived on every run instead of being a sentence in + * a docblock that was true once. + */ + +import { describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import React from 'react'; +import { SchemaRenderer } from '@object-ui/react'; +import { AlertDialogSchema as AlertDialogMirror } from '@object-ui/types/zod'; +import { buttonVariants } from '../ui/button'; +// Registers the renderers at module scope, NOT inside a `beforeAll` — there the +// cold transform is billed to `hookTimeout` (objectui#3010/#3021). +import '../renderers'; + +/* ──────────────────────────────────────────────────────────────────────────── + * Harness + * ───────────────────────────────────────────────────────────────────────── */ + +/** Radix mints a fresh `useId()` per mount; normalise exactly that and nothing else. */ +function normalise(html: string): string { + return html + .replace(/«[^«»]*»/g, '«ID»') + .replace(/:r[0-9a-z]+:/g, ':ID:') + .replace(/\bradix-[A-Za-z0-9_-]+/g, 'radix-ID'); +} + +const CANCEL = 'Keep it'; +const CONFIRM = 'Delete'; + +interface Reading { + dialogHtml: string | null; + classOf: Record; +} + +/** Render one node through the REAL renderer and read the footer classes back. */ +function probe(node: Record): Reading { + cleanup(); + render(); + // Radix portals the content to `document.body`, so the RTL container is empty. + const dialog = document.body.querySelector('[role="alertdialog"]'); + const buttons = dialog ? Array.from(dialog.querySelectorAll('button')) : []; + const classOf: Record = {}; + for (const label of [CANCEL, CONFIRM]) { + const hit = buttons.find((b) => (b.textContent ?? '').trim() === label); + classOf[label] = hit ? tokens(hit.getAttribute('class') ?? '') : null; + } + const reading: Reading = { dialogHtml: dialog ? normalise(dialog.outerHTML) : null, classOf }; + cleanup(); + return reading; +} + +const tokens = (value: string): string[] => value.split(/\s+/).filter(Boolean); + +/** A complete alert-dialog in the dialect the renderer reads, forced open. */ +function baseNode(): Record { + return { + type: 'alert-dialog', + title: 'Delete this account?', + description: 'This action cannot be undone.', + trigger: { type: 'button', label: 'Delete account' }, + cancelText: CANCEL, + actionText: CONFIRM, + defaultOpen: true, + }; +} + +/** + * The tokens `variant` emits that `default` does not, read off `buttonVariants` + * itself. ⛔ Never typed in — see this file's header. + */ +function distinguishing(variant: 'default' | 'destructive' | 'outline' | 'ghost' | 'link' | 'secondary'): string[] { + const base = new Set(tokens(buttonVariants())); + return tokens(buttonVariants({ variant })).filter((token) => !base.has(token)); +} + +/** The tokens `default` emits that `variant` does not — what an override must DISPLACE. */ +function displaced(variant: 'outline' | 'ghost' | 'link' | 'secondary' | 'destructive'): string[] { + const other = new Set(tokens(buttonVariants({ variant }))); + return tokens(buttonVariants()).filter((token) => !other.has(token)); +} + +/* ──────────────────────────────────────────────────────────────────────────── + * Controls — every reading below is void if one of these reds + * ───────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8978 — controls: this harness can see the confirm button and its class', () => { + it('WIRED: the base fixture draws both footer buttons', () => { + const reading = probe(baseNode()); + + expect(reading.dialogHtml).not.toBeNull(); + expect(reading.classOf[CANCEL]).not.toBeNull(); + expect(reading.classOf[CONFIRM]).not.toBeNull(); + }); + + it('ORACLE: `destructive` really does emit tokens `default` does not, so the expectation is not the empty set', () => { + // Without this leg, `toEqual(expect.arrayContaining([]))` below is a + // tautology and every reading in this file passes against a renderer that + // changed nothing at all. + expect(distinguishing('destructive').length).toBeGreaterThan(0); + expect(distinguishing('default')).toEqual([]); + }); + + it('INSTRUMENT: the class reading separates two button variants on this very DOM', () => { + // `AlertDialogCancel` ships `buttonVariants({ variant: 'outline' })` and + // `AlertDialogAction` ships `buttonVariants()`, so a null reading below + // cannot be blamed on class strings being invisible here. + const { classOf } = probe(baseNode()); + + expect(classOf[CANCEL]).not.toEqual(classOf[CONFIRM]); + }); + + it('UNTOUCHED: omitting the key renders a BYTE-IDENTICAL dialog — the blast radius, read rather than argued', () => { + // Every `AlertDialogAction` call site in this tree passes no variant. The + // renderer passes `undefined` for an unauthored key, so `cn()` composes + // exactly what it composed before this card. + const withoutKey = probe(baseNode()); + const explicitUndefined = probe({ ...baseNode(), actionVariant: undefined }); + + expect(withoutKey.dialogHtml).not.toBeNull(); + expect(explicitUndefined.dialogHtml).toEqual(withoutKey.dialogHtml); + }); +}); + +/* ──────────────────────────────────────────────────────────────────────────── + * The two legs — a probe reading the wrong node cannot read "clean" + * ───────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8978 — `actionVariant` moves the confirm button class', () => { + it('LEG 1: the DESTRUCTIVE dialog\'s confirm button carries every token the destructive variant emits', () => { + const { classOf } = probe({ ...baseNode(), actionVariant: 'destructive' }); + + expect(classOf[CONFIRM]).toEqual(expect.arrayContaining(distinguishing('destructive'))); + }); + + it('LEG 1b: and the default look it displaces is GONE — an override that only adds would leave both', () => { + // The reading that separates "the red class is present" from "the button is + // red". Both sets are computed from `buttonVariants`, never typed in. + const { classOf } = probe({ ...baseNode(), actionVariant: 'destructive' }); + + expect(displaced('destructive').length).toBeGreaterThan(0); + for (const token of displaced('destructive')) expect(classOf[CONFIRM]).not.toContain(token); + }); + + it('LEG 2: the DEFAULT dialog\'s confirm button does NOT carry them, and still carries what it carries today', () => { + const authoredDefault = probe({ ...baseNode(), actionVariant: 'default' }); + const unauthored = probe(baseNode()); + + for (const token of distinguishing('destructive')) { + expect(unauthored.classOf[CONFIRM], token).not.toContain(token); + expect(authoredDefault.classOf[CONFIRM], token).not.toContain(token); + } + // `default` is the value the primitive already bakes in, so authoring it + // explicitly must be a no-op on the DOM rather than a second styling path. + expect(authoredDefault.dialogHtml).toEqual(unauthored.dialogHtml); + }); + + it('LEG 3: the CANCEL button is untouched by the confirm button\'s variant', () => { + const plain = probe(baseNode()); + const red = probe({ ...baseNode(), actionVariant: 'destructive' }); + + expect(red.classOf[CANCEL]).toEqual(plain.classOf[CANCEL]); + expect(red.classOf[CONFIRM]).not.toEqual(plain.classOf[CONFIRM]); + }); +}); + +/* ──────────────────────────────────────────────────────────────────────────── + * The mechanism that sets the union's width — re-derived, not recorded + * ───────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8978 — the override channel is why the union is two values wide', () => { + it.each(['default', 'destructive'] as const)( + 'MECHANISM+: `%s` is DECLARED, and the override lands clean — nothing it replaces survives on the node', + (variant) => { + const { classOf } = probe({ ...baseNode(), actionVariant: variant }); + const leftovers = displaced(variant).filter((token) => classOf[CONFIRM]?.includes(token)); + + expect(leftovers).toEqual([]); + }, + ); + + it.each(['outline', 'ghost', 'link'] as const)( + 'MECHANISM-: `%s` is NOT declared, and this DOM reading is why — the default shows through', + (variant) => { + // ⭐ The declared union is narrow because the channel is LOSSY, ⛔ not + // because a ruling said two. Forced past the type into the renderer, these + // three set no background and/or no text colour, so the primitive's + // baked-in tokens have nothing in their own tailwind-merge group to + // displace them and are still on the node afterwards. That is what + // "declared but cannot be rendered" looks like, and it is the shape this + // whole card exists to not repeat. + const { classOf } = probe({ ...baseNode(), actionVariant: variant }); + const leftovers = displaced(variant).filter((token) => classOf[CONFIRM]?.includes(token)); + + expect(leftovers.length).toBeGreaterThan(0); + // And the mirror refuses the value by name, so no AUTHOR can reach it. + expect(AlertDialogMirror.safeParse({ ...baseNode(), actionVariant: variant }).success).toBe(false); + }, + ); +}); + +/* ──────────────────────────────────────────────────────────────────────────── + * Closure against the authoring face + * ───────────────────────────────────────────────────────────────────────── */ + +describe('objectui#8978 — the authoring face admits exactly what the renderer draws', () => { + it.each(['default', 'destructive'] as const)('the mirror accepts `%s`, and the value SURVIVES the parse', (value) => { + const result = AlertDialogMirror.safeParse({ ...baseNode(), actionVariant: value }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.actionVariant).toBe(value); + }); + + it('CONTROL: the same document without the key parses green — the key is optional', () => { + expect(AlertDialogMirror.safeParse(baseNode()).success).toBe(true); + }); + + it('the retired spelling is still refused, and its message now names this key as the remedy', () => { + // ⛔ The retired key was NOT revived to carry this capability. It still reds, + // with the same code it has always reported; only the remedy it names moved. + const result = AlertDialogMirror.safeParse({ ...baseNode(), confirmVariant: 'destructive' }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((candidate) => candidate.path.join('.') === 'confirmVariant'); + expect(issue).toBeDefined(); + expect(issue?.message).toContain('actionVariant'); + expect(issue?.message).toContain('RETIRED'); + }); +}); diff --git a/packages/components/src/renderers/overlay/alert-dialog.tsx b/packages/components/src/renderers/overlay/alert-dialog.tsx index 77968a92c4..82fe9d2c99 100644 --- a/packages/components/src/renderers/overlay/alert-dialog.tsx +++ b/packages/components/src/renderers/overlay/alert-dialog.tsx @@ -17,7 +17,8 @@ import { AlertDialogTitle, AlertDialogDescription, AlertDialogAction, - AlertDialogCancel + AlertDialogCancel, + buttonVariants } from '../../ui'; import { renderChildren } from '../../lib/utils'; @@ -35,7 +36,24 @@ ComponentRegistry.register('alert-dialog', {renderChildren(schema.content)} {schema.cancelText && {schema.cancelText}} - {schema.actionText && {schema.actionText}} + {/* + * `actionVariant` (objectui#8978). `packages/components/src/ui/**` is a + * No-Touch zone (AGENTS.md #7) and `AlertDialogAction` bakes in + * `cn(buttonVariants(), className)` with no variant prop, so the variant + * is expressed as a className OVERRIDE that `cn()`'s tailwind-merge + * resolves over the baked-in default - the same shape + * `notifications/NotificationAlerts.tsx` already uses for this button. + * `undefined` when the key is absent, so an existing document's confirm + * button keeps the exact class it has today. + */} + {schema.actionText && ( + + {schema.actionText} + + )} @@ -48,6 +66,11 @@ ComponentRegistry.register('alert-dialog', { name: 'description', type: 'string' }, { name: 'cancelText', type: 'string' }, { name: 'actionText', type: 'string' }, + { + name: 'actionVariant', + type: 'enum', + enum: ['default', 'destructive'] + }, { name: 'defaultOpen', type: 'boolean' }, { name: 'trigger', diff --git a/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts b/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts index af42f4d1ae..289c8f29d4 100644 --- a/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts +++ b/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts @@ -154,18 +154,49 @@ describe('objectui#7963 — the `alert-dialog` node REFUSES its three footer key expect(message).not.toBe('Invalid input: expected never, received string'); }); - it('`confirmVariant`\'s message says plainly that it has NO survivor — ⛔ it does not point at a label key', () => { - // ⚠️ The asymmetry this card turns on. `cancelText` / `actionText` are the - // footer's two LABELS; neither does a variant's job, so a message shaped like - // its two siblings' would hand the author a key that cannot work. The two - // label spellings DO appear in the text — inside the sentence that rules them - // OUT — so the assertion is on the ruling-out, not on their absence. + it('`confirmVariant`\'s message names the remedy objectui#8978 declared — and still rules the two LABEL keys out', () => { + // ⚠️ RE-DERIVED, ⛔ not deleted. The pre-#8978 form of this leg asserted + // `NO surviving spelling` and `its own card`, because at the time there was + // no key that did this job and the retirement said so plainly. objectui#8978 + // IS that card, and it answered: `actionVariant`. So the leg flips to the + // other side of the same question — the message must now NAME the remedy — + // while the asymmetry the original turned on is untouched and still asserted: + // `cancelText` / `actionText` are the footer's two LABELS, neither does a + // variant's job, and the message still rules them out by name. + // + // ⛔ What did NOT change is the key's own verdict: `confirmVariant` still + // REDS, with the same `invalid_type` code as its two siblings (asserted + // above). A published spelling that refuses an author today must not accept + // one tomorrow — objectui#8978 took a new spelling precisely so that this + // stays true. const message = issueFor(RETIRED_DOC, 'confirmVariant')?.message ?? ''; expect(message).toContain('confirmVariant'); - expect(message).toContain('NO surviving spelling'); - expect(message).toContain('not a variant'); - expect(message).toMatch(/`cancelText` \/ `actionText` are NOT it/); - expect(message).toContain('its own card'); + expect(message).toContain('RETIRED'); + expect(message).toContain('objectui#7963'); + expect(message).toContain('actionVariant'); + expect(message).toContain('objectui#8978'); + expect(message).toMatch(/`cancelText` \/ `actionText` are NOT/); + expect(message).not.toBe('Invalid input: expected never, received string'); + }); + + it('the remedy THAT message names actually parses — and the confirm button it styles is a real reading elsewhere', () => { + // The other half of the leg above: a message is only a remedy if the key it + // names works. ⛔ This asserts the AUTHORING face only; that the value moves + // the confirm button's class is a DOM reading and lives where a DOM exists + // (`packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx`). + const result = AlertDialogZod.safeParse({ ...REMEDY_DOC, actionVariant: 'destructive' }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.actionVariant).toBe('destructive'); + }); + + it('CONTROL — the remedy key is a NARROW enum, so the leg above is not just passthrough admitting anything', () => { + // Without this, the leg above passes identically against a mirror that never + // declared `actionVariant` at all: `BaseSchemaCore` is `.passthrough()`, so + // an UNDECLARED key survives a parse with its value intact. Membership plus a + // refused value is the reading `.success` cannot give. + expect(Object.keys(AlertDialogZod.shape)).toContain('actionVariant'); + expect(AlertDialogZod.safeParse({ ...REMEDY_DOC, actionVariant: 'ghost' }).success).toBe(false); }); it('POSITIVE CONTROL — the same document with the three keys dropped parses green', () => { diff --git a/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts index 90d023ebc0..fcf77bc792 100644 --- a/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts +++ b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts @@ -146,6 +146,13 @@ type KeepsFunction = [Extract, (...args: never[]) => unknown>] export type _Content = Expect>; export type _CancelText = Expect>; export type _ActionText = Expect>; +// objectui#8978 — the capability the retired `confirmVariant` was supposed to +// carry, on a spelling in the `action*` dialect this node already uses for that +// button. Two values, and the DOM reading that earns the narrowness lives in +// `packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx`. +export type _ActionVariant = Expect< + Equal +>; export type _OnAction = Expect void) | undefined>>; export type _OnActionCallable = Expect>; @@ -342,7 +349,11 @@ describe('the trio is RETIRED — the objectui#7104 pin re-derived onto the othe // The prong-2 reading the follow-up judged, re-derived: the three `@default` // tags were the shipped type telling authors a value would be supplied when // nothing read the key at all. They are gone, and each docblock names the - // card. ⛔ The `confirmVariant` block deliberately names NO substitute. + // card. ⚠️ The `confirmVariant` block named NO substitute until + // objectui#8978 answered the separate question the retirement pointed at; it + // now names `actionVariant`, and the KEY is still `?: never` (the leg above + // is what holds that, and the `@ts-expect-error` at the top of this file is + // what holds it at an authoring site). const iface = declaredInterface(); expect(iface).not.toMatch(/@default 'Cancel'/); expect(iface).not.toMatch(/@default 'Confirm'/); @@ -367,10 +378,21 @@ describe('the docs page publishes the read dialect (objectui#7104)', () => { ['content', 'SchemaNode | SchemaNode[]'], ['cancelText', 'string'], ['actionText', 'string'], + ['actionVariant', "'default' | 'destructive'"], ])('row `%s` is published as `%s`, optional — the declaration\'s own spelling', (key, typeText) => { expect(rows().get(key)).toEqual({ optional: true, typeText }); }); + it.each(['content', 'cancelText', 'actionText', 'actionVariant'])( + 'and the page says what the DECLARATION says for `%s` — neither face can drift alone', + (key) => { + // The row texts above are literals, so on their own they pin the page to a + // string rather than to the type. This leg is the other half: the same row + // read off `packages/types/src/overlay.ts`. + expect(rows().get(key)).toEqual(members(declaredInterface()).get(key)); + }, + ); + it('the phantom `actions` row is gone — no surface ever carried it', () => { expect(rows().has('actions')).toBe(false); expect(read(DOC)).not.toMatch(/^\s*actions\?:/m); diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index 69b03a6ef2..9164c74080 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -146,6 +146,40 @@ export interface AlertDialogSchema extends BaseSchema { * refused by name and pointed back here. */ actionText?: string; + /** + * Variant of the confirm (action) button — `'destructive'` for the red + * confirm a delete dialog wants. Omit it and the button keeps the exact look + * it has always had. + * + * READ SITE: `packages/components/src/renderers/overlay/alert-dialog.tsx` — + * the renderer turns the value into `buttonVariants({ variant })` and hands + * it to `AlertDialogAction` as `className`, which `cn()` (tailwind-merge) + * resolves over the primitive's baked-in `buttonVariants()`. An OVERRIDE + * rather than a prop because `packages/components/src/ui/**` is a No-Touch + * zone (AGENTS.md Commandment #7) and `AlertDialogAction` accepts no variant; + * `packages/components/src/notifications/NotificationAlerts.tsx` already + * expresses a footer variant on this very button the same way. + * + * ⚠️ WHY TWO VALUES AND NOT `ButtonSchema.variant`'s six (`./form.ts`). + * ⛔ Not deference to the ruling that named these two — a MEASUREMENT of the + * override channel, re-derived by the pin below rather than recorded here: + * an override can only displace a baked-in class that shares its + * tailwind-merge group, and three of the six upstream variants set no + * background and/or no text colour at all, so the default's `bg-primary` / + * `text-primary-foreground` survive underneath them. Declaring a value this + * node cannot actually render is the {@link confirmVariant} disease one level + * down, at the value instead of the key. The pin measures every declared + * value AND every undeclared one, so widening the union without widening the + * mechanism reds. + * + * ⭐ The DOM reading, not the wiring, is the contract: the pin + * `packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx` + * reads the confirm button's own `class` off the rendered dialog, with a + * firing control on the default. Declared for objectui#8978, which carries + * the capability decision batch #70 granted after {@link confirmVariant} was + * measured inert. + */ + actionVariant?: 'default' | 'destructive'; /** * RETIRED (objectui#7963, ADR-0049 enforce-or-remove; maintainer ruling * 2026-09-10) — nothing has ever read this key, so an authored label drew no @@ -199,13 +233,16 @@ export interface AlertDialogSchema extends BaseSchema { * separate the cancel button's variant from the action button's on this very * DOM. * - * ⚠️ Unlike its two siblings this key has NO surviving spelling, and ⛔ one was - * not invented: {@link cancelText} / {@link actionText} are the footer's two - * LABELS, not a variant, and this node declares no variant key at all — the - * confirm button is `AlertDialogAction`, which ships one fixed - * `buttonVariants()` style. Whether that button should be styleable from - * metadata is a separate question needing its own card and its own ruling. - * @deprecated Not part of this contract — the value was inert, and it has no replacement. + * ⚠️ This key stays RETIRED — ⛔ it was not un-retired when the capability it + * was supposed to carry arrived. {@link cancelText} / {@link actionText} are + * the footer's two LABELS and are still NOT it. The separate card the + * retirement named is objectui#8978, and it answered: the confirm button IS + * styleable from metadata, under {@link actionVariant} — a spelling in the + * `action*` dialect this node already uses for that button, chosen so that no + * published key is retired and then re-added under the same name. + * + * Write {@link actionVariant} instead. + * @deprecated Not part of this contract — the value was inert. Use `actionVariant`. */ confirmVariant?: never; /** diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts index 10bb1d0614..4010211aa3 100644 --- a/packages/types/src/zod/overlay.zod.ts +++ b/packages/types/src/zod/overlay.zod.ts @@ -102,6 +102,14 @@ export const DialogSchema = BaseSchema.extend({ * `content/docs/components/overlay/alert-dialog.mdx` never taught them either * (asserted from the other side by `../__tests__/alert-dialog-read-dialect-7104.test.ts`). * + * ## The capability, delivered under a DIFFERENT spelling (objectui#8978) + * + * The red destructive confirm decision batch #70 granted is live — as + * `actionVariant`, declared above and read by the renderer. ⛔ `confirmVariant` + * was not revived to carry it: a published key that reds must not go green + * again, so the capability took a spelling in the `action*` dialect this node + * already uses for that button, and the tombstone now names it as the remedy. + * * Pinned in `../__tests__/alert-dialog-footer-keys-refusal-7963.test.ts`. */ const ALERT_DIALOG_CANCEL_LABEL_REFUSAL = @@ -117,19 +125,21 @@ const ALERT_DIALOG_CONFIRM_LABEL_REFUSAL = 'renderer reads and the key its registered `inputs` and `defaultProps` ship.'; /** - * ⚠️ This one has NO surviving twin, and its message must say so rather than - * point at a key that does not do the same job: `cancelText` / `actionText` are - * the footer's two LABELS, not a variant. ⛔ A replacement was not invented — - * the ruling retires the key. + * ⚠️ The key stays RETIRED; objectui#8978 moved only its MESSAGE. The separate + * card the retirement pointed at has answered, so the message names the remedy + * instead of saying there is none. ⛔ The key itself was NOT un-retired — a + * published spelling that reds today must not go green again tomorrow + * (「协议不应该改来改去啊,否则元数据应用怎么办」, 2026-09-10). And `cancelText` / + * `actionText` are still NOT the remedy: they are the footer's two LABELS, and a + * variant is not a label. `actionVariant` is. */ const ALERT_DIALOG_CONFIRM_VARIANT_REFUSAL = '`confirmVariant` is RETIRED from the `alert-dialog` node (objectui#7963, ADR-0049 enforce-or-remove): ' + - 'nothing reads it, so an authored variant moved neither the confirm button\'s class nor any other byte ' + - 'of the rendered DOM, and it rode `.passthrough()` through the validator as a silent accept. ' + - '⛔ It has NO surviving spelling, and `cancelText` / `actionText` are NOT it — those are the footer\'s ' + - 'two LABELS, not a variant. This node declares no variant key at all: the confirm button is ' + - '`AlertDialogAction`, which ships one fixed `buttonVariants()` style. Whether that button should be ' + - 'styleable from metadata is a separate question that needs its own card and its own ruling.'; + 'nothing ever read it, so an authored variant moved neither the confirm button\'s class nor any other ' + + 'byte of the rendered DOM, and it rode `.passthrough()` through the validator as a silent accept. ' + + 'Write `actionVariant` instead — the key objectui#8978 declared for this capability, read by the ' + + 'renderer and pinned against the confirm button\'s own class. ⛔ `cancelText` / `actionText` are NOT ' + + 'it: those are the footer\'s two LABELS, not a variant.'; /** * Alert Dialog Schema - Alert dialog component @@ -153,6 +163,15 @@ export const AlertDialogSchema = BaseSchema.extend({ .string() .optional() .describe('Confirm (action) button label; the action button renders only when this is set (no renderer default)'), + actionVariant: z + .enum(['default', 'destructive']) + .optional() + .describe( + 'Confirm (action) button variant; `destructive` paints the red confirm. Two values, not ' + + '`ButtonSchema.variant`\'s six: the renderer applies this as a className OVERRIDE over the ' + + 'primitive\'s baked-in `buttonVariants()`, and the other three upstream variants set no background ' + + 'and/or no text colour, so the default\'s survives underneath them (objectui#8978)', + ), cancelLabel: retirementTombstone(ALERT_DIALOG_CANCEL_LABEL_REFUSAL), confirmLabel: retirementTombstone(ALERT_DIALOG_CONFIRM_LABEL_REFUSAL), confirmVariant: retirementTombstone(ALERT_DIALOG_CONFIRM_VARIANT_REFUSAL), From 4a617ceb8e3b7c7c579cdbb9641dab2dd485771b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 17:11:25 +0000 Subject: [PATCH 2/2] test(components): make the mechanism legs fail when the wiring is deleted (objectui#8978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the ablation, ⛔ not by review. With the renderer's `actionVariant` wiring removed, the three `MECHANISM-` legs (`outline` / `ghost` / `link`) stayed GREEN: each asserted only that the primitive's baked-in classes are still on the node, and "still there" is trivially true of a renderer that applies nothing at all. An assertion that cannot fail through the very ablation that proves the feature is the exact shape the retired key got away with for months, so it is fixed rather than noted: each leg now asserts ARRIVAL first — the tokens the variant itself emits ARE on the node — and only then the loss. Ablation matrix after the fix: 7 failed | 10 passed, against 4 failed | 13 passed before it. Every leg that names the wiring reds; the four controls, the default-path legs and the authoring-face legs stay green, which is what they are for. Also widens the two class-oracle helpers to the full variant union so `MECHANISM+` can ask about `default`, whose displaced set is correctly empty (`tsc -p packages/components/tsconfig.test.json` caught the narrow parameter). Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ Co-Authored-By: Claude --- .../alert-dialog-action-variant-8978.test.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx b/packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx index 7f9744cd10..2696ae4b4f 100644 --- a/packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx +++ b/packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx @@ -130,13 +130,19 @@ function baseNode(): Record { * The tokens `variant` emits that `default` does not, read off `buttonVariants` * itself. ⛔ Never typed in — see this file's header. */ -function distinguishing(variant: 'default' | 'destructive' | 'outline' | 'ghost' | 'link' | 'secondary'): string[] { +type Variant = 'default' | 'secondary' | 'destructive' | 'outline' | 'ghost' | 'link'; + +function distinguishing(variant: Variant): string[] { const base = new Set(tokens(buttonVariants())); return tokens(buttonVariants({ variant })).filter((token) => !base.has(token)); } -/** The tokens `default` emits that `variant` does not — what an override must DISPLACE. */ -function displaced(variant: 'outline' | 'ghost' | 'link' | 'secondary' | 'destructive'): string[] { +/** + * The tokens `default` emits that `variant` does not — what an override must + * DISPLACE. Empty for `default` itself, which is the honest reading: the + * primitive already bakes that variant in, so there is nothing to displace. + */ +function displaced(variant: Variant): string[] { const other = new Set(tokens(buttonVariants({ variant }))); return tokens(buttonVariants()).filter((token) => !other.has(token)); } @@ -251,8 +257,16 @@ describe('objectui#8978 — the override channel is why the union is two values // "declared but cannot be rendered" looks like, and it is the shape this // whole card exists to not repeat. const { classOf } = probe({ ...baseNode(), actionVariant: variant }); - const leftovers = displaced(variant).filter((token) => classOf[CONFIRM]?.includes(token)); + // ⭐ ARRIVAL FIRST. Without this half the leg passes when the renderer + // applies NOTHING at all — "the default's tokens are still there" is + // trivially true of an unwired renderer, so the leg would read green + // through the very ablation that proves this card's wiring works, and + // would be certifying a claim it cannot fail. Measured: it does exactly + // that until this line is here. + expect(classOf[CONFIRM]).toEqual(expect.arrayContaining(distinguishing(variant))); + // THEN the loss: the override arrived, and the default still shows through. + const leftovers = displaced(variant).filter((token) => classOf[CONFIRM]?.includes(token)); expect(leftovers.length).toBeGreaterThan(0); // And the mirror refuses the value by name, so no AUTHOR can reach it. expect(AlertDialogMirror.safeParse({ ...baseNode(), actionVariant: variant }).success).toBe(false);