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

feat(spec)!: retire `page:header` `showStar` — the follow-star switch the reference console no longer draws (#23650)

**BREAKING** — `showStar` on a `page:header` component (`PageHeaderProps`) is now refused. Since objectui#12427 the header's record chip draws the follow star only where the host wires a favorites toggle into the record context, and the reference console wires none: the record's favorites entry is the header's ⋯ menu. So `showStar: true` (the default) drew nothing and `showStar: false` hid nothing. Delete the key; the ⋯ menu's favorites entry is unchanged, and metadata does not control it.

Clause-②: no (narrowing)

Measured before removal, at `140238a47`: no producer. No example app, skill, doc or package source in this repository authors the key outside the spec's own schema and tests, and neither does any app or example in objectui at the pinned `f96f771140f1`.

## FROM → TO

| you wrote (17.7 and earlier) | write instead |
| --- | --- |
| `showStar: true` or `showStar: false` in a `page:header`'s `properties` | nothing — delete the key; the record's favorites entry is the header's ⋯ menu |

**The one-line fix:** delete `showStar` from every `page:header`.

**What an author who still writes it sees.** `tsc` fails at a typed authoring site (`PageHeaderProps` types the key as retired), and `os validate`, `os build` and `os lint` refuse it as an `error` (`component-props-invalid`) at its own path with the prescription:

> `page:header` property `showStar` was removed in @objectstack/spec 18 (ADR-0087 D2) — the reference console draws no star beside the record title, so the key switched nothing: the record's favorites entry is the header's ⋯ menu, and metadata does not control it. Delete the key, whether it was `true` or `false`; the ⋯ menu's favorites entry is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.

A page's own parse keeps its component `properties` as an open bag, so an existing page carrying the key is never refused at load.

## The retirement kit

- **A `retiredKey()` tombstone** on `PageHeaderProps`, a `strictObject`, beside the `icon` and `breadcrumb` this row lost before it. `RETIRED_KEYS_BY_MAJOR[18]`: `ui/PageHeaderProps:showStar`. The authorable-surface row reads `[RETIRED]`, and the `showStar = true` default leaves the defaults baseline.
- **The D2 conversion `page-header-show-star-removed`** (protocol 18, retired from the load path) deletes the key from every `page:header` — region-level, nested in a container's `children`, and in a slotted page's named slot — `true` and `false` alike, one notice per header, as a lossless delete. It is scoped by component `type`: the same key name on another component is left alone.
- **The D3 entry `page-header-show-star-retired`** records the one case the conversion cannot decide: a host of your own that wires the favorites toggle now draws its star on every record header, a header that said `false` included.
- **No deprecation window**, per the project's startup-stage posture.

**Not part of it:** the record context's favorites hook in the published objectui component library (`onToggleFavorite`) stays — this removes the metadata switch, not the host hook. The renderer's read of the key leaves on the objectui side.

⚠️ **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-show-star-removed, page-header-show-star-retired -->
4 changes: 2 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1506,8 +1506,8 @@ View filter rule
| **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; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **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; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **actions** | `string[]` | optional | Action IDs the header draws, resolved against the actions declared on the page's object. An authored list is the whole set: an action that declares `record_header` (or `record_more`) in its `locations` but is not named here is not drawn, and omitting the key draws none of the object's actions. A named action that declares neither location renders nowhere. The host's own Edit / Share / Delete chrome is separate from this list. |
| **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. |
| **recordChrome** | `boolean` | optional (default: `true`) | Render the record chrome — the title as a record chip with its copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. |
| **showStar** | `never` | optional | [REMOVED] `page:header` property `showStar` was removed in @objectstack/spec 18 (ADR-0087 D2) — the reference console draws no star beside the record title, so the key switched nothing: the record's favorites entry is the header's ⋯ menu, and metadata does not control it. Delete the key, whether it was `true` or `false`; the ⋯ menu's favorites entry is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
| **showCopyId** | `boolean` | optional (default: `true`) | Show the copy-record-id button beside the record title. Part of the record chrome — no effect when `recordChrome` is false. |
| **maxVisible** | `integer` | optional | How many header actions render as inline buttons before the rest fold into the overflow menu (renderer default 3). |
| **mobileMaxVisible** | `integer` | optional | The `maxVisible` budget on mobile viewports (renderer default 1). |
Expand Down
23 changes: 22 additions & 1 deletion packages/lint/src/validate-component-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ describe('validateComponentProps — undeclared keys', () => {
}
});

