Skip to content

Commit 183d09b

Browse files
yinlianghuiclaude
andauthored
retire(studio): drop the canonical page:header icon field, restate the alias's on its renderer read (#3829) (#4794)
* retire(studio): drop the canonical page:header icon field, restate the alias's on its renderer read (#3829) objectui#3829 was filed as a three-way fork over `page:header.icon` and `page:card.actions` (wire them / declare them with a KNOWN GAP / retire them upstream). The maintainer ruled route (c) on 2026-08-09 and objectstack#6946 / PR objectstack#7115 executed it: both keys are ADR-0087 D2 tombstones in @objectstack/spec 17.0.0, live on the rc.6 this repo pins. This is the objectui follow-through, and the measurement behind it found the retirement's "zero producers" premise held for one key and not the other. - previews/block-config.ts + both locale tables: the canonical `page:header` icon box and its two now-orphaned i18n keys. This was the one live producer. Before the retirement an authored value was silently dropped; after it the platform rejects the node BY NAME, so a designer field left standing is strictly worse than the gap it papered over. Negative pin (plus the positive non-vacuity half and the spec-side tombstone assertion) in previews/__tests__/block-config.test.ts. Precedent followed verbatim: 38ab505 / PR #4169's global_nav cleanup, "the palette option and its now-orphaned i18n key". - page:card.actions had no producer at all — it is a clean retirement, and only the exemption prose changes for it. - registry-inputs-spec-parity.test.ts: both exemptions rewritten from the "wire it or declare it" MENU to the retired-upstream class, templated on the `record:details.layout` entry. They are NOT deleted: D2 retirement replaces the member with `z.never()` rather than removing it, so `Object.keys(shape)` still reports both keys and the reverse direction still demands cover. They resolve via objectui#3809's tombstone recognition, which the entries now say. Stale line numbers repaired (822-1570 -> 973-1677, 666-695 -> 703-745) and the block header re-based on rc.6. - layout's `page-header` / `layout:page-header` alias KEEPS its `icon` input (route A). That is a different renderer which really reads and draws it (PageHeader.tsx:123, :231-233), the docs page publishes it and the repo's only live demo writes it — withdrawing it would delete a working capability. What changes is the LICENCE: the comment now rests on the renderer read instead of spec parity, and page-header-authorable-keys.test.tsx skips tombstones when it derives the spec key set, so `icon` stops passing the dialect guard for the wrong reason. The carve-out is a named, issue-backed, self-clearing list. The renderer read points themselves are untouched. - content/docs/layout/page-header.mdx: line 40 still taught `icon` as a member of `PageHeaderProps`. Rewritten to rc.6 reality, and the component prop is now distinguished from the retired spec key rather than conflated with it. - PageBlockInspector.i18n.test.tsx: the #3979 placeholder pair re-pointed from `page:header` to `record:alert`, which reproduces the symptom (prose hint under a translated label) on a key the renderer really reads. Same move and same reason as objectui#4649's re-point a few tests above. Part of #3829 Co-authored-by: Claude <noreply@anthropic.com> * docs(layout): point the icon retirement at its issue, not at a version literal (#3829) CI red on `scripts/__tests__/doc-version-claims.test.ts`, self-inflicted by the previous commit: the new PageHeader paragraph wrote "retired in `@objectstack/spec` 17.0.0", and a first-party package name followed by a dotted version IS a version claim on a scanned surface (`content/docs` is a SCAN_ROOT). Nothing in this repo can check that literal, so the ratchet demands it be deleted or registered. Deleted, which is the ratchet's own stated preference and the right one here: the sentence already cites objectstack#6946 and ADR-0087 D2, so the version number carried no fact those two do not — while being exactly the kind of literal objectui#3645 froze at `^3.3.0` for thirteen majors. The actionable form of "which release" is already in the same paragraph as `os migrate meta --from 16`, which is a migration command rather than a claim about a dependency's current version. The same literal appears in this PR's test comments, block-config.ts and the changeset and does NOT trip the gate — those are not scanned surfaces. Only the mdx is, so only the mdx changes here. Verified against the gate's own matcher rather than by eye: FIRST_PARTY + SEP + VERSION, where SEP cannot cross an opening parenthesis, so "`@objectstack/spec` (objectstack#6946" is not a claim. Re-ran the file through a probe built from the test's exact regex fragments (1 hit before, 0 after) and then the test itself. Part of #3829 Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbe8b86 commit 183d09b

