From 00bd2dadd6527effe0d310f0726c2eb8d9e17a9f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:31:18 +0000 Subject: [PATCH] fix(spec): the 18.x action-block variant entry no longer says element:button keeps destructive element:button spells the destructive arm `danger` and refuses `destructive`; only the record blocks that forward a variant to the Button primitive unchanged (record:quick_actions, a record:alert action) still take the primitive's `destructive`. Corrects the D3 entry's reason (printed by the upgrade guide), the D2 conversion's docblock and the conversion test's wording, and pins the corrected sentence against the rows it describes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzjAFu9q3UWTLiMSuk9 --- ...tion-block-variant-entry-element-button.md | 12 ++++ ...lock-variant-destructive-to-danger.test.ts | 57 ++++++++++++++++++- packages/spec/src/conversions/registry.ts | 10 ++-- ...ction-block-variant-destructive-refused.ts | 6 +- 4 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 .changeset/27589-action-block-variant-entry-element-button.md diff --git a/.changeset/27589-action-block-variant-entry-element-button.md b/.changeset/27589-action-block-variant-entry-element-button.md new file mode 100644 index 00000000000..c310f4a7755 --- /dev/null +++ b/.changeset/27589-action-block-variant-entry-element-button.md @@ -0,0 +1,12 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): the 18.x migration entry for the action-block `destructive` → `danger` rename no longer says `element:button` keeps `destructive` (#27589) + +Clause-②: no +Metadata-protocol: none + +The D3 entry `ui-page-action-block-variant-destructive-refused` said that components drawing the Button primitive directly, "`element:button` and the like", keep `destructive`. That is false for `element:button`: `ElementButtonPropsSchema.variant` is `primary | secondary | danger | ghost | link`, so it already spells the arm `danger` and refuses `destructive` at the metadata save door and in `os validate`. An author who followed the sentence wrote a page the save door rejects. The entry's `reason`, which the upgrade guide prints, now says so, and names the two record blocks that do still take the React Button primitive's `destructive` because they forward a variant to it unchanged: `record:quick_actions` (`variant`) and a `record:alert` action (`action.variant`). The same correction is made to the docblock of the D2 conversion `page-action-block-variant-destructive-to-danger`. No schema, value or conversion behaviour changes. + +**Supersedes, in the same release.** The `@objectstack/spec` 18 note for the rename says "Other components that draw the Button primitive directly (`element:button`, toasts, alerts, badges and the like) keep `destructive`". Read `element:button` out of that list: it spells the arm `danger`, as the action blocks now do. 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 index 85500d4eac7..8d85e178f38 100644 --- 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 @@ -7,8 +7,12 @@ import { ActionGroupPropsSchema, ActionIconPropsSchema, ActionMenuPropsSchema, + ElementButtonPropsSchema, + RecordAlertActionSchema, + RecordQuickActionsProps, } from '../ui/component.zod.js'; import { ActionSchema } from '../ui/action.zod.js'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js'; import { applyConversions } from './apply.js'; import { ALL_CONVERSIONS } from './registry.js'; import { applyConversionsToStoredItem } from './stored.js'; @@ -33,8 +37,11 @@ import type { ConversionNotice } from './types.js'; * - 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; + * - another component type's `variant`, even `destructive`, and every other + * variant are left byte-identical with no notice; + * - the D3 entry's text says what the rows measure: `element:button` spells + * the arm `danger` and refuses `destructive`, and only the record blocks + * that forward to the Button primitive still take its `destructive`; * - idempotence, and copy-on-write identity for a page with nothing to do. */ const ID = 'page-action-block-variant-destructive-to-danger'; @@ -177,7 +184,7 @@ describe('page-action-block-variant-destructive-to-danger (ADR-0087 D2)', () => 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' } }], + ["another component type's `destructive` (an `element:button`, whose own row refuses it)", { 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' } } }], @@ -216,3 +223,47 @@ describe('page-action-block-variant-destructive-to-danger (ADR-0087 D2)', () => expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); }); }); + +/** + * The D3 entry's `reason` is compiled into the 18.x upgrade guide, so its + * sentence about the components this rename leaves alone is pinned against + * the rows it describes (#27589). It once said `element:button` keeps + * `destructive`, a value that row refuses: an author who followed the guide + * wrote a page the save door rejects. + */ +describe('the D3 entry says what the untouched components accept (#27589)', () => { + // Read from the generated chain, the source `build-upgrade-guide.ts` prints. + const refusedEntry = MIGRATIONS_BY_MAJOR[18]!.semantic.find( + (s) => s.id === 'ui-page-action-block-variant-destructive-refused', + )!; + + it('is registered on the protocol 18 step', () => { + expect(refusedEntry, 'the D3 entry is on step 18').toBeDefined(); + }); + + it('`element:button` spells the arm `danger` and refuses `destructive`', () => { + expect(ElementButtonPropsSchema.safeParse({ label: 'Reset', variant: 'danger' }).success).toBe(true); + expect(ElementButtonPropsSchema.safeParse({ label: 'Reset', variant: 'destructive' }).success).toBe(false); + expect(refusedEntry.reason).toContain( + '`element:button` already spells it `danger` (`ElementButtonPropsSchema.variant`) and refuses `destructive`, ' + + 'so it is not touched.', + ); + }); + + it('the record blocks that forward to the Button primitive still take its `destructive`', () => { + expect(RecordQuickActionsProps.safeParse({ actionNames: ['purge'], variant: 'destructive' }).success).toBe(true); + expect(RecordAlertActionSchema.safeParse({ actionName: 'purge', variant: 'destructive' }).success).toBe(true); + expect(refusedEntry.reason).toContain( + "`destructive` is the React Button primitive's own word; the two record blocks that forward a variant to that " + + 'primitive unchanged, `record:quick_actions` and a `record:alert` action, still take it and are not touched ' + + 'either.', + ); + }); + + it('no longer tells an author that `element:button` keeps `destructive`', () => { + for (const text of [refusedEntry.reason, refusedEntry.replacement, refusedEntry.acceptanceCriteria]) { + expect(text).not.toMatch(/keeps? `destructive`/); + expect(text).not.toContain('`element:button` and the like'); + } + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2da877fa388..cb5d592cabe 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -11530,9 +11530,11 @@ const ACTION_CONTAINER_BLOCK_TYPES = new Set(['action:group', 'action:menu']); * 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 + * other component type, which this entry never reads: `element:button` + * already spells the arm `danger` and refuses `destructive`, and the record + * blocks that forward a variant to the Button primitive unchanged + * (`record:quick_actions`, a `record:alert` action) keep the primitive's own + * `destructive`. 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 @@ -11607,7 +11609,7 @@ const pageActionBlockVariantDestructiveToDanger: MetadataConversion = { // 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. + // Another component type: not this entry's, whatever it holds (its own row refuses `destructive`). { type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } }, // A container's trigger and one of its members: both renamed, the other member untouched. { 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 index 74e6152b5fe..1779ff5199a 100644 --- 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 @@ -16,8 +16,10 @@ export const entry: SemanticMigration = { + "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 " + + 'refuse `destructive` with the rename. `element:button` already spells it `danger` ' + + '(`ElementButtonPropsSchema.variant`) and refuses `destructive`, so it is not touched. `destructive` is the ' + + "React Button primitive's own word; the two record blocks that forward a variant to that primitive unchanged, " + + "`record:quick_actions` and a `record:alert` action, still take it and are not touched either. 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` '