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
37 changes: 37 additions & 0 deletions .changeset/20758-page-header-breadcrumb-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@objectstack/spec': minor
---

feat(spec)!: retire a page header's `breadcrumb` switch — no renderer ever drew a trail for it (#20758)

**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.

Clause-②: no (narrowing)

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.

## FROM → TO

| you wrote (17.5 and earlier) | write instead |
| --- | --- |
| `{ type: 'page:header', properties: { title, breadcrumb: true } }` | `{ type: 'page:header', properties: { title } }` |
| `{ type: 'page:header', properties: { title, breadcrumb: false } }` | `{ type: 'page:header', properties: { title } }` |

**The one-line fix:** delete `breadcrumb` from every `page:header`'s `properties`.

**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:

> `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.

A typed `PageHeaderProps` input fails `tsc` at the key.

## The retirement kit

- **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.
- **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.
- **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.
- **No deprecation window**, per the project's startup-stage posture.

⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for.

<!-- adr-0087: registered page-header-breadcrumb-removed, page-header-breadcrumb-retired -->
2 changes: 1 addition & 1 deletion content/docs/protocol/objectui/layout-dsl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ widgets are components in their own right — they are not children of a section
```
Page
├─ Region: header
│ └─ Component: page:header (title, actions, breadcrumb)
│ └─ Component: page:header (title, actions)
├─ Region: main
│ ├─ Component: record:details
│ │ └─ Section
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ View filter rule
| **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. |
| **subtitle** | `string \| Record<string, string>` | optional | Page subtitle |
| **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. |
| **breadcrumb** | `boolean` | optional (default: `true`) | Show breadcrumb |
| **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. |
| **actions** | `string[]` | optional | Action IDs to show in header |
| **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. |
| **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. |
Expand Down
18 changes: 17 additions & 1 deletion packages/lint/src/validate-component-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('validateComponentProps — undeclared keys', () => {
it('is silent on a fully declared props bag', () => {
const findings = validateComponentProps(
stackWith([
{ type: 'page:header', properties: { title: 'T', subtitle: 'S', breadcrumb: true } },
{ type: 'page:header', properties: { title: 'T', subtitle: 'S', recordChrome: false } },
{
type: 'record:related_list',
properties: { objectName: 'task', relationshipField: 'project_id', limit: 5 },
Expand All @@ -75,6 +75,22 @@ describe('validateComponentProps — undeclared keys', () => {
expect(findings).toEqual([]);
});

// #20758 — `PageHeaderProps.breadcrumb` is a retiredKey tombstone. A page is
// never hard-refused for carrying it: this rule is the door where an author
// meets the prescription, and every finding it files is a WARNING.
it('reports a retired page-header `breadcrumb` as a warning carrying the prescription, for `true` and `false`', () => {
for (const breadcrumb of [true, false]) {
const findings = validateComponentProps(
stackWith([{ type: 'page:header', properties: { title: 'T', breadcrumb } }]),
);
expect(findings, `breadcrumb: ${breadcrumb}`).toHaveLength(1);
const [f] = findings;
expect(f.severity).toBe('warning');
expect(f.path).toBe('pages[0].regions[0].components[0].properties.breadcrumb');
expect(f.message).toContain('`page:header` property `breadcrumb` was removed in @objectstack/spec 17');
}
});

it('walks components nested inside `properties` (tabs items → children)', () => {
const findings = validateComponentProps(
stackWith([
Expand Down
1 change: 0 additions & 1 deletion packages/spec/authorable-defaults/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@
"ui/PageAccordionProps:variant = \"flush\"",
"ui/PageCardProps:bordered = true",
"ui/PageComponent:properties = {}",
"ui/PageHeaderProps:breadcrumb = true",
"ui/PageHeaderProps:recordChrome = true",
"ui/PageHeaderProps:showCopyId = true",
"ui/PageHeaderProps:showStar = true",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/authorable-surface/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -1095,7 +1095,7 @@
"ui/PageContainerProps:children",
"ui/PageHeaderProps:actions",
"ui/PageHeaderProps:aria",
"ui/PageHeaderProps:breadcrumb",
"ui/PageHeaderProps:breadcrumb [RETIRED]",
"ui/PageHeaderProps:icon [RETIRED]",
"ui/PageHeaderProps:maxVisible",
"ui/PageHeaderProps:mobileMaxVisible",
Expand Down
139 changes: 139 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9087,6 +9087,144 @@ const objectKanbanQuickAddRemoved: MetadataConversion = {
},
};

/**
* `page:header.breadcrumb` leaves the contract (protocol 18, #20758 —
* ADR-0049 enforce-or-remove, the spec half of objectui#11166; the triage
* ruling on the card: 「没有 ⇒ 退役」).
*
* The key switched a trail that never existed. objectui's
* `PageHeaderRenderer` (`containers.tsx`) reads it and, unless it is `false`,
* draws an EMPTY `div[data-page-breadcrumb-slot]` in both header layouts;
* nothing fills the slot. The console draws the navigation trail once, in the
* shell (`@object-ui/app-shell` `AppHeader`, inside `/apps/:appName/*`), so the
* retirement takes the key away rather than building a second trail. The
* tombstone on `PageHeaderProps` refuses it for a live author (advisory, via
* the props lint: `PageComponentSchema.properties` is an open bag).
*
* **A delete of both values.** `true` and `false` go alike: neither ever drew
* a trail, so there is no value to preserve and no rewrite target. The one
* thing either value changed is the empty slot itself — present for `true`
* (and for absence), gone for `false` — which is spacing, not content, and
* leaves with the slot on the objectui half. A stored page that said `false`
* reads as absent after this strip, so it draws the empty slot again until
* that half lands; the D3 entry `page-header-breadcrumb-retired` records it.
*
* Who writes the key, so who this entry is for: objectui's Studio page-block
* inspector publishes a `page:header` "Show breadcrumb" boolean
* (`previews/block-config.ts`), so stored `sys_metadata` pages can carry
* either value. No example app, skill or doc in this repo authors it.
*
* ⚠️ Scoped by component `type`, never by key name, as
* {@link pageStructureInertKeysRemoved} scoped `icon`: `breadcrumb` is an
* ordinary word for an open-namespace component's own prop, and the kebab
* `page-header` spelling is objectui's legacy registration, not a key this
* spec declares. `nav:breadcrumb` — a component TYPE, not this key — is
* untouched: the Studio palette still offers it (`previews/block-types.ts`),
* so it has a producer and stays.
*/
const pageHeaderBreadcrumbRemoved: MetadataConversion = {
id: 'page-header-breadcrumb-removed',
toMajor: 18,
retiredFromLoadPath: true,
retiredAfter: '17.5.0',
surface: 'page.component.page:header.breadcrumb',
summary:
"page:header prop 'breadcrumb' removed, whether 'true' or 'false' (no renderer ever drew a trail for "
+ 'it: objectui drew an empty slot and nothing filled it, and the app shell\'s header draws the '
+ 'navigation trail)',
apply(stack, emit) {
return mapPageComponents(stack, (component, path) => {
if (component.type !== 'page:header') return component;
const properties = component.properties;
if (!isDict(properties) || !('breadcrumb' in properties)) return component;
const stripped = stripKeys(properties, ['breadcrumb'], emit, `${path}.properties`);
return { ...component, properties: stripped };
});
},
fixture: {
before: {
pages: [
{
name: 'lead_record',
regions: [
{
name: 'header',
components: [
// The default value, written out: the Studio inspector's
// "Show breadcrumb" switch left on.
{ type: 'page:header', properties: { title: 'Lead', breadcrumb: true } },
// The switch turned off, on a non-record header.
{ type: 'page:header', properties: { title: 'Pipeline', recordChrome: false, breadcrumb: false } },
// A header WITHOUT the key rides through untouched — the strip
// dispatches on key presence, and copy-on-write keeps the reference.
{ type: 'page:header', properties: { title: 'Settings', recordChrome: false } },
// ⚠️ The same key name on an open-namespace component that is
// NOT a page header — its own prop, not this entry's key.
{ type: 'acme:trail_banner', properties: { breadcrumb: true } },
// The nested position (#6775): a header inside a card's
// `children` is still a page header.
{
type: 'page:card',
properties: {
title: 'Summary',
children: [{ type: 'page:header', properties: { title: 'Inner', breadcrumb: true } }],
},
},
],
},
],
},
// The named-slot shape (#6776): a header authored into a slotted page.
{
name: 'lead_record_detail',
kind: 'slotted',
regions: [],
slots: {
details: { type: 'page:header', properties: { title: 'Lead', breadcrumb: false } },
},
},
],
},
after: {
pages: [
{
name: 'lead_record',
regions: [
{
name: 'header',
components: [
{ type: 'page:header', properties: { title: 'Lead' } },
{ type: 'page:header', properties: { title: 'Pipeline', recordChrome: false } },
{ type: 'page:header', properties: { title: 'Settings', recordChrome: false } },
{ type: 'acme:trail_banner', properties: { breadcrumb: true } },
{
type: 'page:card',
properties: {
title: 'Summary',
children: [{ type: 'page:header', properties: { title: 'Inner' } }],
},
},
],
},
],
},
{
name: 'lead_record_detail',
kind: 'slotted',
regions: [],
slots: {
details: { type: 'page:header', properties: { title: 'Lead' } },
},
},
],
},
// Four notices, one per stripped key: the two region-level headers, the
// nested one and the slotted one. The header without the key and the
// open-namespace component emit none.
expectedNotices: 4,
},
};

/**
* Object-permission lifecycle bits `allowRestore` / `allowPurge` removed
* (protocol 18, #12497 — ADR-0049 enforce-or-remove, maintainer ruling
Expand Down Expand Up @@ -12854,6 +12992,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
{ conversion: pageAssignedProfilesRemoved, order: 31 },
{ conversion: pageComponentFilterRecordToRuleArray, order: 36 },
{ conversion: pageComponentResponsiveRemoved, order: 13 },
{ conversion: pageHeaderBreadcrumbRemoved, order: 49 },
{ conversion: permissionAllowRestorePurgeRemoved, order: 16 },
{ conversion: permissionRlsTagsRemoved, order: 42 },
{ conversion: recordChatterPositionVocabulary, order: 2 },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #20758 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route, the spec
// half of objectui#11166 (triage ruling on the card: RETIRE, no consumer).
// `PageHeaderProps.breadcrumb` switched a trail that never existed: objectui's
// `PageHeaderRenderer` draws an EMPTY `div[data-page-breadcrumb-slot]` unless
// the key is `false`, nothing fills it, and the console draws the navigation
// trail once, in the shell (`AppHeader`). Tombstoned with `retiredKey()` in the
// `strictObject`, beside the `icon` this row lost at 17 (the surface baseline
// line carries `[RETIRED]`); stored and built pages are stripped of both values
// by the D2 conversion `page-header-breadcrumb-removed`, whose D3 record is
// `page-header-breadcrumb-retired`.
//
// ⭐ RETIRED-DEFAULT RESIDUE: not owed, although the key carried
// `.default(true)`. The default was never materialized: a page parses its
// component `properties` as an open bag, and this row is parsed only by the
// advisory props lint, which writes nothing back — so no released toolchain
// emitted `breadcrumb: true` into an artifact nobody authored.
//
// Registered under 18, not 17: v17.0.0 was cut before this landed, so the
// removal ships on the 17.x line (launch-window convention: accept-set
// narrowings ride minor releases) and the prescription lives at the major
// boundary where `migrate meta` users look — the
// `ui/ObjectKanbanProps:quickAdd` precedent.
export const entry = 'ui/PageHeaderProps:breadcrumb';
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #20758 (ADR-0049 enforce-or-remove) — the D3 entry of the
// `page-header-breadcrumb-removed` family (one D3 entry per retirement family,
// even when D2 is lossless). Registered key: `ui/PageHeaderProps:breadcrumb`.
// The strip changes no trail, because none was ever drawn; what it leaves is
// the one visible trace either value had, the empty slot's spacing.
export const entry: SemanticMigration = {
id: 'page-header-breadcrumb-retired',
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
// span AND a table cell.
surface: 'page.component.page:header.breadcrumb — the page header\'s "Show breadcrumb" switch',
replacement:
'Nothing: delete the key, whether it was `true` or `false`. The navigation trail is drawn once, '
+ 'by the app shell\'s header, and is unchanged.',
reason:
'The D2 conversion `page-header-breadcrumb-removed` deletes `breadcrumb` from every page header, '
+ 'and no trail is lost: the renderer drew an empty slot for it and nothing ever filled that slot. '
+ 'The slot was the only thing either value changed — present for `true` and for an absent key, '
+ 'gone for `false` — so a header that said `false` reads as absent after the strip and shows the '
+ 'empty slot\'s spacing again until the renderer stops drawing it. What the conversion cannot '
+ 'decide is whether a page needs a trail of its own: inside an app the shell already draws one, '
+ 'and a page outside the shell that needs one is a feature to ask for, not a key to keep.',
acceptanceCriteria:
'No page header carries `breadcrumb`, and the props lint reports one with the prescription. Every '
+ 'page shows the same navigation trail in the app shell\'s header as before the upgrade, and each '
+ 'page header shows the same title, subtitle and actions.',
};
Loading
Loading