10 files changed

Lines changed: 406 additions & 71 deletions

File tree

‎.changeset/tidy-buttons-repeat.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/layout': patch
4+
---
5+
6+
Studio 页面设计器不再为 canonical `page:header` 提供 `icon` 编辑框(objectui#3829)
7+
8+
`PageHeaderProps.icon` 已在 `@objectstack/spec` 17.0.0 随 ADR-0087 D2 退役
9+
(objectstack#6946 / PR objectstack#7115):canonical `page:header` 渲染器从未读过它,
10+
表头的身份由 record chrome(`recordChrome`)与每个 action 自带的 `icon` 承担。退役前
11+
作者填入的值被静默丢弃,退役后平台按名拒绝整个节点 —— 而设计器仍在提供那个输入框,
12+
等于教作者写出解析失败的元数据。本次移除该字段与它此时已成孤儿的两条 i18n 键
13+
(en / zh 同一次改动,两张表的键集保持一致),并把「不得回潮」钉在
14+
`previews/__tests__/block-config.test.ts`。
15+
16+
`@object-ui/layout` 的 `page-header` / `layout:page-header` 别名**保留** `icon` 输入,
17+
行为不变:那是另一个渲染器,它真读真画(`PageHeader.tsx`),文档与本仓唯一的活 demo
18+
都写它。变的只是这条声明的**依据** —— 从 spec parity 改述为 renderer-read 事实,并让
19+
`page-header-authorable-keys` 守卫在派生 spec 键集时跳过墓碑成员,不再因为
20+
`Object.keys(shape)` 仍列着已退役的 `icon` 而假绿。

‎apps/console/src/__tests__/registry-inputs-spec-parity.test.ts‎

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -398,24 +398,53 @@ const OFF_SPEC_EXEMPTIONS: Record<string, string> = {};
398398
* and its own issue owns it.
399399
*
400400
* Every reason cites an issue, which `references a tracking issue` asserts.
401-
* Verified against renderer read sites at objectui `origin/main` @ `c85268256`
402-
* with `@objectstack/spec@17.0.0-rc.5` — not assumed from the spec's wording.
401+
* Verified against renderer read sites at objectui `origin/main` @ `c25222758`
402+
* with `@objectstack/spec@17.0.0-rc.6` — not assumed from the spec's wording.
403+
* (The four `…-rc.5` mentions left in the entries below are the stale-pin
404+
* entries' own prose and belong to their issues, not to this header.)
403405
*/
404406
const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
405-
// ── B class — spec declares it, NO renderer read point at all (2 keys) ─────
406-
// The instinct here is to add an input, and it is wrong: that publishes a key
407-
// the platform drops on the floor, which is exactly the defect objectui#3797
408-
// spent a repo-wide gate closing. The other instinct — wire it — is a visual
409-
// decision (where an icon sits next to RecordTitleChip; whether a card grows
410-
// an actions area, which reaches into `renderers/action/**`). The third option
411-
// is the `record:activity.showSubscriptionToggle` precedent: declare it and
412-
// say NOT IMPLEMENTED in the description, so both directions are in parity and
413-
// the author is told. Three viable shapes, one public contract — filed as
414-
// objectui#3829 rather than guessed at here.
407+
// ── page:header.icon / page:card.actions — retired upstream (2 keys) ───────
408+
// These two used to be a MENU: objectui#3829 filed them as a three-way fork
409+
// (wire them; declare them with a KNOWN GAP per the
410+
// `record:activity.showSubscriptionToggle` precedent; retire them upstream)
411+
// and this entry listed all three so no implementing agent would guess. The
412+
// fork is closed. The maintainer ruled route (c) on 2026-08-09 —
413+
// zero producers, zero consumers, zero demand — and objectstack#6946 /
414+
// PR objectstack#7115 executed it: both keys are ADR-0087 D2 tombstones in
415+
// `@objectstack/spec` 17.0.0, live on the rc.6 this repo installs. So the
416+
// class here is no longer B (undecided) but the same one as
417+
// `record:details.layout` below: the spec rejects the key BY NAME, and the
418+
// reverse direction demands it anyway because the tombstone is still a member
419+
// of the shape.
420+
//
421+
// Read the upstream prescriptions before touching either key — they say what
422+
// replaces it, which is why neither is coming back. A header's identity is
423+
// drawn by the record chrome (`recordChrome`, on by default) plus each
424+
// action's own `icon`; a card's buttons are authored as components in
425+
// `children` or `footer` (`element:button`, `record:quick_actions`).
426+
//
427+
// DO NOT DELETE THESE TWO ENTRIES YET, and the reason is the one this file
428+
// already writes out twice above: D2 retirement REPLACES the member with
429+
// `z.never()` rather than deleting it, so `Object.keys(shape)` still reports
430+
// both keys as declared and `carries no stale unpublished-key exemption`
431+
// still needs the cover. They resolve when objectui#3809's tombstone
432+
// recognition narrows `specTopLevelKeys` — not on a pin bump, and not by
433+
// declaring the inputs.
434+
//
435+
// The objectui half of route (c) is otherwise complete (objectui#3829).
436+
// `page:card.actions` had no producer at all; `page:header.icon` had exactly
437+
// one — the metadata-admin designer's BLOCK_CONFIG field for the CANONICAL
438+
// `page:header`, which kept offering authors an icon box whose value rc.6
439+
// rejects by name — and it was removed with its two i18n keys in the same
440+
// change that rewrote these entries. The `layout:page-header` ALIAS keeps its
441+
// `icon` input deliberately: that is a different renderer with a real read
442+
// point (`packages/layout/src/PageHeader.tsx`), so the two are opposite facts,
443+
// not an inconsistency.
415444
'page:header.icon':
416-
'Spec declares it; PageHeaderRenderer has NO read point — `icon` in containers.tsx:822-1570 is only ever per-action (`action.icon`, :1321/:1365) or a nav item (:604). Wire it, or declare it with a KNOWN GAP per the showSubscriptionToggle precedent: objectui#3829.',
445+
'Retired upstream by objectstack#6946 / PR objectstack#7115 (ADR-0087 D2 tombstone) — PageHeaderRenderer never had a read point: `icon` inside containers.tsx:973-1677 is only ever per-action (`action.icon`, :1428/:1472) or a nav item (:641/:816), and the spec now rejects the key by name, prescribing the record chrome plus per-action icons instead. Unlike the stale-pin entries below this one is LIVE at @objectstack/spec@17.0.0-rc.6: the tombstone stays in `Object.keys(shape)`, so the reverse direction demands a key the contract refuses. Resolves via objectui#3809 tombstone recognition, not by declaring the input — objectui#3829.',
417446
'page:card.actions':
418-
'Spec declares it; PageCardRenderer (containers.tsx:666-695) renders title/body/footer only and never reads `actions`. Wire it, or declare it with a KNOWN GAP per the showSubscriptionToggle precedent: objectui#3829.',
447+
'Retired upstream by objectstack#6946 / PR objectstack#7115 (ADR-0087 D2 tombstone) — PageCardRenderer (containers.tsx:703-745) builds its card from title/body/children/footer and never had an actions area, and the spec now rejects the key by name, prescribing buttons authored as components in `children` or `footer` (`element:button`, `record:quick_actions`). Unlike the stale-pin entries below this one is LIVE at @objectstack/spec@17.0.0-rc.6: the tombstone stays in `Object.keys(shape)`, so the reverse direction demands a key the contract refuses. Resolves via objectui#3809 tombstone recognition, not by declaring the input — objectui#3829.',
419448

420449
// ── page:tabs.type — the carrier collision, from the other side ────────────
421450
// The mirror image of the `page:tabs.tabStyle` exemption in

‎content/docs/layout/page-header.mdx‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,24 @@ interface PageHeaderComponentProps extends React.HTMLAttributes<HTMLDivElement>
3737
```
3838

3939
`subtitle` is the canonical key: `@objectstack/spec/ui`'s `PageHeaderProps` — the contract
40-
for the authored `page:header` node — declares `title / subtitle / icon / breadcrumb /
41-
actions / aria` and has no `description`. Both `title` and `subtitle` interpolate
42-
`{field.path}` tokens against the surrounding record context.
40+
for the authored `page:header` node — declares `title / subtitle / breadcrumb / actions /
41+
recordChrome / showStar / showCopyId / aria` and has no `description`. Both `title` and
42+
`subtitle` interpolate `{field.path}` tokens against the surrounding record context.
43+
44+
`icon` is deliberately **not** in that list, and the distinction matters because this page
45+
documents a React component while the spec governs an authored node. `PageHeaderProps.icon`
46+
was retired from `@objectstack/spec` (objectstack#6946, ADR-0087 D2): the canonical
47+
`page:header` renderer never read it, so an authored value used to be accepted and dropped,
48+
and the contract now rejects the key by name. On a canonical `page:header` node the
49+
header's identity comes from the record chrome (`recordChrome`, on by default) plus each
50+
action's own `icon`; `os migrate meta --from 16` rewrites stored sources.
51+
52+
The `icon` prop listed above is this component's **own** capability, not that spec key.
53+
`PageHeader` from `@object-ui/layout` — registered as `page-header` and
54+
`layout:page-header` — really does draw it (a string resolves through `LazyIcon`, a React
55+
node renders as-is), which is why it stays on that registration's `inputs` and why the demo
56+
on this page writes `"icon": "users"` (objectui#3829). Do not carry the key over to a
57+
canonical `page:header` node: the same JSON renders here and fails to parse there.
4358

4459
`description` used to be a **legacy alias** of `subtitle` that this component read as
4560
well. It is **retired** (objectui#3789): the prop is gone and the renderer draws a

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -626,8 +626,11 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
626626
'engine.inspector.pageBlock.field.element:button.action': 'Action',
627627
'engine.inspector.pageBlock.field.page:header.title': 'Title',
628628
'engine.inspector.pageBlock.field.page:header.subtitle': 'Subtitle',
629-
'engine.inspector.pageBlock.field.page:header.icon': 'Icon',
630-
'engine.inspector.pageBlock.placeholder.page:header.icon': 'lucide icon name',
629+
// `…field.page:header.icon` and its placeholder retired with the spec key
630+
// itself (objectstack#6946 / PR objectstack#7115, @objectstack/spec
631+
// 17.0.0-rc.6). Their only reader was the canonical `page:header` icon field
632+
// removed from `previews/block-config.ts`; a key kept past its field is dead
633+
// vocabulary that the next author reads as a live surface. objectui#3829.
631634
'engine.inspector.pageBlock.field.page:header.breadcrumb': 'Show breadcrumb',
632635
'engine.inspector.pageBlock.field.page:card.title': 'Title',
633636
'engine.inspector.pageBlock.field.page:card.bordered': 'Bordered',
@@ -2380,8 +2383,9 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
23802383
'engine.inspector.pageBlock.field.element:button.action': '动作',
23812384
'engine.inspector.pageBlock.field.page:header.title': '标题',
23822385
'engine.inspector.pageBlock.field.page:header.subtitle': '副标题',
2383-
'engine.inspector.pageBlock.field.page:header.icon': '图标',
2384-
'engine.inspector.pageBlock.placeholder.page:header.icon': 'lucide 图标名',
2386+
// `…field.page:header.icon` and its placeholder retired with the spec key —
2387+
// see the matching note in the `en` table above. Removed from BOTH tables in
2388+
// the same edit so the two key sets stay identical.
23852389
'engine.inspector.pageBlock.field.page:header.breadcrumb': '显示面包屑',
23862390
'engine.inspector.pageBlock.field.page:card.title': '标题',
23872391
'engine.inspector.pageBlock.field.page:card.bordered': '显示边框',

‎packages/app-shell/src/views/metadata-admin/inspectors/PageBlockInspector.i18n.test.tsx‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ describe("PageBlockInspector's own chrome follows the locale (#3963)", () => {
319319
* straight to the input.
320320
*
321321
* What makes this column different from the two before it is that translating
322-
* ALL of it would be wrong. Ten of the eighteen placeholders are example VALUES,
322+
* ALL of it would be wrong. Ten of the seventeen placeholders are example VALUES,
323323
* and the assertions come in matching pairs: the prose ones must change with the
324324
* locale, the value ones must NOT. Both halves are pinned here because both are
325325
* regressions a reader could introduce while believing they were finishing the
@@ -329,7 +329,17 @@ describe("PageBlockInspector's own chrome follows the locale (#3963)", () => {
329329
describe('PageBlockInspector placeholders follow the locale — but only the prose ones (#3979)', () => {
330330
it('translates the text-field hint under an already-Chinese label', () => {
331331
// The issue's headline symptom: 「图标」 over a box hinting `lucide icon name`.
332-
renderInspector(pageDraft('page:header'), 'zh-CN');
332+
//
333+
// Rendered from `record:alert`, not `page:header`. This pair used to use the
334+
// header's own icon box until objectui#3829 removed it: @objectstack/spec
335+
// 17.0.0 retired `PageHeaderProps.icon` (objectstack#6946 / PR
336+
// objectstack#7115) because the canonical renderer never read it, so the
337+
// field was offering authors a key the platform now rejects by name. Same
338+
// move, same reason, as objectui#4649's re-point a few tests above —
339+
// `record:alert.icon` reproduces the symptom exactly (a `text` field with a
340+
// prose placeholder under a translated label) on a key the renderer really
341+
// reads, so the case cannot be satisfied by a surface that should not exist.
342+
renderInspector(pageDraft('record:alert'), 'zh-CN');
333343

334344
expect(screen.getByText('图标')).toBeTruthy(); // #3913's half, still working
335345
expect(screen.getByPlaceholderText('lucide 图标名')).toBeTruthy();
@@ -341,7 +351,7 @@ describe('PageBlockInspector placeholders follow the locale — but only the pro
341351
it('renders the same panel in English under en-US, unchanged', () => {
342352
// en-US is the baseline: the new keys carry the exact literals the table
343353
// used to hold, so nothing an English admin sees may have moved.
344-
renderInspector(pageDraft('page:header'), 'en-US');
354+
renderInspector(pageDraft('record:alert'), 'en-US');
345355

346356
expect(screen.getByPlaceholderText('lucide icon name')).toBeTruthy();
347357
expectNoRawKeyPlaceholders();

‎packages/app-shell/src/views/metadata-admin/previews/__tests__/block-config-i18n.test.ts‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
*
5151
* #3913 stopped at labels, so the hints under them stayed English: 「图标」 over
5252
* a box reading `lucide icon name`. The column joins the walk above as a fifth
53-
* family, with the SAME positional derivation — but only for the 8 placeholders
53+
* family, with the SAME positional derivation — but only for the 7 placeholders
5454
* that are prose. The other 10 are example VALUES (`20`, `https://…`, a JSON
5555
* sample) and are `{ literal: … }` in `block-config.ts`, never keys: translating
5656
* them would corrupt the thing the author is being told to type.
@@ -178,12 +178,13 @@ describe('block-config labels are translation keys (#3913)', () => {
178178
expect(families.field).toBeGreaterThan(80);
179179
expect(families.add).toBe(5);
180180
expect(families.option).toBeGreaterThan(50);
181-
// Exact, like `add`: the keyed half of the placeholder column is 8 of 18
182-
// (#3979). A 9th prose placeholder should fail here and be translated; a
183-
// 9th VALUE placeholder does not reach this family at all and is caught by
184-
// the literal inventory instead. Either way the new one gets classified
185-
// rather than defaulting to English-on-screen.
186-
expect(families.placeholder).toBe(8);
181+
// Exact, like `add`: the keyed half of the placeholder column is 7 of 17
182+
// (#3979; 8 of 18 until objectui#3829 retired the canonical
183+
// `page:header.icon` field). An 8th prose placeholder should fail here and
184+
// be translated; an 8th VALUE placeholder does not reach this family at all
185+
// and is caught by the literal inventory instead. Either way the new one
186+
// gets classified rather than defaulting to English-on-screen.
187+
expect(families.placeholder).toBe(7);
187188
});
188189

189190
it('stores exactly the key its position implies', () => {
@@ -297,16 +298,24 @@ describe('en-US labels are unchanged by the key migration (#3913)', () => {
297298
'engine.inspector.pageBlock.option.location.record_more': 'Record more menu',
298299
'engine.inspector.pageBlock.option.severity.warning': 'Warning',
299300
// ── the placeholder family (#3979) ───────────────────────────────────────
300-
// ALL EIGHT, not a sample: this column was migrated by hand rather than
301+
// ALL SEVEN, not a sample: this column was migrated by hand rather than
301302
// mechanically, and the en side is the only thing standing between "the key
302303
// resolves" and "the key resolves to what the box used to say". Each value
303304
// below is byte-identical to the literal `block-config.ts` held at
304305
// `origin/main` 6bd6a4d76 — including the U+2026 ellipsis in `Text…`, which
305306
// a re-typed `...` would silently replace.
307+
//
308+
// It was EIGHT until objectui#3829: `…placeholder.page:header.icon` went
309+
// with the canonical `page:header` icon field, retired upstream by
310+
// objectstack#6946 / PR objectstack#7115. Dropping the sample row is the
311+
// right move rather than keeping it as a "still translated" check — the key
312+
// is gone from both locale tables, so `t()` now returns it unchanged and an
313+
// assertion here would be pinning the passthrough against itself. The
314+
// deliberate absence is pinned in `block-config.test.ts` instead, where the
315+
// field's absence is pinned beside it.
306316
'engine.inspector.pageBlock.placeholder.object-metric.icon': 'lucide icon name',
307317
'engine.inspector.pageBlock.placeholder.element:text.content': 'Text…',
308318
'engine.inspector.pageBlock.placeholder.element:button.icon': 'lucide icon name',
309-
'engine.inspector.pageBlock.placeholder.page:header.icon': 'lucide icon name',
310319
'engine.inspector.pageBlock.placeholder.record:details.sections.name': 'snake_case, e.g. contact_info',
311320
'engine.inspector.pageBlock.placeholder.record:alert.icon': 'lucide icon name',
312321
'engine.inspector.pageBlock.placeholder.record:quick_actions.actionNames': 'action name',
@@ -363,11 +372,16 @@ describe('placeholders that must NOT be translated (#3979)', () => {
363372
'record:details.sections.columns': { literal: '2', because: 'a column count' },
364373
};
365374

366-
it('collects all 18 placeholders — 8 keyed, 10 literal', () => {
375+
it('collects all 17 placeholders — 7 keyed, 10 literal', () => {
367376
// Guards the walk: if placeholder collection silently found nothing, every
368377
// assertion here would pass over an empty list.
369-
expect(PLACEHOLDERS.length).toBe(18);
370-
expect(PLACEHOLDERS.filter((p) => p.spec.key !== undefined).length).toBe(8);
378+
//
379+
// 18 / 8 / 10 until objectui#3829: the canonical `page:header` icon box was
380+
// a KEYED placeholder, and it went with the field when objectstack#6946 /
381+
// PR objectstack#7115 retired `PageHeaderProps.icon`. The literal half is
382+
// untouched, which is what this split says.
383+
expect(PLACEHOLDERS.length).toBe(17);
384+
expect(PLACEHOLDERS.filter((p) => p.spec.key !== undefined).length).toBe(7);
371385
expect(PLACEHOLDERS.filter((p) => p.spec.literal !== undefined).length).toBe(10);
372386
});
373387

0 commit comments

Comments
 (0)