From 40a482be93a39cdb69ca5cd9e0b0eb45d69dc136 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 00:56:23 +0000 Subject: [PATCH 1/4] docs(layout): the PageHeader page teaches the canonical page:header node (objectui#3906) Phase 2 of the maintainer ruling on objectui#3906, direction (b): the page's author face becomes the canonical `page:header` node, rendered by `PageHeaderRenderer` in `@object-ui/components` and contracted by the spec's `PageHeaderProps`. - content/docs/layout/page-header.mdx: rewritten against the canonical renderer and PageHeaderProps; props, layouts, styling, accessibility and responsive behaviour re-measured. The `page-header` alias is named once, as an alias. - content/docs/guide/layout.md: the PageHeader section moves to the same key. - The catalog demo is re-keyed to `page:header` with its props in `properties`. It carries no actions or children: the canonical node draws no children, and its action ids resolve against a bound object that a docs demo does not have. Renamed to layout-page-header/basic-page-header accordingly; index regenerated. - The demo's test re-derives shape, render, contract and declaration pins for the canonical node; the alias-registration pins stay, on an inline fixture holding the former demo JSON. - page-actions-refusal-7926 LIT CONTROL now looks for the action-id channel where the guide authors it (page:header, properties.actions). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec --- content/docs/guide/layout.md | 124 +++-- content/docs/layout/page-header.mdx | 431 +++++++----------- examples/schema-catalog/src/index.ts | 10 +- .../layout-page-header/basic-page-header.json | 7 + .../pageheader-with-actions.json | 17 - .../test/pageheader-with-actions.test.tsx | 393 +++++++++------- .../page-actions-refusal-7926.test.ts | 18 +- 7 files changed, 484 insertions(+), 516 deletions(-) create mode 100644 examples/schema-catalog/src/schemas/layout-page-header/basic-page-header.json delete mode 100644 examples/schema-catalog/src/schemas/layout-page-header/pageheader-with-actions.json diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 570a150066..a5e09b0251 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -254,90 +254,80 @@ Available values: - `2xl` - 1536px - `full` - No maximum width (default) -## PageHeader Component - -The `PageHeader` provides consistent page headers with a title, an optional subtitle, -an icon chip, and an action row. - -> **The canonical author key is `page:header`; `page-header` is a legacy alias.** The -> snippets in this section are the `@object-ui/layout` component, which `registerLayout()` -> registers as `page-header` (plus its namespaced form `layout:page-header`) — that node -> still renders, so metadata already written this way is not stranded. The contract knows -> only `page:header`, though: that is the `PageComponentType` value and the -> `ComponentPropsMap` row binding `PageHeaderProps`, and it resolves to a different, -> record-aware renderer in `@object-ui/components`. Props written under the alias have no -> `ComponentPropsMap` row to dispatch, so nothing validates them — a misspelling there is -> neither rejected nor reported. Author metadata pages against `page:header` -> ([Slotted pages](/docs/guide/slotted-pages)); its props are not the ones below — see the -> [PageHeader reference](/docs/layout/page-header). +## Page Header (`page:header`) + +`page:header` is a page's title bar: a title, an optional subtitle and, on a record page, the +record chip and the record's header actions. Its props contract is `PageHeaderProps`, the +`ComponentPropsMap['page:header']` row, and it is rendered by the record-aware +`PageHeaderRenderer` in `@object-ui/components`. The +[Page Header reference](/docs/layout/page-header) documents every key, both layouts and the +styling. ### Usage ```json { - "type": "page-header", - "title": "Customer Details", - "subtitle": "View and edit customer information", - "icon": "users", - "actions": ["edit", "delete"] + "type": "page:header", + "properties": { + "title": "{first_name} {last_name}", + "subtitle": "View and edit customer information", + "actions": ["edit_customer", "send_email"] + } } ``` +The props go in `properties`, which is where `PageHeaderProps` judges them: a misspelled or +removed key there is refused by name. `className` is the one key that belongs on the node +itself, beside `type`. + `title` and `subtitle` both interpolate `{field.path}` tokens against the surrounding -record context, so `"title": "{first_name} {last_name}"` resolves on a record page. -Unresolvable tokens collapse to an empty string rather than leaking the raw template. +record context, so the title above resolves on a record page. Unresolvable tokens collapse to +an empty string rather than leaking the raw template. + +`actions` holds action **ids**, resolved from the object's own `actions` metadata, which +keeps the definitions in one place. They are **not** `SchemaNode` nodes: the contract refuses +an entry that is not a string. Nor does the header draw `children`, so a button written there +does not render. ### Schema API - + ```typescript { - type: 'page-header', - - title: string, // required; {field.path} tokens interpolated - subtitle?: string, // secondary line; {field.path} tokens interpolated - icon?: string, // Lucide icon name, rendered in a chip left of the title - actions?: Array, // action ids, or inline ActionDef objects - showBack?: boolean, // back arrow; inferred from record context when omitted - children?: SchemaNode[], // rendered into the right-aligned slot; `actions` takes precedence - className?: string, + type: 'page:header', + className?: string, // on the node, beside `type` + properties?: { + title?: string | Record, // {field.path} tokens interpolated; derived from the record when omitted on a record page + subtitle?: string | Record, // secondary line; same interpolation + actions?: string[], // action ids, resolved against the object's own actions + breadcrumb?: boolean, // default true — the breadcrumb slot above the title + recordChrome?: boolean, // default true — the record chip on a record page + showStar?: boolean, // default true + 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) + aria?: { ariaLabel?, ariaDescribedBy?, role? }, + }, } ``` -`showBack` defaults to `true` when a record context carrying a `recordId` is in scope and -the header is not rendered inside embedded chrome (drawer / modal, which already provide -their own Close control), and `false` otherwise. Pass it explicitly to override. - -`actions` is handed to the `record:quick_actions` widget with -`location: 'record_header'`. Its entries are **action ids** — resolved from the object's -own `actions` metadata, which keeps the definitions in one place — or inline `ActionDef` -objects. They are **not** `SchemaNode` nodes: a `{ "type": "button", … }` entry -renders nothing here. - -> **Write `subtitle`. `description` is retired.** `@objectstack/spec/ui`'s -> `PageHeaderProps` — the contract for the canonical `page:header` node — declares -> `title / subtitle / breadcrumb / actions / recordChrome / showStar / showCopyId / -> maxVisible / mobileMaxVisible / aria` and has **no** `description`, and -> `page-header`'s registration declares four authorable inputs — `title`, `subtitle`, -> `icon` and `actions`. `icon` sits on exactly one of those two lists on purpose: it is -> an ADR-0087 D2 tombstone on the spec shape, which rejects it by name — "`page:header` -> property `icon` was removed in @objectstack/spec 17.0.0 (#6946, ADR-0087 D2) — no -> renderer ever read it … Delete the key." — while remaining a live input of *this* -> component, whose `` does draw an icon beside the title (objectui#3829). On -> a canonical `page:header` node the key is gone; as a prop of this component it is -> live. The renderer used to read `description` as well, as a legacy alias; objectui#3789 -> removed that read, so `subtitle` is now the only spelling this component draws. Stored -> metadata written the old way is not stranded: protocol 17's ADR-0087 D2 conversion -> `page-header-subtitle-alias` rewrites `description` to `subtitle` on header nodes as the -> stack loads — at every position a header can occupy, regions and slots and containers -> nested to any depth (objectstack#6775 / #6776) — and `os migrate meta` rewrites it at -> rest. See the [PageHeader reference](/docs/layout/page-header) for the per-key -> reference face. - -> **There is no `breadcrumbs` array.** The component reads no breadcrumb property of any -> kind, in either spelling. The spec's `breadcrumb` is singular and a **boolean** — a -> display toggle on the canonical `page:header` node (see -> [Slotted pages](/docs/guide/slotted-pages)), not a list of links. +> **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 +> @objectstack/spec 17.0.0 (ADR-0087 D2) — no renderer ever read it … Delete the key." The +> header's identity comes from the record chip (`recordChrome`), and each action carries its +> own `icon`. + +> **There is no `breadcrumbs` array.** The node's `breadcrumb` is singular and a +> **boolean** display toggle for the slot above the title, not a list of links. A trail of +> links is a `breadcrumb` node — see +> [Breadcrumbs for Deep Navigation](#2-breadcrumbs-for-deep-navigation). + +> **`page-header` is an alias, not the author key.** `page-header` (and its namespaced form +> `layout:page-header`) is still accepted: a node written that way renders. It resolves to a +> different renderer, the `PageHeader` component in `@object-ui/layout`, and it has no +> `ComponentPropsMap` row, so props written under it are never checked against +> `PageHeaderProps`. Author `page:header`. ## SidebarNav Component diff --git a/content/docs/layout/page-header.mdx b/content/docs/layout/page-header.mdx index b00034e0ee..c025502657 100644 --- a/content/docs/layout/page-header.mdx +++ b/content/docs/layout/page-header.mdx @@ -1,128 +1,92 @@ --- -title: "PageHeader" -description: "Page header component with title, subtitle, and actions" +title: "Page Header" +description: "The page:header block: a page's title bar, with a subtitle, the record chip and the record's header actions" --- -> **Canonical author key: `page:header`. This page's key, `page-header`, is a legacy alias -> — and it resolves to a different renderer.** -> -> Measured against the `@objectstack/spec` build this repo installs, `page:header` is the -> only spelling the contract knows: it is the `PageComponentType` value, and it is the -> `ComponentPropsMap` row that binds `PageHeaderProps` — whose own rejection messages name -> the surface `page:header`. Neither `page-header` nor `layout:page-header` appears in -> either place. -> -> The alias is **still accepted** — not retired, and not silently dropped. -> `registerLayout()` registers `page-header` (plus its namespaced form -> `layout:page-header`), so a node written that way renders today: it resolves to the -> `@object-ui/layout` component documented below, which is also what the demo on this page -> renders. What the alias does not carry is a contract. A node's props are validated by -> dispatching its `type` through `ComponentPropsMap`, `page-header` has no row there, and -> `PageComponentSchema.properties` is an open `z.record(z.string(), z.unknown())` bag — so -> a misspelled or retired key written under the alias is neither rejected nor reported. -> Under `page:header` the same key is refused by name. -> -> Author metadata pages against `page:header`. The canonical node is the record-aware -> renderer in `@object-ui/components`; [Slotted pages](/docs/guide/slotted-pages) shows it -> in place. Its props are **not** the props below — `icon`, `action` and `showBack` are this -> component's own, and `icon` is retired on the canonical node (Component Props, below). -> Everything on this page — Props, Styling, Examples, demo — is measured against this -> component, the alias's renderer; converting the page to the canonical node is tracked in -> objectui#3906. - -The PageHeader component provides a consistent header for pages with title, subtitle, and action buttons. +`page:header` is a page's title bar: a heading, an optional subtitle and, on a record page, +the record chip and the record's header actions. It is rendered by `PageHeaderRenderer` in +`@object-ui/components`, and its props contract is `PageHeaderProps` from +`@objectstack/spec/ui`, the row `ComponentPropsMap['page:header']` binds. ## Basic Usage -```tsx -import { PageHeader } from '@object-ui/layout'; - - -``` - -## With Actions - - - -## Component Props - -```ts -interface PageHeaderComponentProps extends React.HTMLAttributes { - title: string; // Page title (required); {field.path} tokens interpolated - subtitle?: string; // Secondary line under the title — the canonical key - icon?: React.ReactNode | string; // Lucide icon name, or a pre-rendered node - action?: React.ReactNode; // Action buttons or elements (right-aligned slot) - actions?: unknown[]; // ActionDef list (or action ids) → record:quick_actions - showBack?: boolean; // Back arrow; inferred from record context when omitted - schema?: { children?: unknown[]; actions?: unknown[]; properties?: { actions?: unknown[] } }; - children?: React.ReactNode; // Additional content (right-aligned slot) - className?: string; // Additional CSS classes +```json +{ + "type": "page:header", + "properties": { + "title": "Users", + "subtitle": "Manage your team members and permissions" + } } ``` -`subtitle` is the canonical key: `@objectstack/spec/ui`'s `PageHeaderProps` — the contract -for the authored `page:header` node — declares `title / subtitle / breadcrumb / actions / -recordChrome / showStar / showCopyId / maxVisible / mobileMaxVisible / aria` and has no -`description`. Both `title` and `subtitle` interpolate `{field.path}` tokens against the -surrounding record context. - -`maxVisible` and `mobileMaxVisible` are the canonical node's action-overflow budget, and -they belong to that contract rather than to this component: the `page:header` renderer in -`@object-ui/components` reads them, and `@object-ui/layout`'s `PageHeader` — the component -documented above — does not. The shape describes them as "How many header actions render as -inline buttons before the rest fold into the overflow menu (renderer default 3)" and "The -`maxVisible` budget on mobile viewports (renderer default 1)"; -[Slotted pages](/docs/guide/slotted-pages) shows them authored on a page. - -`icon` is deliberately **not** in that list, and the distinction matters because this page -documents a React component while the spec governs an authored node. `PageHeaderProps.icon` -was retired from `@objectstack/spec` (objectstack#6946, ADR-0087 D2): the canonical -`page:header` renderer never read it, so an authored value used to be accepted and dropped, -and the contract now rejects the key by name. On a canonical `page:header` node the -header's identity comes from the record chrome (`recordChrome`, on by default) plus each -action's own `icon`; `os migrate meta --from 16` rewrites stored sources. - -The `icon` prop listed above is this component's **own** capability, not that spec key. -`PageHeader` from `@object-ui/layout` — registered as `page-header` and -`layout:page-header` — really does draw it (a string resolves through `LazyIcon`, a React -node renders as-is), which is why it stays on that registration's `inputs` and why the demo -on this page writes `"icon": "users"` (objectui#3829). Do not carry the key over to a -canonical `page:header` node: the same JSON renders here and fails to parse there. - -`description` used to be a **legacy alias** of `subtitle` that this component read as -well. It is **retired** (objectui#3789): the prop is gone and the renderer draws a -secondary line only for `subtitle`. - -Nothing is lost by that, because the normalization moved upstream where it belongs. The -ADR-0087 D2 conversion entry `page-header-subtitle-alias` is **live** in protocol 17: the -loader rewrites a header node's `properties.description` to `properties.subtitle` as the -stack is loaded, so a page stored with the old key still renders its second line. An -already-canonical `subtitle` wins, and a `description` it shadows is left exactly as -authored rather than the loader choosing between two different second lines. - -The retirement waited on a measurement rather than a date: every position a `page-header` -node can occupy had to be shown to pass through that rewrite. It did not, at first — the -conversion walk reached only `regions[].components[]`, leaving `slots.header` and -container-nested headers unconverted (objectstack#6775). With that walk widened -(objectstack#6776 for slots, PR #7034 for containers nested to any depth), all seven -positions convert; `packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts` -re-measures them on every run against the spec build this repo consumes. - -If you are hand-feeding schema JSON to the renderer without going through an ObjectStack -loader, write `subtitle` — that is the only spelling that renders on every path. Stored -metadata is migrated in place by `os migrate meta`. - -`actions` is delegated to the `record:quick_actions` widget with -`location: 'record_header'`, so authors get the standard toolbar (icons, overflow menu, -permission filtering, confirm dialogs) without declaring a sibling node. Precedence for -the right-aligned slot is `action` → React `children` → `actions` → schema children. - -`showBack` renders a back arrow that navigates one level up the URL. When omitted it -defaults to `true` on record pages (a `recordId` in scope) that are not embedded in a -drawer or modal, and `false` otherwise. + + +The header's props go in `properties`, the page-component spelling of the contract, and that +is where they are judged. `PageHeaderProps` is a closed shape, so a misspelled or removed key +there is refused by name: `subTitle` comes back as an unrecognized key with `subtitle` +suggested, and `icon` as a removed property. + +The renderer reads the same keys written directly on the node, beside `type`, but nothing +judges them there: the spec's page-component shape refuses `title` or `subtitle` in that +position as unrecognized keys, and objectui's schema mirror (`safeValidateSchema` from +`@object-ui/types/zod`) passes them through without checking them. `className` is the one +exception, and it goes the other way: it belongs on the node, beside `type`, and the contract +refuses it inside `properties`. + +## Properties + +Every key below sits in `properties`. Defaults marked *spec* are applied by +`PageHeaderProps`; defaults marked *renderer* are what the renderer uses when the key is +absent. + +| Key | Type | Default | What it does | +|---|---|---|---| +| `title` | string, or an inline locale map | derived from the record on a record page | The heading. `{field.path}` tokens are interpolated against the record in scope. | +| `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. | +| `breadcrumb` | boolean | `true` (*spec*) | Renders a breadcrumb slot above the title. `false` removes it. | +| `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 follow star beside the record title. Part of the record chip. | +| `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. | +| `aria` | `{ ariaLabel?, ariaDescribedBy?, role? }` | — | Accessibility attributes for the `
` element (see [Accessibility](#accessibility)). | + +**`title` and `subtitle`** take a plain string or an inline locale map such as +`{ "en": "Orders" }`, resolved for the display locale. A `{field.path}` token that resolves +to nothing collapses to an empty string rather than leaking the raw template: with no record +in scope, `"Hello {name}"` renders `Hello`. Without a record, a header with no `title` draws +no `

` at all. On a record page, omit `title` and the heading is derived from the record. + +There is no `description` key on this node: the contract refuses it and names `subtitle`. + +**`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 +renders none of them and says so once per id in the console. That is why the demo above +carries no actions. Placement (`locations: ['record_header']` or `record_more`), ordering and +the overflow menu are covered in [Slotted pages](/docs/guide/slotted-pages). + +**`breadcrumb`** renders an empty slot element, marked `data-page-breadcrumb-slot`, above +the title. The renderer draws no trail into it itself. + +**`maxVisible` / `mobileMaxVisible`** must be positive integers: the contract rejects `0` +and fractional values, and the renderer falls back to its default for a value the contract +rejects. + +**`icon` is not a key of this node.** The contract refuses it by name, because no renderer +ever read it. The header's identity comes from the record chip (`recordChrome`) and each +action carries its own `icon`. + +On the node itself, beside `type`, `className` is merged onto the `
` element, so +`"className": "mb-6"` adds space below the header. + +## Children + +`page:header` draws no children. Buttons written in `children` do not render, and objectui's +schema mirror refuses `children` on this node by name. Header buttons are `actions` ids; +anything else that belongs next to the header goes in a sibling node. ## Gating a header action on a relation field @@ -170,174 +134,115 @@ The identity side of the comparison is `os.user` — the spec's canonical CEL id scope, and the same one the server binds — with `current_user`, `user` and `ctx.user` available as aliases of it. -## Examples - -### Simple Header - -```plaintext - -``` - -### With Subtitle - -```plaintext - -``` - -### With Action Buttons +## Layout -```plaintext - - - - - } -/> -``` +The renderer picks one of two layouts when it renders: -### With Children +- **Bare heading** — no record in scope, or `recordChrome: false`. The title and subtitle + stack on the left, the action row sits on the right. +- **Record chip** — a record page (a record context with its object schema) with the record + chrome on. The title renders as the record chip: the heading, the object's label, the + copy-id button and the follow star. ```plaintext - - - - +┌──────────────────────────────────────────────┐ +│ (breadcrumb slot) │ +│ Title [Actions] [⋯] │ +│ Subtitle │ +└──────────────────────────────────────────────┘ ``` -## Layout - -The PageHeader uses a flex layout: - -``` -┌─────────────────────────────────────┐ -│ Title [Actions] │ -│ Subtitle │ -└─────────────────────────────────────┘ -``` - -- **Left Side**: Title and subtitle stacked vertically -- **Right Side**: Action buttons or custom content -- **Responsive**: Actions move below title on mobile (if needed) - ## Styling -### Title - -- Font size: `text-2xl` on mobile, `text-3xl` on desktop -- Font weight: `font-bold` -- Tracking: `tracking-tight` - -### Subtitle - -- Font size: `text-sm` -- Color: `text-muted-foreground` (gray) - -### Container - -- Padding bottom: `pb-4` at every breakpoint — there is no responsive variant -- Border: `border-b` along the bottom edge -- Gap: `gap-3` on the outer column; the title row itself uses `gap-x-4 gap-y-2` - -## Usage with Page Component - -The Page component includes built-in PageHeader support: - -```plaintext -Add Widget} - body={[ - // Page content - ]} -/> -``` - -Note that `description` here is **the Page component's own prop**, not the header's — it -renders the page's prose under the page title and is a declared input of the `page` -renderer. It is unrelated to the `description` alias retired above: that one was a second -spelling of the header's `subtitle`, this one is the `page` node's own live key. Do not -rename it to `subtitle`, and do not assume objectui#3789 touched it. - -## Complete Example - -```tsx -import { PageHeader } from '@object-ui/layout'; -import { Button } from '@object-ui/components'; -import { Plus, Download, Settings } from 'lucide-react'; - -function UsersPage() { - return ( -
- - - - -
+### Bare heading + +- Root: a `
` with `flex flex-col gap-2 pb-4 border-b`, the same at every breakpoint +- Title row: `flex items-center justify-between gap-4` +- Title: an `

` with `text-2xl font-semibold tracking-tight`, the same at every breakpoint +- Subtitle: a `

` with `text-sm text-muted-foreground` + +The demo's test (`examples/schema-catalog/test/pageheader-with-actions.test.tsx`) renders the +demo above and re-derives the root and title classes on every run. + +### Record chip + +- Root: a `

` with `flex flex-col sm:flex-row sm:flex-wrap items-start sm:items-center + justify-between gap-3 sm:gap-4 pb-4 border-b` — a column below the `sm` breakpoint, a + wrapping row from `sm` up +- Title column: `flex flex-col min-w-0 sm:min-w-48 flex-1` +- Title: an `

` with `text-xl sm:text-2xl font-bold truncate` +- Subtitle: a `

` with `text-sm text-muted-foreground mt-1` + +No docs test re-derives the record-chip classes; they are read from the renderer. + +## Usage in a Page + +Put the header in the page's `header` region (or first in its `children`): + +```json +{ + "type": "page", + "title": "Users", + "regions": [ + { + "name": "header", + "components": [ + { + "type": "page:header", + "properties": { + "title": "Users", + "subtitle": "Manage your team members and permissions" + } } - /> - - {/* Page content */} -

- {/* Table, cards, etc. */} -
- - ); + ] + }, + { + "name": "main", + "components": [ + { "type": "text", "content": "Page content" } + ] + } + ] } ``` +A page has exactly one `

`. When a `page:header` with a literal title sits in the page's +regions or children, the `page` renderer draws no heading of its own and the header's `

` +is the page heading. + ## Accessibility -The PageHeader includes: -- Semantic HTML (`

` for title) -- Proper heading hierarchy -- Color contrast for text -- Responsive layout +- The title is the page's `

`: the bare heading's own `

`, or the record chip's. +- The action row is a `role="toolbar"` labelled "Page header actions", and the overflow + trigger is labelled "More actions". -On a canonical `page:header` node, the spec's `aria` bag lands on the `
` element in -both layouts: `ariaLabel` (a plain string, or an inline locale map such as -`{ "en": "Orders", "zh-CN": "订单" }`, resolved for the display locale) renders `aria-label`, -`ariaDescribedBy` renders `aria-describedby`, and `role` renders `role`. The header adds no -role of its own. `page:tabs`, `page:card` and `page:accordion` put the same bag on their -root element (objectui#11083). +The spec's `aria` bag lands on the `
` element in both layouts: `ariaLabel` (a plain +string, or an inline locale map such as `{ "en": "Orders", "zh-CN": "订单" }`, resolved for +the display locale) renders `aria-label`, `ariaDescribedBy` renders `aria-describedby`, and +`role` renders `role`. The header adds no role of its own. `page:tabs`, `page:card` and +`page:accordion` put the same bag on their root element (objectui#11083). ## Responsive Behavior -- **Mobile**: Actions stack below title if needed -- **Desktop**: Actions align to right side -- **Spacing**: Identical at every breakpoint — there is no responsive padding variant; the only breakpoint-driven change is the title's font size (see Styling → Title) +- **Bare heading**: no breakpoint variant — the same row, spacing and title size at every + width. +- **Record chip**: a column on narrow screens and a wrapping row from `sm` up; the title + steps from `text-xl` to `text-2xl`, and its column keeps a `sm:min-w-48` floor, so a long + action row wraps onto its own line instead of squeezing the title. +- **Actions**: up to `maxVisible` inline buttons on desktop and `mobileMaxVisible` on a + mobile viewport; the rest fold into the `⋯` menu. + +## The `page-header` alias + +`page-header` (and its namespaced form `layout:page-header`) is still accepted: a node +written that way renders. It is an alias, not the author key. It resolves to a different +renderer, the `PageHeader` component in `@object-ui/layout`, and it has no +`ComponentPropsMap` row, so props written under it are never checked against +`PageHeaderProps`. Author `page:header`. ## Related +- [Slotted pages](/docs/guide/slotted-pages) - Header actions, overflow and the refresh button on record pages - [Page](/docs/components/layout/page) - Complete page layout - [AppShell](/docs/layout/app-shell) - Application shell - [SidebarNav](/docs/layout/sidebar-nav) - Sidebar navigation diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts index 578ac0720b..6c558ed165 100644 --- a/examples/schema-catalog/src/index.ts +++ b/examples/schema-catalog/src/index.ts @@ -393,7 +393,7 @@ import forms_create_user_form from './schemas/forms/create-user-form.json' with import forms_newsletter_signup from './schemas/forms/newsletter-signup.json' with { type: 'json' }; import forms_payment_form from './schemas/forms/payment-form.json' with { type: 'json' }; import forms_settings_form from './schemas/forms/settings-form.json' with { type: 'json' }; -import layout_page_header_pageheader_with_actions from './schemas/layout-page-header/pageheader-with-actions.json' with { type: 'json' }; +import layout_page_header_basic_page_header from './schemas/layout-page-header/basic-page-header.json' with { type: 'json' }; import marketing_call_to_action from './schemas/marketing/call-to-action.json' with { type: 'json' }; import marketing_features_grid from './schemas/marketing/features-grid.json' with { type: 'json' }; import marketing_pricing_table from './schemas/marketing/pricing-table.json' with { type: 'json' }; @@ -3906,14 +3906,14 @@ const REGISTRY: Record = { }, schema: forms_settings_form, }, - 'layout-page-header/pageheader-with-actions': { - id: 'layout-page-header/pageheader-with-actions', + 'layout-page-header/basic-page-header': { + id: 'layout-page-header/basic-page-header', meta: { - title: "Pageheader With Actions", + title: "Basic Page Header", description: "", category: 'layout-page-header', }, - schema: layout_page_header_pageheader_with_actions, + schema: layout_page_header_basic_page_header, }, 'marketing/call-to-action': { id: 'marketing/call-to-action', diff --git a/examples/schema-catalog/src/schemas/layout-page-header/basic-page-header.json b/examples/schema-catalog/src/schemas/layout-page-header/basic-page-header.json new file mode 100644 index 0000000000..9c1d6c929c --- /dev/null +++ b/examples/schema-catalog/src/schemas/layout-page-header/basic-page-header.json @@ -0,0 +1,7 @@ +{ + "type": "page:header", + "properties": { + "title": "Users", + "subtitle": "Manage your team members and permissions" + } +} diff --git a/examples/schema-catalog/src/schemas/layout-page-header/pageheader-with-actions.json b/examples/schema-catalog/src/schemas/layout-page-header/pageheader-with-actions.json deleted file mode 100644 index 2231cb9114..0000000000 --- a/examples/schema-catalog/src/schemas/layout-page-header/pageheader-with-actions.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "type": "page-header", - "title": "Users", - "subtitle": "Manage your team members and permissions", - "icon": "users", - "children": [ - { - "type": "button", - "label": "Export", - "variant": "outline" - }, - { - "type": "button", - "label": "Add User" - } - ] -} diff --git a/examples/schema-catalog/test/pageheader-with-actions.test.tsx b/examples/schema-catalog/test/pageheader-with-actions.test.tsx index d846d824b2..992ae8b446 100644 --- a/examples/schema-catalog/test/pageheader-with-actions.test.tsx +++ b/examples/schema-catalog/test/pageheader-with-actions.test.tsx @@ -1,52 +1,49 @@ /** - * The PageHeader docs demo renders the COMPONENT, not a hand-rolled header - * (objectui#3787). + * The PageHeader docs demo is the CANONICAL `page:header` node (objectui#3906), + * and it renders the component rather than a hand-rolled header (objectui#3787). * - * `layout-page-header/pageheader-with-actions` is the only runnable example on - * `content/docs/layout/page-header.mdx`. It used to be a `div`/`text`/`button` - * tree carrying Tailwind classes copied out of `PageHeader.tsx` — so the page - * documenting the component shipped a copy-paste reference that told authors - * (AI authors included) to bypass it, it exercised none of `page-header`'s - * rendering, and it held a third, already-drifted copy of the component's - * spacing numbers (objectui#3786 fixed the second copy, in the prose). + * `layout-page-header/basic-page-header` is the only runnable example on + * `content/docs/layout/page-header.mdx`. Until objectui#3906 it was a + * `page-header` node — the `@object-ui/layout` alias, a different renderer with + * no `ComponentPropsMap` row — so the page's live demo taught the one spelling + * the contract does not know. The maintainer's ruling on that card (direction + * (b)) made the page's author face the canonical key; this file pins the demo to + * it. * - * Five things are pinned here, and they are different facts: + * ⚠️ The file keeps its old name on purpose. Tests in `packages/layout` cite it + * BY PATH for the alias-registration and manifest pins at the bottom (the + * `diagnose()` helper lives here), so renaming it would silently falsify those + * pointers. The demo it pins was renamed instead: it carries no actions any + * more, for the reason the render pin below states. * - * 1. SHAPE — the example's root node is a `page-header`, and it contains none - * of the class strings that only exist inside `PageHeader.tsx`. This is the - * regression that would fire if anyone hand-rolls the header again. - * 2. RENDER — driven through the real `SchemaRenderer`, the node produces the - * header: an `

` title, the subtitle, and BOTH schema children in the - * right-hand slot. - * 3. VALIDATE — the same JSON, put through the manifest the app really builds - * from the live registry, draws no `not-a-container` diagnostic (#3900). - * 4. VALIDATE, PROPS — the same JSON draws no `unknown-prop` either, and an - * array-valued `navigation-renderer.items` draws no `type-mismatch` (#3972). - * Same lie as (3) on the prop face instead of the containment face. - * 5. VALIDATE, OPTIONALITY — a `navigation-renderer` WITHOUT `items` now draws - * an error-level `missing-required-prop` (#3987), and the props the renderer - * defaults still draw nothing. Unlike (3) and (4) this one is a tightening, - * not a false-diagnostic fix; see that describe's header. + * Pinned for the demo, each a different fact: * - * (2) and (3) are two halves of one contradiction that used to be live. The - * render path never consults `isContainer` — `SchemaRenderer` strips `children` - * from the React props but always passes the whole node as `schema`, and - * `PageHeader` re-introduces `schema.children` itself — so the header rendered - * its children correctly while `packages/layout/src/index.ts` omitted - * `isContainer: true` from the registration. The flag's consumers are elsewhere: - * `sdui-parser`'s `not-a-container` diagnostic, the Studio palette, and the - * react-page tag map. So the omission never blocked anything; it made the - * VALIDATOR contradict the docs, the demo and the render — an author following - * this very page got a warning telling them their working schema was invalid. - * #3900 added the flag (maintainer ruling, route A: `children` is a base - * property of every protocol node, not a `PageHeaderProps` key, so declaring it - * mints no authoring surface outside the spec). (3) is what keeps the two faces - * from drifting apart again in either direction. + * 1. SHAPE — the root is `page:header`, its props sit in `properties` (the + * page-component spelling, the only place the contract judges them), and the + * JSON restates none of the classes the canonical renderer owns. The last + * half is the hand-rolled-copy guard of objectui#3787, re-derived against the + * canonical renderer's classes. + * 2. RENDER — through the real `SchemaRenderer`, the node produces the bare + * header layout: a `
` root, an `

` title, the subtitle, and the + * container classes the docs page's Styling section states. + * 3. CONTRACT — the demo passes the spec's page-component shape and the + * `ComponentPropsMap['page:header']` row in FULL (value-level, not only + * "no unrecognized key"), and objectui's own schema mirror. Each pass is + * paired with a planted mistake the same validator must still refuse. + * 4. DECLARATION — every key the demo writes is a declared input of the + * `page:header` registration, read through the manifest the app builds. * - * Module-scope imports, not `beforeAll` (AGENTS.md §测试纪律): the child - * `button` node resolves through `@object-ui/components`' registration - * side-effects, and paying that cost at import time keeps it out of every - * test/hook timeout budget. + * Why no actions and no children (measured, objectui#3906): the canonical + * renderer draws no `children` at all, and its `actions` are action IDS + * resolved against the object bound to the page. A docs demo is bound to no + * object, so ids there resolve to nothing (the renderer warns once per id) — a + * demo "with actions" would render none. The contract refuses the other two + * shapes the old demo used: `children` on this node (objectui#9256) and an + * inline action object in `actions` (the spec's `z.array(z.string())`). + * + * Module-scope imports, not `beforeAll` (AGENTS.md §测试纪律): the registrations + * are import-time side effects, and paying that cost at import time keeps it out + * of every test/hook timeout budget. */ import { describe, it, expect, beforeAll } from 'vitest'; import { render, screen } from '@testing-library/react'; @@ -56,20 +53,32 @@ import { SchemaRenderer } from '@object-ui/react'; import { registerLayout } from '@object-ui/layout'; import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser'; import type { Diagnostic, SchemaElement } from '@object-ui/sdui-parser'; +import { safeValidateSchema } from '@object-ui/types/zod'; +import { ComponentPropsMap, PageComponentSchema } from '@objectstack/spec/ui'; import { getExample } from '../src/index.js'; -const EXAMPLE_ID = 'layout-page-header/pageheader-with-actions'; +const EXAMPLE_ID = 'layout-page-header/basic-page-header'; + +interface DemoNode { + type?: string; + properties?: Record; + [key: string]: unknown; +} + +const demo = (): DemoNode => getExample(EXAMPLE_ID).schema as DemoNode; /** - * Class strings that exist to style the header and live in `PageHeader.tsx` - * (`:210`, `:211`, `:231`, `:233`). Their presence in the example JSON is the - * signature of a hand-rolled copy — the defect, not a styling choice. + * Class strings the canonical renderer's bare layout puts on its own elements. + * Their presence in the example JSON is the signature of a hand-rolled copy — + * the defect objectui#3787 removed, not a styling choice. */ const COMPONENT_OWNED_CLASSES = [ - 'text-2xl', + 'border-b', + 'pb-4', + 'font-semibold', 'tracking-tight', + 'text-2xl', 'text-muted-foreground', - 'pb-4', ]; beforeAll(() => { @@ -83,8 +92,8 @@ beforeAll(() => { * `getJsxManifest()` in `packages/components/src/renderers/layout/page.tsx` * (module-private, hence the four lines here): key it off `getAllConfigs()` * instead and the bare `page-header` tag authors write is absent from the - * manifest, so every assertion below would pass on `unknown-component` and - * never reach the containment check at all. + * manifest, so every alias assertion below would pass on `unknown-component` + * and never reach the check it names. */ const diagnose = (schema: unknown): Diagnostic[] => { const configs = ComponentRegistry.getKnownTypes().map((t) => { @@ -95,93 +104,192 @@ const diagnose = (schema: unknown): Diagnostic[] => { return validateTree(schema as SchemaElement, manifest).diagnostics; }; -describe('the page-header docs demo uses the page-header component (#3787)', () => { - it('is rooted at a `page-header` node', () => { - const schema = getExample(EXAMPLE_ID).schema as { type?: string }; - expect(schema.type).toBe('page-header'); +/** Every issue as `path: code`, so a red run says what broke rather than `false`. */ +const issuesOf = (result: { success: boolean; error?: { issues: Array<{ path: PropertyKey[]; code: string }> } }) => + result.success ? [] : (result.error?.issues ?? []).map((i) => `${i.path.map(String).join('.')}: ${i.code}`); + +describe('the page-header docs demo is the canonical `page:header` node (objectui#3906)', () => { + it('is rooted at `page:header` and carries its props in `properties`', () => { + const schema = demo(); + expect(schema.type).toBe('page:header'); + // Nothing else on the node: a prop written beside `type` is read by the + // renderer but judged by no validator (see the CONTRACT describe below), + // which is exactly the unchecked spelling the page tells authors not to use. + expect(Object.keys(schema).sort()).toEqual(['properties', 'type']); + expect(schema.properties).toEqual({ + title: 'Users', + subtitle: 'Manage your team members and permissions', + }); }); - it('declares title/subtitle on the component instead of restating its classes', () => { - const schema = getExample(EXAMPLE_ID).schema as Record; - expect(schema.title).toBe('Users'); - expect(schema.subtitle).toBe('Manage your team members and permissions'); - // The third copy of the spacing/typography numbers (#3786) is gone, and - // stays gone: re-hand-rolling the header re-introduces these strings. - const json = JSON.stringify(schema); + it('restates none of the classes the canonical renderer owns (the objectui#3787 guard)', () => { + const json = JSON.stringify(demo()); for (const cls of COMPONENT_OWNED_CLASSES) { expect(json, `example JSON should not restate \`${cls}\``).not.toContain(cls); } }); - it('renders the real header: h1 title, subtitle, and both children in the action slot', () => { - const { container } = render( - , - ); + it('renders the real canonical header: `
` root, `

` title, subtitle', () => { + const { container } = render(); + + const root = container.querySelector('[data-obj-type="page:header"]'); + // The canonical renderer, not the alias's `
` root. + expect(root?.tagName).toBe('HEADER'); - // The title is the document heading — the page's Accessibility section - // claims an `

`, and the hand-rolled demo it replaces emitted a `span`. const h1 = container.querySelector('h1'); expect(h1?.textContent).toBe('Users'); expect(screen.getByText('Manage your team members and permissions')).toBeTruthy(); - // Both children reach the right-hand slot. This held even while the - // registration omitted `isContainer` — the render path never reads the flag - // (see the module header), which is why the omission was invisible here and - // only the validator complained. - expect(screen.getByRole('button', { name: 'Export' })).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Add User' })).toBeTruthy(); + // No actions authored and none resolvable without a bound object, and the + // node draws no children: the header renders no button at all. + expect(container.querySelectorAll('button')).toHaveLength(0); }); - it('renders the container spacing the docs Styling section states (#3786)', () => { - // Pins the CODE side of the three Container bullets in - // `content/docs/layout/page-header.mdx`: `pb-4` with no responsive - // variant, `gap-3` on the outer column, and a `border-b`. Changing any of - // them turns this red, which is the prompt to update that section — the - // doc drifted precisely because nothing was watching. (Mechanising the - // prose itself is the separate decision #3786 deferred.) - const { container } = render( - , - ); - const root = container.querySelector('[data-obj-type="page-header"]'); + it('renders the classes the docs Styling section states for the bare layout', () => { + // Pins the CODE side of the bare-layout bullets in + // `content/docs/layout/page-header.mdx`. Changing any of them turns this + // red, which is the prompt to update that section. + const { container } = render(); + const root = container.querySelector('[data-obj-type="page:header"]'); const cls = root?.className ?? ''; - - expect(cls).toContain('gap-3'); + expect(cls).toContain('gap-2'); expect(cls).toContain('pb-4'); expect(cls).toContain('border-b'); - // No responsive padding variant — the doc used to promise `pb-8` on desktop. - expect(cls).not.toMatch(/\b(sm|md|lg|xl):pb-/); + expect(cls).not.toMatch(/\b(sm|md|lg|xl):(pb|gap)-/); + + const h1Cls = container.querySelector('h1')?.className ?? ''; + expect(h1Cls).toContain('text-2xl'); + expect(h1Cls).toContain('font-semibold'); + expect(h1Cls).not.toMatch(/\b(sm|md|lg|xl):text-/); }); }); /** - * The validator agrees with the docs, the demo and the render (#3900). + * The demo satisfies the contract the page teaches — and the same validators + * still refuse the mistakes the page warns about. + * + * Every positive is paired with a control, because "passes" is also what a + * validator that stopped judging looks like. Controls assert the issue's CODE + * and PATH, never its wording. + */ +describe('the demo satisfies the `page:header` contract (objectui#3906)', () => { + const row = ComponentPropsMap['page:header']; + + it('passes the spec page-component shape and the `page:header` props row in full', () => { + const schema = demo(); + expect(issuesOf(PageComponentSchema.safeParse(schema))).toEqual([]); + // A FULL parse, not only "no unrecognized key": the row also judges values. + expect(issuesOf(row.safeParse(schema.properties))).toEqual([]); + }); + + it('passes objectui’s own schema mirror', () => { + expect(issuesOf(safeValidateSchema(demo()))).toEqual([]); + }); + + it('CONTROL — the row refuses the retired `icon` by name', () => { + const issues = issuesOf(row.safeParse({ ...demo().properties, icon: 'users' })); + expect(issues).toEqual(['icon: invalid_type']); + }); + + it('CONTROL — the mirror refuses a misspelled key inside `properties`', () => { + const result = safeValidateSchema({ ...demo(), properties: { ...demo().properties, subTitle: 'x' } }); + expect(issuesOf(result)).toEqual(['properties: unrecognized_keys']); + }); + + it('CONTROL — the spec refuses a prop written beside `type`, which is why the demo uses `properties`', () => { + const issues = issuesOf(PageComponentSchema.safeParse({ ...demo(), title: 'Users' })); + expect(issues).toEqual([': unrecognized_keys']); + }); + + it('CONTROL — the mirror refuses `children` on this node, which is why the demo carries none', () => { + const result = safeValidateSchema({ ...demo(), children: [{ type: 'button', label: 'Export' }] }); + expect(issuesOf(result)).toEqual(['children: invalid_type']); + }); +}); + +/** + * The declaration face: every key the demo writes is a declared input of the + * `page:header` registration. + * + * `validateTree` reads keys on the node itself, the way the JSX tier writes them + * (as attributes); it has no notion of the `properties` bag. `SchemaRenderer` + * hoists `properties.*` onto the node before the renderer reads it, so the demo + * is validated here in that hoisted shape — the shape the registration's + * `inputs` describe. + */ +describe('the `page:header` registration declares every key the demo writes (objectui#3906)', () => { + const hoisted = (extra: Record = {}) => { + const { properties, ...node } = demo(); + return { ...node, ...properties, ...extra }; + }; + + it('draws no diagnostic at all', () => { + const diagnostics = diagnose(hoisted()); + // Reachability first: an `unknown-component` return never reaches the prop + // check, and an empty list would then mean nothing. + expect(diagnostics.filter((d) => d.code === 'unknown-component')).toEqual([]); + expect(diagnostics.map((d) => d.code)).toEqual([]); + }); + + it('CONTROL — a misspelled key still draws `unknown-prop`', () => { + expect(diagnose(hoisted({ subTitle: 'x' })).map((d) => d.code)).toContain('unknown-prop'); + }); + + it('CONTROL — children under `page:header` draw `not-a-container`', () => { + // The manifest agrees with the renderer and the mirror: the canonical node + // takes no child list. + const codes = diagnose(hoisted({ children: [{ type: 'button', label: 'Export' }] })).map((d) => d.code); + expect(codes).toContain('not-a-container'); + }); +}); + +/** + * ── The `page-header` ALIAS registration, below this line ───────────────────── + * + * Everything from here down is about `@object-ui/layout`'s registrations, not + * about the docs demo. The alias stays registered (retiring it is not + * objectui#3906's job), and `packages/layout` tests point here by path for the + * manifest half of these facts. Until objectui#3906 these pins read the docs + * demo; they now read `ALIAS_FIXTURE`, which is that former demo JSON verbatim, + * so what they measure has not moved. + */ +const ALIAS_FIXTURE = { + type: 'page-header', + title: 'Users', + subtitle: 'Manage your team members and permissions', + icon: 'users', + children: [ + { type: 'button', label: 'Export', variant: 'outline' }, + { type: 'button', label: 'Add User' }, + ], +}; + +/** + * The validator agrees with the alias's render (#3900). * * Two directions, and BOTH are load-bearing. Asserting only the first would * leave the suite green if someone silenced the containment check outright, or * flipped `isContainer` on by default — the test would then be measuring * nothing. The second case is the control that keeps the first one meaningful. */ -describe('the documented demo validates clean of `not-a-container` (#3900)', () => { +describe('the `page-header` alias validates clean of `not-a-container` (#3900)', () => { const CONTAINMENT = 'not-a-container'; - it('reports no `not-a-container` for the demo the docs page ships', () => { - const schema = getExample(EXAMPLE_ID).schema as { children?: unknown[] }; - const diagnostics = diagnose(schema); + it('reports no `not-a-container` for an alias node with children', () => { + const diagnostics = diagnose(ALIAS_FIXTURE); // Reachability BEFORE the absence — an empty result proves nothing if the // containment branch never ran. Two ways it silently would not: // `validateTree` reports `unknown-component` and returns before the // containment check when the tag is missing from the manifest, and the - // branch is guarded by `node.children?.length`, so a demo that lost its + // branch is guarded by `node.children?.length`, so a fixture that lost its // children would satisfy the assertion below for the wrong reason. expect(diagnostics.filter((d) => d.code === 'unknown-component')).toEqual([]); - expect(schema.children?.length ?? 0).toBeGreaterThan(0); + expect(ALIAS_FIXTURE.children.length).toBeGreaterThan(0); - // Filtered by code, not asserted against an empty diagnostic list. When this - // was written the demo's `icon` drew a live `unknown-prop` — the same lie as - // #3900 on the prop face — which #3972 has since fixed and pins in the - // describe below. The filter stays: this test owns the CONTAINMENT fact only, - // so a future diagnostic on another key belongs to that key's pin, not here. + // Filtered by code, not asserted against an empty diagnostic list: this test + // owns the CONTAINMENT fact only, so a future diagnostic on another key + // belongs to that key's pin, not here. expect(diagnostics.filter((d) => d.code === CONTAINMENT)).toEqual([]); }); @@ -192,17 +300,10 @@ describe('the documented demo validates clean of `not-a-container` (#3900)', () // author must still hear about it. If this component ever legitimately // becomes a container, this assertion goes red — move the control to // another childless registration rather than deleting it. - // `items: []` is written for the same single-fact reason the two earlier - // versions of this comment gave, arrived at from the opposite direction each - // time. It used to be OMITTED because writing it drew a `type-mismatch` (the - // registration said `type: 'object'` while `NavigationRendererProps.items` is - // `NavigationItem[]` — fixed by #3972, pinned in the describe below), and - // because its absence drew nothing at all. Both halves have since moved: the - // array form now validates clean, and the absence draws - // `missing-required-prop` (#3987 — omitting `items` crashes the renderer, so - // the declaration says `required: true`). Supplying the empty array keeps the - // planted defect — children under a childless component — the only thing - // wrong with this node. + // `items: []` keeps the planted defect — children under a childless + // component — the only thing wrong with this node: an array-valued `items` + // validates clean (#3972) and omitting it draws `missing-required-prop` + // (#3987), both pinned below. const codes = diagnose({ type: 'navigation-renderer', items: [], @@ -220,38 +321,29 @@ describe('the documented demo validates clean of `not-a-container` (#3900)', () * top-level props against, so a key the renderer reads and `inputs` omits comes * back as `unknown-prop`, and a key declared with the wrong `type` comes back as * `type-mismatch` — on CORRECT authoring, both times. Two keys were in that - * state: `page-header.icon` (rendered at `PageHeader.tsx:224-226`, declared by the - * spec, written by the demo below) and `navigation-renderer.items` (declared - * `object`, actually `NavigationItem[]`). + * state: `page-header.icon` (the alias's renderer-own key, objectui#3829) and + * `navigation-renderer.items` (declared `object`, actually `NavigationItem[]`). * * Every assertion here comes in pairs, positive then control, because "no - * diagnostic" is exactly what a silenced check also looks like: if `unknown-prop` - * or `type-mismatch` were removed from `validate.ts`, or `inputs` were widened to - * accept anything, the positive halves would all still pass. The controls are - * chosen so they can only stay green while the check is still working. + * diagnostic" is exactly what a silenced check also looks like. */ -describe('the declaration face matches what the renderers read (#3972)', () => { +describe('the alias declaration face matches what the renderers read (#3972)', () => { const codesFor = (schema: unknown): string[] => diagnose(schema).map((d) => d.code); - it('draws no `unknown-prop` on the demo the docs page ships', () => { - const schema = getExample(EXAMPLE_ID).schema as Record; - - // Reachability first: the demo must still WRITE `icon`, otherwise the absence - // below is satisfied by a fixture that stopped exercising the key. (The docs - // page documents `icon` in its Component Props block, so a demo without one - // is its own defect.) - expect(schema.icon).toBe('users'); - expect(diagnose(schema).filter((d) => d.code === 'unknown-component')).toEqual([]); + it('draws no `unknown-prop` on the alias fixture', () => { + // Reachability first: the fixture must still WRITE `icon`, otherwise the + // absence below is satisfied by a fixture that stopped exercising the key. + expect(ALIAS_FIXTURE.icon).toBe('users'); + expect(diagnose(ALIAS_FIXTURE).filter((d) => d.code === 'unknown-component')).toEqual([]); - expect(diagnose(schema).filter((d) => d.code === 'unknown-prop')).toEqual([]); + expect(diagnose(ALIAS_FIXTURE).filter((d) => d.code === 'unknown-prop')).toEqual([]); }); it('still draws `unknown-prop` for a near-miss the declaration does not carry', () => { // The control: `subTitle` is a misspelling of the declared `subtitle`. If this // goes green, the check stopped running, and the assertion above is then - // measuring nothing. It was `description` until objectui#11044 — see below. - const schema = { ...(getExample(EXAMPLE_ID).schema as Record), subTitle: 'x' }; - expect(codesFor(schema)).toContain('unknown-prop'); + // measuring nothing. + expect(codesFor({ ...ALIAS_FIXTURE, subTitle: 'x' })).toContain('unknown-prop'); }); it('draws nothing for `description`, the retired alias of `subtitle` — the named cost of objectui#11044', () => { @@ -262,17 +354,15 @@ describe('the declaration face matches what the renderers read (#3972)', () => { // registrations declare, a base prop wherever a type declares no input of that // name, so the parser tier no longer reports it on `page-header`. Pinned so that // restoring the warning is a deliberate change rather than a silent one. - const schema = { ...(getExample(EXAMPLE_ID).schema as Record), description: 'x' }; + const schema = { ...ALIAS_FIXTURE, description: 'x' }; expect(diagnose(schema).filter((d) => d.message.includes('"description"'))).toEqual([]); }); - it('accepts the `actions` array the docs page documents', () => { - // `actions` is read at `PageHeader.tsx:119` / `:192-196` and declared by the - // spec, so it was `unknown-prop` for the same reason `icon` was. Type matters - // as much as presence here: it is declared `array`, matching the canonical - // `page:header`, so the array form authors write validates clean… + it('accepts an `actions` array on the alias, and reports a non-array', () => { + // `actions` is declared `array` on the alias, matching the canonical + // `page:header`, so the array form validates clean… expect(codesFor({ type: 'page-header', title: 'Users', actions: ['export'] })).toEqual([]); - // …and a non-array is now reported instead of silently accepted. + // …and a non-array is reported instead of silently accepted. expect(codesFor({ type: 'page-header', title: 'Users', actions: { export: true } })).toContain( 'type-mismatch', ); @@ -305,22 +395,19 @@ describe('the declaration face matches what the renderers read (#3972)', () => { * * `navigation-renderer.items` is non-optional in TS and has no default, so a node * that omits it throws `TypeError: items is not iterable` on the first thing the - * render does with it (`NavigationRenderer.tsx:1242` → `:1410`; measured in + * render does with it (measured in * `packages/layout/src/__tests__/navigation-renderer-items-declaration.test.tsx`). - * The declaration used to leave `required` unset, and `validate.ts:55-64` reports + * The declaration used to leave `required` unset, and `validateTree` reports * `missing-required-prop` only when it is set — so the ONE node shape guaranteed * to crash was also the one shape the validator had nothing to say about. * * This is a TIGHTENING, not a false-diagnostic fix like #3900/#3972: it adds an - * error-level diagnostic to schemas that validated clean yesterday. That is the + * error-level diagnostic to schemas that validated clean before. That is the * point — the schemas it newly rejects are exactly the ones that cannot render — * but it is also why the control below matters more than usual. "Required" has to - * stay a per-prop fact read off the component: if it ever becomes a blanket, this - * gate starts rejecting perfectly renderable schemas and authors learn to ignore - * `missing-required-prop` the way #3972's authors were being taught to ignore - * `type-mismatch`. + * stay a per-prop fact read off the component. */ -describe('the validator now reports the node shape that is guaranteed to crash (#3987)', () => { +describe('the validator reports the node shape that is guaranteed to crash (#3987)', () => { const REQUIRED = 'missing-required-prop'; it('reports `missing-required-prop` for a `navigation-renderer` without `items`', () => { @@ -339,8 +426,6 @@ describe('the validator now reports the node shape that is guaranteed to crash ( }); it('reports nothing once `items` is supplied', () => { - // The array form the renderer actually consumes: no `missing-required-prop`, - // and no `type-mismatch` either (that pair is #3972's, asserted above). expect(diagnose({ type: 'navigation-renderer', items: [] }).map((d) => d.code)).toEqual([]); expect( diagnose({ @@ -353,12 +438,8 @@ describe('the validator now reports the node shape that is guaranteed to crash ( it('does not report the optional props the renderer defaults', () => { // The control that keeps `required` a per-prop fact. `basePath` is omitted - // here (as it is in the first node of the test above, while the second one - // supplies it — both validate clean): the renderer defaults it - // (`basePath = ''`), the declaration leaves `required` unset, and the gate - // must stay silent about it. If this goes red, the tightening has spread - // from "the prop whose absence crashes" to "every declared prop", and the - // assertions above stop meaning what they say. + // here: the renderer defaults it (`basePath = ''`), the declaration leaves + // `required` unset, and the gate must stay silent about it. const codes = diagnose({ type: 'navigation-renderer', items: [] }).map((d) => d.code); expect(codes).not.toContain(REQUIRED); diff --git a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts index 746a54685a..ae2bd49f01 100644 --- a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts +++ b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts @@ -195,17 +195,19 @@ describe('objectui#7926 — the guide no longer authors `actions` on a `page` no // controls. The last one is the important one: `page:header`'s `actions` is // the READ action-id channel (objectui#7182) and it must STILL be here — a // refusal that took it with it would be the collateral damage the census - // exists to prevent. + // exists to prevent. It is read where the guide authors it since + // objectui#3906: on the canonical `page:header` node, inside `properties` + // (this control used to look for the `page-header` alias, which the guide + // no longer teaches). expect(fences.length).toBeGreaterThan(5); expect(fences.filter((f) => isPageNode(f.doc)).length).toBeGreaterThan(3); expect( - fences.some( - (f) => - !!f.doc && - typeof f.doc === 'object' && - (f.doc as Record).type === 'page-header' && - Array.isArray((f.doc as Record).actions), - ), + fences.some((f) => { + if (!f.doc || typeof f.doc !== 'object') return false; + const doc = f.doc as Record; + const properties = doc.properties as Record | undefined; + return doc.type === 'page:header' && Array.isArray(properties?.actions); + }), ).toBe(true); }); From 746235e262976f1275818e3a3248a54ae47a710d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 00:57:40 +0000 Subject: [PATCH 2/4] chore(changeset): declare objectui#3906 as releasing nothing The only file under a released package the change touches is a test in @object-ui/types; an empty-frontmatter changeset declares it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec --- .changeset/3906-page-header-canonical-docs.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/3906-page-header-canonical-docs.md diff --git a/.changeset/3906-page-header-canonical-docs.md b/.changeset/3906-page-header-canonical-docs.md new file mode 100644 index 0000000000..6594f7cc3e --- /dev/null +++ b/.changeset/3906-page-header-canonical-docs.md @@ -0,0 +1,4 @@ +--- +--- + +No release. objectui#3906 moves the PageHeader docs page, its catalog demo and that demo's pins to the canonical `page:header` node. The only file under a released package it touches is a test, `@object-ui/types`' `page-actions-refusal-7926.test.ts`, whose guide-reading control now looks for the header's action-id channel on `page:header` instead of the `page-header` alias; no published behaviour changes. From a9212f6629d52d9df5f1bcd1c868cfa40df57b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 01:01:39 +0000 Subject: [PATCH 3/4] test(schema-catalog): the button census follows the re-keyed PageHeader demo (objectui#3906) The PageHeader docs demo no longer authors two `button` children: the canonical `page:header` node draws none and the contract refuses the key on it. The dom-leak census counts catalog button nodes, so it moves 118 -> 116, the catalog-authored case its header sanctions. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec --- .../test/form-control-dom-leak-5632.test.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx b/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx index 11027573d9..4d1bd0d9a4 100644 --- a/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx +++ b/examples/schema-catalog/test/form-control-dom-leak-5632.test.tsx @@ -129,7 +129,13 @@ const NODE_CENSUS: Readonly 116 with objectui#3906: the PageHeader docs demo was a `page-header` + // alias node carrying two `type: 'button'` children, and is now the canonical + // `page:header` node, which draws no children (the contract refuses the key + // on it), so the demo authors none. Catalog-authored, no renderer touched — + // the case this table's header sanctions. + button: { rendered: 116, noElement: 0 }, input: { rendered: 48, noElement: 0 }, checkbox: { rendered: 12, noElement: 0 }, switch: { rendered: 7, noElement: 0 }, From c79ae7497bb575a6cf12c32d4d815ff51b82d232 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 01:13:18 +0000 Subject: [PATCH 4/4] docs(layout): the page-header in-a-page example is a non-record page (objectui#3906) A `page` node defaults to `pageType: record`, whose header always owns the heading, so the example could not show the delegation it describes. With `pageType: app` the titled `page:header` is what drops the page's own `

` (measured: one `

` with the header, the page's own without it). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec --- content/docs/layout/page-header.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/content/docs/layout/page-header.mdx b/content/docs/layout/page-header.mdx index c025502657..696fe2a641 100644 --- a/content/docs/layout/page-header.mdx +++ b/content/docs/layout/page-header.mdx @@ -182,6 +182,7 @@ Put the header in the page's `header` region (or first in its `children`): ```json { "type": "page", + "pageType": "app", "title": "Users", "regions": [ { @@ -206,9 +207,10 @@ Put the header in the page's `header` region (or first in its `children`): } ``` -A page has exactly one `

`. When a `page:header` with a literal title sits in the page's -regions or children, the `page` renderer draws no heading of its own and the header's `

` -is the page heading. +A page has exactly one `

`. On a record page (`pageType` defaults to `record`) the header +always owns it. On any other page type, a `page:header` with a literal title in the page's +regions or children makes the `page` renderer drop its own heading, so the header's `

` is +the page heading; without one, the page draws its `title` itself. ## Accessibility