diff --git a/.changeset/12627-page-header-show-star-retired.md b/.changeset/12627-page-header-show-star-retired.md new file mode 100644 index 0000000000..b798ccf8db --- /dev/null +++ b/.changeset/12627-page-header-show-star-retired.md @@ -0,0 +1,9 @@ +--- +'@object-ui/components': minor +--- + +`page:header` no longer reads `showStar`, ahead of `@objectstack/spec` 18 retiring the key (objectstack#23650). + +- **The favorites star follows the host toggle alone.** The star beside the record title draws exactly where the host's record context carries `onToggleFavorite`, and metadata does not control it. An authored `showStar`, on the node or under `properties`, changes nothing. Before, `showStar: false` hid the star even where the host wired a toggle. `RecordContextValue` is unchanged: `isFavorite` and `onToggleFavorite` stay the way a host opts in. +- **The `page:header` registration no longer publishes a `showStar` input**, so the designer panel and the generated manifest stop offering it. +- **`RecordTitleChip` drops its `showStar` prop (breaking for direct users).** The chip draws its star exactly when `onToggleFavorite` is passed. A caller that passed `showStar={false}` should stop passing `onToggleFavorite` instead; a caller that relied on the star drawing without a toggle gets no star. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 8a93caa838..648a6d0777 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1039,7 +1039,10 @@ const UNJUDGED_SPEC_BLOCKS: Record = { * - **objectstack#6776** — `page:header` now declares `recordChrome`, * `showStar` and `showCopyId`; `page:accordion` declares `variant`; * `page:tabs` declares `tabStyle`. The five keys the renderer had read all - * along are contract now. `page:tabs` is the interesting one: the spec + * along are contract now. `showStar` has since left again: the spec + * retires it (objectstack#23650, landed as objectstack#23781), so the + * renderer stopped reading it and the registration stopped publishing it + * (objectui#12627). Its `settledUpstream` row below left with the input. `page:tabs` is the interesting one: the spec * declares BOTH `tabStyle` and `type`, so the carrier collision written up in * the deleted entry was resolved upstream by declaring the alias rather than * by renaming — which is why `page:tabs.type` stays in @@ -1344,6 +1347,24 @@ const UNPUBLISHED_EXEMPTIONS: Record = { 'object-grid.title': '@deprecated in ObjectGridSchema ("Use label instead"); GA describes it as the "Fallback for `label` (the renderer reads `label || title`)". Read as back-compat, deliberately not published — the canonical `label` IS declared. Same ruled carve-out class as the five the ruling enumerated, measured on this branch — objectui#4648 (maintainer 2026-08-16).', + // ── `page:header.showStar`: retired upstream, bump not installed yet ─────── + // objectstack#23650's ruling retires the metadata switch, and it landed as + // objectstack#23781: a `retiredKey()` tombstone on `PageHeaderProps`. The + // installed `@objectstack/spec` predates that commit and still lists the key + // as live, so the reverse direction demands it. objectui#12627 stopped the + // renderer reading it and the registration publishing it AHEAD of the bump: + // the favorites star follows the host's `onToggleFavorite` alone. + // + // ⚠️ THIS ENTRY DIES ON THE BUMP, by design, and nothing has to remember it. + // The first installed spec that carries objectstack#23781 tombstones the + // key, `specTopLevelKeys` subtracts it, and `every unpublished-key exemption + // names a key the spec really declares` and `carries no stale unpublished-key + // exemption` both name this entry. Deleting it is the whole fix. ⛔ Do not + // resolve that red by re-declaring the input or by listing this key in + // `GA_PENDING_UNPUBLISHED_KEYS`: the first publishes a key the contract + // refuses, the second would hide this entry from both checks. + 'page:header.showStar': + 'Retired upstream by objectstack#23650 (landed as objectstack#23781), which the installed spec predates. The renderer no longer reads it and the registration no longer publishes it (objectui#12627): the favorites star draws where the host wires onToggleFavorite, and metadata does not control it. Goes dangling and stale on the bump that installs the tombstone; delete it there.', // ── objectui#8176: the two lazily-registered blocks, judged for the first ── // time. ONE key left of the eighteen that entered, and as of objectui#8201 @@ -5784,7 +5805,9 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide) // exemption-shaped hole. const settledUpstream: Array<[string, string]> = [ ['page:header', 'recordChrome'], - ['page:header', 'showStar'], + // `['page:header', 'showStar']` left with the input (objectui#12627): + // the spec retires the key (objectstack#23650), so neither half of + // "declared AND published" holds for it any more. ['page:header', 'showCopyId'], ['page:accordion', 'variant'], ['page:tabs', 'tabStyle'], diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 39f697afc5..ccec1b615f 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -370,7 +370,6 @@ does not render. subtitle?: string | Record, // secondary line; same interpolation actions?: string[], // action ids, resolved against the object's own actions recordChrome?: boolean, // default true — the record chip on a record page - showStar?: boolean, // default true; drawn only where the host wires a favorites toggle showCopyId?: boolean, // default true maxVisible?: number, // positive integer; inline action buttons (renderer default 3) mobileMaxVisible?: number, // positive integer; the same on mobile (renderer default 1) @@ -379,6 +378,8 @@ does not render. } ``` +The favorites star beside the record title draws where the host wires a favorites toggle, and metadata does not control it. + > **Write `subtitle`; there is no `description` and no `icon`.** The contract refuses > `description` on this node and names `subtitle` in its place. It refuses `icon` by name as > an ADR-0087 D2 tombstone — "`page:header` property `icon` was removed in diff --git a/content/docs/layout/page-header.mdx b/content/docs/layout/page-header.mdx index 3aea91ac53..76101d4ed1 100644 --- a/content/docs/layout/page-header.mdx +++ b/content/docs/layout/page-header.mdx @@ -46,7 +46,6 @@ absent. | `subtitle` | string, or an inline locale map | — | The secondary line under the title. Same interpolation as `title`. | | `actions` | array of action ids | — | Names of actions declared on the object's own metadata, drawn as the header's button row. | | `recordChrome` | boolean | `true` (*spec*) | On a record page, renders the title as the record chip. `false` keeps the bare heading. | -| `showStar` | boolean | `true` (*spec*) | The favorite star beside the record title. Part of the record chip, and drawn only where the host wires a favorites toggle (the record context carries `onToggleFavorite`); `false` hides it even then. | | `showCopyId` | boolean | `true` (*spec*) | The copy-record-id button beside the record title. Part of the record chip. | | `maxVisible` | positive integer | `3` (*renderer*) | How many header actions render as inline buttons before the rest fold into the `⋯` menu. | | `mobileMaxVisible` | positive integer | `1` (*renderer*) | The same budget on a mobile viewport. | @@ -60,6 +59,8 @@ no `

` at all. On a record page, omit `title` and the heading is derived from There is no `description` key on this node: the contract refuses it and names `subtitle`. +The favorites star beside the record title draws where the host wires a favorites toggle, and metadata does not control it. + **`actions`** holds action ids, not nodes and not inline definitions. Each id is resolved against the object bound to the page, the same lookup `record:quick_actions` performs, so the definition lives once on the object. A header that is bound to no object resolves no ids: it diff --git a/packages/components/src/__tests__/page-header-star-host-toggle-12427.test.tsx b/packages/components/src/__tests__/page-header-star-host-toggle-12427.test.tsx index 86b3e40b94..c4066b1253 100644 --- a/packages/components/src/__tests__/page-header-star-host-toggle-12427.test.tsx +++ b/packages/components/src/__tests__/page-header-star-host-toggle-12427.test.tsx @@ -13,8 +13,8 @@ * The star is a control that stores something, so it draws only when the * `RecordContext` carries `onToggleFavorite`. Without one, the chip used to * draw a star that flipped on its own local state and stored nothing; it now - * draws none. The authored `showStar: false` still hides it when a host does - * wire a toggle. + * draws none. Metadata does not control it: an authored `showStar` changes + * nothing, because `@objectstack/spec` 18 retires the key (objectui#12627). * * Pinned through the real `SchemaRenderer` and registry, on the record-chip * layout `page:header` draws inside a `RecordContextProvider` (the harness of @@ -28,6 +28,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { render, cleanup, fireEvent } from '@testing-library/react'; import { ActionProvider, RecordContextProvider, SchemaRenderer } from '@object-ui/react'; import { I18nProvider } from '@object-ui/i18n'; +import { ComponentRegistry } from '@object-ui/core'; // Registers every renderer at module scope, not in a hook // (object-ui/no-dynamic-import-in-test-hook, objectui#3010). import '../renderers'; @@ -114,11 +115,42 @@ describe('page:header record chrome: the favorite star draws only where the host ]); }); - it('the authored showStar: false hides the star even when the host wires a toggle', () => { - const { header } = renderHeader( - { isFavorite: false, onToggleFavorite: () => {} }, - { type: 'page:header', properties: { showStar: false } }, - ); - expect(stars(header)).toHaveLength(0); +}); + +describe('page:header: an authored showStar changes nothing — the host toggle is the one switch (objectui#12627)', () => { + // `@objectstack/spec` 18 retires `PageHeaderProps.showStar` + // (objectstack#23650). The renderer stopped reading it ahead of that bump, so + // the star follows `onToggleFavorite` alone in both carriers. + const AUTHORED_FALSE: Array<[string, Record]> = [ + ['on the node', { type: 'page:header', showStar: false }], + ['under properties', { type: 'page:header', properties: { showStar: false } }], + ]; + + it.each(AUTHORED_FALSE)('showStar: false %s still draws the star where the host wires a toggle', (_where, node) => { + const onToggleFavorite = vi.fn(); + const { header } = renderHeader({ isFavorite: false, onToggleFavorite }, node); + const [star, ...rest] = stars(header); + expect(rest).toHaveLength(0); + expect(star?.getAttribute('aria-label')).toBe('Add to favorites'); + fireEvent.click(star); + expect(onToggleFavorite).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['on the node', { type: 'page:header', showStar: true }], + ['under properties', { type: 'page:header', properties: { showStar: true } }], + ] as Array<[string, Record]>)( + 'showStar: true %s draws no star where the host wires no toggle', + (_where, node) => { + const { header } = renderHeader({ isFavorite: true }, node); + expect(stars(header)).toHaveLength(0); + }, + ); + + it('the page:header registration publishes no showStar input', () => { + const inputs = (ComponentRegistry.getConfig('page:header')?.inputs ?? []).map((input) => input.name); + // Non-vacuous: the sibling chip switch is still published. + expect(inputs).toContain('showCopyId'); + expect(inputs).not.toContain('showStar'); }); }); diff --git a/packages/components/src/custom/RecordTitleChip.tsx b/packages/components/src/custom/RecordTitleChip.tsx index 294a53f938..29835e284b 100644 --- a/packages/components/src/custom/RecordTitleChip.tsx +++ b/packages/components/src/custom/RecordTitleChip.tsx @@ -41,13 +41,14 @@ export interface RecordTitleChipProps { objectLabel?: string; /** Record identifier shown next to the object label with a copy button. */ resourceId?: string; - /** Hide the favourite star (default: shown). */ - showStar?: boolean; /** Hide the copy-id button (default: shown when `resourceId` is given). */ showCopyId?: boolean; /** Controlled favourite state. When omitted, the chip manages its own. */ isFavorite?: boolean; - /** Called when the favourite star is toggled. */ + /** + * Called when the favourite star is toggled. The star draws exactly when + * this is given: a star with nothing to call would store nothing. + */ onToggleFavorite?: (next: boolean) => void; /** Extra classes for the outer flex column. */ className?: string; @@ -60,7 +61,6 @@ export const RecordTitleChip: React.FC = ({ icon, objectLabel, resourceId, - showStar = true, showCopyId = true, isFavorite: isFavoriteProp, onToggleFavorite, @@ -131,7 +131,7 @@ export const RecordTitleChip: React.FC = ({ {copyLabel} )} - {showStar && ( + {onToggleFavorite && (