Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/page-header-subtitle-alias-conversion.md
Original file line number Diff line number Diff line change
@@ -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 迁移链。
1 change: 1 addition & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions packages/spec/spec-changes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
152 changes: 152 additions & 0 deletions packages/spec/src/conversions/conversions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down Expand Up @@ -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<string, unknown>[]) => ({
pages: [{ name: 'crm_lead_detail', regions: [{ name: 'header', components }] }],
});
const componentsOf = (stack: Record<string, unknown>) =>
(stack.pages as Array<{ regions: Array<{ components: Array<Record<string, unknown>> }> }>)[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<string, unknown>) => ({
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);
});
});
});
Loading
Loading