// #23650 — `PageHeaderProps.showStar` is a retiredKey tombstone, the
// `breadcrumb` route one key over: the reference console draws no star, and
// the record's favorites entry is the header's ⋯ menu. It replaces the
// acceptance this file used to pin for `showStar: false` (#6776, below).
it('refuses a retired page-header `showStar` as an error carrying the prescription, for `true` and `false`', () => {
for (const showStar of [true, false]) {
const findings = validateComponentProps(
stackWith([{ type: 'page:header', properties: { title: 'T', showStar, showCopyId: false } }]),
);
expect(findings, `showStar: ${showStar}`).toHaveLength(1);
const [f] = findings;
expect(f.rule).toBe('component-props-invalid');
expect(f.severity).toBe('error');
expect(f.path).toBe('pages[0].regions[0].components[0].properties.showStar');
expect(f.message).toContain('`page:header` property `showStar` was removed in @objectstack/spec 18');
expect(f.message).toContain('the record\'s favorites entry is the header\'s ⋯ menu, and metadata does not control it');
}
});

// #21589 — an `object-master-detail-form` detail entry's `sortField` is a
// retiredKey tombstone, one array level down. The same door and, since
// #23026, the same refusal: an error that names the entry's key.
Expand Down Expand Up @@ -535,7 +554,9 @@ describe('validateComponentProps — value verdicts', () => {
// objectui `apps/console/src/preview-samples.ts:68` verbatim — the
// measured warning site, on a non-record page.
{ type: 'page:header', properties: { title: 'Welcome to the CRM', recordChrome: false } },
{ type: 'page:header', properties: { title: 'Lead', showStar: false, showCopyId: false } },
// `showStar` left this shape at 18 (#23650, a tombstone now refused
// below); its live sibling stays.
{ type: 'page:header', properties: { title: 'Lead', showCopyId: false } },
{ type: 'page:accordion', properties: { items: [], variant: 'card' } },
{ type: 'page:tabs', properties: { tabStyle: 'card', items: [] } },
]),
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/PageComponent:properties = {}",
"ui/PageHeaderProps:recordChrome = true",
"ui/PageHeaderProps:showCopyId = true",
"ui/PageHeaderProps:showStar = true",
"ui/PageTabsProps:position = \"top\"",
"ui/PageTabsProps:tabStyle = \"line\"",
"ui/PageVariable:type = \"string\"",
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 @@ -1108,7 +1108,7 @@
"ui/PageHeaderProps:mobileMaxVisible",
"ui/PageHeaderProps:recordChrome",
"ui/PageHeaderProps:showCopyId",
"ui/PageHeaderProps:showStar",
"ui/PageHeaderProps:showStar [RETIRED]",
"ui/PageHeaderProps:subtitle",
"ui/PageHeaderProps:title",
"ui/PageNavItem:badge",
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/page.json

Large diffs are not rendered by default.

133 changes: 133 additions & 0 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11031,6 +11031,138 @@ const pageHeaderBreadcrumbRemoved: MetadataConversion = {
},
};

/**
* `page:header.showStar` leaves the contract (protocol 18, #23650 — ADR-0049
* enforce-or-remove; ruling record 6203775759, letter A: 「A 退役 showStar
* (Recommended)」).
*
* The key switched a star the reference console no longer draws. Since
* objectui#12427 the `page:header` record chip draws the follow star only
* where the host wires a favorites toggle into the record context
* (`onToggleFavorite`), and no reference host does: the record's favorites
* entry is the header's ⋯ overflow menu. `true` drew nothing and `false` hid
* nothing. The tombstone on `PageHeaderProps` refuses it for a live author:
* `os validate`, `os build` and `os lint` refuse it as an `error` with the
* prescription (the component-props gate, `@objectstack/lint`; the load path
* keeps the open `properties` bag as written).
*
* **A delete of both values.** Neither changes what the reference console
* draws, so there is no value to preserve and no rewrite target. The host
* hook is not the key: a third-party host of the published component library
* that wires `onToggleFavorite` keeps its star, and after the strip a header
* that said `false` shows it there as an absent key would. The D3 entry
* `page-header-show-star-retired` records that.
*
* Who writes the key: nobody measured. No example app, skill or doc in this
* repo, and no objectui app or example, authors it; the entry is for stored
* `sys_metadata` pages and built artifacts written by hand.
*
* ⚠️ Scoped by component `type`, never by key name, exactly as
* {@link pageHeaderBreadcrumbRemoved}: `showStar` is an ordinary name for an
* open-namespace component's own prop.
*/
const pageHeaderShowStarRemoved: MetadataConversion = {
id: 'page-header-show-star-removed',
toMajor: 18,
retiredFromLoadPath: true,
retiredAfter: '17.7.0',
surface: 'page.component.page:header.showStar',
summary:
"page:header prop 'showStar' removed, whether 'true' or 'false' (the reference console draws no star "
+ 'beside the record title: the record\'s favorites entry is the header\'s ⋯ menu, and metadata does '
+ 'not control it)',
apply(stack, emit) {
return mapPageComponents(stack, (component, path) => {
if (component.type !== 'page:header') return component;
const properties = component.properties;
if (!isDict(properties) || !('showStar' in properties)) return component;
const stripped = stripKeys(properties, ['showStar'], emit, `${path}.properties`);
return { ...component, properties: stripped };
});
},
fixture: {
before: {
pages: [
{
name: 'account_record',
regions: [
{
name: 'header',
components: [
// The default value, written out.
{ type: 'page:header', properties: { title: 'Account', showStar: true } },
// The switch turned off, beside the live copy-id switch.
{ type: 'page:header', properties: { title: 'Contract', showStar: false, showCopyId: 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: 'Overview', 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:rating_badge', properties: { showStar: 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', showStar: true } }],
},
},
],
},
],
},
// The named-slot shape (#6776): a header authored into a slotted page.
{
name: 'account_record_detail',
kind: 'slotted',
regions: [],
slots: {
details: { type: 'page:header', properties: { title: 'Account', showStar: false } },
},
},
],
},
after: {
pages: [
{
name: 'account_record',
regions: [
{
name: 'header',
components: [
{ type: 'page:header', properties: { title: 'Account' } },
{ type: 'page:header', properties: { title: 'Contract', showCopyId: false } },
{ type: 'page:header', properties: { title: 'Overview', recordChrome: false } },
{ type: 'acme:rating_badge', properties: { showStar: true } },
{
type: 'page:card',
properties: {
title: 'Summary',
children: [{ type: 'page:header', properties: { title: 'Inner' } }],
},
},
],
},
],
},
{
name: 'account_record_detail',
kind: 'slotted',
regions: [],
slots: {
details: { type: 'page:header', properties: { title: 'Account' } },
},
},
],
},
// 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,
},
};

