[C09] Page Header (component)

Summary

C09 is a component spec, not a screen — the second such entry after C07 Data Table. It is the canonical page header for the admin portal: the band that sits directly beneath the shell chrome and names the screen the operator is looking at.

Every admin-portal screen has a page header. As of 2026-08-26 no two agree. An audit of the fourteen routed screens found six distinct header idioms across three heading levels, and nine screens leaking a use-case ID (T03 · Stock return) into production markup. This page is the contract that ends that drift; the Angular component ap-page-header is its single implementation.

The page header does not own screen identity at instance scope. C01's entity header — the band the shell renders above the tab strip whenever the URL carries an instance — already names the event, its status and its dates, and already carries the document’s <h1>. A page header that repeats any of that is a duplicate, not a header. See Scope rule — who owns the <h1>.

Design landed 2026-08-26. PageHeader is implemented in the design system at shared/shared-ui.jsx, and every screen in the canvas project composes it — no private page header remains. The eyebrow is derived by sectionFor(screenKey, scope) in shared/nav-model.jsx. :design-url: is a durable project link rather than a share hash, which expires within hours; prefer that form for component specs.

Actor & Context

Actor: indirect — operators meet C09 on every screen; the component has no actor of its own.
Frequency: every page render.
Precondition: the consuming screen knows its own section and title. Nothing else.
Entry point: mounted by the screen as <ap-page-header>, immediately inside the router outlet, above all other content.

Anatomy

┌──────────────────────────────────────────────────────────────┐
│ ENTRIES                              ← eyebrow (section)     │
│ Participants                         ← title  (the screen)   │
│ 1 240 rows across 4 categories       ← subtitle (optional)   │
│                        [Import] [Add] [Export]  ← [actions]  │
└──────────────────────────────────────────────────────────────┘
Slot Required Content

eyebrow

Yes

The section the screen belongs to, in the vocabulary the sidebar and tab strip already use — Inventory, Entries, Money, Results, Setup, Tenant admin. One word or two. Never a use-case ID. See Eyebrow vocabulary.

title

Yes

The screen, as a noun phrase: Participants, Return numbers to stock, Stock reports. Never the entity the screen is scoped to. No counts, no icons, no badges.

subtitle

No

One line of orienting context — a count, a scope note, a state summary. Muted, small. Omitted rather than filled with filler.

[actions]

No

Projected content, right-aligned, vertically bottom-aligned with the title. The screen’s primary verbs, or the bulk-action bar that replaces them. Owned by List Screen Affordances; C09 only provides the slot and guarantees its position is height-stable.

[meta]

No

Projected content beneath the subtitle — stage bars, progress pills, chips. The import flow’s four-stage bar lands here.

Scope rule — who owns the <h1>

The portal has two headers stacked, and only one of them may be the document heading.

Scope Entity header (C01) Page header (C09)

Tenant/o/:orgSeg/inventory/numbers

Not rendered.

Carries the <h1>. Title names the screen.

Instance/o/:orgSeg/events/145/participants

Rendered. Carries the <h1> — the event name, its status chip, its date and venue.

Demotes to <h2>. Title names the screen only. It must not repeat the entity’s name, id, date, venue or status.

The component derives its own heading level from ScopeService; consumers never choose it. This is not a styling preference — it is the accessibility contract. Exactly one <h1> per page, and it is the most specific thing on screen that the operator navigated to.

Anti-patterns this rule retires
  • Participants — Event 145 beneath a header that already says Killarney 1. The suffix is noise; the id is not operator vocabulary.

  • A second <h1> restating the event name, with a second status chip that can disagree with the first (the E01 defect logged as T1.6 in the hardening journal).

  • Screens with no heading element at all, where the visually-largest text is a <div>.

Eyebrow vocabulary

The eyebrow answers "where am I?", so it must speak the same language as the thing the operator clicked to get here.

The eyebrow is derived, not authored. The design side settled this on 2026-08-26 with a sectionFor(screenKey, scope) helper in the navigation model — sidebar group label at tenant and platform scope, tab label at instance scope. Looking it up removes the failure mode a written rule cannot: an eyebrow that was correct when typed and silently wrong after the nav was regrouped.

The Angular implementation must do the same. ap-page-header derives its eyebrow from the screen registry via ScopeService; screens do not pass an eyebrow string unless they are overriding a derived value, and an override is a signal that the nav model is wrong.

Rule Detail

Use the section

The sidebar group at tenant scope, or the tab at instance scope. The eyebrow and the highlighted nav item always match — because both read the same registry entry.

Never a use-case ID

T02, E06, C05 are our filing system. They are not on any screen the operator can navigate from, appear in no documentation the operator reads, and cannot be looked up from inside the portal. See Use-case IDs are not shipped.

Never a restatement of the title

Stock reports / Stock reports is not an eyebrow. Where the section and the screen genuinely share a name (Orders / Orders), the eyebrow is the parent section, or is omitted.

Sentence case, rendered uppercase

Authored as Inventory; the component applies text-transform. Authors never write shouting strings.

Use-case IDs are not shipped

Nine screens shipped their use-case ID in the eyebrow. The provenance is traceable: the Claude Design canvases label each artboard with its ID — necessary there, because the canvas is a wall of twenty screens and the label is how you find one — and the implementation transcribed the artboard label as page content.

Decision (2026-08-26): the IDs are removed from the UI entirely. They are not rendered in development builds either. The affordance for cross-referencing a screen to its spec is the file, not the running app.

The rule is enforced, not merely documented, in two places. PageHeader strips a leading `<prefix><NN> · ` from any eyebrow handed to it, so a transcribed code cannot render. A unit test additionally scans component templates for the pattern and fails the build.

