diff --git a/.changeset/26779-action-block-variant-danger.md b/.changeset/26779-action-block-variant-danger.md new file mode 100644 index 00000000000..414ed624f49 --- /dev/null +++ b/.changeset/26779-action-block-variant-danger.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +'@object-ui/components': minor +'@object-ui/types': minor +'@object-ui/plugin-detail': minor +--- + +feat(spec)!: the action blocks spell the destructive variant `danger`, the word `ActionSchema.variant` uses + +An action's variant was spelled in two dialects. `ActionSchema.variant`, the declaration the action runner dispatches, takes `primary | secondary | danger | ghost | link`. The four action blocks (`action:button`, `action:icon`, `action:group`, `action:menu`) and the group/menu members took the Button primitive's `default | destructive | outline | secondary | ghost | link` (plus `primary` on the button, the icon and the members) and refused `danger`. So one destructive action was `danger` as a declaration and `destructive` as a block, and each block renderer translated between the two. + +The block rows now take `danger` in place of `destructive`: `default | danger | outline | secondary | ghost | link`, plus `primary` where it was accepted before. `danger` draws the destructive button (a red item in a dropdown or `action:menu`) and asks a `confirmText` question through the destructive confirm button. `destructive` is refused at `variant` with the rename. Other components that draw the Button primitive directly (`element:button`, toasts, alerts, badges and the like) keep `destructive`, the primitive's own word, and are not touched. + +The four block renderers forward `danger` to the runner as it is and draw it as the primitive's `destructive`. They no longer read `destructive`: stored pages are rewritten by the conversion below, so no renderer keeps a bridge for the old word. `UIActionSchema.variant` (`@object-ui/types`) follows the rows by reference. The record detail views' Delete entry in the `⋯` menu is spelled `danger` too. + +This replaces the variant sentences of the #26665 entry in this release: the action blocks no longer translate `destructive` to `danger` at their forward to the runner, and `danger` is the spelling they accept (`UIActionSchema.variant` included). That entry's confirm-dialog behaviour is unchanged: a destructive block with a `confirmText` asks through the destructive confirm button, now when it is declared `variant: 'danger'`. + +Stored rows and older built artifacts: the ADR-0087 D2 conversion `page-action-block-variant-destructive-to-danger` rewrites `variant: 'destructive'` to `'danger'` on the four blocks' `properties.variant` and on each `action:group` / `action:menu` member's `variant`, at every position a page nests a component (regions, slots, nested containers). It renames an `action:bar`'s own `variant` and the `variant` of each `actions` / `systemActions` member as well. The bar has no props row, so nothing refuses `destructive` on it, but it draws each member through the block renderers, which no longer read that word. A stored `page` row is rewritten when it is rehydrated, so it keeps drawing the destructive button and confirming through the destructive confirm button. The conversion is retired from the load path, so a source that still writes `destructive` on a block is refused, not rewritten. The D3 entry `ui-page-action-block-variant-destructive-refused` carries the judgment for `os migrate meta`. + +Clause-②: no (narrowing) — the action block rows refuse `destructive`, a value they used to accept. `danger` takes its place on the same rows. + +Metadata-protocol: narrowed (`variant: 'destructive'` is refused on the action blocks and their members; write `danger`) + + + +**BREAKING** for an author who wrote `variant: 'destructive'` on an action block. + +**`destructive` → `danger`.** + +- **Before.** `{ type: 'action:button', properties: { label: 'Purge', actionType: 'script', variant: 'destructive' } }` parsed; `variant: 'danger'` was refused. +- **Now.** `destructive` is refused at `variant` (or at `actions.N.variant` on a group or menu member) with a message naming the rename. +- **Fix.** Write `variant: 'danger'` on the block or member. Nothing else changes: it draws the same destructive button. `os migrate meta --from 17 --write` applies the rename to your sources. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index f9bfb31a62e..6202a20c497 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -32,7 +32,7 @@ const result = ActionButtonPropsSchema.parse(data); | **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map | | **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself | -| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary, plus `primary`, which renders as `default` (renderer default: `default`) | +| **variant** | `Enum<'default' \| 'danger' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary with its destructive arm spelled `danger`, the action runner's word, plus `primary`, which renders as `default` (renderer default: `default`). `danger` draws the destructive button and asks a `confirmText` question through the destructive confirm button | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Button size — the Button primitive's vocabulary, plus `md`, which renders as `default` (renderer default: `default`) | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the button is not rendered when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the button is shown but cannot be pressed while it is TRUE. Omit for never-disabled | @@ -78,7 +78,7 @@ const result = ActionButtonPropsSchema.parse(data); | **location** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>` | optional | Render only the members whose `locations` include this location. Omit to render every member | | **label** | `string` | optional | Dropdown trigger text (renderer default: `Actions`). Inline mode renders no group label. A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name on the dropdown trigger. Inline mode renders no group icon | -| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'>` | optional | Button variant for the dropdown trigger and for every inline member that sets none — the Button primitive's vocabulary (renderer default: `outline`) | +| **variant** | `Enum<'default' \| 'danger' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'>` | optional | Button variant for the dropdown trigger and for every inline member that sets none — the Button primitive's vocabulary with its destructive arm spelled `danger`, the action runner's word (renderer default: `outline`) | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon'>` | optional | Button size for the dropdown trigger and for every inline member that sets none — the Button primitive's vocabulary | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate for the whole group — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds. Omit for always-visible | @@ -90,7 +90,7 @@ const result = ActionButtonPropsSchema.parse(data); | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | -| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly | +| **variant** | `Enum<'default' \| 'danger' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary with its destructive arm spelled `danger`, the action runner's word, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `danger` red and every other variant plainly. `danger` asks a `confirmText` question through the destructive confirm button | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | | **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn | @@ -127,7 +127,7 @@ const result = ActionButtonPropsSchema.parse(data); | **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) | | **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type` | -| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary, plus `primary`, which renders as `default` (renderer default: `ghost`) | +| **variant** | `Enum<'default' \| 'danger' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary with its destructive arm spelled `danger`, the action runner's word, plus `primary`, which renders as `default` (renderer default: `ghost`). `danger` draws the destructive button and asks a `confirmText` question through the destructive confirm button | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is FALSE. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled | | **description** | `string` | optional | Action description — the tooltip text when there is no `label`, and forwarded to the runner for the parameter dialog | @@ -168,7 +168,7 @@ const result = ActionButtonPropsSchema.parse(data); | **actions** | `{ name?: string; label?: string; icon?: string; type?: string; … }[]` | optional | The menu's actions, in order — each an action object the menu draws and runs itself (`name`, `label`, `icon`, `type`, `target`, `visible`, `disabled`, `tags`, …); a member's executor is its `type` | | **label** | `string` | optional | Trigger text and accessible label; omit for an icon-only trigger labelled "More actions". A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name on the trigger (renderer default: the horizontal ellipsis) | -| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'>` | optional | Trigger button variant — the Button primitive's vocabulary (renderer default: `ghost`) | +| **variant** | `Enum<'default' \| 'danger' \| 'outline' \| 'secondary' \| 'ghost' \| 'link'>` | optional | Trigger button variant — the Button primitive's vocabulary with its destructive arm spelled `danger`, the action runner's word (renderer default: `ghost`) | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon'>` | optional | Trigger button size — the Button primitive's vocabulary (renderer default: `icon`) | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate for the whole menu — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; a predicate that fails to evaluate hides it. Omit for always-visible | @@ -180,7 +180,7 @@ const result = ActionButtonPropsSchema.parse(data); | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | | **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | -| **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly | +| **variant** | `Enum<'default' \| 'danger' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary with its destructive arm spelled `danger`, the action runner's word, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `danger` red and every other variant plainly. `danger` asks a `confirmText` question through the destructive confirm button | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | | **tags** | `Enum<'separator-before'>[]` | optional | Item tags — `separator-before` draws a divider above the item in a dropdown or menu (not above the first item); no other tag is drawn | diff --git a/packages/spec/src/conversions/page-action-block-variant-destructive-to-danger.test.ts b/packages/spec/src/conversions/page-action-block-variant-destructive-to-danger.test.ts new file mode 100644 index 00000000000..85500d4eac7 --- /dev/null +++ b/packages/spec/src/conversions/page-action-block-variant-destructive-to-danger.test.ts @@ -0,0 +1,218 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { + ActionButtonPropsSchema, + ActionGroupPropsSchema, + ActionIconPropsSchema, + ActionMenuPropsSchema, +} from '../ui/component.zod.js'; +import { ActionSchema } from '../ui/action.zod.js'; +import { applyConversions } from './apply.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import type { ConversionNotice } from './types.js'; + +/** + * `page-action-block-variant-destructive-to-danger` — one spelling for an + * action's destructive variant (#26779). + * + * `ActionSchema.variant`, the declaration the action runner dispatches, says + * `danger`; the action block rows said `destructive` and refused `danger`. + * The rows now take `danger` and refuse `destructive` with the rename, and + * this D2 entry rewrites the old value in data at rest. The fixture pair in + * `conversions.test.ts` already proves before → after over the whole table. + * What this file pins: + * + * - the six rows accept `danger` and refuse `destructive` with the rename + * prescription at `variant`, while every other miss keeps the enum's own + * message, and the block dialect's `danger` is the declaration's word; + * - the stored-row seam REWRITES `destructive` to `danger` on every row + * shape, and the result parses against the row that now refuses it; + * - the authoring funnel does NOT replay it; + * - the same seam renames an `action:bar`'s own `variant` and its members', + * though the bar has no row: it draws its members through the blocks; + * - another component's `destructive` (the Button primitive's own word) and + * every other variant are left byte-identical with no notice; + * - idempotence, and copy-on-write identity for a page with nothing to do. + */ +const ID = 'page-action-block-variant-destructive-to-danger'; + +type Parse = { success: boolean; error?: { issues: { path: PropertyKey[]; message: string }[] } }; +type Parser = { safeParse: (value: unknown) => Parse }; + +/** Each row, and how to place `variant` on it. */ +const ROWS: Array<[string, Parser, (variant: string) => Record, PropertyKey[]]> = [ + ['action:button', ActionButtonPropsSchema, (variant) => ({ label: 'Purge', variant }), ['variant']], + ['action:icon', ActionIconPropsSchema, (variant) => ({ icon: 'trash', variant }), ['variant']], + ['action:group', ActionGroupPropsSchema, (variant) => ({ actions: [], variant }), ['variant']], + ['action:group member', ActionGroupPropsSchema, (variant) => ({ actions: [{ label: 'Purge', type: 'script', variant }] }), ['actions', 0, 'variant']], + ['action:menu', ActionMenuPropsSchema, (variant) => ({ actions: [], variant }), ['variant']], + ['action:menu member', ActionMenuPropsSchema, (variant) => ({ actions: [{ label: 'Purge', type: 'script', variant }] }), ['actions', 0, 'variant']], +]; + +/** A stored `page` row carrying one block in a region. */ +const pageWith = (component: Record) => ({ + name: 'ops_cleanup', + regions: [{ name: 'main', components: [component] }], +}); + +/** The one block of a converted {@link pageWith} row. */ +const blockOf = (page: unknown) => + (page as { regions: { components: Record[] }[] }).regions[0]!.components[0]!; + +/** Replay the stored chain over `page`, collecting this entry's notices. */ +function replayStored(page: Record) { + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('page', page, { onNotice: (n) => notices.push(n) }); + return { out, notices: notices.filter((n) => n.conversionId === ID) }; +} + +const AT = 'pages[0].regions[0].components[0].properties'; + +describe('the action block rows spell the destructive variant `danger` (#26779)', () => { + it('`danger` is the word `ActionSchema.variant` declares', () => { + expect(ActionSchema.safeParse({ name: 'purge', label: 'Purge', type: 'script', target: 'purge', variant: 'danger' }).success).toBe(true); + }); + + it.each(ROWS)('%s accepts `danger`', (_row, schema, place) => { + expect(schema.safeParse(place('danger')).success).toBe(true); + }); + + it.each(ROWS)('%s refuses `destructive` with the rename onto `danger`', (_row, schema, place, at) => { + const refused = schema.safeParse(place('destructive')); + expect(refused.success).toBe(false); + const issues = refused.error!.issues.filter((issue) => issue.path.join('.') === at.join('.')); + expect(issues).toHaveLength(1); + expect(issues[0]!.message).toContain("`variant: 'destructive'` was renamed `'danger'` in @objectstack/spec 18"); + expect(issues[0]!.message).toContain("Write `variant: 'danger'`"); + }); + + it.each(ROWS)('CONTROL: %s keeps the enum\'s own message for any other miss', (_row, schema, place, at) => { + const refused = schema.safeParse(place('loud')); + expect(refused.success).toBe(false); + const issues = refused.error!.issues.filter((issue) => issue.path.join('.') === at.join('.')); + expect(issues).toHaveLength(1); + expect(issues[0]!.message).not.toContain('renamed'); + expect(issues[0]!.message).toContain('"danger"'); + }); +}); + +describe('page-action-block-variant-destructive-to-danger (ADR-0087 D2)', () => { + it('is registered for protocol 18 and retired from the authoring load path', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + }); + + it.each([ + ['action:button', ActionButtonPropsSchema, { label: 'Purge', actionType: 'script', confirmText: 'This cannot be undone.' }], + ['action:icon', ActionIconPropsSchema, { icon: 'trash', actionType: 'script' }], + ['action:group', ActionGroupPropsSchema, { actions: [] }], + ['action:menu', ActionMenuPropsSchema, { actions: [] }], + ] as const)('the stored-row seam rewrites `variant: \'destructive\'` to `\'danger\'` on `%s`', (type, schema, rest) => { + const props = { ...rest, variant: 'destructive' }; + expect(schema.safeParse(props).success).toBe(false); + const { out, notices } = replayStored(pageWith({ type, properties: props })); + const block = blockOf(out); + expect(block.properties).toEqual({ ...rest, variant: 'danger' }); + expect(schema.safeParse(block.properties).success).toBe(true); + expect(notices.map((n) => [n.from, n.to, n.path])).toEqual([['destructive', 'danger', `${AT}.variant`]]); + }); + + it.each([ + ['action:group', ActionGroupPropsSchema], + ['action:menu', ActionMenuPropsSchema], + ] as const)('the stored-row seam rewrites a `%s` member\'s `variant: \'destructive\'`, and only that member', (type, schema) => { + const props = { + actions: [ + { label: 'Copy', type: 'script', variant: 'ghost' }, + { label: 'Purge', type: 'script', variant: 'destructive' }, + ], + }; + expect(schema.safeParse(props).success).toBe(false); + const { out, notices } = replayStored(pageWith({ type, properties: props })); + const block = blockOf(out); + expect(block.properties).toEqual({ + actions: [ + { label: 'Copy', type: 'script', variant: 'ghost' }, + { label: 'Purge', type: 'script', variant: 'danger' }, + ], + }); + expect(schema.safeParse(block.properties).success).toBe(true); + // The untouched member keeps its identity (copy-on-write). + expect((block.properties as { actions: unknown[] }).actions[0]).toBe(props.actions[0]); + expect(notices.map((n) => [n.from, n.to, n.path])).toEqual([['destructive', 'danger', `${AT}.actions[1].variant`]]); + }); + + it('the stored-row seam rewrites an `action:bar`\'s own `variant` and its `actions` / `systemActions` members\'', () => { + const props = { + variant: 'destructive', + actions: [ + { name: 'copy', label: 'Copy', type: 'script', variant: 'ghost' }, + { name: 'purge', label: 'Purge', type: 'script', variant: 'destructive' }, + ], + systemActions: [{ name: 'sys_delete', label: 'Delete', type: 'script', variant: 'destructive' }], + }; + const { out, notices } = replayStored(pageWith({ type: 'action:bar', properties: props })); + const block = blockOf(out); + expect(block.properties).toEqual({ + variant: 'danger', + actions: [ + { name: 'copy', label: 'Copy', type: 'script', variant: 'ghost' }, + { name: 'purge', label: 'Purge', type: 'script', variant: 'danger' }, + ], + systemActions: [{ name: 'sys_delete', label: 'Delete', type: 'script', variant: 'danger' }], + }); + expect((block.properties as { actions: unknown[] }).actions[0]).toBe(props.actions[0]); + expect(notices.map((n) => [n.from, n.to, n.path])).toEqual([ + ['destructive', 'danger', `${AT}.variant`], + ['destructive', 'danger', `${AT}.actions[1].variant`], + ['destructive', 'danger', `${AT}.systemActions[0].variant`], + ]); + }); + + it.each([ + ['the canonical `danger`', { type: 'action:button', properties: { label: 'Purge', variant: 'danger' } }], + ['another variant', { type: 'action:icon', properties: { icon: 'copy', variant: 'outline' } }], + ["an `element:button`'s `destructive` (the Button primitive's own word)", { type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } }], + ["a `destructive` member on a block that has no members", { type: 'action:button', properties: { label: 'Go', actions: [{ variant: 'destructive' }] } }], + ['a member that is not an object', { type: 'action:menu', properties: { actions: ['destructive'] } }], + ['`actions` that is not a list', { type: 'action:group', properties: { actions: { variant: 'destructive' } } }], + ["a `systemActions` list on a block that has no such list", { type: 'action:menu', properties: { systemActions: [{ variant: 'destructive' }] } }], + ])('control: %s is left as stored, with no notice', (_label, component) => { + const page = pageWith(component); + const { out, notices } = replayStored(page); + expect(out).toEqual(page); + expect(notices).toEqual([]); + }); + + it('control: a canonical page comes back as the SAME reference, with no notice', () => { + const clean = { pages: [pageWith({ type: 'action:icon', properties: { icon: 'trash', variant: 'danger' } })] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(clean, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(out, 'nothing to rename ⇒ copy-on-write returns the input').toBe(clean); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions( + { pages: [pageWith({ type: 'action:button', properties: { label: 'Purge', variant: 'destructive' } })] }, + { includeRetired: true }, + ); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); + + it('the authoring funnel does not replay it — an author meets the rows\' refusal instead', () => { + const authored = { pages: [pageWith({ type: 'action:button', properties: { label: 'Purge', variant: 'destructive' } })] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(authored, { onNotice: (n) => notices.push(n) }); + expect(out).toBe(authored); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 831ca79e7f8..22e914c6297 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -10841,6 +10841,224 @@ const listViewSortStringClauseToArray: MetadataConversion = { */ const ACTION_CONTAINER_BLOCK_TYPES = new Set(['action:group', 'action:menu']); +/** + * An action block's `variant: 'destructive'` → `'danger'` (protocol 18, #26779). + * + * The action variant was spelled in two dialects. `ActionSchema.variant`, the + * declaration the action runner dispatches, says `danger`; the four action + * blocks' rows (`action:button`, `action:icon`, `action:group`, `action:menu` + * and the containers' members) took the Button primitive's word, + * `destructive`, and refused `danger`. So one destructive action changed + * spelling between a declaration and a block, and each block renderer mapped + * `destructive` onto `danger` at its forward to the runner (#26665). The rows + * now take `danger` and refuse `destructive` with the rename + * (`refuseActionBlockDestructiveVariant`, `ui/component.zod.ts`). + * + * **Lossless**: on a block both spellings draw the primitive's destructive + * button and ask a `confirmText` question through the destructive confirm + * button, so the rewrite changes no rendering and no run. A page's + * `properties` is not parsed on the metadata load path, so a stored page + * written with `destructive` keeps rendering only while a renderer keeps the + * old word; this entry carries it across at the stored-row seam, the + * artifact door and `os migrate meta --write`, which is why the block + * renderers forward `danger` directly and keep no `destructive` bridge. + * + * Its surfaces are value renames and nothing else: `properties.variant` on + * each of the four block nodes (the containers' own value is their trigger's), + * and `variant` on each `action:group` / `action:menu` member of + * `properties.actions`. `action:bar` is visited too, though it has no row + * here: the bar spreads each member of `properties.actions` and + * `properties.systemActions` onto an `action:button` (or folds it into its + * `action:menu`) with `variant: member.variant || properties.variant`, so a + * stored bar written `destructive` reaches the block renderers, which no + * longer read that word, and would fall back to a plain button with no + * refusal anywhere to say so. Its own `variant` and its members' are renamed + * as the four blocks' are. Every other value is left alone, and so is every + * other component type: `destructive` stays the Button primitive's word on + * `element:button` and the other primitive-backed components, which this + * entry never reads. A member that is not an object, or a list that is not an + * array, is left as stored. The reach is every position `mapPageComponents` + * visits — regions, slots, nested containers — the props gate's own reach. + * Idempotent by construction: a rewritten value reads `'danger'`, which this + * entry never touches. + * + * **Retired from the load path from day one**, as + * {@link pageActionRowNavigationExecutorToUrl} is: the authoring door teaches + * with the rows' refusal, and this entry serves the paths that rewrite + * EXISTING data. + */ +const pageActionBlockVariantDestructiveToDanger: MetadataConversion = { + id: 'page-action-block-variant-destructive-to-danger', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '18.2.0', + surface: 'page.component.action:button.variant / page.component.action:icon.variant / ' + + 'page.component.action:group.variant / page.component.action:menu.variant / ' + + 'page.component.action:group.actions[].variant / page.component.action:menu.actions[].variant / ' + + 'page.component.action:bar.variant / page.component.action:bar.actions[].variant / ' + + 'page.component.action:bar.systemActions[].variant', + summary: + "action block variant 'destructive' → 'danger' — the spelling ActionSchema.variant and the action runner " + + 'use; on a block both draw the destructive button and confirm through the destructive confirm button, ' + + 'so the rename is lossless', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + const type = component.type; + if (typeof type !== 'string') return component; + const memberLists = ACTION_CONTAINER_BLOCK_TYPES.has(type) + ? ['actions'] as const + : type === 'action:bar' + ? ['actions', 'systemActions'] as const + : ACTION_RUNNER_BLOCK_TYPES.has(type) ? [] as const : undefined; + if (!memberLists) return component; + const properties = component.properties; + if (!isDict(properties)) return component; + let next = properties; + if (properties.variant === 'destructive') { + emit({ from: 'destructive', to: 'danger', path: `${path}.properties.variant` }); + next = { ...next, variant: 'danger' }; + } + for (const key of memberLists) { + const members = properties[key]; + if (!Array.isArray(members)) continue; + let changed = false; + const nextMembers = members.map((member, i) => { + if (!isDict(member) || member.variant !== 'destructive') return member; + changed = true; + emit({ from: 'destructive', to: 'danger', path: `${path}.properties.${key}[${i}].variant` }); + return { ...member, variant: 'danger' }; + }); + if (changed) next = { ...next, [key]: nextMembers }; + } + return next === properties ? component : { ...component, properties: next }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'ops_cleanup', + regions: [ + { + name: 'main', + components: [ + // The measured shape: a destructive button with a confirm question. + { + type: 'action:button', + properties: { label: 'Purge', actionType: 'script', variant: 'destructive', confirmText: 'This cannot be undone.' }, + }, + { type: 'action:icon', properties: { icon: 'trash', actionType: 'script', variant: 'destructive' } }, + // Canonical already, and another value: untouched. + { type: 'action:button', properties: { label: 'Archive', actionType: 'script', variant: 'danger' } }, + { type: 'action:button', properties: { label: 'Open', actionType: 'url', target: '/apps/ops', variant: 'outline' } }, + // The Button primitive's own word on another component: not this entry's. + { type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } }, + // A container's trigger and one of its members: both renamed, the other member untouched. + { + type: 'action:group', + properties: { + variant: 'destructive', + actions: [ + { label: 'Delete', type: 'script', variant: 'destructive' }, + { label: 'Copy', type: 'script', variant: 'ghost' }, + ], + }, + }, + // Nested one container down. + { + type: 'page:card', + properties: { + title: 'Danger zone', + children: [ + { type: 'action:menu', properties: { actions: [{ label: 'Drop', type: 'script', variant: 'destructive' }] } }, + ], + }, + }, + // A bar: no row of its own, but its members reach the block renderers. + { + type: 'action:bar', + properties: { + actions: [{ name: 'purge', label: 'Purge', type: 'script', variant: 'destructive' }], + systemActions: [{ name: 'sys_delete', label: 'Delete', type: 'script', variant: 'destructive' }], + }, + }, + ], + }, + ], + }, + // A slotted record page's named slot. + { + name: 'ops_cleanup_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'action:icon', properties: { icon: 'trash', actionType: 'script', variant: 'destructive' } }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'ops_cleanup', + regions: [ + { + name: 'main', + components: [ + { + type: 'action:button', + properties: { label: 'Purge', actionType: 'script', variant: 'danger', confirmText: 'This cannot be undone.' }, + }, + { type: 'action:icon', properties: { icon: 'trash', actionType: 'script', variant: 'danger' } }, + { type: 'action:button', properties: { label: 'Archive', actionType: 'script', variant: 'danger' } }, + { type: 'action:button', properties: { label: 'Open', actionType: 'url', target: '/apps/ops', variant: 'outline' } }, + { type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } }, + { + type: 'action:group', + properties: { + variant: 'danger', + actions: [ + { label: 'Delete', type: 'script', variant: 'danger' }, + { label: 'Copy', type: 'script', variant: 'ghost' }, + ], + }, + }, + { + type: 'page:card', + properties: { + title: 'Danger zone', + children: [ + { type: 'action:menu', properties: { actions: [{ label: 'Drop', type: 'script', variant: 'danger' }] } }, + ], + }, + }, + { + type: 'action:bar', + properties: { + actions: [{ name: 'purge', label: 'Purge', type: 'script', variant: 'danger' }], + systemActions: [{ name: 'sys_delete', label: 'Delete', type: 'script', variant: 'danger' }], + }, + }, + ], + }, + ], + }, + { + name: 'ops_cleanup_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'action:icon', properties: { icon: 'trash', actionType: 'script', variant: 'danger' } }, + }, + }, + ], + }, + // The button, the icon, the group's trigger, the group member, the nested + // menu member, the bar's two members and the slotted header icon. + expectedNotices: 8, + }, +}; + /** * A page action row's executor value `'navigation'` → `'url'` (protocol 18). * @@ -14328,6 +14546,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: objectKanbanQuickAddRemoved, order: 15 }, { conversion: objectMasterDetailFormDetailSortFieldRemoved, order: 60 }, { conversion: objectTenancyOrganizationFieldRemoved, order: 35 }, + { conversion: pageActionBlockVariantDestructiveToDanger, order: 77 }, { conversion: pageActionRowNavigationExecutorToUrl, order: 63 }, { conversion: pageActionRowPatchRemoved, order: 70 }, { conversion: pageAssignedProfilesRemoved, order: 31 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-block-variant-destructive-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-block-variant-destructive-refused.ts new file mode 100644 index 00000000000..74e6152b5fe --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-block-variant-destructive-refused.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-page-action-block-variant-destructive-refused', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span already, and a nested backtick would close it. + surface: "variant: 'destructive' on an action block: an action:button, action:icon, action:group or action:menu " + + 'node, an action:group / action:menu member, or an action:bar node or member', + replacement: "`variant: 'danger'`, the spelling `ActionSchema.variant` and the action runner use. The rename is " + + 'lossless on a block: it draws the same destructive button, and a `confirmText` question asks through the ' + + 'destructive confirm button as before.', + reason: + "An action's variant was spelled in two dialects. `ActionSchema.variant`, the declaration the action runner " + + "dispatches, says `danger`; the action block rows took the Button primitive's word, `destructive`, and " + + 'refused `danger`, so one destructive action changed spelling between a declaration and a block, and each ' + + 'block renderer translated between the two at its forward to the runner. The blocks now take `danger` and ' + + 'refuse `destructive` with the rename. Other components that draw the Button primitive directly ' + + "(`element:button` and the like) keep `destructive`, the primitive's own word, and are not touched. A page's " + + '`properties` is not parsed on the metadata load path, and the next save of the page meets the ' + + 'component-props gate at the metadata save door; `os validate` reports the old value as a ' + + '`component-props-invalid` finding. The D2 conversion `page-action-block-variant-destructive-to-danger` ' + + 'performs the rename, retired from the load path, so an author meets the refusal while stored rows, built ' + + 'artifacts and `os migrate meta --write` are rewritten. An `action:bar` has no props row, so nothing refuses ' + + "`destructive` on it; the conversion still renames the bar's own `variant` and its `actions` / " + + "`systemActions` members' `variant`, because the bar draws each member through the block renderers, " + + 'which no longer read `destructive`.', + acceptanceCriteria: + "No `action:button` / `action:icon` / `action:group` / `action:menu` `properties.variant` and no " + + "`action:group` / `action:menu` member `variant` reads `'destructive'`; `os validate` reports no " + + '`component-props-invalid` finding at `properties.variant` or `properties.actions.N.variant`; no ' + + "`action:bar` `properties.variant` or `actions` / `systemActions` member `variant` reads `'destructive'`; and " + + 'each such block still draws the destructive button and confirms through the destructive confirm button.', + conversionIds: ['page-action-block-variant-destructive-to-danger'], +}; diff --git a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts index 939018d9cfb..14a14e5860b 100644 --- a/packages/spec/src/shared/retired-key-removal-version.pin.test.ts +++ b/packages/spec/src/shared/retired-key-removal-version.pin.test.ts @@ -274,6 +274,14 @@ const JOIN: Readonly> = { 'declared-index-unique-scope': [ refusal('indexes[].unique: true', () => IndexSchema.safeParse({ fields: ['code'], unique: true })), ], + 'page-action-block-variant-destructive-to-danger': [ + refusal("action:button variant 'destructive'", () => ActionButtonPropsSchema.safeParse({ label: 'Purge', variant: 'destructive' })), + refusal("action:icon variant 'destructive'", () => ActionIconPropsSchema.safeParse({ icon: 'trash', variant: 'destructive' })), + refusal("action:group variant 'destructive'", () => ActionGroupPropsSchema.safeParse({ actions: [], variant: 'destructive' })), + refusal("action:group member variant 'destructive'", () => ActionGroupPropsSchema.safeParse({ actions: [{ label: 'Purge', type: 'script', variant: 'destructive' }] })), + refusal("action:menu variant 'destructive'", () => ActionMenuPropsSchema.safeParse({ actions: [], variant: 'destructive' })), + refusal("action:menu member variant 'destructive'", () => ActionMenuPropsSchema.safeParse({ actions: [{ label: 'Purge', type: 'script', variant: 'destructive' }] })), + ], 'page-action-row-navigation-executor-to-url': [ refusal("action:button actionType 'navigation'", () => ActionButtonPropsSchema.safeParse({ label: 'Clone', actionType: 'navigation', target: '/apps/crm/tasks' })), refusal("action:icon actionType 'navigation'", () => ActionIconPropsSchema.safeParse({ icon: 'copy', label: 'Clone', actionType: 'navigation', target: '/apps/crm/tasks' })), diff --git a/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts b/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts index 7d5544d04fe..f71df3665fc 100644 --- a/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts +++ b/packages/spec/src/ui/component-report-items-action-members-typed.pin.test.ts @@ -192,7 +192,7 @@ describe('§1 each member accepts every shape a measured writer authors', () => { name: 'shown', label: 'SHOWN', type: 'run' }, { name: 'hidden', label: 'HIDDEN', type: 'run', visible: false }, { name: 'greyed', label: 'GREYED', type: 'run', disabled: true }, - { name: 'loud', label: 'LOUD', type: 'run', variant: 'destructive', size: 'lg' }, + { name: 'loud', label: 'LOUD', type: 'run', variant: 'danger', size: 'lg' }, { name: 'alpha', label: 'ALPHA', type: 'run', variant: 'default', size: 'md' }, { name: 'promoted', label: 'PROMOTED', type: 'run', variant: 'primary', size: 'icon' }, ], @@ -205,7 +205,7 @@ describe('§1 each member accepts every shape a measured writer authors', () => ['a destructive menu item below a separator', 'action:menu', { actions: [ { name: 'archive', label: 'Archive', type: 'script' }, - { name: 'nameonly', type: 'run', tags: ['separator-before'], variant: 'destructive' }, + { name: 'nameonly', type: 'run', tags: ['separator-before'], variant: 'danger' }, ], }], // objectui `types/src/__tests__/held-public-block-arms-10872.test.ts:188` / `:189`. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 5b5f9111547..f80182df692 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3429,10 +3429,50 @@ const actionCondition = () => error: (issue) => evaluatedExpressionUnionRefusal(issue.input), }); -/** objectui's `Button` primitive vocabulary (its read point is recorded in `liveness/page.json`, row `regions`). */ -const BUTTON_PRIMITIVE_VARIANTS = ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const; +/** + * The action blocks' variant vocabulary: objectui's `Button` primitive's + * (its read point is recorded in `liveness/page.json`, row `regions`), with + * its destructive arm spelled `danger`, the word `ActionSchema.variant` and + * the action runner use. + * + * Until protocol 18 the four blocks spelled that arm `destructive`, the + * primitive's own word, so one destructive action was `danger` as a + * declaration and `destructive` as a block, and each block renderer mapped + * between the two at its forward to the runner (#26665, #26779). The blocks + * now speak the action dialect: `danger` draws the primitive's destructive + * button and makes the runner's confirm button destructive. `destructive` is + * refused with the rename ({@link refuseActionBlockDestructiveVariant}); the + * ADR-0087 D2 conversion `page-action-block-variant-destructive-to-danger` + * (`conversions/registry.ts`) rewrites stored rows and sources. + */ +const ACTION_BLOCK_VARIANTS = ['default', 'danger', 'outline', 'secondary', 'ghost', 'link'] as const; const BUTTON_PRIMITIVE_SIZES = ['default', 'sm', 'lg', 'icon'] as const; +/** + * An action block's `variant` enum, with the `destructive` refusal in the + * enum's own error map — keyed on `issue.input`, so only the value that used + * to be legal gets the rename, and every other miss keeps the enum's message. + * `subject` names the key the author wrote, so the prescription points at it. + */ +function actionBlockVariant(subject: string, extra: E) { + return z.enum([...ACTION_BLOCK_VARIANTS, ...extra], { + error: (issue) => (issue.input === 'destructive' ? refuseActionBlockDestructiveVariant(subject) : undefined), + }); +} + +/** + * The prescription for `variant: 'destructive'` on an action block: the rename + * onto `'danger'`, which is lossless — the block draws the same destructive + * button, and since `danger` is the runner's own word the confirm button is + * destructive too. + */ +function refuseActionBlockDestructiveVariant(subject: string): string { + return `${subject} \`variant: 'destructive'\` was renamed \`'danger'\` in @objectstack/spec 18: an action has one ` + + 'spelling for a destructive variant, the one `ActionSchema.variant` and the action runner use. Write ' + + "`variant: 'danger'`: it draws the same destructive button, and the confirm question asks through the " + + 'destructive confirm button. Run `os migrate meta --from 17 --write` to rewrite existing sources.'; +} + /** * The action-row aliases shared by `action:button` and `action:icon`. * @@ -3898,8 +3938,8 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ .describe('Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon)'), actionType: z.string().optional() .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner\'s alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself'), - variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() - .describe('Button variant — the Button primitive\'s vocabulary, plus `primary`, which renders as `default` (renderer default: `default`)'), + variant: actionBlockVariant("An `action:button`'s", ['primary']).optional() + .describe('Button variant — the Button primitive\'s vocabulary with its destructive arm spelled `danger`, the action runner\'s word, plus `primary`, which renders as `default` (renderer default: `default`). `danger` draws the destructive button and asks a `confirmText` question through the destructive confirm button'), size: z.enum([...BUTTON_PRIMITIVE_SIZES, 'md']).optional() .describe('Button size — the Button primitive\'s vocabulary, plus `md`, which renders as `default` (renderer default: `default`)'), visible: actionCondition().optional() @@ -4000,8 +4040,8 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ .describe('Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`)'), actionType: z.string().optional() .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner\'s alias of `url`, is refused: write `url`. Write it here, not as `type`'), - variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() - .describe('Button variant — the Button primitive\'s vocabulary, plus `primary`, which renders as `default` (renderer default: `ghost`)'), + variant: actionBlockVariant("An `action:icon`'s", ['primary']).optional() + .describe('Button variant — the Button primitive\'s vocabulary with its destructive arm spelled `danger`, the action runner\'s word, plus `primary`, which renders as `default` (renderer default: `ghost`). `danger` draws the destructive button and asks a `confirmText` question through the destructive confirm button'), visible: actionCondition().optional() .describe('Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is FALSE. Omit for always-visible'), disabled: actionCondition().optional() @@ -4072,7 +4112,7 @@ export type ActionIconPropsParsed = z.infer; * dropdown item (`DropdownActionItem`) — and in the menu's item * (`ActionMenuItem`): `visible` (the shared fail-closed gate), `disabled`, * `icon`, `label` falling back to `name`, `variant` (group inline, `primary` - * drawn as `default`; the dropdown and menu items, where `destructive` draws + * drawn as `default`; the dropdown and menu items, where `danger` draws * the item red) and `tags` (a `separator-before` tag draws a divider above * the item); * - **`size`**, on an `action:group` member only: its inline button reads it, @@ -4148,8 +4188,8 @@ function actionContainerMemberShape() { .describe('Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon)'), type: z.string().optional() .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner\'s alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`)'), - variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() - .describe('Item variant — the Button primitive\'s vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group\'s `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly'), + variant: actionBlockVariant("An `action:group` / `action:menu` member's", ['primary']).optional() + .describe('Item variant — the Button primitive\'s vocabulary with its destructive arm spelled `danger`, the action runner\'s word, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group\'s `variant`); a dropdown or `action:menu` item draws `danger` red and every other variant plainly. `danger` asks a `confirmText` question through the destructive confirm button'), visible: actionCondition().optional() .describe('Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible'), disabled: actionCondition().optional() @@ -4201,7 +4241,7 @@ const actionContainerMemberGuidance = (container: 'action:group' | 'action:menu' + 'toast as `successMessage`.', className: 'A member is drawn by its container, not as a page component, and carries no `className`. Style it with its ' - + '`variant` (`destructive` draws it red), or style the whole container with the node\'s own `className`.', + + '`variant` (`danger` draws it red), or style the whole container with the node\'s own `className`.', properties: 'A member carries no `properties` bag: its static parameter values (`properties.params`) are not part of the ' + 'inline action vocabulary. For a `type: \'api\'` member\'s request body write `bodyExtra`; to run an action ' @@ -4363,8 +4403,8 @@ export const ActionGroupPropsSchema = lazySchema(() => strictObject({ .describe('Dropdown trigger text (renderer default: `Actions`). Inline mode renders no group label. A literal string — localize through the translation bundle entry for this component id'), icon: z.string().optional() .describe('Lucide icon name on the dropdown trigger. Inline mode renders no group icon'), - variant: z.enum(BUTTON_PRIMITIVE_VARIANTS).optional() - .describe('Button variant for the dropdown trigger and for every inline member that sets none — the Button primitive\'s vocabulary (renderer default: `outline`)'), + variant: actionBlockVariant("An `action:group`'s", []).optional() + .describe('Button variant for the dropdown trigger and for every inline member that sets none — the Button primitive\'s vocabulary with its destructive arm spelled `danger`, the action runner\'s word (renderer default: `outline`)'), size: z.enum(BUTTON_PRIMITIVE_SIZES).optional() .describe('Button size for the dropdown trigger and for every inline member that sets none — the Button primitive\'s vocabulary'), visible: actionCondition().optional() @@ -4418,8 +4458,8 @@ export const ActionMenuPropsSchema = lazySchema(() => strictObject({ .describe('Trigger text and accessible label; omit for an icon-only trigger labelled "More actions". A literal string — localize through the translation bundle entry for this component id'), icon: z.string().optional() .describe('Lucide icon name on the trigger (renderer default: the horizontal ellipsis)'), - variant: z.enum(BUTTON_PRIMITIVE_VARIANTS).optional() - .describe('Trigger button variant — the Button primitive\'s vocabulary (renderer default: `ghost`)'), + variant: actionBlockVariant("An `action:menu`'s", []).optional() + .describe('Trigger button variant — the Button primitive\'s vocabulary with its destructive arm spelled `danger`, the action runner\'s word (renderer default: `ghost`)'), size: z.enum(BUTTON_PRIMITIVE_SIZES).optional() .describe('Trigger button size — the Button primitive\'s vocabulary (renderer default: `icon`)'), visible: actionCondition().optional() diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index 1be0dba0fe6..2f314dd051d 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -13,8 +13,8 @@ ], "source": "built-tree", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-10-27", - "sha256": "8cda5b35b4d6395ff937cb88f5a3d8d773e07dc8f02a24a40af07c97dc44a461", + "generatedAt": "2026-10-28", + "sha256": "de11057952187fdd2b100a98f3ce1a4f123b76be013bb0eaa754346904b3fdde", "components": 106, "inTree": { "modulesRoot": "ui/apps/console", diff --git a/sdui.manifest.json b/sdui.manifest.json index e4cb7f209a0..9b217aa148b 100644 --- a/sdui.manifest.json +++ b/sdui.manifest.json @@ -2535,7 +2535,7 @@ "default", "primary", "secondary", - "destructive", + "danger", "outline", "ghost" ] @@ -2846,7 +2846,7 @@ "enum": [ "default", "secondary", - "destructive", + "danger", "outline", "ghost" ] diff --git a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index ac7700e1983..76d2971ba10 100644 --- a/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/ui/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -2854,7 +2854,7 @@ const MEMBER_PINS: Record = { }, 'action:menu.actions': { file: 'packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx', - pins: 'Members are ACTION OBJECTS, one menu item each, read through the real `SchemaRenderer` with the Radix menu opened: `label` is the item text (falling back to `name`), a member\'s own `visible` hides and its own `disabled` greys THAT item (a sibling is the control), `tags: [separator-before]` draws the divider, `variant: destructive` paints the item, and selecting an item hands the runner the member under its OWN `type` with its own keys (`target`, `objectName`). The list kind is pinned against the contract: the spec refuses an `object` at `actions` by kind and accepts a list of objects (objectui#11168 slice 1).', + pins: 'Members are ACTION OBJECTS, one menu item each, read through the real `SchemaRenderer` with the Radix menu opened: `label` is the item text (falling back to `name`), a member\'s own `visible` hides and its own `disabled` greys THAT item (a sibling is the control), `tags: [separator-before]` draws the divider, `variant: danger` paints the item, and selecting an item hands the runner the member under its OWN `type` with its own keys (`target`, `objectName`). The list kind is pinned against the contract: the spec refuses an `object` at `actions` by kind and accepts a list of objects (objectui#11168 slice 1).', }, 'action:menu.visible': { file: 'packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx', diff --git a/ui/content/docs/guide/layout.md b/ui/content/docs/guide/layout.md index 24341021fa3..29f3ea22112 100644 --- a/ui/content/docs/guide/layout.md +++ b/ui/content/docs/guide/layout.md @@ -649,7 +649,7 @@ block's `properties.title`, not on the page. "properties": { "name": "delete_record", "label": "Delete", - "variant": "destructive", + "variant": "danger", "icon": "trash", "actionType": "deleteRecord" } diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index de4f1de4dbb..fac82d75c1f 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -3755,7 +3755,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri icon: 'trash', type: 'script', locations: ['record_header'], - variant: 'destructive', + variant: 'danger', order: 120, component: 'action:menu', // objectui#4213 — `delete.disabledWhen` greys the overflow entry, the diff --git a/ui/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx b/ui/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx index 6a8ee589f5c..06d2b560792 100644 --- a/ui/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx +++ b/ui/packages/components/src/renderers/action/__tests__/action-group-menu-inputs-11168.test.tsx @@ -250,7 +250,7 @@ describe('action:group — the member shape of `actions` (the MEMBER-PIN directi member('shown'), member('hidden', { visible: false }), member('greyed', { disabled: true }), - member('loud', { variant: 'destructive', size: 'lg', className: 'member-class' }), + member('loud', { variant: 'danger', size: 'lg', className: 'member-class' }), ], }); expect(screen.getByRole('button', { name: 'SHOWN' })).toBeEnabled(); @@ -369,7 +369,7 @@ describe('action:menu — the member shape of `actions` (the MEMBER-PIN directio member('shown'), member('hidden', { visible: false }), member('greyed', { disabled: true }), - { name: 'nameonly', type: 'run', tags: ['separator-before'], variant: 'destructive' }, + { name: 'nameonly', type: 'run', tags: ['separator-before'], variant: 'danger' }, ], }); const menu = await openMenu(screen.getByRole('button', { name: MORE })); diff --git a/ui/packages/components/src/renderers/action/__tests__/action-variant-confirm-26665.test.tsx b/ui/packages/components/src/renderers/action/__tests__/action-variant-confirm-26665.test.tsx index f92a6c21876..186762e4049 100644 --- a/ui/packages/components/src/renderers/action/__tests__/action-variant-confirm-26665.test.tsx +++ b/ui/packages/components/src/renderers/action/__tests__/action-variant-confirm-26665.test.tsx @@ -7,15 +7,15 @@ */ /** - * objectstack#26665 — a `destructive` action block confirms through the - * destructive button. + * objectstack#26665, objectstack#26779 — a `danger` action block confirms + * through the destructive button. * * The runner paints its confirm button destructive when the action it runs - * declares `variant: 'danger'` (`confirmOptions`, objectstack#26580). The four - * action blocks speak the spec's BLOCK dialect, where that value is spelled - * `destructive`, and before this card none of them forwarded `variant` at all, - * so a destructive block asked its question through a neutral button. Each - * renderer now forwards its variant through `toRunnerVariant`. + * declares `variant: 'danger'` (`confirmOptions`, objectstack#26580). Before + * objectstack#26665 none of the four action blocks forwarded `variant` at all, + * so a destructive block asked its question through a neutral button. Since + * objectstack#26779 the blocks spell that value `danger` too, the runner's + * word, and each renderer forwards it as it is through `toRunnerVariant`. * * Every row reads the confirm handler's own call, the artefact the dialog is * drawn from, and first asserts it was called once (the positive control). The @@ -37,7 +37,7 @@ import '../action-button'; import '../action-icon'; import '../action-group'; import '../action-menu'; -import { toRunnerVariant } from '../runner-variant'; +import { toButtonVariant, toRunnerVariant } from '../runner-variant'; const ACTION = { name: 'purge_history', @@ -82,9 +82,9 @@ async function confirmOptionsFor(type: string, schema: Record, return confirm.mock.calls[0][1]; } -describe.each(SURFACES)('%s forwards its variant in the runner dialect (objectstack#26665)', (type, schemaFor, click) => { - it('`destructive` confirms through the destructive button', async () => { - expect(await confirmOptionsFor(type, schemaFor('destructive'), click)).toEqual({ +describe.each(SURFACES)('%s forwards its variant to the runner (objectstack#26665, objectstack#26779)', (type, schemaFor, click) => { + it('`danger` confirms through the destructive button', async () => { + expect(await confirmOptionsFor(type, schemaFor('danger'), click)).toEqual({ title: 'Purge history', confirmText: 'Purge history', destructive: true, @@ -99,9 +99,9 @@ describe.each(SURFACES)('%s forwards its variant in the runner dialect (objectst }); }); -describe('toRunnerVariant (objectstack#26665)', () => { - it('maps the block dialect onto the action dialect', () => { - expect(toRunnerVariant('destructive')).toBe('danger'); +describe('toRunnerVariant (objectstack#26779)', () => { + it('forwards the values the action dialect shares, `danger` as it is', () => { + expect(toRunnerVariant('danger')).toBe('danger'); expect(toRunnerVariant('primary')).toBe('primary'); expect(toRunnerVariant('secondary')).toBe('secondary'); expect(toRunnerVariant('ghost')).toBe('ghost'); @@ -110,4 +110,22 @@ describe('toRunnerVariant (objectstack#26665)', () => { expect(toRunnerVariant('outline')).toBeUndefined(); expect(toRunnerVariant(undefined)).toBeUndefined(); }); + + it('CONTROL: the retired block spelling `destructive` is not read', () => { + expect(toRunnerVariant('destructive' as never)).toBeUndefined(); + }); +}); + +describe('toButtonVariant (objectstack#26779)', () => { + it('draws `danger` as the Button primitive\'s `destructive`, `primary` as `default`', () => { + expect(toButtonVariant('danger', 'ghost')).toBe('destructive'); + expect(toButtonVariant('primary', 'ghost')).toBe('default'); + expect(toButtonVariant('outline', 'ghost')).toBe('outline'); + expect(toButtonVariant('link', 'ghost')).toBe('link'); + expect(toButtonVariant(undefined, 'ghost')).toBe('ghost'); + }); + + it('CONTROL: the retired block spelling `destructive` draws the renderer default', () => { + expect(toButtonVariant('destructive' as never, 'outline')).toBe('outline'); + }); }); diff --git a/ui/packages/components/src/renderers/action/action-bar.tsx b/ui/packages/components/src/renderers/action/action-bar.tsx index 02186c3bab6..b8faceb3bec 100644 --- a/ui/packages/components/src/renderers/action/action-bar.tsx +++ b/ui/packages/components/src/renderers/action/action-bar.tsx @@ -41,7 +41,7 @@ * location: 'record_header', * actions: [ * { name: 'mark_complete', label: 'Mark Complete', type: 'script', icon: 'check', component: 'action:button' }, - * { name: 'delete', label: 'Delete', type: 'api', icon: 'trash', variant: 'destructive', component: 'action:button' }, + * { name: 'delete', label: 'Delete', type: 'api', icon: 'trash', variant: 'danger', component: 'action:button' }, * ], * }} /> * ``` diff --git a/ui/packages/components/src/renderers/action/action-button.tsx b/ui/packages/components/src/renderers/action/action-button.tsx index ab291f1a85b..5e11793b241 100644 --- a/ui/packages/components/src/renderers/action/action-button.tsx +++ b/ui/packages/components/src/renderers/action/action-button.tsx @@ -31,7 +31,7 @@ import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; import { useAutoTriggerOnce } from './auto-trigger'; import { readStaticParamValues } from './static-params'; -import { toRunnerVariant } from './runner-variant'; +import { toButtonVariant, toRunnerVariant } from './runner-variant'; import { DisabledReasonTrigger, describedByWithReason, useDisabledReason } from './disabled-reason'; import type { RendererHostProps } from '../../lib/renderer-host-props'; @@ -204,8 +204,9 @@ const ActionButtonRenderer = forwardRef< // Resolve icon const Icon = resolveIcon(schema.icon); - // Map schema variant to Shadcn button variant - const variant = schema.variant === 'primary' ? 'default' : (schema.variant || 'default'); + // Map schema variant to Shadcn button variant: `danger` draws the + // primitive's `destructive` (objectstack#26779). See `./runner-variant`. + const variant = toButtonVariant(schema.variant, 'default'); const size = schema.size === 'md' ? 'default' : (schema.size || 'default'); const handleClick = useCallback(async () => { @@ -326,9 +327,9 @@ const ActionButtonRenderer = forwardRef< // objectui#12941. operation: schema.operation, confirmText: schema.confirmText, - // The block's `variant`, in the runner's dialect: a `destructive` - // button confirms through the destructive button, as a `danger` - // action does (objectstack#26665). See `./runner-variant`. + // The block's `variant`, forwarded: a `danger` button confirms + // through the destructive button, as a `danger` action does + // (objectstack#26665, objectstack#26779). See `./runner-variant`. variant: toRunnerVariant(schema.variant), successMessage: schema.successMessage, // Success copy per handler outcome (spec 17.6.0, objectui#11344): the @@ -525,7 +526,7 @@ ComponentRegistry.register('button', ActionButtonRenderer, { { name: 'variant', type: 'enum', - enum: ['default', 'primary', 'secondary', 'destructive', 'outline', 'ghost'], + enum: ['default', 'primary', 'secondary', 'danger', 'outline', 'ghost'], }, // objectui#11168 slice 2 — the five sizes the spec row declares, each // measured through the real `SchemaRenderer`: `default`, `sm`, `lg` and diff --git a/ui/packages/components/src/renderers/action/action-group.tsx b/ui/packages/components/src/renderers/action/action-group.tsx index b0402fee0b7..3760188172e 100644 --- a/ui/packages/components/src/renderers/action/action-group.tsx +++ b/ui/packages/components/src/renderers/action/action-group.tsx @@ -36,7 +36,7 @@ import { Loader2, ChevronDown } from 'lucide-react'; import { resolveIcon } from './resolve-icon'; import { hasDeclaredVisibilityGate } from './visibility-gate'; import { readActionEntryParamValues } from './static-params'; -import { toRunnerVariant } from './runner-variant'; +import { toButtonVariant, toRunnerVariant } from './runner-variant'; import { DisabledReasonMenuLabel, DisabledReasonTrigger, @@ -135,7 +135,7 @@ const InlineActionButton: React.FC<{ ); const Icon = resolveIcon(action.icon); - const btnVariant = (action.variant as string) === 'primary' ? 'default' : (action.variant || variant || 'outline'); + const btnVariant = toButtonVariant((action.variant || variant) as UIActionSchema['variant'], 'outline'); const btnSize = action.size === 'md' ? 'default' : (action.size || size || 'sm'); const handleClick = useCallback(async () => { @@ -258,7 +258,7 @@ export const DropdownActionItem: React.FC<{ await onSelect(action); }} className={cn( - (action.variant as string) === 'destructive' && 'text-destructive focus:text-destructive', + action.variant === 'danger' && 'text-destructive focus:text-destructive', action.className, )} > @@ -431,7 +431,7 @@ const ActionGroupRenderer = forwardRef