Skip to content

Commit 7538f27

Browse files
fix(spec): the 18.x action-block variant migration entry says element:button spells it danger, not destructive (#27652)
domain:spec#3 · os-support-ai · session_01WGufzjAFu9q3UWTLiMSuk9 <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBKUxGsrA3itzdun1PSZn7o5)_ Fixes #27589 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:18.x 升级指南里 `destructive` → `danger` 那一条迁移说明,原来说「`element:button` 之类直接画 Button 原件的组件保留 `destructive`」,现在改成:`element:button` 本来就写 `danger`、拒收 `destructive`;只有把 variant 原样转给 Button 原件的 `record:quick_actions` 和 `record:alert` 的 action 还收 `destructive`。 - 为什么改:这句话会在下一个 18.x 发版时固化进升级指南。作者照着它在 `element:button` 上写 `destructive`,保存时就被拒;人和读指南的 AI 都会被教错。 - 风险与回滚:只改文字(迁移条目 `reason`、D2 转换的注释、一个测试的用例名),没有任何 schema、取值或转换行为变化;回滚即还原本 PR。 - 先看:`packages/spec/src/migrations/entries/semantic/18.ui-page-action-block-variant-destructive-refused.ts`(改的那句)、`packages/spec/src/conversions/page-action-block-variant-destructive-to-danger.test.ts`(新 pin,句子与三处 schema 实测绑定)、`.changeset/27589-action-block-variant-entry-element-button.md`(同版本 changelog 的更正段)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/conversions/page-action-block-variant-destructive-to-danger.test.ts`,应见 41 个用例全绿;把条目文件换回 `origin/main` 的版本再跑,新增的 3 个用例转红。 - 需要你做的:无,席位落地。 ## What changes **Before** (`origin/main` `178b5238`), the D3 entry `ui-page-action-block-variant-destructive-refused`, `reason`, L19–L20: > 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. **After:** > The blocks now take `danger` and 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. Measured on `origin/main`: - `ElementButtonPropsSchema.variant` (`ui/component.zod.ts`) is `z.enum(['primary', 'secondary', 'danger', 'ghost', 'link'])` on a strict object: `danger` parses, `destructive` is refused. - **The card's wanted wording "no metadata block accepts it" does not hold**, so this PR does not say it. Two record rows still take the primitive's vocabulary on purpose: `RecordQuickActionsProps.variant` and `RecordAlertActionSchema.variant` are both `z.enum(['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'])`, described as "the Button primitive's own vocabulary". The D2 conversion does not visit either type. The corrected sentence names them instead, and the pin asserts both accept `destructive`. The upgrade guide is generated, not committed (`content/docs/protocol-upgrade/` is git-ignored; the docs build and the release lane run `build-upgrade-guide.ts`). Regenerated locally from this head with `--out` and `--docs`: the protocol 18 page prints the new sentence, and `` `element:button` and the like `` has 0 hits in either output (1 hit each on `origin/main`). Copies of the same claim, searched in `packages/spec`, `content/docs/**` and `skills/**`: | Where | Governed | Change | |:---|:---|:---| | `packages/spec/src/conversions/registry.ts`, the D2 conversion's docblock ("`destructive` stays the Button primitive's word on `element:button` and the other primitive-backed components") | no | corrected to the same facts | | `packages/spec/src/conversions/registry.ts`, fixture comment "The Button primitive's own word on another component" | no | reworded to "Another component type: not this entry's, whatever it holds (its own row refuses `destructive`)"; the fixture value is unchanged (prose only) | | `packages/spec/src/conversions/page-action-block-variant-destructive-to-danger.test.ts`, header bullet and one control label | no | reworded; the control case is unchanged | | `.changeset/26779-action-block-variant-danger.md` ("`element:button`, toasts, alerts, badges and the like keep `destructive`") | yes (`check-empty-changeset` refuses edits to a changeset the base carries) | not edited; this PR's changeset carries a "Supersedes, in the same release" paragraph | | `content/docs/**`, `skills/**` | | 0 hits | The card body of #27243, which quotes the sentence, is the seat's to correct and is not touched here. ## Tests `page-action-block-variant-destructive-to-danger.test.ts` gains `the D3 entry says what the untouched components accept (#27589)`, which reads the entry from `MIGRATIONS_BY_MAJOR[18]` (the generated chain `build-upgrade-guide.ts` prints) and pins: - `element:button` accepts `danger` and refuses `destructive`, and the `reason` carries the corrected `element:button` sentence; - `record:quick_actions` and a `record:alert` action accept `destructive`, and the `reason` carries the sentence naming them; - none of `reason`, `replacement`, `acceptanceCriteria` matches ``/keeps? `destructive`/`` or contains "`element:button` and the like". One-shot negative run: with the entry file checked out from `origin/main` and the registry regenerated, the three new cases go red (`Tests 3 failed | 38 passed (41)`, lock VERDICT command-exit 1); restored with `git checkout HEAD --`, blob hash matched HEAD. All on head `00bd2dad`, under `os-verify-lock`: <details><summary>spec local suite + typecheck, spec repo suite</summary> - `pnpm --filter @objectstack/spec test && pnpm --filter @objectstack/spec typecheck`: `Test Files 751 passed | 1 skipped (752)`, `Tests 22345 passed | 1 skipped | 1 todo (22347)`; `check:test-typecheck: OK`; VERDICT command-exit 0. - `pnpm --filter @objectstack/spec test:repo`: `Test Files 86 passed (86)`, `Tests 1433 passed (1433)`; VERDICT command-exit 0. </details> No dogfood run: no `!` in the title and no `(narrowing)` arm. ## Acceptance notes - The conversion's fixture keeps an `element:button` with `variant: 'destructive'` as its "another component type" control. That value is one `element:button`'s own row refuses; it stays because the binding is prose only and the control is still true (the conversion never visits the type). Noted, not changed. - The 26779 changeset's list also names "toasts, alerts, badges"; those were not measured here and are left as stated. ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 81 families on `00bd2dad` (4 paths vs merge base `178b5238`). <details><summary>81 derived gates: 81 exit 0</summary> - First serial pass: 70 exit 0, 11 exit 3 (built-output prerequisites: the spec `dist`, and the pinned fixture commit for `check-plugin-teardown-shape --self-test` on the shallow clone). - After `turbo run build --concurrency=2 --filter='@objectstack/lint...'` (VERDICT command-exit 0) and `git fetch origin 621a487`, the 11 were rerun: all exit 0 (`check:api-surface`, `check:browser-reachable-entries`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`, `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, lint `check:doc-formula-expressions`, `check-plugin-teardown-shape --self-test`). - `dispatch-gates --ran`: "81 derived famil(ies) accounted for — 81 run, 0 NOT-MEASURED". </details> `check-changeset-no-major` with this body as the event: exit 0, "✓ LEVEL AXIS: this PR declares clause-② `no` and metadata-protocol `none`, and it introduces no changeset entry graded `minor` or above". CI families are read from this PR's check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzjAFu9q3UWTLiMSuk9)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzjAFu9q3UWTLiMSuk9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 50d05cb commit 7538f27

4 files changed

Lines changed: 76 additions & 9 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): the 18.x migration entry for the action-block `destructive` → `danger` rename no longer says `element:button` keeps `destructive` (#27589)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
**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.

‎packages/spec/src/conversions/page-action-block-variant-destructive-to-danger.test.ts‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import {
77
ActionGroupPropsSchema,
88
ActionIconPropsSchema,
99
ActionMenuPropsSchema,
10+
ElementButtonPropsSchema,
11+
RecordAlertActionSchema,
12+
RecordQuickActionsProps,
1013
} from '../ui/component.zod.js';
1114
import { ActionSchema } from '../ui/action.zod.js';
15+
import { MIGRATIONS_BY_MAJOR } from '../migrations/registry.js';
1216
import { applyConversions } from './apply.js';
1317
import { ALL_CONVERSIONS } from './registry.js';
1418
import { applyConversionsToStoredItem } from './stored.js';
@@ -33,8 +37,11 @@ import type { ConversionNotice } from './types.js';
3337
* - the authoring funnel does NOT replay it;
3438
* - the same seam renames an `action:bar`'s own `variant` and its members',
3539
* though the bar has no row: it draws its members through the blocks;
36-
* - another component's `destructive` (the Button primitive's own word) and
37-
* every other variant are left byte-identical with no notice;
40+
* - another component type's `variant`, even `destructive`, and every other
41+
* variant are left byte-identical with no notice;
42+
* - the D3 entry's text says what the rows measure: `element:button` spells
43+
* the arm `danger` and refuses `destructive`, and only the record blocks
44+
* that forward to the Button primitive still take its `destructive`;
3845
* - idempotence, and copy-on-write identity for a page with nothing to do.
3946
*/
4047
const ID = 'page-action-block-variant-destructive-to-danger';
@@ -177,7 +184,7 @@ describe('page-action-block-variant-destructive-to-danger (ADR-0087 D2)', () =>
177184
it.each([
178185
['the canonical `danger`', { type: 'action:button', properties: { label: 'Purge', variant: 'danger' } }],
179186
['another variant', { type: 'action:icon', properties: { icon: 'copy', variant: 'outline' } }],
180-
["an `element:button`'s `destructive` (the Button primitive's own word)", { type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } }],
187+
["another component type's `destructive` (an `element:button`, whose own row refuses it)", { type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } }],
181188
["a `destructive` member on a block that has no members", { type: 'action:button', properties: { label: 'Go', actions: [{ variant: 'destructive' }] } }],
182189
['a member that is not an object', { type: 'action:menu', properties: { actions: ['destructive'] } }],
183190
['`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)', () =>
216223
expect(notices.filter((n) => n.conversionId === ID)).toEqual([]);
217224
});
218225
});
226+
227+
/**
228+
* The D3 entry's `reason` is compiled into the 18.x upgrade guide, so its
229+
* sentence about the components this rename leaves alone is pinned against
230+
* the rows it describes (#27589). It once said `element:button` keeps
231+
* `destructive`, a value that row refuses: an author who followed the guide
232+
* wrote a page the save door rejects.
233+
*/
234+
describe('the D3 entry says what the untouched components accept (#27589)', () => {
235+
// Read from the generated chain, the source `build-upgrade-guide.ts` prints.
236+
const refusedEntry = MIGRATIONS_BY_MAJOR[18]!.semantic.find(
237+
(s) => s.id === 'ui-page-action-block-variant-destructive-refused',
238+
)!;
239+
240+
it('is registered on the protocol 18 step', () => {
241+
expect(refusedEntry, 'the D3 entry is on step 18').toBeDefined();
242+
});
243+
244+
it('`element:button` spells the arm `danger` and refuses `destructive`', () => {
245+
expect(ElementButtonPropsSchema.safeParse({ label: 'Reset', variant: 'danger' }).success).toBe(true);
246+
expect(ElementButtonPropsSchema.safeParse({ label: 'Reset', variant: 'destructive' }).success).toBe(false);
247+
expect(refusedEntry.reason).toContain(
248+
'`element:button` already spells it `danger` (`ElementButtonPropsSchema.variant`) and refuses `destructive`, '
249+
+ 'so it is not touched.',
250+
);
251+
});
252+
253+
it('the record blocks that forward to the Button primitive still take its `destructive`', () => {
254+
expect(RecordQuickActionsProps.safeParse({ actionNames: ['purge'], variant: 'destructive' }).success).toBe(true);
255+
expect(RecordAlertActionSchema.safeParse({ actionName: 'purge', variant: 'destructive' }).success).toBe(true);
256+
expect(refusedEntry.reason).toContain(
257+
"`destructive` is the React Button primitive's own word; the two record blocks that forward a variant to that "
258+
+ 'primitive unchanged, `record:quick_actions` and a `record:alert` action, still take it and are not touched '
259+
+ 'either.',
260+
);
261+
});
262+
263+
it('no longer tells an author that `element:button` keeps `destructive`', () => {
264+
for (const text of [refusedEntry.reason, refusedEntry.replacement, refusedEntry.acceptanceCriteria]) {
265+
expect(text).not.toMatch(/keeps? `destructive`/);
266+
expect(text).not.toContain('`element:button` and the like');
267+
}
268+
});
269+
});