Anchor the test’s pattern at the start of the string. An unanchored [CETMSA]\d{0,2} · ` matches ordinary content — a timestamp ending `SAST · ` matches on the `T — and a guard that cries wolf gets deleted.

Table 1. Settled eyebrows (design side swept 2026-08-26; Angular to follow)
Screen Was Now

T02

T02 · Number stock

Operations

T03

T03 · Stock return

Operations

T05

Inventory · Stock reports

Insights

E02

E02 · Event participants

Entries

C06

C06 · Order detail

Events

C05

C05 · Mapping flow · Step N of 4

Entries

E06

E06 · Import participants / E06 · Import summary

Entries

E08

E08 · Import results / E08 · Result import

Results

Membership import

M · Import memberships

derived when the screen is designed

Three of these differ from the values first guessed in this spec (Inventory for T02/T03, Money for C06). That is the point of deriving rather than authoring: the guesses were plausible and wrong, because Inventory is not a sidebar group and orders sits in the Events group. The derivation is authoritative; where it reads oddly, the fix is the nav model, not the eyebrow.

T05 is the clearest case. Its old eyebrow named a section — Inventory — that does not exist in the sidebar at all, and the screen had already been moved to the Insights anchor in the 2026-08-14 rewire. Nothing flagged the mismatch because the string was typed by hand.

Open — C06 under Events. orders is a row in the Events sidebar group, so sectionFor returns Events, which is correct for the navigation and reads strangely above a single order. A Money or Orders group would fit better. This is a sidebar-grouping question, not an eyebrow question — resolve it in C01 and C09 follows automatically.

The C05 stage screens dropped Step N of 4 along with the code. The stage bar immediately below the header already carries the step, and an eyebrow is a section, not a progress indicator.

The E08 pair is worth noting on its own: the upload step says Import results and the summary step says Result import. Two labels, one flow, differing by word order — the kind of drift that only a shared component with a fixed vocabulary prevents.

API Surface

Input Type Purpose

eyebrow

string

Section label. Required.

title

string

Screen name. Required.

subtitle

string | null

Optional orienting line. Omit rather than pass ''.

Content slot Purpose

[actions]

Right-aligned verb group or bulk bar.

[meta]

Stage bar, progress chips, secondary context beneath the subtitle.

Heading level is derived, not an input. There is deliberately no headingLevel prop: making it a choice makes it a per-screen decision, which is the problem this spec exists to remove.

Visual Treatment

Locked to the values import-page-shell already ships, which are the most-reviewed of the six current idioms:

Element Treatment

Band

White, 1px bottom border #e5e7eb, padding 0.875rem 1.5rem 0.

Eyebrow

0.6875rem / 700 / #4f46e5 / letter-spacing 0.6px / uppercase / margin-bottom 0.25rem.

Title

1.25rem / 700 / #111827 / letter-spacing -0.3px.

Subtitle

0.8125rem / #6b7280.

Actions

Bottom-aligned with the title baseline; flex, gap 0.5rem; the row does not change height when the bulk bar replaces it.

The <h2> at instance scope renders at the same size as the <h1> at tenant scope. Demotion is semantic, not visual — the operator should see one consistent header treatment across the portal.

Consumers

All fourteen routed screens, plus the four nested detail routes. import-page-shell retains the two-column body and right rail, and mounts ap-page-header for its band rather than drawing its own.

E05 and C03 are the two exemptions: E05’s Welcome back, {name} greeting is a personalised landing device, and C03 is a pre-auth marketing page outside the shell entirely. Both are documented exemptions, not drift.

Out of Scope (today)

  • Breadcrumbs — owned by C01’s topbar.

  • Entity identity, status chips, tab strips — owned by C01’s entity header.

  • Action-button semantics, stats strips, filter chips — owned by List Screen Affordances.

  • Responsive collapse below 720px. The portal is desktop-first; the mobile treatment needs its own pass.

Design Decisions

Decision Rationale

Use-case IDs removed from the UI entirely, including dev builds

They are internal filing references with no operator meaning and no in-app lookup. A dev-only variant was considered and rejected: a conditional label is one isDevMode() regression away from shipping, and the cross-reference belongs in the .adoc.

Heading level derived from scope, not passed in

A headingLevel input recreates the per-screen decision the spec exists to remove. Derivation makes "exactly one `<h1>`" a property of the system rather than a rule authors must remember.

Component lives in app/shared/, not imports/shared/

The existing header band is already correct; it is only unreachable. Promoting it is a move, not a rewrite.

Eyebrow tracks the nav, not the module

An operator’s mental model of "where am I" is built from what they clicked. Matching the eyebrow to the highlighted sidebar group or tab makes the header confirm the navigation rather than introduce a second taxonomy.

A test guards the ID pattern

Nine screens acquired the ID by transcription from a canvas, not by decision. Documentation alone would not have stopped any of them.

Future Use Cases

FU-1 — Responsive collapse

Below the 720px breakpoint the eyebrow and subtitle likely fold into the title and the actions collapse to an overflow menu. Needs a design pass alongside the rest of the portal’s mobile treatment.

FU-2 — ALL TENANTS pill

C01 places the platform-scope ALL TENANTS pill "beside the page title". When platform screens are built, C09 gains a slot for it rather than each platform screen positioning its own.

FU-3 — Sticky behaviour

C06 currently sticks its header on scroll; no other screen does. Either every page header sticks or none does — pick one when there is a second screen that wants it.

Notes

Claude Design. The canvas project’s shared library (shared/shared-ui.jsx) exports Icon, Chrome, Avatar, Tag and Dot — there is no PageHeader. Every canvas redraws the header inline, which is why no two agree. Adding a PageHeader to that library, with the artboard’s use-case ID rendered as canvas chrome outside the frame, stops the drift at source instead of correcting it downstream on every import.