Skip to content

Commit f10d802

Browse files
feat(spec)!: retire a page header's breadcrumb switch — no renderer draws a trail for it (#20758) (#20785)
Closes #20758 Clause-②: no (narrowing) `PageHeaderProps.breadcrumb` retires through the ADR-0087 route, the way `icon` left the same row. It has a `retiredKey()` tombstone, the D2 conversion `page-header-breadcrumb-removed` (it strips `true` and `false` from stored and built pages with a notice, retired from the load path), the `RETIRED_KEYS_BY_MAJOR[18]` entry and the D3 entry `page-header-breadcrumb-retired`. A stored page is never refused for the key: the props lint reports it as a warning. `nav:breadcrumb` stays, because objectui's Studio page palette writes it. The published skill `skills/objectstack-ui/rules/pages.md` stops teaching the key (two lines). That path is governed, so this PR lands Tier H, after an authorized approval. `@objectstack/spec` minor, BREAKING. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72f8c38 commit f10d802

15 files changed

Lines changed: 775 additions & 12 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: retire a page header's `breadcrumb` switch — no renderer ever drew a trail for it (#20758)
6+
7+
**BREAKING** — `breadcrumb` on a `page:header` component (`PageHeaderProps`) is retired, with its `true` default: no renderer ever drew a trail for it. objectui drew an empty slot that nothing filled, and the console draws the navigation trail once, in the app shell's header. Delete the key, whether it was `true` or `false`. The shell's trail is unchanged.
8+
9+
Clause-②: no (narrowing)
10+
11+
Measured before removal: objectui's `PageHeaderRenderer` reads the key only to draw an empty `div[data-page-breadcrumb-slot]`, and nothing fills it. The one producer is objectui's Studio page-block inspector ("Show breadcrumb"), so stored pages may carry either value. The one in-repo author found was the published `objectstack-ui` skill's record-page example. No example app authors it. The `nav:breadcrumb` component type is not part of this retirement: the Studio page palette still offers it.
12+
13+
## FROM → TO
14+
15+
| you wrote (17.5 and earlier) | write instead |
16+
| --- | --- |
17+
| `{ type: 'page:header', properties: { title, breadcrumb: true } }` | `{ type: 'page:header', properties: { title } }` |
18+
| `{ type: 'page:header', properties: { title, breadcrumb: false } }` | `{ type: 'page:header', properties: { title } }` |
19+
20+
**The one-line fix:** delete `breadcrumb` from every `page:header`'s `properties`.
21+
22+
**What an author who still writes it sees.** A page is never refused for it. A page component's `properties` is an open bag, so `definePage()`, `defineStack({ pages })` and the page write door accept the page as before. `os validate` / `os build` / `os lint` report the key as a warning at `properties.breadcrumb`, with the prescription:
23+
24+
> `page:header` property `breadcrumb` was removed in @objectstack/spec 17 (ADR-0087 D2) — no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the navigation trail is drawn once, by the app shell's header. Delete the key, whether it was `true` or `false`; the shell's trail is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.
25+
26+
A typed `PageHeaderProps` input fails `tsc` at the key.
27+
28+
## The retirement kit
29+
30+
- **A `retiredKey()` tombstone** on `PageHeaderProps`, a `strictObject`, beside the `icon` that row lost at 17. `RETIRED_KEYS_BY_MAJOR[18]`: `ui/PageHeaderProps:breadcrumb`. No retired-default residue stage is owed: the `true` default was never written into a built artifact, because a page parses its component `properties` as an open bag and only the advisory props lint reads this row.
31+
- **The D2 conversion `page-header-breadcrumb-removed`** (protocol 18, retired from the load path) deletes the key from every `page:header`, `true` and `false` alike, with one notice per header. It reaches headers in regions, nested in a container's `children`, and in a slotted page's named slots. A stored `page` row or a built artifact that carries the key loads through the rehydration seams, which replay it.
32+
- **The D3 entry `page-header-breadcrumb-retired`**: a header that said `false` reads as absent after the strip, so it shows the empty slot's spacing again until the renderer stops drawing the slot.
33+
- **No deprecation window**, per the project's startup-stage posture.
34+
35+
⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for.
36+
37+
<!-- adr-0087: registered page-header-breadcrumb-removed, page-header-breadcrumb-retired -->

