diff --git a/.changeset/7963-alert-dialog-footer-keys-retired.md b/.changeset/7963-alert-dialog-footer-keys-retired.md new file mode 100644 index 0000000000..3ed9947a46 --- /dev/null +++ b/.changeset/7963-alert-dialog-footer-keys-retired.md @@ -0,0 +1,57 @@ +--- +'@object-ui/types': minor +'@object-ui/components': patch +--- + +`AlertDialogSchema` retires `cancelLabel`, `confirmLabel` and `confirmVariant` +(objectui#7963, ADR-0049 enforce-or-remove; maintainer ruling 2026-09-10). +`cancelText` and `actionText` are the surviving spellings, and `confirmVariant` +has **no** survivor at all. + +**Breaking, and graded `minor` by this repo's convention** (AGENTS.md — a +breaking change here is `minor`; a `major` would drag the whole 39-package fixed +group off `@objectstack`'s cadence). A document that authored any of the three +used to parse **green**; it now reds at that key. + +**What was measured.** Tree-wide on `72bcd7783`, a point-access probe scores +`schema.cancelLabel` / `schema.confirmLabel` / `schema.confirmVariant` at +**0 / 0 / 0**, against firing controls on the very renderer under test +(`packages/components/src/renderers/overlay/alert-dialog.tsx`): +`schema.cancelText` = **15**, read at `:37`, and `schema.actionText` = **5**, +read at `:38`. The keys did reach the Radix root through the renderer's +rest-spread, so a grep alone was not a verdict — the DOM reading is, and it is +kept as a live pin: varying one key per fixture through the real renderer leaves +the normalised dialog HTML unmoved, against a `CHANNEL` control (`open`, unread +and live through that same spread) and a `WIRED` control (`cancelText` / +`actionText` drawing both footer buttons). The `AlertDialog` root renders a +context provider rather than an element, so an unknown prop is dropped before +reaching any node. An author who wrote the declared trio got an **empty footer**. + +**A named refusal, not a deletion.** `BaseSchemaCore` ends `.passthrough()` and +the TypeScript `BaseSchema` closes with `[key: string]: any`, so a *dropped* +member key is kept, not refused — deleting the declarations would have left the +silent accept exactly as it was. Each key stays declared and unwritable: +`retirementTombstone()` on the Zod face, `?: never` on the TypeScript face. The +messages name the remedy. + +| retired key | what to write instead | +| --- | --- | +| `cancelLabel` | `cancelText` | +| `confirmLabel` | `actionText` | +| `confirmVariant` | **nothing** — see below | + +⚠️ `confirmVariant` has no replacement and its message says so plainly rather +than pointing at a key that does not do the same job: `cancelText` / `actionText` +are the footer's two *labels*, not a variant, and the 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. + +**Nothing else moves.** These spellings are overloaded across the tree and every +other owner is a live key on a different declaration — +`FormSchema.cancelLabel`, `objectql.ts`'s `confirmLabel`, `plugin-designer`'s +`ConfirmDialog` React props, `plugin-grid`'s `def.confirmLabel`, and +`plugin-form`'s `ModalForm` / `DrawerForm`, which build a local `cancelLabel` +*from* `schema.cancelText`. None is an `AlertDialogSchema`; none is touched, and +a pin asserts it. No fixture, catalog schema, example app or doc fence authored +any of the three on an `alert-dialog` node, so no shipped document is stranded. diff --git a/packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx b/packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx index baf3874578..198975d0b7 100644 --- a/packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx +++ b/packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx @@ -74,16 +74,41 @@ * `confirmVariant` row demonstrably distinguishes one button variant from * another on this very DOM. * - * ⛔ This file measures. It does not retire anything, it does not teach the - * renderer a new key, and it does not animate `confirmVariant` — whether a - * footer button variant is a capability this project wants is the maintainer - * ruling objectui#7963 asks for, and this reading is that ruling's INPUT. + * ⛔ This file measures. It does not teach the renderer a new key and it does not + * animate `confirmVariant`. + * + * ## The ruling this reading fed, and why this file is KEPT + * + * ⭐ RE-POINTED, ⛔ not deleted. The maintainer ruled on 2026-09-10: retire all + * three from `AlertDialogSchema`, both faces, ADR-0049 enforce-or-remove, with + * `cancelText` / `actionText` as the surviving spellings and NO survivor for + * `confirmVariant`. This file is the measurement that ruling was taken on — a + * retirement does not retire its own evidence, so every reading below stays, + * and every one of them must go on reading the same after the change as before. + * + * ⚠️ Read what did and did not move, because it is easy to get backwards: + * + * - the RENDERER is untouched. It never read the three keys and still does not, + * so the DOM readings below are unchanged BY CONSTRUCTION. If one of them + * ever flips, something taught the renderer a retired key. + * - the SCHEMA changed, and this package does not validate. `SchemaRenderer` + * renders a node; it does not `safeParse` one. So the "renders an EMPTY + * footer" reading at the bottom is STILL TRUE of a raw node handed straight + * to the renderer — what the retirement moved is the gate one step earlier, + * where an author is now refused BY NAME instead of silently drawing nothing. + * That is asserted at the bottom of this file against the mirror, so the two + * halves of the card are tied together in one place; its full contract lives + * in `packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts`. */ import { describe, expect, it } from 'vitest'; import { cleanup, render } from '@testing-library/react'; import React from 'react'; import { SchemaRenderer } from '@object-ui/react'; +// The schema half of this card. Imported for the closure leg at the bottom +// only — nothing above validates, and that asymmetry is the point of the note +// in this file's header. +import { AlertDialogSchema as AlertDialogMirror } from '@object-ui/types/zod'; // Registers the renderers at module scope, NOT inside a `beforeAll` — there the // cold transform is billed to `hookTimeout` (objectui#3010/#3021). import '../renderers'; @@ -283,28 +308,57 @@ describe('objectui#7963 — the three declared footer keys, one varied per fixtu }); /* ──────────────────────────────────────────────────────────────────────────── - * The user-visible consequence the card reported + * The user-visible consequence the card reported — and where it is caught now * ───────────────────────────────────────────────────────────────────────── */ -describe('objectui#7963 — a document written strictly against the declared keys', () => { - it('renders an EMPTY footer', () => { +/** The footer an author used to write against the three keys the type declared. */ +const RETIRED_ONLY = { + type: 'alert-dialog', + title: 'Delete this account?', + trigger: { type: 'button', label: 'Delete account' }, + cancelLabel: 'Keep it', + confirmLabel: 'Delete', + confirmVariant: 'destructive', + defaultOpen: true, +}; + +describe('objectui#7963 — a document written strictly against the three retired keys', () => { + it('STILL renders an EMPTY footer — the renderer does not validate, and it did not change', () => { // The card's headline claim, measured rather than reasoned: an author who - // writes only what `AlertDialogSchema` declares for the footer gets no - // footer buttons at all. Paired with `WIRED` above, which is the same node - // in the read dialect drawing two. - const declaredOnly = { - type: 'alert-dialog', - title: 'Delete this account?', - trigger: { type: 'button', label: 'Delete account' }, - cancelLabel: 'Keep it', - confirmLabel: 'Delete', - confirmVariant: 'destructive', - defaultOpen: true, - }; - - const reading = probe(declaredOnly); + // wrote only what `AlertDialogSchema` used to declare for the footer got no + // footer buttons at all. Paired with `WIRED` above, which is the same node in + // the surviving dialect drawing two. + // + // ⚠️ This reading is UNCHANGED by the retirement, and that is the point. + // `SchemaRenderer` renders a node, it never `safeParse`s one, so a raw node + // reaching this renderer still draws nothing. The retirement did not repair + // the render — it moved the failure one step earlier, to a place where the + // author is told why (the leg below). + const reading = probe(RETIRED_ONLY); expect(reading.dialogHtml).not.toBeNull(); // the dialog itself DID mount expect(reading.footerLabels).toEqual([]); // …with nothing in its footer }); + + it('and is now REFUSED BY NAME at the schema, instead of being accepted in silence', () => { + // The closure objectui#7963 landed, asserted here beside the reading that + // justified it so the two halves cannot drift apart. Before the retirement + // this document parsed GREEN — `BaseSchemaCore` ends `.passthrough()`, so an + // authored value was KEPT, not refused, which is why a bare deletion of the + // declarations would have changed nothing an author could see. + const result = AlertDialogMirror.safeParse(RETIRED_ONLY); + + expect(result.success).toBe(false); + const paths = (result.success ? [] : result.error.issues).map((issue) => issue.path.join('.')); + for (const key of ['cancelLabel', 'confirmLabel', 'confirmVariant']) { + expect(paths, key).toContain(key); + } + }); + + it('CONTROL — the surviving dialect, the node `WIRED` draws two buttons from, parses green', () => { + // Without this the leg above would pass just as well against a mirror that + // refused every alert-dialog document, and the refusal would read as working + // while it had in fact taken the whole node down. + expect(AlertDialogMirror.safeParse(baseNode()).success).toBe(true); + }); }); 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 new file mode 100644 index 0000000000..af42f4d1ae --- /dev/null +++ b/packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts @@ -0,0 +1,333 @@ +/** + * 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. + */ + +/** + * `cancelLabel`, `confirmLabel` and `confirmVariant` are REFUSED on the + * `alert-dialog` node, on both faces (objectui#7963, ADR-0049 enforce-or-remove; + * maintainer ruling 2026-09-10). `cancelText` / `actionText` are the surviving + * spellings — and `confirmVariant` has NO survivor at all. + * + * ## What this is, and what it deliberately is NOT + * + * objectui#7104 declared the keys the renderer READS (`content`, `cancelText`, + * `actionText`, `onAction`) and, in the same breath, LEFT this trio declared and + * unread on purpose — its pin says so out loud ("the pins below record today's + * state so that the PR which retires them re-derives these lines deliberately + * rather than passing unnoticed"). This file is that retirement; that pin is + * RE-POINTED, not deleted, so the closure stays asserted rather than becoming a + * silent absence. The same discipline objectui#8871 applied to objectui#7926's + * deliberately-left `breadcrumbs`. + * + * ## Why a REFUSAL and not a bare deletion + * + * `BaseSchemaCore` ends `.passthrough()` and the TS `BaseSchema` closes with + * `[key: string]: any`, so a dropped MEMBER key is KEPT, not refused. Deleting + * the three declarations would have left the silent accept exactly as it was and + * thrown the diagnostic away with it. `retirementTombstone()` keeps the key + * DECLARED and unwritable — that is what makes the refusal loud, and it is why + * the membership legs below are assertions rather than leftovers. + * + * ## What was measured — the frame is BASE `72bcd7783`, stated out loud + * + * ZERO readers, ⛔ measured with a POINT-ACCESS probe rather than a bare word. + * Tree-wide on the base, `schema.cancelLabel` / `schema.confirmLabel` / + * `schema.confirmVariant` each score **0**, against the FIRING CONTROLS on the + * very renderer under test — `schema.cancelText` = **15** (read at + * `packages/components/src/renderers/overlay/alert-dialog.tsx:37`) and + * `schema.actionText` = **5** (read at `:38`). The three zeros are readings of + * the same instrument on the same file the controls light up. + * + * ⛔ A BARE-WORD probe would have lied, and towards "live": these spellings are + * overloaded across this tree and every OTHER owner is a LIVE key on a + * DIFFERENT declaration — `FormSchema.cancelLabel`, `objectql.ts`'s + * `confirmLabel`, `plugin-designer`'s `ConfirmDialog` React props, + * `plugin-grid`'s `def.confirmLabel`, and `plugin-form`'s `ModalForm` / + * `DrawerForm`, which BUILD a local `cancelLabel` FROM `schema.cancelText` — the + * opposite direction. A bare grep reports dozens of readers, none of them on an + * `alert-dialog` node. `the neighbouring owners are untouched` below is that + * ruling-out kept as a live assertion rather than as prose. + * + * ## The rest-spread near-miss, closed by MEASUREMENT + * + * The three keys DO reach the primitive — they are not on `SchemaRenderer`'s + * strip list, so they ride `componentProps` into the renderer's `...props` and + * onto ``, the same channel that made + * `CollapsibleSchema.open` live (objectui#8236). What settles it is a DOM + * reading, and it lives where a DOM exists: + * `packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx`. + * That file is KEPT and re-pointed, ⛔ not retired with the keys — a retirement + * does not retire the measurement that justified it. This package cannot see a + * renderer, so the render-side half is deliberately not duplicated here; what is + * asserted here is the reading this package CAN take, the renderer's source + * text. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { AlertDialogSchema } from '../overlay'; +import { AlertDialogSchema as AlertDialogZod } from '../zod/overlay.zod.js'; +import { FormSchema as FormZod } from '../zod/form.zod.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..', '..', '..', '..'); +const read = (relative: string): string => readFileSync(join(REPO_ROOT, relative), 'utf8'); + +const RENDERER = 'packages/components/src/renderers/overlay/alert-dialog.tsx'; + +/** The three keys this card retires. */ +const RETIRED = ['cancelLabel', 'confirmLabel', 'confirmVariant'] as const; +/** The two keys that survive — the renderer's own dialect. */ +const SURVIVING = ['cancelText', 'actionText'] as const; + +/** The footer an author used to write against the declared keys, verbatim in shape. */ +const RETIRED_DOC = { + type: 'alert-dialog', + title: 'Delete this account?', + trigger: { type: 'button', label: 'Delete account' }, + cancelLabel: 'Keep it', + confirmLabel: 'Delete', + confirmVariant: 'destructive', +} as const; + +/** The same footer in the dialect the renderer reads. */ +const REMEDY_DOC = { + type: 'alert-dialog', + title: 'Delete this account?', + trigger: { type: 'button', label: 'Delete account' }, + cancelText: 'Keep it', + actionText: 'Delete', +} as const; + +const issueFor = (doc: unknown, key: string) => { + const result = AlertDialogZod.safeParse(doc); + return (result.success ? [] : result.error.issues).find((issue) => issue.path.join('.') === key); +}; + +/* ──────────────────────────────────────────────────────────────────────────── + * The contract + * ───────────────────────────────────────────────────────────────────────── */ + +describe('objectui#7963 — the `alert-dialog` node REFUSES its three footer keys', () => { + it.each(RETIRED)('`%s` is still DECLARED, which is what makes the refusal loud rather than a strip', (key) => { + // Under `.passthrough()` an UNDECLARED key is kept in silence. A refusal has + // to be declared, which is why a bare deletion was never the shape here. + expect(Object.keys(AlertDialogZod.shape)).toContain(key); + }); + + it('refuses the retired document at parse — one issue per key, each at its own path', () => { + const result = AlertDialogZod.safeParse(RETIRED_DOC); + expect(result.success).toBe(false); + const paths = (result.success ? [] : result.error.issues).map((issue) => issue.path.join('.')); + for (const key of RETIRED) expect(paths, key).toContain(key); + }); + + it.each(RETIRED)('`%s` reports `invalid_type` — the tombstone code, not a custom arm', (key) => { + // `retirementTombstone` is a `z.never` arm: CODE and PATH are what a bare + // `z.never()` reports and only the MESSAGE is customised. Its sibling + // `handlerKeyRefusal` reports `custom` instead — and this very schema carries + // three of those (`onAction` / `onConfirm` / `onCancel`), so the two really + // are adjacent here and asserting the code is what keeps them apart. + expect(issueFor(RETIRED_DOC, key)?.code).toBe('invalid_type'); + }); + + it.each([ + ['cancelLabel', 'cancelText'], + ['confirmLabel', 'actionText'], + ])('`%s`\'s message names the key, the card and the surviving spelling `%s`', (key, remedy) => { + // The named subject and the remedy, NOT the whole sentence: pinning prose + // byte-for-byte turns every wording fix red for no gain. + const message = issueFor(RETIRED_DOC, key)?.message ?? ''; + expect(message).toContain(key); + expect(message).toContain('objectui#7963'); + expect(message).toContain('RETIRED'); + expect(message).toContain(remedy); + // Zod's own default for a `never` arm says none of this. + 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. + 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'); + }); + + it('POSITIVE CONTROL — the same document with the three keys dropped parses green', () => { + // Without this leg, a schema that refused EVERY alert-dialog document would + // satisfy every assertion above. + const { cancelLabel, confirmLabel, confirmVariant, ...rest } = RETIRED_DOC; + expect([cancelLabel, confirmLabel, confirmVariant].every(Boolean)).toBe(true); + expect(AlertDialogZod.safeParse(rest).success).toBe(true); + }); + + it('the remedy the two label messages name actually parses — and survives the parse', () => { + const result = AlertDialogZod.safeParse(REMEDY_DOC); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.cancelText).toBe('Keep it'); + expect(result.data.actionText).toBe('Delete'); + }); + + it.each(SURVIVING)('`%s` is untouched — still declared, still a string member', (key) => { + expect(Object.keys(AlertDialogZod.shape)).toContain(key); + expect(AlertDialogZod.safeParse({ ...REMEDY_DOC, [key]: 42 }).success).toBe(false); + }); + + it('the refusal is TARGETED, not a strict node', () => { + // The cheap way to refuse three keys is `.strict()`. It is the wrong shape: + // this node's `BaseSchema` is `.passthrough()` by design and other pins read + // that openness. Three keys, by name. + const result = AlertDialogZod.safeParse({ ...REMEDY_DOC, someRendererProp: 42 }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.someRendererProp).toBe(42); + }); +}); + +describe('objectui#7963 — the TypeScript twins refuse them too', () => { + it('`?: never` on all three — the `@ts-expect-error` IS the assertion', () => { + // Compiled by `tsc -p packages/types/tsconfig.test.json`: each line fails to + // compile if the key ever becomes assignable again. The pair with the zod + // arms is what `zod-mirror-parity.test.ts` compares. + const authored: AlertDialogSchema = { + type: 'alert-dialog', + title: 'Delete this account?', + // @ts-expect-error `cancelLabel` is refused by name on the alert-dialog node (objectui#7963) + cancelLabel: 'Keep it', + // @ts-expect-error `confirmLabel` is refused by name on the alert-dialog node (objectui#7963) + confirmLabel: 'Delete', + // @ts-expect-error `confirmVariant` is refused by name on the alert-dialog node (objectui#7963) + confirmVariant: 'destructive', + }; + expect(authored.type).toBe('alert-dialog'); + }); + + it('CONTROL — the surviving spellings still compile at the same site', () => { + // Without this, the three legs above would pass just as well against an + // interface that refused the whole footer. + const authored: AlertDialogSchema = { + type: 'alert-dialog', + cancelText: 'Keep it', + actionText: 'Delete', + }; + expect(authored.cancelText).toBe('Keep it'); + }); +}); + +describe('objectui#7963 — the neighbouring owners of these spellings are UNTOUCHED', () => { + it('`FormSchema.cancelLabel` still accepts a string — a live key on a different declaration', () => { + // ⛔ The retirement is scoped to `AlertDialogSchema`. `FormSchema.cancelLabel` + // is read at `renderers/form/form.tsx:1063,3266`; if this ever reds, the + // refusal has escaped its node. + const result = FormZod.safeParse({ + type: 'form', + // `fields` is REQUIRED on this mirror — without it the leg would red for a + // reason that has nothing to do with `cancelLabel`, and read as an escape. + fields: [{ name: 'notes', label: 'Notes', type: 'text' }], + showCancel: true, + cancelLabel: 'Discard', + }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.cancelLabel).toBe('Discard'); + }); +}); + +describe('objectui#7963 — the renderer is UNCHANGED, and the measurement stays standing', () => { + it('still reads the surviving dialect, and its `inputs` / `defaultProps` still ship it', () => { + const renderer = read(RENDERER); + for (const key of SURVIVING) expect(renderer, key).toContain(`schema.${key}`); + expect(renderer).toMatch(/^\s*cancelText:\s*'Cancel',/m); + expect(renderer).toMatch(/^\s*actionText:\s*'Continue',/m); + }); + + it('still reads none of the three, and never learned one of them as a substitute', () => { + // ⛔ The ruling was RETIRE, not "teach the renderer the other dialect". + // Teaching it `cancelLabel` would have blanked the footer of every document + // that works today (the objectui#7104 producer census). + const renderer = read(RENDERER); + for (const key of RETIRED) expect(renderer, key).not.toContain(key); + }); + + it('control: the scan can find things — this IS the alert-dialog registration', () => { + expect(read(RENDERER)).toContain("ComponentRegistry.register('alert-dialog'"); + }); +}); + +describe('objectui#7963 — no reader anywhere in the tree, kept as a standing probe', () => { + /** + * ⭐ TREE-SCOPED, never file-scoped: a file-scoped absence check only sees the + * files its author thought of, and what escapes is exactly the reader he did + * not know about — including one written AFTER the retirement. The scan runs + * over every tracked file and subtracts only what cannot be a read site: + * + * - `CHANGELOG.md` / `.changeset/` — the historical record of this very + * retirement, which must keep naming the keys; + * - this pin and the two it re-points, which SPELL the keys in order to trip + * the refusal and to measure the DOM; + * - the two DECLARATION files, `zod/overlay.zod.ts` and `overlay.ts`: the + * tombstones and their `?: never` twins ARE the refusal, so a scan that + * reddened on them would be asserting the retirement had not landed. That + * they refuse rather than read is not taken on trust — the parse legs and + * the `@ts-expect-error` leg above fail if either face accepts again. + * + * ⛔ No allow-list FILE: a list on disk outlives the reason for each of its + * rows. The exclusions are spelled here, beside the reason. + */ + const EXCLUDED = [ + ':!*CHANGELOG.md', + ':!.changeset/', + ':!packages/types/src/__tests__/alert-dialog-footer-keys-refusal-7963.test.ts', + ':!packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts', + ':!packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx', + ':!packages/types/src/zod/overlay.zod.ts', + ':!packages/types/src/overlay.ts', + ]; + + /** `git grep -nE `, exit 1 (no match) normalised to an empty list. */ + const grepTree = (pattern: string): string[] => { + try { + const out = execFileSync('git', ['grep', '-nE', pattern, '--', '.', ...EXCLUDED], { + cwd: REPO_ROOT, + encoding: 'utf8', + }); + return out.split('\n').filter(Boolean); + } catch (e) { + // `git grep` exits 1 for "no matches" — the PASS case here, distinguished + // from a real failure (exit > 1) rather than swallowed. + const status = (e as { status?: number }).status; + if (status === 1) return []; + throw e; + } + }; + + it.each(RETIRED)('nothing reads `schema.%s` — the POINT-ACCESS probe, tree-wide', (key) => { + // ⛔ Not a bare-word probe: every other owner of these spellings is live, and + // a bare grep reports dozens of readers that are not on this node. + expect(grepTree(`schema\\.${key}`)).toEqual([]); + }); + + it('LIT CONTROL — the same probe on the SURVIVING spellings still fires, hard', () => { + // Without this, the three legs above pass on a broken `git grep` invocation, + // a wrong cwd, or an exclusion list that swallowed the tree. These are the + // controls the ruling was taken on, re-derived as a standing assertion. + expect(grepTree('schema\\.cancelText').length).toBeGreaterThan(3); + expect(grepTree('schema\\.actionText').length).toBeGreaterThan(0); + }); +}); 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 6817845ca6..90d023ebc0 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 @@ -48,13 +48,27 @@ * over the four new members, which resolved to `any` through the index * signature. The PR body carries the counts from that run. * - * ## What this file pins as UNRESOLVED, on purpose + * ## What this file left OPEN, and what has since closed it * - * - `cancelLabel` / `confirmLabel` / `confirmVariant` stay declared on both - * faces and read by nothing. Retiring them is a NARROWING with its own card - * and its own changeset grade (the objectui#7104 ruling); the pins below - * record today's state so that the PR which retires them re-derives these - * lines deliberately rather than passing unnoticed. + * - RESOLVED, and re-derived rather than deleted: `cancelLabel` / + * `confirmLabel` / `confirmVariant` used to stay declared on both faces and + * read by nothing, and the pins below recorded that state so that the PR + * retiring them would re-derive these lines deliberately rather than pass + * unnoticed. That PR is objectui#7963 (maintainer ruling 2026-09-10, ADR-0049 + * enforce-or-remove), and this file went red exactly where it was built to: + * the three type-level `StillDeclared` legs and the `@default` docblock leg. + * The block near the bottom is the SAME pin re-derived onto the other side of + * the flip — the trio is now REFUSED BY NAME rather than merely inert, so what + * it asserts is that the keys stay DECLARED (which is what makes a refusal + * loud under `.passthrough()`), that the renderer still reads none of them, + * and that their docblocks now teach the retirement instead of publishing an + * `@default` nothing applies. The parse-level and TypeScript-level contract of + * that refusal lives in its own file, + * `alert-dialog-footer-keys-refusal-7963.test.ts`, and the DOM reading it + * rests on lives with the renderer, in + * `packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx` + * — deliberately not duplicated here, for the same reason the objectui#7693 + * half below is not: this package cannot see a renderer. * - RESOLVED, and re-derived rather than deleted: the four schema-catalog * fixtures used to author `actions`, a key no surface carries, so the docs * page's own examples rendered an empty footer — objectui#7693. That card @@ -91,8 +105,18 @@ const FIXTURES = ['basic-alert-dialog', 'confirmation-dialog', 'custom-actions', /** The three JSON-authorable keys the renderer reads. */ const READ_KEYS = ['content', 'cancelText', 'actionText'] as const; -/** The three keys the type declares for the same affordance and nothing reads. */ -const INERT_DECLARED = ['cancelLabel', 'confirmLabel', 'confirmVariant'] as const; +/** + * The three keys the type declared for the same affordance and nothing read. + * + * ⚠️ RE-POINTED by objectui#7963, ⛔ not deleted. They were `INERT_DECLARED` — + * declared, accepted, and read by nothing. They are now REFUSED BY NAME on both + * faces (`retirementTombstone()` in the mirror, `?: never` on the interface), so + * "inert" became wrong in its own terms: an authored value no longer rides + * `.passthrough()` through, it reds at parse. The list keeps its members and its + * job here — naming the keys the renderer must go on NOT reading and the docs + * page must go on NOT teaching — under the name that is now true of them. + */ +const RETIRED_DECLARED = ['cancelLabel', 'confirmLabel', 'confirmVariant'] as const; const shape = AlertDialogZod.shape; @@ -132,12 +156,27 @@ export type _MirrorCancelText = Expect>; export type _MirrorOnActionRefused = Expect>; -// The inert trio still reads as DECLARED — the retirement card re-derives these. -export type _CancelLabelStillDeclared = Expect>; -export type _ConfirmLabelStillDeclared = Expect>; -export type _ConfirmVariantStillDeclared = Expect< - Equal ->; +// The trio, re-derived by objectui#7963: `?: never`, so the member's type is +// `undefined` and no value is assignable to it. These three legs were +// `string | undefined` / the variant union until the retirement landed — that +// is the flip this file was built to make visible. +export type _CancelLabelRetired = Expect>; +export type _ConfirmLabelRetired = Expect>; +export type _ConfirmVariantRetired = Expect>; + +// `Equal<…, undefined>` alone would also hold for a key typed `?: undefined`, so +// the refusal itself is asserted where it bites: at the authoring site. The +// `@ts-expect-error` IS the assertion — it stops compiling if any of the three +// ever becomes assignable again. +export const retiredTrioRefused: AlertDialogSchema = { + type: 'alert-dialog', + // @ts-expect-error objectui#7963 — `cancelLabel` is retired; write `cancelText` + cancelLabel: 'Keep it', + // @ts-expect-error objectui#7963 — `confirmLabel` is retired; write `actionText` + confirmLabel: 'Delete', + // @ts-expect-error objectui#7963 — `confirmVariant` is retired, with no surviving spelling + confirmVariant: 'destructive', +}; // A wrong-typed value is now a compile error AT the key. Before objectui#7104 // the index signature absorbed it: `cancelText: 123` compiled clean. @@ -263,7 +302,7 @@ describe('the fact the declaration records: the renderer READS these keys and te expect(renderer).toMatch(/name:\s*'content'/); expect(renderer).toMatch(/^\s*cancelText:\s*'Cancel',/m); expect(renderer).toMatch(/^\s*actionText:\s*'Continue',/m); - for (const key of INERT_DECLARED) expect(renderer, key).not.toContain(key); + for (const key of RETIRED_DECLARED) expect(renderer, key).not.toContain(key); }); it('control: the scan can find things — this IS the alert-dialog registration', () => { @@ -273,24 +312,51 @@ describe('the fact the declaration records: the renderer READS these keys and te }); }); -describe('the declared-but-unread trio is UNTOUCHED here — recorded for its own card (objectui#7104)', () => { - it.each(INERT_DECLARED)('`%s` is still declared on the mirror', (key) => { +describe('the trio is RETIRED — the objectui#7104 pin re-derived onto the other side of the flip (objectui#7963)', () => { + it.each(RETIRED_DECLARED)('`%s` is still a MEMBER of the mirror — which is what makes the refusal loud', (key) => { + // ⛔ Deleting the member was never the shape: `BaseSchemaCore` ends + // `.passthrough()`, so a dropped key is KEPT in silence. This leg reads + // identically before and after the retirement and means the OPPOSITE thing — + // which is exactly why the leg below now asks what the member DOES. expect(shape[key]).toBeDefined(); }); - it.each(INERT_DECLARED)('`%s` is still declared on the TS interface', (key) => { - expect(members(declaredInterface()).get(key)?.optional).toBe(true); + it.each(RETIRED_DECLARED)('`%s` is a REFUSAL arm, not a value type — the reading `.shape` membership cannot give', (key) => { + expect(AlertDialogZod.safeParse({ ...AUTHORED, [key]: 'anything' }).success).toBe(false); }); - it.each(INERT_DECLARED)('`%s` is still read by nothing in the renderer', (key) => { + it.each(RETIRED_DECLARED)('`%s` is still declared on the TS interface, now as `?: never`', (key) => { + const member = members(declaredInterface()).get(key); + expect(member?.optional).toBe(true); + expect(member?.typeText).toBe('never'); + }); + + it.each(RETIRED_DECLARED)('`%s` is still read by nothing in the renderer', (key) => { + // ⭐ The reading the retirement rests on, kept standing rather than retired + // with the keys. The ruling was RETIRE, ⛔ not "teach the renderer the other + // dialect" — so this must go on being true after the change, not before it. expect(read(RENDERER)).not.toContain(key); }); - it('their docblocks still publish an `@default` the renderer never applies — the prong-2 reading the follow-up judges', () => { + it('their docblocks now teach the retirement instead of publishing an `@default` the renderer never applied', () => { + // 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. const iface = declaredInterface(); - expect(iface).toMatch(/@default 'Cancel'[\s\S]{0,40}cancelLabel\?: string;/); - expect(iface).toMatch(/@default 'Confirm'[\s\S]{0,40}confirmLabel\?: string;/); - expect(iface).toMatch(/@default 'default'[\s\S]{0,40}confirmVariant\?: 'default' \| 'destructive';/); + expect(iface).not.toMatch(/@default 'Cancel'/); + expect(iface).not.toMatch(/@default 'Confirm'/); + for (const key of RETIRED_DECLARED) { + expect(iface, key).toMatch(new RegExp(`objectui#7963[\\s\\S]{0,4000}?${key}\\?: never;`)); + } + }); + + it('control: the surviving spellings are NOT retired on either face', () => { + // Without this, every leg above would pass against an interface that had + // retired the whole footer. + expect(members(declaredInterface()).get('cancelText')?.typeText).toBe('string'); + expect(members(declaredInterface()).get('actionText')?.typeText).toBe('string'); + expect(AlertDialogZod.safeParse({ ...AUTHORED, cancelText: 'Keep it' }).success).toBe(true); }); }); @@ -315,7 +381,7 @@ describe('the docs page publishes the read dialect (objectui#7104)', () => { }); it('the page does not teach the declared-but-unread trio either', () => { - for (const key of INERT_DECLARED) expect(rows().has(key), key).toBe(false); + for (const key of RETIRED_DECLARED) expect(rows().has(key), key).toBe(false); }); it('control: the rows both faces always agreed on are still there', () => { diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index 0f468287e8..69b03a6ef2 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -131,7 +131,8 @@ export interface AlertDialogSchema extends BaseSchema { * * Declared for objectui#7104: this is the key the renderer reads and the key * its registered `inputs` and `defaultProps` ship. `cancelLabel` below is the - * declared twin nothing reads. + * RETIRED twin (objectui#7963) — declared only so an author who writes it is + * refused by name and pointed back here. */ cancelText?: string; /** @@ -141,24 +142,72 @@ export interface AlertDialogSchema extends BaseSchema { * * Declared for objectui#7104: this is the key the renderer reads and the key * its registered `inputs` and `defaultProps` ship. `confirmLabel` below is the - * declared twin nothing reads. + * RETIRED twin (objectui#7963) — declared only so an author who writes it is + * refused by name and pointed back here. */ actionText?: string; /** - * Cancel button label - * @default 'Cancel' - */ - cancelLabel?: string; - /** - * Confirm button label - * @default 'Confirm' - */ - confirmLabel?: string; - /** - * Confirm button variant - * @default 'default' - */ - confirmVariant?: '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 + * button at all. Measured on this branch's BASE `72bcd7783` with a + * POINT-ACCESS probe: `schema.cancelLabel` scores **0** tree-wide, against + * the FIRING CONTROLS on the very renderer under test + * (`packages/components/src/renderers/overlay/alert-dialog.tsx`) — + * `schema.cancelText` = **15**, read at `:37`, and `schema.actionText` = + * **5**, read at `:38`. + * ⛔ A bare-word probe is worthless here and would fail towards "live": every + * OTHER owner of this spelling is a live key on a DIFFERENT declaration — + * `FormSchema.cancelLabel` (`./form.ts`), `plugin-designer`'s `ConfirmDialog` React + * prop, and `plugin-form`'s `ModalForm` / `DrawerForm`, which BUILD a local + * `cancelLabel` FROM `schema.cancelText`. None of them is an + * `AlertDialogSchema`, and none of them is touched. + * The key reached the Radix root through the rest-spread and stopped there — + * that root renders a context provider, not an element, so the prop was + * dropped before any DOM node + * (`packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx`). + * `BaseSchema` closes with `[key: string]: any`, so the value was never + * refused, only KEPT. + * + * Write {@link cancelText} instead — the key the renderer reads. + * + * `?: never` is the twin of `zod/overlay.zod.ts`'s `retirementTombstone` arm: + * the pair is what `__tests__/zod-mirror-parity.test.ts` compares, and it is + * what makes `tsc` refuse the key at the authoring site before anything runs. + * @deprecated Not part of this contract — the value was inert. Use `cancelText`. + */ + cancelLabel?: never; + /** + * 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 + * button at all. Same frame and the same two controls as + * {@link cancelLabel}: `schema.confirmLabel` scores **0** tree-wide on BASE + * `72bcd7783`. Its other owners are live keys on other declarations + * (`objectql.ts`, `plugin-designer`'s `ConfirmDialog` prop, `plugin-grid`'s + * `def.confirmLabel`), ⛔ none of them an `AlertDialogSchema`. + * + * Write {@link actionText} instead — the key the renderer reads. + * @deprecated Not part of this contract — the value was inert. Use `actionText`. + */ + confirmLabel?: never; + /** + * RETIRED (objectui#7963, ADR-0049 enforce-or-remove; maintainer ruling + * 2026-09-10) — nothing has ever read this key. `schema.confirmVariant` + * scores **0** tree-wide on BASE `72bcd7783` under the same two controls, and + * the DOM reading is sharper still: with the key authored, the confirm + * button's own `class` is byte-identical to the reading without it, measured + * against a `VARIANT_INSTRUMENT` control proving that same reading DOES + * 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. + */ + confirmVariant?: never; /** * Confirm (action) button click handler. * diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts index acc05084a3..10bb1d0614 100644 --- a/packages/types/src/zod/overlay.zod.ts +++ b/packages/types/src/zod/overlay.zod.ts @@ -37,6 +37,100 @@ export const DialogSchema = BaseSchema.extend({ onOpenChange: handlerKeyRefusal('onOpenChange', 'runtime-slot', 'Open change handler'), }); +/** + * The three ALERT-DIALOG FOOTER REFUSALS (objectui#7963) — `cancelLabel`, + * `confirmLabel` and `confirmVariant`, retired from `AlertDialogSchema` on BOTH + * faces under ADR-0049 enforce-or-remove (maintainer ruling 2026-09-10, taken + * on the readings below; the direction is not re-opened by a later card). + * + * ## Why a REFUSAL and not a deletion + * + * `BaseSchemaCore` ends `.passthrough()` and the TS `BaseSchema` closes with + * `[key: string]: any`, so a dropped MEMBER key is KEPT, not refused — deleting + * these three declarations would have left the silent accept exactly as it was + * and thrown away the diagnostic with it. {@link retirementTombstone} keeps the + * key DECLARED and unwritable, which is what makes the refusal loud. Same + * mechanism, same reasoning as the `actions` and `breadcrumbs` refusal arms on + * `PageNodeSchema` (`./layout.zod.ts`, objectui#7926 / objectui#8871). + * ⚠️ Both spelled WITHOUT a leading dot on purpose: objectui#8871 keeps a + * TREE-SCOPED point-access probe standing over every tracked file, and a prose + * mention here is a hit that probe cannot tell from a reader. + * + * ## What was measured — the frame is BASE `72bcd7783`, stated out loud + * + * ZERO readers, ⛔ measured with a POINT-ACCESS probe rather than a bare word. + * Tree-wide on the base, `schema.cancelLabel`, `schema.confirmLabel` and + * `schema.confirmVariant` each score **0**. The FIRING CONTROLS are the sibling + * half on the very file under test + * (`packages/components/src/renderers/overlay/alert-dialog.tsx`): + * `schema.cancelText` = **15** (read at `:37`) and `schema.actionText` = **5** + * (read at `:38`). The three zeros are therefore readings of the same + * instrument on the same renderer the controls light up, ⛔ not a probe that + * failed to run. + * + * ⛔ A BARE-WORD probe would have lied here, and it would have lied in the + * DANGEROUS direction — these spellings are heavily overloaded in this tree, + * and every other owner is LIVE: `FormSchema.cancelLabel` (`../form.ts`, read + * at `renderers/form/form.tsx:1063,3266`), `objectql.ts`'s `confirmLabel`, + * `plugin-designer`'s `ConfirmDialog` React props, `plugin-grid`'s + * `def.confirmLabel`, and `plugin-form`'s `ModalForm` / `DrawerForm`, which + * BUILD a local `cancelLabel` FROM `schema.cancelText` — the opposite + * direction. A bare grep reports dozens of "readers", ⛔ none of them on an + * `alert-dialog` node. This retirement touches none of them. + * + * ## The rest-spread near-miss, closed by MEASUREMENT rather than by reasoning + * + * The three keys DO reach the primitive: they are not on `SchemaRenderer`'s + * strip list, so they ride `componentProps` into the renderer's `...props` and + * onto ``. That is the same channel that made + * `CollapsibleSchema.open` live (objectui#8236), so "no `schema.KEY` read" was + * not safe to read as dead on its own. What settles it is a DOM reading: + * `packages/components/src/__tests__/alert-dialog-footer-keys-liveness-7963.test.tsx` + * varies one key per fixture through the real renderer and finds the normalised + * dialog HTML unmoved, against a `CHANNEL` control (`open`, unread and live + * through that same spread) and a `WIRED` control (`cancelText` / `actionText` + * drawing both buttons). The mechanism it names: the `AlertDialog` root renders + * a CONTEXT PROVIDER, not an element, so an unknown prop is dropped without + * reaching any node. That pin is kept, not retired — a retirement does not + * retire the measurement that justified it. + * + * ## No authored document is stranded + * + * Tree-wide, no fixture, catalog schema, example app, doc fence or template + * authors any of the three ON AN `alert-dialog` NODE; the only sites that write + * them are the two pins, which write them to TRIP the refusal. + * `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`). + * + * Pinned in `../__tests__/alert-dialog-footer-keys-refusal-7963.test.ts`. + */ +const ALERT_DIALOG_CANCEL_LABEL_REFUSAL = + '`cancelLabel` is RETIRED from the `alert-dialog` node (objectui#7963, ADR-0049 enforce-or-remove): ' + + 'nothing reads it, so an authored label drew no button at all and rode `.passthrough()` through the ' + + 'validator as a silent accept. Author the cancel button label as `cancelText` instead — the key the ' + + 'renderer reads and the key its registered `inputs` and `defaultProps` ship.'; + +const ALERT_DIALOG_CONFIRM_LABEL_REFUSAL = + '`confirmLabel` is RETIRED from the `alert-dialog` node (objectui#7963, ADR-0049 enforce-or-remove): ' + + 'nothing reads it, so an authored label drew no button at all and rode `.passthrough()` through the ' + + 'validator as a silent accept. Author the confirm button label as `actionText` instead — the key the ' + + '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. + */ +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.'; + /** * Alert Dialog Schema - Alert dialog component */ @@ -59,9 +153,9 @@ export const AlertDialogSchema = BaseSchema.extend({ .string() .optional() .describe('Confirm (action) button label; the action button renders only when this is set (no renderer default)'), - cancelLabel: z.string().optional().describe('Cancel button label'), - confirmLabel: z.string().optional().describe('Confirm button label'), - confirmVariant: z.enum(['default', 'destructive']).optional().describe('Confirm button variant'), + cancelLabel: retirementTombstone(ALERT_DIALOG_CANCEL_LABEL_REFUSAL), + confirmLabel: retirementTombstone(ALERT_DIALOG_CONFIRM_LABEL_REFUSAL), + confirmVariant: retirementTombstone(ALERT_DIALOG_CONFIRM_VARIANT_REFUSAL), onAction: handlerKeyRefusal('onAction', 'runtime-slot', 'Action button click handler'), onConfirm: handlerKeyRefusal('onConfirm', 'retired', 'Confirm handler'), onCancel: handlerKeyRefusal('onCancel', 'retired', 'Cancel handler'),