From a702058c8f52873f5e89954bf293a49f0cb4de7c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:23:03 +0000 Subject: [PATCH] feat(spec): register ADR-0087 D2 conversion `page-header-subtitle-alias` (#4827) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit page-header 节点 `properties.description` → canonical `subtitle`,protocol 17 live window。objectui 的 kebab 遗留别名与协议 canonical 键对同一个「页面副标题」 概念声明了两套 authorable 拼写,消费端用裸 `subtitle ?? description` 兜底 (PD #12)。不能走直接删路线:该别名的全部理由是仓外消费者 schema,删读会静默 丢副标题。改为在加载期改写,declared / loud / tested / expiring。 - `mapPageComponents` 加入 `conversions/walk.ts`(copy-on-write,region 级) - 条目 + fixture 落 `conversions/registry.ts`,并登记进 D3 链 step 17 - 覆盖 kebab `page-header` 与 canonical `page:header` 两种拼写;不改写 type - canonical 已在场时不改写、不发通知(照 `flow-node-crud-object-alias` 惯例) - 重生成 spec-changes.json / protocol-upgrade-guide.md Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --- .../page-header-subtitle-alias-conversion.md | 33 ++++ docs/protocol-upgrade-guide.md | 1 + packages/spec/spec-changes.json | 12 ++ .../spec/src/conversions/conversions.test.ts | 152 ++++++++++++++++++ packages/spec/src/conversions/registry.ts | 129 ++++++++++++++- packages/spec/src/conversions/walk.ts | 48 ++++++ packages/spec/src/migrations/registry.ts | 1 + 7 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 .changeset/page-header-subtitle-alias-conversion.md diff --git a/.changeset/page-header-subtitle-alias-conversion.md b/.changeset/page-header-subtitle-alias-conversion.md new file mode 100644 index 0000000000..39647c24a1 --- /dev/null +++ b/.changeset/page-header-subtitle-alias-conversion.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `page-header` 节点的 `description` 在加载期改写为 canonical 的 `subtitle` —— ADR-0087 D2 条目 `page-header-subtitle-alias`(#4827,objectui#3226) + +「页面副标题」这一个概念长期有两套 authorable 拼写,一套渲染器一套:`@objectstack/spec` +的 `PageHeaderProps` 只声明 `subtitle`,而 objectui 的 kebab 遗留别名 `page-header` +在注册 `inputs` 里宣告 `description`,渲染器用一个裸 `subtitle ?? description` 兜住 —— +正是 Prime Directive #12 描述的「producer 写方言、consumer 用 `??` 兜」。 + +现在按 ADR-0087 D2 收口:protocol 17 的 **live window** 条目 +`page-header-subtitle-alias`,在加载期(`defineStack` / `validate` / `lint`,以及 +`applyConversionsToStoredItem` 覆盖的 `sys_metadata` 存量行)把 page-header 节点 +`properties` 上的 `description` 改写为 `subtitle`,每次改写发出一条结构化 +`ConversionNotice`。消费端因此只需读 `subtitle`;objectui#3226 随后删掉那个 `??`。 + +FROM → TO: + +- `pages[].regions[].components[]`,`type` 为 `page-header`(kebab 遗留别名)或 + `page:header`(协议 canonical 键):`properties.description` → + `properties.subtitle` + +两点语义按既有惯例、并有测试钉住: + +- **canonical 优先**:`subtitle` 已在场时不改写、不发通知,被遮蔽的 `description` + 原样留在那里(与 `flow-node-crud-object-alias` 一致)。 +- **只动 header 节点**:`description` 在同一层的其他组件上是活的已声明属性 + (`element:text_input` 的辅助文本),不受影响。 + +本条目**不**改写节点 `type`:kebab 别名的存废是 objectui 侧的事,按其自身节奏推进。 +`description` 从未在 `PageHeaderProps` 上声明过,因此没有 schema 键被移除,也无需 +tombstone;老拼写在 protocol 17 全程被接受,18 退出加载路径并转入 D3 迁移链。 diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 6c744ecf45..d46c689c96 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -252,6 +252,7 @@ The same is true of the protocol-17 retirement that closes this list, and the pa | `hook-body-crypto-hash-removed` | `hook.body.capabilities / action.body.capabilities` | script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too) | retired — `migrate meta` only | | `connector-rate-limit-config-removed` | `connector.rateLimitConfig` | connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it) | retired — `migrate meta` only | | `theme-inert-token-scales-removed` | `theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex` | theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim) | retired — `migrate meta` only | +| `page-header-subtitle-alias` | `page.component.page-header.description` | page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires) | live — protocol 17 loader accepts the old shape | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 22b86446fb..adec636c9f 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -319,6 +319,12 @@ "to": "theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim)", "conversionId": "theme-inert-token-scales-removed", "toMajor": 17 + }, + { + "surface": "page.component.page-header.description", + "to": "page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires)", + "conversionId": "page-header-subtitle-alias", + "toMajor": 17 } ], "migrated": [ @@ -1014,6 +1020,12 @@ "to": "theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim)", "conversionId": "theme-inert-token-scales-removed", "toMajor": 17 + }, + { + "surface": "page.component.page-header.description", + "to": "page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires)", + "conversionId": "page-header-subtitle-alias", + "toMajor": 17 } ], "migrated": [ diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 9237056172..0771cb62c0 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -5,8 +5,11 @@ import { describe, expect, it } from 'vitest'; import { FlowSchema } from '../automation/flow.zod.js'; import { ScriptConfigSchema } from '../automation/schemaless-node-config.zod.js'; import { normalizeStackInput } from '../shared/metadata-collection.zod.js'; +import { PageHeaderProps } from '../ui/component.zod.js'; +import { PageSchema } from '../ui/page.zod.js'; import { applyConversions, collectConversionNotices } from './apply.js'; import { ALL_CONVERSIONS, CONVERSIONS_BY_MAJOR } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; import { CONVERSION_NOTICE_CODE, type ConversionNotice } from './types.js'; describe('conversion layer (ADR-0087 D2)', () => { @@ -644,4 +647,153 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(notices).toHaveLength(0); }); }); + + /** + * `page-header-subtitle-alias` (#4827, objectui#3226). + * + * The PD #12 retirement of objectui's `subtitle ?? description` fallback on + * the page header. Unlike the entries above this one is a LIVE window, so + * every case here runs the plain load posture (no `includeRetired`) — that + * the rewrite happens without it is the property objectui is waiting on. + */ + describe('page-header-subtitle-alias (#4827 — the `subtitle ?? description` retirement)', () => { + const pageWith = (...components: Record[]) => ({ + pages: [{ name: 'crm_lead_detail', regions: [{ name: 'header', components }] }], + }); + const componentsOf = (stack: Record) => + (stack.pages as Array<{ regions: Array<{ components: Array> }> }>)[0]! + .regions[0]!.components; + + it('rewrites `description` → `subtitle` on the kebab legacy alias node', () => { + const before = pageWith({ type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }); + // Direction, stated before the run: the authored key is `description` + // and there is no `subtitle` at all — this is what a consumer's page + // looks like on the way in. + expect(componentsOf(before)[0]!.properties).toEqual({ title: 'Leads', description: 'All open leads' }); + + const { stack, notices } = collectConversionNotices(structuredClone(before)); + + // …and this is what the runtime sees: the second line survives under the + // canonical key, and the dialect spelling is gone rather than shadowing it. + expect(componentsOf(stack)[0]!.properties).toEqual({ title: 'Leads', subtitle: 'All open leads' }); + expect(componentsOf(stack)[0]!.properties).not.toHaveProperty('description'); + expect(notices).toHaveLength(1); + }); + + it('rewrites it on the CANONICAL `page:header` node too — where it is dropped on the floor today', () => { + const { stack, notices } = collectConversionNotices( + pageWith({ type: 'page:header', properties: { title: 'Lead', description: 'One lead' } }), + ); + expect(componentsOf(stack)[0]!.properties).toEqual({ title: 'Lead', subtitle: 'One lead' }); + expect(notices).toHaveLength(1); + }); + + it('does NOT rewrite the node TYPE — the kebab alias is objectui\'s to retire', () => { + const { stack } = collectConversionNotices( + pageWith({ type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }), + ); + expect(componentsOf(stack)[0]!.type).toBe('page-header'); + }); + + it('leaves a shadowed `description` alone when `subtitle` is already there (canonical wins)', () => { + // The house precedence `renameKey` encodes, same as + // flow-node-crud-object-alias: no rewrite, no notice, no deletion. + const before = pageWith({ + type: 'page:header', + properties: { title: 'Both', subtitle: 'wins', description: 'ignored' }, + }); + const { stack, notices } = collectConversionNotices(structuredClone(before)); + expect(stack).toEqual(before); + expect(notices).toHaveLength(0); + }); + + it('touches header nodes ONLY — `description` is a live declared prop elsewhere', () => { + // element:text_input declares its own `description` (helper text). A + // conversion keyed on the key rather than the node would eat it. + const before = pageWith( + { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, + { type: 'record:details', properties: { description: 'not a subtitle' } }, + ); + const { stack, notices } = collectConversionNotices(structuredClone(before)); + expect(stack).toEqual(before); + expect(notices).toHaveLength(0); + }); + + it('emits a notice that names the surface, the site and the live window', () => { + const { notices } = collectConversionNotices( + pageWith( + { type: 'element:divider' }, + { type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }, + ), + ); + expect(notices).toHaveLength(1); + expect(notices[0]).toMatchObject({ + conversionId: 'page-header-subtitle-alias', + surface: 'page.component.page-header.description', + from: 'description', + to: 'subtitle', + path: 'pages[0].regions[0].components[1].properties.subtitle', + toMajor: 17, + retiresIn: 18, + }); + }); + + it('is idempotent — the canonical shape is not a match', () => { + const before = pageWith({ type: 'page:header', properties: { title: 'Leads', subtitle: 'All open leads' } }); + const { stack, notices } = collectConversionNotices(structuredClone(before)); + expect(stack).toEqual(before); + expect(notices).toHaveLength(0); + }); + + it('copies on write — a page with no header passes through by reference', () => { + const stack = pageWith({ type: 'record:details' }); + expect(applyConversions(stack)).toBe(stack); + }); + + it('reaches a STORED page row, so data at rest canonicalizes on rehydration', () => { + // `applyConversionsToStoredItem` wraps the row as `{ pages: [row] }` + // (#3903) — the walker meets it there with no extra wiring. + const notices: ConversionNotice[] = []; + const row = { + name: 'crm_lead_detail', + regions: [{ name: 'header', components: [{ type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }] }], + }; + const out = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); + expect(out.regions[0]!.components[0]!.properties).toEqual({ title: 'Leads', subtitle: 'All open leads' }); + expect(notices.map((n) => n.conversionId)).toEqual(['page-header-subtitle-alias']); + }); + + /** + * The premise pin, and the one test that fails if the alias ever comes + * BACK. This conversion is only correct while the spec declares exactly one + * spelling: `PageHeaderProps` accepts `subtitle` and silently strips + * `description` (the schema is not `.strict()`), which is precisely why an + * authored `description` needed a conversion rather than an error. + */ + it('the canonical props schema declares `subtitle` and no `description`', () => { + expect(PageHeaderProps.parse({ title: 'Leads', subtitle: 'All open leads' }).subtitle) + .toBe('All open leads'); + expect(PageHeaderProps.parse({ title: 'Leads', description: 'All open leads' })) + .not.toHaveProperty('description'); + }); + + /** + * Reachability, judged by what this rule guards: a KEY inside the page + * component's free-form `properties` record. So the criterion is that the + * page schema accepts the fixture at all — before AND after. Before-green + * is the defect's mechanism (nothing rejects the dialect spelling, which is + * why it fails silently); after-green is the conversion's obligation (it + * must not produce a page the loader then refuses). + */ + it('both shapes parse green against PageSchema — the conversion moves a key, it does not fix a rejection', () => { + const page = (properties: Record) => ({ + name: 'crm_lead_detail', + label: 'Lead Detail', + type: 'record' as const, + regions: [{ name: 'header', components: [{ type: 'page:header', properties }] }], + }); + expect(PageSchema.safeParse(page({ title: 'Leads', description: 'All open leads' })).success).toBe(true); + expect(PageSchema.safeParse(page({ title: 'Leads', subtitle: 'All open leads' })).success).toBe(true); + }); + }); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index ff7bc00827..a07bcbb19e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -16,7 +16,15 @@ */ import type { ConversionApplication, MetadataConversion } from './types.js'; -import { mapCollection, mapDatasources, mapFlowNodes, mapPages, renameConfigKey, renameKey } from './walk.js'; +import { + mapCollection, + mapDatasources, + mapFlowNodes, + mapPageComponents, + mapPages, + renameConfigKey, + renameKey, +} from './walk.js'; import { resolveDriverId, type BuiltinDriverId } from '../data/driver/config-registry.zod.js'; /** @@ -4093,6 +4101,124 @@ const themeInertTokenScalesRemoved: MetadataConversion = { }, }; +/** + * The two spellings of the page-header node, both converted by + * {@link pageHeaderSubtitleAlias}. + * + * `page:header` is the protocol type (`PageComponentType`, `ComponentPropsMap`); + * `page-header` is objectui's kebab legacy alias (`@object-ui/layout`), which is + * authorable here because `PageComponentSchema.type` is deliberately + * `z.union([PageComponentType, z.string()])` — an open namespace for custom + * components. + */ +const PAGE_HEADER_COMPONENT_TYPES = new Set(['page:header', 'page-header']); + +/** + * Page-header component prop `description` → `subtitle` (protocol 17, + * objectui#3226 / #4827). + * + * One concept — the page's second line — carried two authorable spellings, one + * per renderer. `@objectstack/spec`'s `PageHeaderProps` (`ui/component.zod.ts`) + * declares `subtitle` and nothing else; objectui's kebab legacy alias + * `page-header` advertised `description` in its registration `inputs` and its + * renderer read a bare `subtitle ?? description`. That is the Prime Directive + * #12 shape exactly: a producer dialect held up by a consumer fallback, with + * the registry teaching the off-spec key on the way in. + * + * **Why a conversion and not a deletion** — the load-bearing half of this + * entry. The alias's entire reason to exist is out-of-repo consumer schemas, + * so "no in-repo author writes `description`" is not evidence that nobody + * does; on this key in particular, in-repo evidence has zero coverage. + * Deleting the consumer read would drop those pages' second line *silently* — + * the title still renders, one line vanishes, nothing errors — which is the + * least reportable failure shape there is. So the alias retires the ADR-0087 + * D2 way instead: rewritten to the canonical key at load, and at every stored + * row's rehydration (`applyConversionsToStoredItem` walks `pages` for free), + * declared, loud, tested and expiring. objectui#3226 deletes its `??` once + * this ships. + * + * **Both spellings of the node are converted**, deliberately. `page-header` is + * the alias the fallback served; `page:header` is the canonical type, where an + * authored `description` is *already* dropped on the floor today because that + * renderer only ever read `subtitle` — the same defect, one spelling over. The + * alias here is the KEY, not the type: this entry does **not** rewrite + * `page-header` → `page:header`. That registration is objectui's to retire, on + * its own schedule, and rewriting a type over an open namespace is the class of + * move {@link flowNodeHttpRename}'s conflict guard exists for. + * + * Precedence is the house rule {@link renameKey} encodes and nothing new: an + * already-canonical `subtitle` WINS, and the shadowed `description` is left + * exactly where it sits — unconverted, unreported, unremoved — as in + * {@link flowNodeCrudObjectAlias}. Only header nodes are touched: `description` + * is a live declared prop elsewhere on the same surface (`element:text_input` + * helper text), and those components are not this entry's business. + * + * **Live window**; retires at 18. + */ +const pageHeaderSubtitleAlias: MetadataConversion = { + id: 'page-header-subtitle-alias', + toMajor: 17, + surface: 'page.component.page-header.description', + summary: + "page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires)", + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + const type = component.type; + if (typeof type !== 'string' || !PAGE_HEADER_COMPONENT_TYPES.has(type)) return component; + const properties = component.properties; + if (!isDict(properties)) return component; + const renamed = renameKey(properties, 'description', 'subtitle'); + if (!renamed) return component; + emit({ from: 'description', to: 'subtitle', path: `${path}.properties.subtitle` }); + return { ...component, properties: renamed }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'crm_lead_detail', + regions: [ + { + name: 'header', + components: [ + // The kebab legacy alias — the spelling the `??` fallback served. + { type: 'page-header', properties: { title: 'Leads', description: 'All open leads' } }, + // The canonical type authored with the legacy key: converted too, + // because today this second line is dropped on the floor. + { type: 'page:header', properties: { title: 'Lead', description: 'One lead' } }, + // Canonical already present → the shadowed alias is left alone (no notice). + { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'ignored' } }, + // `description` is this component's OWN declared prop (helper text) — untouched. + { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, + ], + }, + ], + }, + ], + }, + after: { + pages: [ + { + name: 'crm_lead_detail', + regions: [ + { + name: 'header', + components: [ + { type: 'page-header', properties: { title: 'Leads', subtitle: 'All open leads' } }, + { type: 'page:header', properties: { title: 'Lead', subtitle: 'One lead' } }, + { type: 'page:header', properties: { title: 'Both', subtitle: 'wins', description: 'ignored' } }, + { type: 'element:text_input', properties: { label: 'Note', description: 'Helper text' } }, + ], + }, + ], + }, + ], + }, + expectedNotices: 2, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -4143,6 +4269,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly Dict return { ...stack, pages: nextPages }; } +/** + * Immutably map every **region-level** page component in + * `stack.pages[].regions[].components[]`. + * + * `mapper` receives each component dict and its path + * (`pages[i].regions[j].components[k]`) and returns the same reference (no + * change) or a new dict. The stack, `pages`, a page, its `regions`, a region + * and its `components` are each copied only when a descendant actually + * changed — {@link mapPages}' contract, one level deeper. + * + * Region level is the whole surface a page-component conversion can reach: + * `PageComponentSchema` declares no children key, so anything nested (tab + * panels, card bodies) sits inside another component's free-form `properties` + * and is not typed page-component shape. Same boundary, drawn for the same + * reason, as `translatePage` in `system/i18n-resolver.ts`. + */ +export function mapPageComponents( + stack: Dict, + mapper: (component: Dict, path: string) => Dict, +): Dict { + return mapPages(stack, (page, pagePath) => { + const regions = page.regions; + if (!Array.isArray(regions)) return page; + + let regionsChanged = false; + const nextRegions = regions.map((region, ri) => { + if (!isDict(region)) return region; + const components = region.components; + if (!Array.isArray(components)) return region; + + let componentsChanged = false; + const nextComponents = components.map((component, ci) => { + if (!isDict(component)) return component; + const mapped = mapper(component, `${pagePath}.regions[${ri}].components[${ci}]`); + if (mapped !== component) componentsChanged = true; + return mapped; + }); + + if (!componentsChanged) return region; + regionsChanged = true; + return { ...region, components: nextComponents }; + }); + + if (!regionsChanged) return page; + return { ...page, regions: nextRegions }; + }); +} + /** * Immutably map every datasource in `stack.datasources[]`. * diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 8c1fd62ce9..f1c1bcafe3 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -999,6 +999,7 @@ const step17: MigrationStep = { 'dashboard-widget-action-aria-removed', 'dashboard-widget-compareto-converged', 'theme-inert-token-scales-removed', + 'page-header-subtitle-alias', ], semantic: [ {