/**
* `page.requires` removed from the page kinds whose source is never compiled at
* save — `react`, `full` and `slotted`, a page that omits `kind` included
Expand Down Expand Up @@ -16670,6 +16802,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [
{ conversion: pageComponentFilterRecordToRuleArray, order: 36 },
{ conversion: pageComponentResponsiveRemoved, order: 13 },
{ conversion: pageHeaderBreadcrumbRemoved, order: 49 },
{ conversion: pageHeaderShowStarRemoved, order: 66 },
{ conversion: pageRequiresNonCompiledKindRemoved, order: 58 },
{ conversion: permissionAdminScopeBindingAuthoringRemoved, order: 65 },
{ conversion: permissionAllowRestorePurgeRemoved, order: 16 },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #23650 — ADR-0049 enforce-or-remove through the ADR-0087 D2 route (ruling
// record 6203775759, letter A). `PageHeaderProps.showStar` switched a follow
// star the reference console no longer draws: since objectui#12427 the record
// chip draws it only where the host wires `onToggleFavorite`, and no reference
// host does — the record's favorites entry is the header's ⋯ menu. Tombstoned
// with `retiredKey()` in the `strictObject`, beside the `icon` and `breadcrumb`
// this row lost before it (the surface baseline line carries `[RETIRED]`);
// stored and built pages are stripped of both values by the D2 conversion
// `page-header-show-star-removed`, whose D3 record is
// `page-header-show-star-retired`.
//
// ⭐ RETIRED-DEFAULT RESIDUE: not owed, although the key carried
// `.default(true)` — the `breadcrumb` reasoning, unchanged: a page parses its
// component `properties` as an open bag, and this row is parsed only by the
// props lint, which writes nothing back, so no released toolchain emitted
// `showStar: true` into an artifact nobody authored.
export const entry = 'ui/PageHeaderProps:showStar';
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

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

// #23650 (ADR-0049 enforce-or-remove) — the D3 entry of the
// `page-header-show-star-removed` family (one D3 entry per retirement family,
// even when D2 is lossless). Registered key: `ui/PageHeaderProps:showStar`.
// The strip changes nothing the reference console draws; what it can change
// is a third-party host that wires its own favorites toggle.
export const entry: SemanticMigration = {
id: 'page-header-show-star-retired',
// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code
// span AND a table cell.
surface: 'page.component.page:header.showStar — the follow star beside the record title',
replacement:
'Nothing: delete the key, whether it was `true` or `false`. The record\'s favorites entry is the '
+ 'header\'s ⋯ menu, and metadata does not control it.',
reason:
'The D2 conversion `page-header-show-star-removed` deletes `showStar` from every page header, and '
+ 'nothing the reference console draws changes: its record chip draws the follow star only where '
+ 'the host wires a favorites toggle into the record context, and the console wires none, so '
+ '`true` drew nothing and `false` hid nothing. What the conversion cannot decide is a host of '
+ 'your own: if it wires that favorites toggle, the star now draws on every record header, a '
+ 'header that said `false` included, and hiding it there is the host\'s call, not the page\'s.',
acceptanceCriteria:
'No page header carries `showStar`, and the props lint reports one with the prescription. Every '
+ 'record page shows the same header in the console as before the upgrade — title, copy-id '
+ 'button and actions — and the record\'s favorites entry is still in the header\'s ⋯ menu.',
};
Loading
Loading