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
9 changes: 9 additions & 0 deletions .changeset/12627-page-header-show-star-retired.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 25 additions & 2 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1039,7 +1039,10 @@ const UNJUDGED_SPEC_BLOCKS: Record<string, string> = {
* - **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
Expand Down Expand Up @@ -1344,6 +1347,24 @@ const UNPUBLISHED_EXEMPTIONS: Record<string, string> = {
'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
Expand Down Expand Up @@ -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'],
Expand Down
3 changes: 2 additions & 1 deletion content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,6 @@ does not render.
subtitle?: string | Record<string, string>, // 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)
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion content/docs/layout/page-header.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -60,6 +59,8 @@ no `<h1>` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand Down Expand Up @@ -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<string, unknown>]> = [
['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<string, unknown>]>)(
'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');
});
});
10 changes: 5 additions & 5 deletions packages/components/src/custom/RecordTitleChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -60,7 +61,6 @@ export const RecordTitleChip: React.FC<RecordTitleChipProps> = ({
icon,
objectLabel,
resourceId,
showStar = true,
showCopyId = true,
isFavorite: isFavoriteProp,
onToggleFavorite,
Expand Down Expand Up @@ -131,7 +131,7 @@ export const RecordTitleChip: React.FC<RecordTitleChipProps> = ({
<TooltipContent>{copyLabel}</TooltipContent>
</Tooltip>
)}
{showStar && (
{onToggleFavorite && (
<Tooltip>
<TooltipTrigger asChild>
<Button
Expand Down
17 changes: 8 additions & 9 deletions packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1575,13 +1575,10 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
schema?.recordChrome === false || schema?.properties?.recordChrome === false;
// The favorite star is a control that stores something, so it draws only
// where the host wires a toggle: the `RecordContext` carries
// `onToggleFavorite` (objectui#12427). Without one, the chip used to draw a
// star that flipped on its own local state and stored nothing. The authored
// `showStar: false` still hides it on a host that does wire one.
const showStar =
!!ctx?.onToggleFavorite &&
schema?.showStar !== false &&
schema?.properties?.showStar !== false;
// `onToggleFavorite` (objectui#12427), and that is the one switch. Metadata
// does not control it: `@objectstack/spec` 18 retires `showStar`
// (objectstack#23650), so this renderer no longer reads it and an authored
// value changes nothing (objectui#12627).
const showCopyId = schema?.showCopyId !== false && schema?.properties?.showCopyId !== false;

// Inline header actions — authored pages embed action buttons directly on
Expand Down Expand Up @@ -2613,7 +2610,6 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
icon={recordPicture}
objectLabel={objectLabel}
resourceId={data?.id ? String(data.id) : undefined}
showStar={showStar}
showCopyId={showCopyId}
isFavorite={ctx?.isFavorite}
onToggleFavorite={
Expand Down Expand Up @@ -2678,7 +2674,10 @@ ComponentRegistry.register('header', PageHeaderRenderer, {
// carries the retirement (objectstack#20758, a tombstone refused by name),
// so the entry left at that bump (objectui#11438), as this comment asked.
{ name: 'recordChrome', type: 'boolean', description: 'Set false for the bare h1 header on non-record pages' },
{ name: 'showStar', type: 'boolean' },
// No `showStar`: the favorites star draws where the host's record context
// wires `onToggleFavorite`, and metadata does not control it. The spec
// retires the key (objectstack#23650), so the input left ahead of that
// bump (objectui#12627).
{ name: 'showCopyId', type: 'boolean' },
// The inline/overflow budget, DECLARED rather than merely honoured
// (objectui#4668). Both are @objectstack/spec 17.0.0 GA keys this renderer
Expand Down
Loading