diff --git a/.changeset/20758-page-header-breadcrumb-retired.md b/.changeset/20758-page-header-breadcrumb-retired.md new file mode 100644 index 00000000000..472260f57e7 --- /dev/null +++ b/.changeset/20758-page-header-breadcrumb-retired.md @@ -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. + + diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 54467ed7888..77db8df73e3 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -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 diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 825a9343ef4..d3645e62d3a 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1186,7 +1186,7 @@ View filter rule | **title** | `string \| Record` | 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` | 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. | diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 96035d4f925..39d5fe04a52 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -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 }, @@ -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([ diff --git a/packages/spec/authorable-defaults/ui.json b/packages/spec/authorable-defaults/ui.json index e8c2be98578..6edaf610e42 100644 --- a/packages/spec/authorable-defaults/ui.json +++ b/packages/spec/authorable-defaults/ui.json @@ -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", diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index f034e256898..7cde538aa43 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -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", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 25b45aad6e8..bd643526ba8 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -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 @@ -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 }, diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__PageHeaderProps__breadcrumb.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__PageHeaderProps__breadcrumb.ts new file mode 100644 index 00000000000..553e2574b5e --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__PageHeaderProps__breadcrumb.ts @@ -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'; diff --git a/packages/spec/src/migrations/entries/semantic/18.page-header-breadcrumb-retired.ts b/packages/spec/src/migrations/entries/semantic/18.page-header-breadcrumb-retired.ts new file mode 100644 index 00000000000..1021715149f --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-header-breadcrumb-retired.ts @@ -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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 52cdeccd401..c2ea59f1c52 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5699,6 +5699,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'objectui really compiles. The mechanical conversion strips the key from stored pages ' + '(pure lossless delete — it never had an effect to lose).', }, + { + id: 'page-header-breadcrumb-retired', + order: 51, + text: + 'It also retires the page header\'s `breadcrumb` switch (ADR-0049 enforce-or-remove): no ' + + 'renderer ever drew a trail for it — objectui drew an empty slot that nothing filled — and ' + + 'the navigation trail is drawn once, by the app shell\'s header. The key is a retiredKey ' + + 'tombstone on `PageHeaderProps`, beside the `icon` that row lost at 17, and the D2 ' + + 'conversion `page-header-breadcrumb-removed` strips it from every `page:header`, `true` and ' + + '`false` alike, retired from the load path. Its D3 record is the semantic entry ' + + '`page-header-breadcrumb-retired`. The `nav:breadcrumb` component type is not part of it: ' + + 'the Studio page palette still offers it.', + }, { id: 'permission-restore-purge-bits-retired', order: 18, @@ -14191,6 +14204,32 @@ const step18: MigrationStep = { + 'shows it — a component declared hidden on the narrowest breakpoint is absent there and ' + 'present above it.', }, + // #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. + { + 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.', + }, // #12497 (ADR-0049, maintainer ruling accepting #1883's recommendation B) — the // D3 entry of the `permission-allow-restore-purge-removed` family (ruling B on // #17152: one D3 entry per retirement family, even when D2 is lossless). The @@ -22636,6 +22675,29 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // boundary where `migrate meta` users look (the precedent of commit 4bfe1a539, PR #8666). // Sources are rewritten by the D2 conversion `page-component-responsive-removed`. 'ui/PageComponent:responsive', + // #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. + 'ui/PageHeaderProps:breadcrumb', // #10054 — ADR-0049 enforce-or-remove (maintainer ruling 2026-08-21, executing // the 2026-08-20 census verdict). `icon` on the object arm of // `RecordHighlightsField` was declared, described (`Icon name (lucide icon diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index ff13a928856..f38983aaa79 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -36,7 +36,6 @@ describe('PageHeaderProps', () => { it('should accept minimal header', () => { const result = PageHeaderProps.parse({ title: 'My Page' }); expect(result.title).toBe('My Page'); - expect(result.breadcrumb).toBe(true); expect(result.subtitle).toBeUndefined(); expect(result.actions).toBeUndefined(); }); @@ -45,11 +44,11 @@ describe('PageHeaderProps', () => { const header = { title: 'Dashboard', subtitle: 'Overview', - breadcrumb: false, + recordChrome: false, actions: ['action-1', 'action-2'], }; const result = PageHeaderProps.parse(header); - expect(result.breadcrumb).toBe(false); + expect(result.recordChrome).toBe(false); expect(result.actions).toHaveLength(2); }); @@ -69,7 +68,7 @@ describe('PageHeaderProps', () => { it('accepts a completely empty header — every field optional or defaulted', () => { const result = PageHeaderProps.parse({}); expect(result.title).toBeUndefined(); - expect(result.breadcrumb).toBe(true); + expect(result.recordChrome).toBe(true); }); it('still validates a present title as an I18nLabel', () => { diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 84cc4a58b95..a83e7a54e18 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -515,7 +515,30 @@ export const PageHeaderProps = strictObject({ + 'carries its own `icon`. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.', ), - breadcrumb: z.boolean().default(true).describe('Show breadcrumb'), + /** + * REMOVED (#20758, ADR-0049 enforce-or-remove through the ADR-0087 D2 route, + * the way `icon` above left this row; the spec half of objectui#11166). + * + * A trail switch with no trail behind it. `PageHeaderRenderer` + * (`containers.tsx`) reads the key and, when it is not `false`, draws an + * EMPTY `div[data-page-breadcrumb-slot]` in both layouts; nothing fills it. + * The console's navigation trail is drawn once, by the shell (`AppHeader` + * inside `/apps/:appName/*`), so a page-level renderer would duplicate it + * rather than supply something missing. The default `true` was never + * materialized into a built artifact: `PageComponentSchema.properties` is an + * open bag, and this row is parsed only by the advisory props lint, which + * writes nothing back — so no retired-default residue stage is owed. + * + * Stored and built pages that carry the key (`true` or `false`) are stripped + * by the D2 conversion `page-header-breadcrumb-removed`, with a notice. + */ + breadcrumb: retiredKey( + '`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: z.array(z.string()).optional().describe('Action IDs to show in header'), /** * Which of the two page-header layouts the renderer builds (#6776). diff --git a/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts b/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts new file mode 100644 index 00000000000..d6f640c172a --- /dev/null +++ b/packages/spec/src/ui/page-header-breadcrumb-retirement.test.ts @@ -0,0 +1,433 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A page header's `breadcrumb` switch RETIRED (#20758) — ADR-0049 + * enforce-or-remove through the ADR-0087 D2 route, the spec half of + * objectui#11166, the way `icon` left the same row at 17. + * + * Measured before removal: objectui's `PageHeaderRenderer` reads the key and + * draws an EMPTY `div[data-page-breadcrumb-slot]` unless it is `false`; nothing + * fills the slot, and the console draws the navigation trail once, in the + * shell's `AppHeader`. The one producer of the key is objectui's Studio + * page-block inspector ("Show breadcrumb", `previews/block-config.ts`), so + * stored pages can carry either value — which is what the D2 strip is for. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on `PageHeaderProps`, a `strictObject` — the + * refusal carries the prescription, and the key's input type is `never`. + * `PageComponentSchema.properties` is an open bag, so the props schema is + * reached by the advisory props lint and never by the page parse: an + * existing page is never hard-refused. + * 2. D2 conversion `page-header-breadcrumb-removed` (step 18), a strip of + * both values from every `page:header`, retired from the load path: a + * stored or built page replays clean, with a notice. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries `ui/PageHeaderProps:breadcrumb`, and + * the family's D3 entry is `page-header-breadcrumb-retired`. + * 4. `nav:breadcrumb` — a component TYPE, not this key — stays: objectui's + * Studio page palette offers it, so it has a producer. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` — no HTTP door parses this row + * (the page write door parses `properties` as an open bag). So each refusal is + * pinned by the issue `code`, the `path` naming the key, and the prescription. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ComponentPropsMap, PageHeaderProps } from './component.zod'; +import { PageComponentType, PageSchema } from './page.zod'; + +const CONVERSION_ID = 'page-header-breadcrumb-removed'; +const D3_ID = 'page-header-breadcrumb-retired'; +const REGISTERED_KEY = 'ui/PageHeaderProps:breadcrumb'; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself. +const PRESCRIPTION = + /`page:header` property `breadcrumb` was removed in @objectstack\/spec 17 \(ADR-0087 D2\) — no renderer ever drew a trail for it.*Delete the key, whether it was `true` or `false`.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand\./s; + +/** A page header's live keys — what an author commonly writes, not the retired one. */ +const HEADER = { title: 'Lead', subtitle: '{company}', actions: ['convert_lead'] } as const; + +/** A stored `page` row whose header carries `breadcrumb` — the Studio inspector's output. */ +const storedPage = (breadcrumb: boolean) => ({ + name: 'lead_record', + label: 'Lead', + type: 'record', + object: 'lead', + regions: [ + { + name: 'header', + components: [{ type: 'page:header', properties: { ...HEADER, breadcrumb } }], + }, + ], +}); + +type Notice = Pick; +const brief = (n: ConversionNotice): Notice => ({ + conversionId: n.conversionId, + path: n.path, + from: n.from, + to: n.to, +}); + +describe('page:header breadcrumb retirement — the tombstone', () => { + it('refuses `true` AND `false` at the key, with the prescription', () => { + for (const value of [true, false]) { + const r = PageHeaderProps.safeParse({ ...HEADER, breadcrumb: value }); + expect(r.success, `breadcrumb: ${value}`).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual(['breadcrumb']); + expect(issue.message).toMatch(PRESCRIPTION); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith('`page:header` property `breadcrumb` was removed')).toBe(true); + } + }); + + it('the row the props lint dispatches on is the same schema, so it refuses it too', () => { + // `validateComponentProps` (packages/lint) reads `ComponentPropsMap[type]`; + // a rebinding to some other shape would pass the pin above and still accept + // the key where an author meets it. + expect(ComponentPropsMap['page:header']).toBe(PageHeaderProps); + expect(() => ComponentPropsMap['page:header'].parse({ breadcrumb: true })).toThrow(PRESCRIPTION); + }); + + it('refuses the key by the TOMBSTONE, not by the strict unknown-key arm — the two are different answers', () => { + const retired = PageHeaderProps.safeParse({ ...HEADER, breadcrumb: true }); + expect(retired.success).toBe(false); + expect((retired.error?.issues ?? []).map((i) => i.code)).not.toContain('unrecognized_keys'); + // CONTROL: an undeclared sibling on the same header comes back as + // `unrecognized_keys`. Without this pair, a shape that had simply DROPPED + // the key would pass the pin above on the strict arm's generic message. + const undeclared = PageHeaderProps.safeParse({ ...HEADER, bogusProp: true }); + expect(undeclared.success).toBe(false); + const issue = undeclared.error?.issues.find((i) => i.code === 'unrecognized_keys') as + | { keys?: string[] } + | undefined; + expect(issue?.keys).toEqual(['bogusProp']); + }); + + it('CONTROL: a header without the key parses, grows no `breadcrumb`, and keeps its live defaults', () => { + const r = PageHeaderProps.safeParse(HEADER); + expect(r.success).toBe(true); + if (!r.success) return; + // Absence stays absence: the retired `default(true)` materializes nothing. + expect(r.data).not.toHaveProperty('breadcrumb'); + // The live default still applies, so the empty reading above is the + // retirement and not a schema that stopped emitting defaults. + expect(r.data.recordChrome).toBe(true); + expect(r.data.actions).toEqual(HEADER.actions); + }); + + it('the walked shape keeps `breadcrumb` as a key — the authorable-surface row stays reachable', () => { + const shape = (PageHeaderProps as unknown as { shape?: Record }).shape; + expect(shape, 'PageHeaderProps must expose its shape').toBeDefined(); + expect(Object.keys(shape!)).toContain('breadcrumb'); + expect(Object.keys(shape!), 'CONTROL: its live neighbour').toContain('recordChrome'); + }); + + it('fails tsc at the authoring site: the input type of `breadcrumb` is `never`', () => { + const header: z.input = { + ...HEADER, + // @ts-expect-error — `breadcrumb` is a retiredKey() tombstone: its input type is `never`. + breadcrumb: true, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => PageHeaderProps.parse(header)).toThrow(PRESCRIPTION); + }); + + it('never hard-refuses an existing page: the page parse still accepts a header carrying the key', () => { + // `PageComponentSchema.properties` is an open bag — the page door does not + // dispatch on the component type. Refusing an existing page is exactly + // what the ruling forbids; the tombstone speaks through the props lint. + for (const value of [true, false]) { + const r = PageSchema.safeParse(storedPage(value)); + expect(r.success, `breadcrumb: ${value} — ${JSON.stringify(r.error?.issues ?? [])}`).toBe(true); + } + }); +}); + +describe('page:header breadcrumb retirement — the D2 conversion (the card\'s pins)', () => { + it('a STORED page whose header says `true`, and one that says `false`, both load with the key stripped and the notice recorded', () => { + for (const value of [true, false]) { + const notices: ConversionNotice[] = []; + const row = storedPage(value); + const rehydrated = applyConversionsToStoredItem('page', row, { + onNotice: (n) => notices.push(n), + }) as ReturnType; + + expect(notices.map(brief), `breadcrumb: ${value}`).toEqual([ + { + conversionId: CONVERSION_ID, + path: 'pages[0].regions[0].components[0].properties.breadcrumb', + from: 'breadcrumb', + to: '(removed)', + }, + ]); + const properties = rehydrated.regions[0]!.components[0]!.properties; + expect(properties).not.toHaveProperty('breadcrumb'); + // Every live key on the same header survives byte-for-byte. + expect(properties).toEqual(HEADER); + // The rehydrated header is exactly what the props row accepts now, and + // the page still parses. + expect(PageHeaderProps.safeParse(properties).success).toBe(true); + expect(PageSchema.safeParse(rehydrated).success).toBe(true); + } + }); + + it('a BUILT artifact replays the same strip — every position a header can sit in', () => { + // What the artifact-ingestion door replays (the full chain, retired + // entries included). Region-level, nested inside a card's `children`, and + // in a slotted page's named slot. + const artifact = { + pages: [ + { + name: 'lead_record', + regions: [ + { + name: 'header', + components: [ + { type: 'page:header', properties: { title: 'Lead', breadcrumb: true } }, + { + type: 'page:card', + properties: { children: [{ type: 'page:header', properties: { title: 'Inner', breadcrumb: false } }] }, + }, + ], + }, + ], + }, + { + name: 'lead_detail', + kind: 'slotted', + regions: [], + slots: { details: { type: 'page:header', properties: { title: 'Lead', breadcrumb: true } } }, + }, + ], + }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID).map((n) => n.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.breadcrumb', + 'pages[0].regions[0].components[1].properties.children[0].properties.breadcrumb', + 'pages[1].slots.details.properties.breadcrumb', + ]); + expect(JSON.stringify(stack)).not.toContain('"breadcrumb"'); + }); + + it('CONTROL: a header without the key is unchanged — no notice, and the row comes back by reference', () => { + const row = { + name: 'lead_record', + regions: [{ name: 'header', components: [{ type: 'page:header', properties: { ...HEADER } }] }], + }; + const notices: ConversionNotice[] = []; + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(rehydrated).toBe(row); + expect(rehydrated).toEqual({ + name: 'lead_record', + regions: [{ name: 'header', components: [{ type: 'page:header', properties: HEADER }] }], + }); + }); + + it('is scoped by component TYPE: the same key name on another component is not this entry\'s', () => { + const artifact = { + pages: [ + { + name: 'p', + regions: [{ name: 'main', components: [{ type: 'acme:trail_banner', properties: { breadcrumb: true } }] }], + }, + ], + }; + const { stack, notices } = collectConversionNotices(artifact, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toBe(artifact); + }); + + it('is idempotent by construction: a second replay converts nothing', () => { + const { stack } = collectConversionNotices({ pages: [storedPage(true)] }, { includeRetired: true }); + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — the authoring funnel does not rewrite a live source', () => { + const input = { pages: [storedPage(true)] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('page:header breadcrumb retirement — the ADR-0087 ledger row', () => { + it('declares ONE key under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(REGISTERED_KEY); + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => k.startsWith('ui/PageHeaderProps:'))).toEqual([ + 'ui/PageHeaderProps:icon', + REGISTERED_KEY, + ]); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('page.component.page:header.breadcrumb'); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.replacement).toContain('delete the key'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); + + it('`nav:breadcrumb` is NOT part of it — the component type keeps its vocabulary member and its row', () => { + // objectui's Studio page palette offers `nav:breadcrumb` + // (`previews/block-types.ts`), so the type has a producer and stays. + expect(PageComponentType.safeParse('nav:breadcrumb').success).toBe(true); + expect(() => ComponentPropsMap['nav:breadcrumb'].parse({})).not.toThrow(); + }); +}); + +// ─── Tree-scoped absence, inside the radius the package already declares ─── +// +// `tsc` sweeps only TYPED authoring sites, and a page component's `properties` +// is an open bag, so `tsc` does not reach a header authored through +// `definePage`/`defineStack` at all. This walk covers every text file under the +// five repo roots `scripts/cross-package-test-inputs.mjs` declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees. +// +// The matcher judges the AUTHORING SHAPE, never a mention: `breadcrumb` in key +// position with a boolean value (TS / JS / JSON, and YAML). React props +// (`breadcrumb={…}`) and the shell's trail vocabulary never take that shape. +// Prose mentions are spelled in inline code in this repo, and inline code is +// stripped before judging. The bound, stated: a value that is not a boolean +// literal, a shorthand property, and `docs/**`, `.claude/**`, `.github/**` and +// the repo-root files are outside what this walk sees. A future schema that +// declares a boolean `breadcrumb` of its own would trip this walk: narrow the +// matcher to page headers then, never exclude the new file. +describe('tree-scoped absence: nothing inside the declared radius still authors a page-header breadcrumb', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const AUTHORING = /(^|[^\w.$])["']?breadcrumb["']?[ \t]*:[ \t]*(true|false)\b/m; + + /** Inline code spans are prose; newline-bounded, so a fenced example is still judged. */ + const stripInlineCode = (text: string): string => text.replace(/`[^`\n]*`/g, ''); + const judge = (text: string): RegExpExecArray | null => AUTHORING.exec(stripInlineCode(text)); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell the retired key. + */ + const EXCLUDED = new Set([ + // This pin authors the key to assert its refusal and its conversion. + THIS_FILE, + // The props-lint pin authors the key to assert the advisory warning an + // author meets at `os validate` / `os build` / `os lint`. + 'packages/lint/src/validate-component-props.test.ts', + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement header on purpose. + 'packages/spec/src/conversions/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `component.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a prose mention and a React prop (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("{ type: 'page:header', properties: { title: 'T', breadcrumb: true } }")).not.toBeNull(); + expect(judge(" properties: {\n breadcrumb: false,\n },")).not.toBeNull(); + expect(judge('{ "type": "page:header", "properties": { "breadcrumb": true } }')).not.toBeNull(); + expect(judge(' properties:\n breadcrumb: true\n')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefinePage({ regions: [{ components: [{ properties: { breadcrumb: true } }] }] });\n```\n")).not.toBeNull(); + // Neighbours that must NOT match. + expect(judge('a header that said `breadcrumb: false` reads as absent')).toBeNull(); + expect(judge('"ui/PageHeaderProps:breadcrumb [RETIRED]",')).toBeNull(); + expect(judge('breadcrumb: retiredKey(PRESCRIPTION),')).toBeNull(); + expect(judge('')).toBeNull(); + expect(judge("showBreadcrumbs: z.boolean().default(true),\n showBreadcrumbs: true,")).toBeNull(); + expect(judge("'nav:breadcrumb': emptyProps('nav:breadcrumb'),")).toBeNull(); + }); + + it('no page-header breadcrumb authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined) continue; + const m = judge(text); + if (m) offenders.push(`${rel} authors \`${m[0].trim().replace(/\s+/g, ' ')}\``); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree and the example apps' sources. + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(offenders, 'a page-header breadcrumb authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index ab81a516f2c..158c15b2450 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -42,6 +42,7 @@ "src/system/constants/platform-object-names.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", + "src/ui/page-header-breadcrumb-retirement.test.ts", "src/ui/view-item-owner-hidden-retirement.test.ts", "src/ui/view-list-tabs-retirement.test.ts" ] diff --git a/skills/objectstack-ui/rules/pages.md b/skills/objectstack-ui/rules/pages.md index 37097652675..d09fa4c8383 100644 --- a/skills/objectstack-ui/rules/pages.md +++ b/skills/objectstack-ui/rules/pages.md @@ -47,7 +47,7 @@ which contain components. | `type` | Use | |:---------------------|:----| -| `page:header` | Title + subtitle + breadcrumb + `actions: string[]` (action ids) | +| `page:header` | Title + subtitle + `actions: string[]` (action ids) | | `page:card` | Bordered/un-bordered card with `children: Component[]` (plus an optional `footer: Component[]` slot) | | `flex` | Generic styleable box (`properties.children`) — the workhorse for custom layout; style via `responsiveStyles` (see Styling below) | | `element:text` | Text node — `properties.content`; style via `responsiveStyles` | @@ -86,7 +86,6 @@ export const LeadDetailPage = definePage({ properties: { title: '{first_name} {last_name}', subtitle: '{company}', - breadcrumb: true, actions: ['convert_lead'], // ids of `lead`'s actions }, },