‎packages/spec/src/conversions/registry.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11530,9 +11530,11 @@ const ACTION_CONTAINER_BLOCK_TYPES = new Set(['action:group', 'action:menu']);
1153011530
* longer read that word, and would fall back to a plain button with no
1153111531
* refusal anywhere to say so. Its own `variant` and its members' are renamed
1153211532
* as the four blocks' are. Every other value is left alone, and so is every
11533-
* other component type: `destructive` stays the Button primitive's word on
11534-
* `element:button` and the other primitive-backed components, which this
11535-
* entry never reads. A member that is not an object, or a list that is not an
11533+
* other component type, which this entry never reads: `element:button`
11534+
* already spells the arm `danger` and refuses `destructive`, and the record
11535+
* blocks that forward a variant to the Button primitive unchanged
11536+
* (`record:quick_actions`, a `record:alert` action) keep the primitive's own
11537+
* `destructive`. A member that is not an object, or a list that is not an
1153611538
* array, is left as stored. The reach is every position `mapPageComponents`
1153711539
* visits — regions, slots, nested containers — the props gate's own reach.
1153811540
* Idempotent by construction: a rewritten value reads `'danger'`, which this
@@ -11607,7 +11609,7 @@ const pageActionBlockVariantDestructiveToDanger: MetadataConversion = {
1160711609
// Canonical already, and another value: untouched.
1160811610
{ type: 'action:button', properties: { label: 'Archive', actionType: 'script', variant: 'danger' } },
1160911611
{ type: 'action:button', properties: { label: 'Open', actionType: 'url', target: '/apps/ops', variant: 'outline' } },
11610-
// The Button primitive's own word on another component: not this entry's.
11612+
// Another component type: not this entry's, whatever it holds (its own row refuses `destructive`).
1161111613
{ type: 'element:button', properties: { label: 'Reset', variant: 'destructive' } },
1161211614
// A container's trigger and one of its members: both renamed, the other member untouched.
1161311615
{

‎packages/spec/src/migrations/entries/semantic/18.ui-page-action-block-variant-destructive-refused.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ export const entry: SemanticMigration = {
1616
+ "dispatches, says `danger`; the action block rows took the Button primitive's word, `destructive`, and "
1717
+ 'refused `danger`, so one destructive action changed spelling between a declaration and a block, and each '
1818
+ 'block renderer translated between the two at its forward to the runner. The blocks now take `danger` and '
19-
+ 'refuse `destructive` with the rename. Other components that draw the Button primitive directly '
20-
+ "(`element:button` and the like) keep `destructive`, the primitive's own word, and are not touched. A page's "
19+
+ 'refuse `destructive` with the rename. `element:button` already spells it `danger` '
20+
+ '(`ElementButtonPropsSchema.variant`) and refuses `destructive`, so it is not touched. `destructive` is the '
21+
+ "React Button primitive's own word; the two record blocks that forward a variant to that primitive unchanged, "
22+
+ "`record:quick_actions` and a `record:alert` action, still take it and are not touched either. A page's "
2123
+ '`properties` is not parsed on the metadata load path, and the next save of the page meets the '
2224
+ 'component-props gate at the metadata save door; `os validate` reports the old value as a '
2325
+ '`component-props-invalid` finding. The D2 conversion `page-action-block-variant-destructive-to-danger` '

0 commit comments

Comments
 (0)