diff --git a/.changeset/7104-alert-dialog-read-dialect-declared.md b/.changeset/7104-alert-dialog-read-dialect-declared.md new file mode 100644 index 0000000000..6ecda53472 --- /dev/null +++ b/.changeset/7104-alert-dialog-read-dialect-declared.md @@ -0,0 +1,41 @@ +--- +'@object-ui/types': minor +--- + +`AlertDialogSchema` now declares the four keys the `alert-dialog` renderer actually reads +(objectui#7104): `content` (the dialog body, `SchemaNode | SchemaNode[]` like every sibling +overlay), `cancelText` and `actionText` (the footer's two button labels — each button renders +only when its label is set; there is no renderer default) on BOTH faces, and `onAction` (the +confirm button's click handler) as a RUNTIME SLOT in the objectui#6124 shape: callable on the +TypeScript face, refused by name in the zod mirror because JSON has no function value. + +Until now none of the four was declared anywhere. They were accepted only through +`BaseSchema`'s `[key: string]: any` and the mirror's `.passthrough()` — no editor completed +them, no page named them, and a wrong-typed value rode through unexamined — while the keys +the type DID declare for the same affordance (`cancelLabel` / `confirmLabel` / +`confirmVariant`) are read by nothing, so a document written strictly against the shipped +type rendered an empty footer. The renderer's own registered `inputs` and `defaultProps` were +already written in the read dialect; this change makes that single de-facto contract legible +instead of minting a second one (AGENTS.md #0.1: one strict contract, not N dialects). + +**Accept-set change on the published zod mirror — breaking, shipped as `minor` per this +repo's version-alignment policy (majors track `@objectstack`).** Declared keys are validated +even under `.passthrough()`, so three documents that parsed green yesterday are refused +today, each at its own path: `cancelText` or `actionText` carrying a non-string +(`cancelText: 123` — the renderer drew it as button text), `content` carrying a value that is +not a node or node array (an object without `type`), and `onAction` carried at all (a JSON +author cannot write a function; a string or object there was accepted and forwarded to the +button, where it did nothing or threw at click). A document in the read dialect with +well-typed values parses exactly as before and its values now survive the parse typed. +Undeclared keys still pass through unchanged. On the TypeScript face, `cancelText: 123` is +now a compile error at the key where the index signature used to absorb it. + +**No renderer change; no runtime behaviour changes.** The `alert-dialog` renderer, its +`inputs` and its `defaultProps` are untouched. The three declared-but-unread keys are +deliberately NOT retired here — that is a narrowing with its own card and its own grade; +their per-key liveness readings are on objectui#7104. + +Docs: `content/docs/components/overlay/alert-dialog.mdx` now publishes the read dialect in +its Schema block and no longer lists `actions?: BaseSchema[]`, a key no surface ever carried. +The four schema-catalog examples that page embeds still author `actions` and render an empty +footer — filed as objectui#7693, not converted here (the conversion is lossy). diff --git a/content/docs/components/overlay/alert-dialog.mdx b/content/docs/components/overlay/alert-dialog.mdx index bf37523c82..e6af5fe13c 100644 --- a/content/docs/components/overlay/alert-dialog.mdx +++ b/content/docs/components/overlay/alert-dialog.mdx @@ -23,20 +23,28 @@ The Alert Dialog component is used to interrupt the user with important content ```plaintext interface AlertDialogSchema { type: 'alert-dialog'; - title: string; // Dialog title - description: string; // Dialog description - + title?: string; // Dialog title + description?: string; // Dialog description + // Trigger - trigger: SchemaNode; // Component that triggers the dialog - - // Actions - actions?: BaseSchema[]; // Action buttons - + trigger: SchemaNode; // Component that triggers the dialog + + // Body and footer + 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 + + // Open state + defaultOpen?: boolean; // Initial state when uncontrolled (default: false) + open?: boolean; // Controlled open state + // Styling className?: string; } ``` +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. + ## Examples ### With Custom Actions 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 new file mode 100644 index 0000000000..6f78d5f17b --- /dev/null +++ b/packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts @@ -0,0 +1,332 @@ +/** + * objectui#7104 — `AlertDialogSchema` declares the keys the `alert-dialog` + * renderer READS. + * + * Measured on `origin/main` `a3eb5d07a`, re-measured unchanged on `6eebc54b6` + * (2026-09-05, the branch's merge-base at push time): the renderer + * (`packages/components/src/renderers/overlay/alert-dialog.tsx`) reads + * `schema.content` (the body, through `renderChildren`), `schema.cancelText` + * (draws `AlertDialogCancel` only when truthy), `schema.actionText` (draws + * `AlertDialogAction` only when truthy) and `schema.onAction` (that button's + * `onClick`) — and NONE of the four was declared on the TS interface or in the + * zod mirror. They were accepted only because `BaseSchema` carries + * `[key: string]: any` and the mirror is `.passthrough()`: no editor completed + * them, no page named them, and a wrong-typed value rode through unexamined. + * Meanwhile the three keys the type DID declare for the same affordance + * (`cancelLabel` / `confirmLabel` / `confirmVariant`) are read by nothing, so a + * document written strictly against the shipped type renders an EMPTY footer. + * + * Direction (the PM ruling on the card): declare what the renderer reads. The + * read dialect is the one live documents are written in — the component's own + * registered `inputs` and `defaultProps` ship `cancelText` / `actionText`, and + * the in-repo producer census (lit controls in the PR body) finds three + * producers of the read dialect on an alert-dialog node and zero of the + * declared one. Teaching the renderer `cancelLabel` instead would silently + * blank the footer of every document that works today. ⛔ Neither dialect is + * declared twice: one affordance, one authoring name (AGENTS.md #0.1). + * + * ## Both faces, per key + * + * - `content`, `cancelText`, `actionText` — declared on BOTH faces with the + * value domain the read enforces: `renderChildren` takes a node or a node + * array (the sibling overlays' `content` shape), and the two labels are + * truthiness-gated strings with NO renderer default (omit one and that + * button is not drawn; the designer palette seeds `'Cancel'` / `'Continue'`). + * - `onAction` — a RUNTIME SLOT in the objectui#6124 shape: callable on the TS + * face (the renderer wires it as the action button's `onClick`), refused BY + * NAME in the mirror through `handlerKeyRefusal()` because JSON has no + * function value. It is the live key the `onConfirm` tombstone points at. + * + * ## Red first + * + * Written and run BEFORE the schema edit, on the untouched base. Predicted and + * observed there: the membership legs red (no such mirror members), the + * wrong-typed-value legs red (the values parsed green through passthrough), + * the `onAction` legs red, the docs rows red (the page still published the + * phantom `actions` row); the controls, the renderer scan, the inert-trio pins + * and the fixture pins green. The compile-time leg failed on every `Equal` + * 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 + * + * - `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. + * - The four schema-catalog fixtures author `actions`, a key no surface + * carries, so the docs page's own examples render an empty footer — + * objectui#7693. Pinned here as that card's filed premise; its fix goes red + * here and re-derives the pin. + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { z } from 'zod'; + +import type { SchemaNode } from '../base'; +import type { AlertDialogSchema } from '../overlay'; +import { AlertDialogSchema as AlertDialogZod } from '../zod/overlay.zod.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); +const read = (relative: string): string => readFileSync(join(REPO_ROOT, relative), 'utf8'); + +const RENDERER = 'packages/components/src/renderers/overlay/alert-dialog.tsx'; +const DECLARATION = 'packages/types/src/overlay.ts'; +const DOC = 'content/docs/components/overlay/alert-dialog.mdx'; +const FIXTURE_DIR = 'examples/schema-catalog/src/schemas/components-overlay-alert-dialog'; +const FIXTURES = ['basic-alert-dialog', 'confirmation-dialog', 'custom-actions', 'destructive-action'] as const; + +/** 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; + +const shape = AlertDialogZod.shape; + +/** A document in the read dialect, every declared value well-typed. */ +const AUTHORED = { + type: 'alert-dialog', + title: 'Delete this account?', + description: 'This action cannot be undone.', + trigger: { type: 'button', label: 'Delete account', variant: 'destructive' }, + content: [{ type: 'text', content: 'Everything under the account goes with it.' }], + cancelText: 'Keep it', + actionText: 'Delete', +} as const; + +/* -- Type-level leg: compiled by `tsc -p packages/types/tsconfig.test.json` -- */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** A runtime slot keeps a callable member (the objectui#6124 pin's shape). */ +type KeepsFunction = [Extract, (...args: never[]) => unknown>] extends [never] + ? false + : true; + +// The read keys, at the read's own value domain. `SchemaNode` already admits +// `undefined`, so no `| undefined` on `content` (the objectui#7082 note). +export type _Content = Expect>; +export type _CancelText = Expect>; +export type _ActionText = Expect>; +export type _OnAction = Expect void) | undefined>>; +export type _OnActionCallable = Expect>; + +// The mirror's INPUT side: the two labels accept a string, the slot accepts +// nothing a JSON author can write. +type MirrorInput = z.input; +export type _MirrorCancelText = Expect>; +export type _MirrorActionText = 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 +>; + +// A wrong-typed value is now a compile error AT the key. Before objectui#7104 +// the index signature absorbed it: `cancelText: 123` compiled clean. +export const wellTyped: AlertDialogSchema = { type: 'alert-dialog', cancelText: 'Cancel', actionText: 'Continue' }; +export const wrongTyped: AlertDialogSchema = { + type: 'alert-dialog', + // @ts-expect-error objectui#7104 — `cancelText` is a string, no longer `any` through the index signature + cancelText: 123, +}; + +/* -- Readers for the docs Schema block and the TS interface (the objectui#7082 shape) -- */ + +interface Member { + readonly optional: boolean; + readonly typeText: string; +} + +function schemaFence(doc: string): string { + const fences = [...doc.matchAll(/```plaintext\n([\s\S]*?)```/g)].map((match) => match[1]); + if (fences.length !== 1) throw new Error(`expected exactly one plaintext fence in ${DOC}, found ${fences.length}`); + return fences[0]; +} + +function interfaceBody(source: string, opener: string): string { + const start = source.indexOf(opener); + if (start === -1) throw new Error(`no \`${opener}\` block`); + const end = source.indexOf('\n}', start); + if (end === -1) throw new Error(`unterminated \`${opener}\` block`); + return source.slice(start + opener.length, end); +} + +function members(body: string): Map { + const bare = body.replace(/\/\*\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); + const found = new Map(); + for (const match of bare.matchAll(/^ {2}(\w+)(\?)?:\s*([^;]+);/gm)) { + found.set(match[1], { optional: match[2] === '?', typeText: match[3].trim() }); + } + return found; +} + +const declaredInterface = () => + interfaceBody(read(DECLARATION), 'export interface AlertDialogSchema extends BaseSchema {'); + +/* -- Runtime leg -- */ + +describe('the three read keys are DECLARED on the mirror (objectui#7104)', () => { + it.each(READ_KEYS)('`%s` is a member of the mirror shape (membership cannot be read off acceptance under passthrough)', (key) => { + expect(shape[key]).toBeDefined(); + }); + + it.each(READ_KEYS)('`%s`: the declared value parses green and SURVIVES the parse', (key) => { + // Green under passthrough before the change too — survival alone cannot + // tell a declared key from an undeclared one, which is why membership is + // asserted off `.shape` above and refusal is asserted below. + const result = AlertDialogZod.safeParse(AUTHORED); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data[key]).toEqual(AUTHORED[key]); + }); + + it.each([ + ['cancelText', 123], + ['actionText', ['Continue']], + // `SchemaNodeSchema` is `BaseSchemaCore | primitive`, and `BaseSchemaCore` + // requires `type` — an object without one is not a node. + ['content', { label: 'a node without a type' }], + ] as const)('`%s` refuses a wrong-typed value AT the key — the enforcement the declaration adds', (key, wrong) => { + const result = AlertDialogZod.safeParse({ ...AUTHORED, [key]: wrong }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.map((issue) => String(issue.path[0]))).toContain(key); + }); + + it('control: the SAME wrong-typed value under an UNDECLARED key is still admitted unexamined — passthrough is unchanged', () => { + const result = AlertDialogZod.safeParse({ ...AUTHORED, actionLabel: 123 }); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.actionLabel).toBe(123); + }); + + it('control: a document that omits all three still parses green — every new member is optional', () => { + const { content: _content, cancelText: _cancelText, actionText: _actionText, ...rest } = AUTHORED; + expect(AlertDialogZod.safeParse(rest).success).toBe(true); + }); +}); + +describe('`onAction` is a RUNTIME SLOT — callable on the TS face, refused BY NAME in the mirror (objectui#7104, the objectui#6124 shape)', () => { + it('is a member of the mirror shape, carrying the runtime-slot guidance as its description', () => { + const member = shape.onAction as { description?: string } | undefined; + expect(member).toBeDefined(); + expect(member?.description).toContain('RUNTIME SLOT'); + expect(member?.description).toContain('`onAction`'); + expect(member?.description).not.toContain('RETIRED'); + }); + + it('a JSON author writing it is refused at its own path and pointed at the node-type spelling', () => { + const result = AlertDialogZod.safeParse({ ...AUTHORED, onAction: { action: 'toast', title: 'Deleted' } }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((candidate) => String(candidate.path[0]) === 'onAction'); + expect(issue).toBeDefined(); + expect(issue?.code).toBe('custom'); + expect(issue?.message).toContain('action:button'); + }); + + it('a live function is refused too — the mirror is not the programmatic channel', () => { + expect(AlertDialogZod.safeParse({ ...AUTHORED, onAction: () => undefined }).success).toBe(false); + }); +}); + +describe('the fact the declaration records: the renderer READS these keys and teaches them (objectui#7104)', () => { + it('reads `schema.content`, `schema.cancelText`, `schema.actionText` and `schema.onAction`', () => { + const renderer = read(RENDERER); + for (const key of ['content', 'cancelText', 'actionText', 'onAction']) { + expect(renderer, key).toContain(`schema.${key}`); + } + }); + + it('its registered `inputs` and `defaultProps` ship the read dialect, and none of the declared-but-unread trio', () => { + const renderer = read(RENDERER); + expect(renderer).toMatch(/name:\s*'cancelText'/); + expect(renderer).toMatch(/name:\s*'actionText'/); + 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); + }); + + it('control: the scan can find things — this IS the alert-dialog registration', () => { + const renderer = read(RENDERER); + expect(renderer).toContain("ComponentRegistry.register('alert-dialog'"); + expect(renderer).toContain('renderChildren(schema.trigger)'); + }); +}); + +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) => { + 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(INERT_DECLARED)('`%s` is still read by nothing in the renderer', (key) => { + 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', () => { + 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';/); + }); +}); + +describe('the docs page publishes the read dialect (objectui#7104)', () => { + const rows = () => members(interfaceBody(schemaFence(read(DOC)), 'interface AlertDialogSchema {')); + + it.each([ + ['content', 'SchemaNode | SchemaNode[]'], + ['cancelText', 'string'], + ['actionText', 'string'], + ])('row `%s` is published as `%s`, optional — the declaration\'s own spelling', (key, typeText) => { + expect(rows().get(key)).toEqual({ optional: true, typeText }); + }); + + 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); + }); + + it('`onAction` is not published as an authorable row — a runtime slot has no JSON spelling', () => { + expect(rows().has('onAction')).toBe(false); + }); + + 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); + }); + + it('control: the rows both faces always agreed on are still there', () => { + expect(rows().get('type')?.typeText).toBe("'alert-dialog'"); + expect(rows().get('trigger')?.typeText).toBe('SchemaNode'); + }); +}); + +describe('the schema-catalog fixtures still author `actions` — objectui#7693, pinned as its filed premise', () => { + it.each(FIXTURES)('%s.json carries an `actions` array and neither `cancelText` nor `actionText`', (name) => { + const fixture = JSON.parse(read(`${FIXTURE_DIR}/${name}.json`)) as Record; + expect(Array.isArray(fixture.actions)).toBe(true); + expect(fixture).not.toHaveProperty('cancelText'); + expect(fixture).not.toHaveProperty('actionText'); + }); + + it('and every one of them parses GREEN regardless — passthrough is why nothing red covers objectui#7693', () => { + for (const name of FIXTURES) { + expect(AlertDialogZod.safeParse(JSON.parse(read(`${FIXTURE_DIR}/${name}.json`))).success, name).toBe(true); + } + }); +}); diff --git a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts index a1d9b23ad0..07f07cc050 100644 --- a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts +++ b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts @@ -48,7 +48,9 @@ * `schema.onX`, calls `props.onX`, or spreads leftover props onto a Radix * root / DOM listener slot is a live channel (36 sites, `RUNTIME_SLOT` * below — 37 since objectui#6576 minted `ObjectDataTableSchema` with an - * `onRowClick` arm, the first on an `objectql` mirror). A key nothing reads + * `onRowClick` arm, the first on an `objectql` mirror; 38 since objectui#7104 + * declared `AlertDialogSchema.onAction`, a key the renderer had been reading + * UNDECLARED). A key nothing reads * gets the `?: never` tombstone (22 sites, `RETIRED` below; the `crud.ts` * `confirm` / `base.ts` convention). * @@ -204,7 +206,9 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => /** * 36 keys whose function value REACHES a renderer at runtime — the TypeScript - * interface keeps the function type. Channel measured per key on this tree: + * interface keeps the function type (38 since: `ObjectDataTableSchema.onRowClick`, + * objectui#6576, and `AlertDialogSchema.onAction`, objectui#7104, joined after + * this census). Channel measured per key on this tree: * `schema.onX` read/forwarded (kanban, chatbot, data-table, form, code-editor, * menu items), `props.onX` called after `SchemaRenderer`'s spread (input, * textarea, select, checkbox, file-upload, date-picker, input-otp, pagination, @@ -246,6 +250,8 @@ const RUNTIME_SLOT: readonly Site[] = [ ['objectql.zod.ts', 'ObjectDataTableSchema', 'onRowClick', ObjectDataTableZod], ['overlay.zod.ts', 'DialogSchema', 'onOpenChange', DialogZod], ['overlay.zod.ts', 'AlertDialogSchema', 'onOpenChange', AlertDialogZod], + // objectui#7104 — the action button's `onClick`; the renderer read `schema.onAction` UNDECLARED until then. + ['overlay.zod.ts', 'AlertDialogSchema', 'onAction', AlertDialogZod], ['overlay.zod.ts', 'SheetSchema', 'onOpenChange', SheetZod], ['overlay.zod.ts', 'DrawerSchema', 'onOpenChange', DrawerZod], ['overlay.zod.ts', 'PopoverSchema', 'onOpenChange', PopoverZod], @@ -351,13 +357,15 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje ]); }); - it('59 sites are ledgered, 37 runtime slots + 22 retired, with no key filed twice', () => { + it('60 sites are ledgered, 38 runtime slots + 22 retired, with no key filed twice', () => { // 58 from objectui#6124; the 59th is `ObjectDataTableSchema.onRowClick`, - // minted with its arm by objectui#6576 / #6914. - expect(RUNTIME_SLOT).toHaveLength(37); + // minted with its arm by objectui#6576 / #6914; the 60th is + // `AlertDialogSchema.onAction`, declared by objectui#7104 for a key the + // renderer had been reading undeclared. + expect(RUNTIME_SLOT).toHaveLength(38); expect(RETIRED).toHaveLength(22); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(59); + expect(new Set(ids).size).toBe(60); }); it.each(ALL_SITES)('%s %s.%s is DECLARED on the mirror shape, with the objectui#6124 guidance as its description', (_file, _schema, key, mirror) => { @@ -536,6 +544,7 @@ export type assertionRuntimeSlotsKeepTheirFunctionType = [ Expect>, Expect>, Expect>, + Expect>, Expect>, Expect>, Expect>, diff --git a/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts b/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts index f6c31b0b48..791fa25e74 100644 --- a/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts +++ b/packages/types/src/__tests__/overlay-node-slot-doc-types-7082.test.ts @@ -57,13 +57,17 @@ * because the type they compared against no longer exists. See the note where * they stood. * - * ## Two rows this file records instead of asserting green + * ## One row this file records instead of asserting green (two until objectui#7104) * - * `AlertDialogSchema.actions` and `EmptySchema.action` are documented but + * `AlertDialogSchema.actions` and `EmptySchema.action` were documented but * declared NOWHERE -- not on the TS interface, not in the mirror. Renaming * either to `SchemaNode` would have swapped one false claim for another, so - * both keep their rows and are pinned as UNDECLARED. The day either is - * declared, this file goes red and the page is owed a row. + * both kept their rows and were pinned as UNDECLARED. objectui#7104 then + * REMOVED the `actions` row from the alert-dialog page: the key was a phantom in + * every direction (declared nowhere, read by nothing), and the page now + * publishes the keys the renderer reads (`alert-dialog-read-dialect-7104.test.ts` + * pins those rows). `EmptySchema.action` keeps its row and its pin. The day it + * is declared, this file goes red and the page is owed a row. * * Likewise the requiredness of `AlertDialogSchema.trigger`, `SheetSchema.trigger` * and `SheetSchema.content`: all three are declared OPTIONAL and published @@ -285,7 +289,6 @@ describe('objectui#7081 is NOT pre-empted: the singular rows stay singular (obje describe('rows a docs-only edit cannot honestly resolve, recorded rather than renamed (objectui#7082)', () => { it.each([ - ['AlertDialogSchema', 'actions', 'content/docs/components/overlay/alert-dialog.mdx'], ['EmptySchema', 'action', 'content/docs/components/feedback/empty.mdx'], ])('%s.%s is documented but declared nowhere', (owner, key) => { expect(docRow(owner, key)).toBeDefined(); @@ -310,7 +313,7 @@ describe('rows a docs-only edit cannot honestly resolve, recorded rather than re expect(renderer).toContain("typeof actionSchema === 'object'"); }); - it('`AlertDialogSchema.actions` is read by nothing at all', () => { + it('`AlertDialogSchema.actions` is read by nothing at all -- the row objectui#7104 removed from the page was a phantom on the read side too', () => { const renderer = read('packages/components/src/renderers/overlay/alert-dialog.tsx'); expect(renderer).not.toMatch(/schema\.actions/); // Control: this IS the renderer, and the scan can find things in it. diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index bc125618fe..dcb8ecfe09 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -57,7 +57,11 @@ * already pins equal to `keyof Declared`. Nothing asserts it against a written * number, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **40 entries** in `KnownDrift`, **56 keys** across them — 39 / 55 until + * - **40 entries** in `KnownDrift`, **57 keys** across them — 40 / 56 until + * objectui#7104 declared `AlertDialogSchema.onAction`, the action button's + * `onClick` the renderer had been reading UNDECLARED, as a RUNTIME SLOT on an + * already-ledgered pair (growth on an existing entry, both faces measured); + * 39 / 55 until * objectui#7455 SEEDED `app.zod.ts#AppComponentSchema` with its one * spec-derived key `hidden` (a pair born ledgered, not growth on an existing * entry: both faces read `boolean` until the base was widened, and only the @@ -880,8 +884,16 @@ interface KnownDrift { * pre-#6124 state of that file, not a rule for new mirrors. */ 'objectql.zod.ts#ObjectDataTableSchema': 'onRowClick'; - /** RUNTIME SLOT (objectui#6124): the `alert-dialog` renderer spreads leftover props onto the Radix `AlertDialog` root. (`onConfirm` / `onCancel` are NOT here: the footer is wired to `schema.onAction` and `AlertDialogCancel`, so nothing reads them — both faces retire them.) */ - 'overlay.zod.ts#AlertDialogSchema': 'onOpenChange'; + /** + * RUNTIME SLOT (objectui#6124): the `alert-dialog` renderer spreads leftover props + * onto the Radix `AlertDialog` root (`onOpenChange`). `onAction` joined with + * objectui#7104, which DECLARED a key the renderer had been reading undeclared: + * it is the action button's `onClick`, so the TS face keeps the callable and the + * mirror refuses it by name. (`onConfirm` / `onCancel` are NOT here: the footer is + * wired to `schema.onAction` and `AlertDialogCancel`, so nothing reads them — both + * faces retire them.) + */ + 'overlay.zod.ts#AlertDialogSchema': 'onOpenChange' | 'onAction'; /** RUNTIME SLOT (objectui#6124): the `dialog` renderer spreads leftover props onto the Radix `Dialog` root. */ 'overlay.zod.ts#DialogSchema': 'onOpenChange'; /** RUNTIME SLOT (objectui#6124): the `drawer` renderer spreads leftover props onto the vaul `Drawer` root. */ diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index 2ea4efd359..9df1af15e1 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -90,6 +90,15 @@ export interface AlertDialogSchema extends BaseSchema { * Dialog description */ description?: string; + /** + * Dialog body, rendered between the header (title / description) and the + * footer — the same slot every sibling overlay declares. + * + * Declared for objectui#7104: the `alert-dialog` renderer reads + * `schema.content` through `renderChildren`; until then the key was accepted + * only through `BaseSchema`'s index signature. + */ + content?: SchemaNode | SchemaNode[]; /** * Dialog trigger */ @@ -103,6 +112,26 @@ export interface AlertDialogSchema extends BaseSchema { * Controlled open state */ open?: boolean; + /** + * Cancel button label. The renderer draws `AlertDialogCancel` ONLY when this + * is set — omit it and no cancel button renders. There is no renderer + * default; the designer palette seeds `'Cancel'`. + * + * 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. + */ + cancelText?: string; + /** + * Confirm (action) button label. The renderer draws `AlertDialogAction` ONLY + * when this is set — omit it and no confirm button renders. There is no + * renderer default; the designer palette seeds `'Continue'`. + * + * 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. + */ + actionText?: string; /** * Cancel button label * @default 'Cancel' @@ -118,6 +147,17 @@ export interface AlertDialogSchema extends BaseSchema { * @default 'default' */ confirmVariant?: 'default' | 'destructive'; + /** + * Confirm (action) button click handler. + * + * RUNTIME SLOT (objectui#6124 shape; declared by objectui#7104) — a + * host-supplied function, NOT authorable metadata: JSON has no function + * value, so the zod twin refuses this key by name and points at the + * node-type spelling. Kept callable here because the renderer wires it as + * `AlertDialogAction`'s `onClick`. It is the live key the retired + * `onConfirm` below points at. + */ + onAction?: () => void; /** * RETIRED (objectui#6124, ADR-0049) — JSON has no function value, and the * `alert-dialog` renderer wires its action button to `schema.onAction` and diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts index 8d8d7b8b34..5e4caa80f6 100644 --- a/packages/types/src/zod/overlay.zod.ts +++ b/packages/types/src/zod/overlay.zod.ts @@ -43,12 +43,25 @@ export const AlertDialogSchema = BaseSchema.extend({ type: z.literal('alert-dialog'), title: z.string().optional().describe('Alert dialog title'), description: z.string().optional().describe('Alert dialog description'), + content: z + .union([SchemaNodeSchema, z.array(SchemaNodeSchema)]) + .optional() + .describe('Dialog body, rendered between the header and the footer'), trigger: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Dialog trigger'), defaultOpen: z.boolean().optional().describe('Default open state'), open: z.boolean().optional().describe('Controlled open state'), + cancelText: z + .string() + .optional() + .describe('Cancel button label; the cancel button renders only when this is set (no renderer default)'), + actionText: z + .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'), + onAction: handlerKeyRefusal('onAction', 'runtime-slot', 'Action button click handler'), onConfirm: handlerKeyRefusal('onConfirm', 'retired', 'Confirm handler'), onCancel: handlerKeyRefusal('onCancel', 'retired', 'Cancel handler'), onOpenChange: handlerKeyRefusal('onOpenChange', 'runtime-slot', 'Open change handler'),