‎content/docs/protocol/objectui/layout-dsl.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ widgets are components in their own right — they are not children of a section
5858
```
5959
Page
6060
├─ Region: header
61-
│ └─ Component: page:header (title, actions, breadcrumb)
61+
│ └─ Component: page:header (title, actions)
6262
├─ Region: main
6363
│ ├─ Component: record:details
6464
│ │ └─ Section

‎content/docs/references/ui/component.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1198,7 +1198,7 @@ View filter rule
11981198
| **title** | `string \| Record<string, string>` | optional | Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from. |
11991199
| **subtitle** | `string \| Record<string, string>` | optional | Page subtitle |
12001200
| **icon** | `never` | optional | [REMOVED] `page:header` property `icon` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), never off the header's own props bag, and the component registry never published it as an input, so an authored value was accepted and dropped. Delete the key. The header's own identity is drawn by the record chrome (`recordChrome`, on by default) and each action carries its own `icon`. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. |
1201-
| **breadcrumb** | `boolean` | optional (default: `true`) | Show breadcrumb |
1201+
| **breadcrumb** | `never` | optional | [REMOVED] `page:header` property `breadcrumb` was removed in @objectstack/spec 17 (ADR-0087 D2) — no renderer ever drew a trail for it: objectui drew an empty slot and nothing filled it, and the navigation trail is drawn once, by the app shell's header. Delete the key, whether it was `true` or `false`; the shell's trail is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. |
12021202
| **actions** | `string[]` | optional | Action IDs to show in header |
12031203
| **recordChrome** | `boolean` | optional (default: `true`) | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. |
12041204
| **showStar** | `boolean` | optional (default: `true`) | Show the follow (favourite) star beside the record title. Part of the record chrome — no effect when `recordChrome` is false. |

‎packages/lint/src/validate-component-props.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('validateComponentProps — undeclared keys', () => {
6565
it('is silent on a fully declared props bag', () => {
6666
const findings = validateComponentProps(
6767
stackWith([
68-
{ type: 'page:header', properties: { title: 'T', subtitle: 'S', breadcrumb: true } },
68+
{ type: 'page:header', properties: { title: 'T', subtitle: 'S', recordChrome: false } },
6969
{
7070
type: 'record:related_list',
7171
properties: { objectName: 'task', relationshipField: 'project_id', limit: 5 },
@@ -75,6 +75,22 @@ describe('validateComponentProps — undeclared keys', () => {
7575
expect(findings).toEqual([]);
7676
});
7777

78+
// #20758 — `PageHeaderProps.breadcrumb` is a retiredKey tombstone. A page is
79+
// never hard-refused for carrying it: this rule is the door where an author
80+
// meets the prescription, and every finding it files is a WARNING.
81+
it('reports a retired page-header `breadcrumb` as a warning carrying the prescription, for `true` and `false`', () => {
82+
for (const breadcrumb of [true, false]) {
83+
const findings = validateComponentProps(
84+
stackWith([{ type: 'page:header', properties: { title: 'T', breadcrumb } }]),
85+
);
86+
expect(findings, `breadcrumb: ${breadcrumb}`).toHaveLength(1);
87+
const [f] = findings;
88+
expect(f.severity).toBe('warning');
89+
expect(f.path).toBe('pages[0].regions[0].components[0].properties.breadcrumb');
90+
expect(f.message).toContain('`page:header` property `breadcrumb` was removed in @objectstack/spec 17');
91+
}
92+
});
93+
7894
it('walks components nested inside `properties` (tabs items → children)', () => {
7995
const findings = validateComponentProps(
8096
stackWith([

‎packages/spec/authorable-defaults/ui.json‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@
7575
"ui/PageAccordionProps:variant = \"flush\"",
7676
"ui/PageCardProps:bordered = true",
7777
"ui/PageComponent:properties = {}",
78-
"ui/PageHeaderProps:breadcrumb = true",
7978
"ui/PageHeaderProps:recordChrome = true",
8079
"ui/PageHeaderProps:showCopyId = true",
8180
"ui/PageHeaderProps:showStar = true",

‎packages/spec/authorable-surface/ui.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1095,7 +1095,7 @@
10951095
"ui/PageContainerProps:children",
10961096
"ui/PageHeaderProps:actions",
10971097
"ui/PageHeaderProps:aria",
1098-
"ui/PageHeaderProps:breadcrumb",
1098+
"ui/PageHeaderProps:breadcrumb [RETIRED]",
10991099
"ui/PageHeaderProps:icon [RETIRED]",
11001100
"ui/PageHeaderProps:maxVisible",
11011101
"ui/PageHeaderProps:mobileMaxVisible",

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

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9087,6 +9087,144 @@ const objectKanbanQuickAddRemoved: MetadataConversion = {
90879087
},
90889088
};
90899089

9090+
/**
9091+
* `page:header.breadcrumb` leaves the contract (protocol 18, #20758 —
9092+
* ADR-0049 enforce-or-remove, the spec half of objectui#11166; the triage
9093+
* ruling on the card: 「没有 ⇒ 退役」).
9094+
*
9095+
* The key switched a trail that never existed. objectui's
9096+
* `PageHeaderRenderer` (`containers.tsx`) reads it and, unless it is `false`,
9097+
* draws an EMPTY `div[data-page-breadcrumb-slot]` in both header layouts;
9098+
* nothing fills the slot. The console draws the navigation trail once, in the
9099+
* shell (`@object-ui/app-shell` `AppHeader`, inside `/apps/:appName/*`), so the
9100+
* retirement takes the key away rather than building a second trail. The
9101+
* tombstone on `PageHeaderProps` refuses it for a live author (advisory, via
9102+
* the props lint: `PageComponentSchema.properties` is an open bag).
9103+
*
9104+
* **A delete of both values.** `true` and `false` go alike: neither ever drew
9105+
* a trail, so there is no value to preserve and no rewrite target. The one
9106+
* thing either value changed is the empty slot itself — present for `true`
9107+
* (and for absence), gone for `false` — which is spacing, not content, and
9108+
* leaves with the slot on the objectui half. A stored page that said `false`
9109+
* reads as absent after this strip, so it draws the empty slot again until
9110+
* that half lands; the D3 entry `page-header-breadcrumb-retired` records it.
9111+
*
9112+
* Who writes the key, so who this entry is for: objectui's Studio page-block
9113+
* inspector publishes a `page:header` "Show breadcrumb" boolean
9114+
* (`previews/block-config.ts`), so stored `sys_metadata` pages can carry
9115+
* either value. No example app, skill or doc in this repo authors it.
9116+
*
9117+
* ⚠️ Scoped by component `type`, never by key name, as
9118+
* {@link pageStructureInertKeysRemoved} scoped `icon`: `breadcrumb` is an
9119+
* ordinary word for an open-namespace component's own prop, and the kebab
9120+
* `page-header` spelling is objectui's legacy registration, not a key this
9121+
* spec declares. `nav:breadcrumb` — a component TYPE, not this key — is
9122+
* untouched: the Studio palette still offers it (`previews/block-types.ts`),
9123+
* so it has a producer and stays.
9124+
*/
9125+
const pageHeaderBreadcrumbRemoved: MetadataConversion = {
9126+
id: 'page-header-breadcrumb-removed',
9127+
toMajor: 18,
9128+
retiredFromLoadPath: true,
9129+
retiredAfter: '17.5.0',
9130+
surface: 'page.component.page:header.breadcrumb',
9131+
summary:
9132+
"page:header prop 'breadcrumb' removed, whether 'true' or 'false' (no renderer ever drew a trail for "
9133+
+ 'it: objectui drew an empty slot and nothing filled it, and the app shell\'s header draws the '
9134+
+ 'navigation trail)',
9135+
apply(stack, emit) {
9136+
return mapPageComponents(stack, (component, path) => {
9137+
if (component.type !== 'page:header') return component;
9138+
const properties = component.properties;
9139+
if (!isDict(properties) || !('breadcrumb' in properties)) return component;
9140+
const stripped = stripKeys(properties, ['breadcrumb'], emit, `${path}.properties`);
9141+
return { ...component, properties: stripped };
9142+
});
9143+
},
9144+
fixture: {
9145+
before: {
9146+
pages: [
9147+
{
9148+
name: 'lead_record',
9149+
regions: [
9150+
{
9151+
name: 'header',
9152+
components: [
9153+
// The default value, written out: the Studio inspector's
9154+
// "Show breadcrumb" switch left on.
9155+
{ type: 'page:header', properties: { title: 'Lead', breadcrumb: true } },
9156+
// The switch turned off, on a non-record header.
9157+
{ type: 'page:header', properties: { title: 'Pipeline', recordChrome: false, breadcrumb: false } },
9158+
// A header WITHOUT the key rides through untouched — the strip
9159+
// dispatches on key presence, and copy-on-write keeps the reference.
9160+
{ type: 'page:header', properties: { title: 'Settings', recordChrome: false } },
9161+
// ⚠️ The same key name on an open-namespace component that is
9162+
// NOT a page header — its own prop, not this entry's key.
9163+
{ type: 'acme:trail_banner', properties: { breadcrumb: true } },
9164+
// The nested position (#6775): a header inside a card's
9165+
// `children` is still a page header.
9166+
{
9167+
type: 'page:card',
9168+
properties: {
9169+
title: 'Summary',
9170+
children: [{ type: 'page:header', properties: { title: 'Inner', breadcrumb: true } }],
9171+
},
9172+
},
9173+
],
9174+
},
9175+
],
9176+
},
9177+
// The named-slot shape (#6776): a header authored into a slotted page.
9178+
{
9179+
name: 'lead_record_detail',
9180+
kind: 'slotted',
9181+
regions: [],
9182+
slots: {
9183+
details: { type: 'page:header', properties: { title: 'Lead', breadcrumb: false } },
9184+
},
9185+
},
9186+
],
9187+
},
9188+
after: {
9189+
pages: [
9190+
{
9191+
name: 'lead_record',
9192+
regions: [
9193+
{
9194+
name: 'header',
9195+
components: [
9196+
{ type: 'page:header', properties: { title: 'Lead' } },
9197+
{ type: 'page:header', properties: { title: 'Pipeline', recordChrome: false } },
9198+
{ type: 'page:header', properties: { title: 'Settings', recordChrome: false } },
9199+
{ type: 'acme:trail_banner', properties: { breadcrumb: true } },
9200+
{
9201+
type: 'page:card',
9202+
properties: {
9203+
title: 'Summary',
9204+
children: [{ type: 'page:header', properties: { title: 'Inner' } }],
9205+
},
9206+
},
9207+
],
9208+
},
9209+
],
9210+
},
9211+
{
9212+
name: 'lead_record_detail',
9213+
kind: 'slotted',
9214+
regions: [],
9215+
slots: {
9216+
details: { type: 'page:header', properties: { title: 'Lead' } },
9217+
},
9218+
},
9219+
],
9220+
},
9221+
// Four notices, one per stripped key: the two region-level headers, the
9222+
// nested one and the slotted one. The header without the key and the
9223+
// open-namespace component emit none.
9224+
expectedNotices: 4,
9225+
},
9226+
};
9227+
90909228
/**
90919229
* Object-permission lifecycle bits `allowRestore` / `allowPurge` removed
90929230
* (protocol 18, #12497 — ADR-0049 enforce-or-remove, maintainer ruling
@@ -12854,6 +12992,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
1285412992
{ conversion: pageAssignedProfilesRemoved, order: 31 },
1285512993
{ conversion: pageComponentFilterRecordToRuleArray, order: 36 },
1285612994
{ conversion: pageComponentResponsiveRemoved, order: 13 },
12995+
{ conversion: pageHeaderBreadcrumbRemoved, order: 49 },
1285712996
{ conversion: permissionAllowRestorePurgeRemoved, order: 16 },
1285812997
{ conversion: permissionRlsTagsRemoved, order: 42 },
1285912998
{ conversion: recordChatterPositionVocabulary, order: 2 },
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
// #20758 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec
4+
// half of objectui#11166 (triage ruling on the card: RETIRE, no consumer).
5+
// `PageHeaderProps.breadcrumb` switched a trail that never existed: objectui's
6+
// `PageHeaderRenderer` draws an EMPTY `div[data-page-breadcrumb-slot]` unless
7+
// the key is `false`, nothing fills it, and the console draws the navigation
8+
// trail once, in the shell (`AppHeader`). Tombstoned with `retiredKey()` in the
9+
// `strictObject`, beside the `icon` this row lost at 17 (the surface baseline
10+
// line carries `[RETIRED]`); stored and built pages are stripped of both values
11+
// by the D2 conversion `page-header-breadcrumb-removed`, whose D3 record is
12+
// `page-header-breadcrumb-retired`.
13+
//
14+
// ⭐ RETIRED-DEFAULT RESIDUE: not owed, although the key carried
15+
// `.default(true)`. The default was never materialized: a page parses its
16+
// component `properties` as an open bag, and this row is parsed only by the
17+
// advisory props lint, which writes nothing back — so no released toolchain
18+
// emitted `breadcrumb: true` into an artifact nobody authored.
19+
//
20+
// Registered under 18, not 17: v17.0.0 was cut before this landed, so the
21+
// removal ships on the 17.x line (launch-window convention: accept-set
22+
// narrowings ride minor releases) and the prescription lives at the major
23+
// boundary where `migrate meta` users look — the
24+
// `ui/ObjectKanbanProps:quickAdd` precedent.
25+
export const entry = 'ui/PageHeaderProps:breadcrumb';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #20758 (ADR-0049 enforce-or-remove) — the D3 entry of the
6+
// `page-header-breadcrumb-removed` family (one D3 entry per retirement family,
7+
// even when D2 is lossless). Registered key: `ui/PageHeaderProps:breadcrumb`.
8+
// The strip changes no trail, because none was ever drawn; what it leaves is
9+
// the one visible trace either value had, the empty slot's spacing.
10+
export const entry: SemanticMigration = {
11+
id: 'page-header-breadcrumb-retired',
12+
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
13+
// span AND a table cell.
14+
surface: 'page.component.page:header.breadcrumb — the page header\'s "Show breadcrumb" switch',
15+
replacement:
16+
'Nothing: delete the key, whether it was `true` or `false`. The navigation trail is drawn once, '
17+
+ 'by the app shell\'s header, and is unchanged.',
18+
reason:
19+
'The D2 conversion `page-header-breadcrumb-removed` deletes `breadcrumb` from every page header, '
20+
+ 'and no trail is lost: the renderer drew an empty slot for it and nothing ever filled that slot. '
21+
+ 'The slot was the only thing either value changed — present for `true` and for an absent key, '
22+
+ 'gone for `false` — so a header that said `false` reads as absent after the strip and shows the '
23+
+ 'empty slot\'s spacing again until the renderer stops drawing it. What the conversion cannot '
24+
+ 'decide is whether a page needs a trail of its own: inside an app the shell already draws one, '
25+
+ 'and a page outside the shell that needs one is a feature to ask for, not a key to keep.',
26+
acceptanceCriteria:
27+
'No page header carries `breadcrumb`, and the props lint reports one with the prescription. Every '
28+
+ 'page shows the same navigation trail in the app shell\'s header as before the upgrade, and each '
29+
+ 'page header shows the same title, subtitle and actions.',
30+
};

0 commit comments

Comments
 (0)