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..2696ae4b4f --- /dev/null +++ b/packages/components/src/__tests__/alert-dialog-action-variant-8978.test.tsx @@ -0,0 +1,304 @@ +/** + * 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. + */ +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. 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)); +} + +/* ──────────────────────────────────────────────────────────────────────────── + * 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 }); + + // ⭐ 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); + }, + ); +}); + +/* ──────────────────────────────────────────────────────────────────────────── + * 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),