Agent docs

PageTitle

The page header row: identity, title and subtitle, inline zone, actions.

The page header row: an identity block (icon or avatar), a title, a meta line and a subtitle, an inline zone right next to the title, and an actions bar, with an optional back-link row above all of it. The icon sits on the title line; meta and subtitle start at the title's left edge, indented under the title text.

The canon behind it. method/design-language/09-page-composition.md §A4 (09-page-composition.md:25, PageTitle's slot in the fixed composition order), method/design-language/08-navigation-and-shell.md §A2 (08-navigation-and-shell.md:17, the one map a page's icon and label read from), and method/design-language/04-loading-and-states.md §A3 (04-loading-and-states.md:21, never blank a title already known).

Entrance. The title row fades up as one; the icon disc arrives a beat later (animate-discIn, 0.14s delay, scale from 0.7), owner ruling doc 59 S3. noAnimate stills both.

Phone width. The title block keeps a 14rem floor (min-w-[min(100%,14rem)]), so a wide actions bar wraps under the title on a phone rather than squeezing the subtitle into a one-word column (doc 59 S6 family, found by the wave 2b categorical pass). An actions bar that wraps lands on its own row, left-aligned, and wraps again inside that row if three buttons still do not fit; it is never clipped. Beside the title the bar sits at the right edge because the title block takes the free space.

Laws it enforces#

  • Every composite declares its narrow behaviour: actions wrap to a left-aligned row of their own, a long id truncates instead of overflowing, and the loading header mirrors the meta line so it does not jump.
  • Fit, then truncate, never clip: a string subtitle stops at two lines and carries the full sentence on hover, only when something is actually cut.
  • Things beside a label share its line: the icon disc sits on the title line, not in the middle of title, meta and subtitle.

Toolbar actions. The right-aligned actions bar follows the same rule as a card toolbar: no bare outline-plus-neutral button; an arrow for navigation, a shade plus a tone for an action; icon-only actions carry the rich two-line tooltip (canon doc 2 §A10, doc 60 §6, tooltip.md "The rich preset").

When to reach for it#

PageTitle is a fixed step in the page composition order, not an optional header. Canon doc 9 §A4 states the order inside a page's main column: breadcrumbs (2+ levels deep only) → route outlet → panel-inner[.wide] → PageTitle (pinned shrink-0 inside scrolling flex columns) → stat tiles → cards, sections, tables. "Pages differ in content, not in ordering." Doc 8 §B lists it as one of the shell's own presets, alongside the sidebar and breadcrumbs, because the sidebar, the page title, and any tooltip naming a page all read the same PAGE_META entry (doc 8 §A2), "so a page's icon/label can never drift from its sidebar entry."

Every page in both apps that isn't a bare auth screen opens with a PageTitle. It is the one place a page states what it is before rendering anything else.

Contract#

Identity (renders only when Icon or avatar is given, avatar wins):

  • Icon / iconSize / iconClassName / iconWrapClassName (IconImportProps). The disc aligns to the title line, not to the title-plus-subtitle block: the title line is as tall as the disc (h-10) and the disc sits at the top of the row, so a one-line or two-line subtitle never moves it.
  • avatar: a full JSX node (a gravatar, a glyph) that replaces the icon inside the same disc. Real usage renders a module glyph or an icon component directly as the avatar (speedway/app/routes/workspaces/modules.tsx:684, 786).

Title row:

  • meta: one short muted line directly under the title, above the subtitle (a freshness stamp such as "Updated 10:42:03", an owner, a count). It is the home for text that used to be squeezed into actions in a faint ink. An array renders as separate items that wrap without leaving a stray separator at a line break. The slot class is metaClassName.
  • title (required) and subtitle: string or JSX via renderNode. A string subtitle is clamped to two lines and shows the full text in a tooltip when the clamp hides words; a JSX subtitle is not clamped. A string gets the row's own styling (text-2xl font-bold for the title, text-base-content/65 text-sm for the subtitle, page-title.tsx:89, 92). JSX passes through unstyled, so a JSX title must dress itself: speedway/app/routes/workspaces/jobs/job.tsx:256-272 wraps an InlineEdit in its own <h1 className="truncate text-2xl font-bold">, matching page-title.tsx:89's class verbatim, with the reasoning stated inline: "PageTitle styles STRING titles only; a JSX title must dress itself."
  • titleExtra: an inline zone right beside the title, for a status pill or a small toggle that belongs to the title itself rather than the actions bar. walmart-mvp/frontend/src/pages/Jobs.tsx:833-852 puts a clickable StatusPill count here ("in review"), toggled as a filter.
  • actions: the right-aligned actions bar. The same Jobs.tsx block pairs a last-fetched timestamp, a Refresh button, and a primary "New job" button in one actions node (:853-878); the kit's shape for the first two is RefreshControl, one round icon button with the freshness in its tooltip and no clock beside it.

Back row:

  • back: its own row above the title. Pass the app's own <Link>; the kit adds the left arrow and the muted hover styling (page-title.tsx:62-71), so a caller only supplies the link text and destination, never the arrow markup.

State:

  • noAnimate: opts out of the animate-fadeInUp entrance the row plays by default on mount (skeleton mode never animates). Every page opens with one PageTitle, so the row's own entrance is the app's de facto "page ready" cue; this is why no playground page needs a dedicated animation demo, every page header already shows it.
  • skeleton: a slot-aware placeholder. It mirrors the real row's structure and draws bars only for the slots the page actually uses (an identity disc bar only if Icon/avatar would render, an actions bar only if actions would render), per page-title.tsx:27-50. No shipped usage of skeleton found in either app. Every skeleton and boot-loading page found instead renders PageTitle with its real, usually static, title and subtitle: walmart-mvp/frontend/src/lib/routeSkeletons.tsx:42-46, 87-91, 101-105 renders the exact same icon/title/subtitle a route's own component would, inside what is otherwise a placeholder page. This matches canon doc 4 §A3 directly: "Static, known-ahead-of-time text renders immediately... the title is derived from the URL when possible: name it instead of an anonymous bar." Reach for skeleton only when the title itself is genuinely unknown at render time, not merely because the rest of the page hasn't loaded.

Slots and DOM:

  • Slot classnames per PageTitleClassNames: className, iconWrapClassName, titleClassName, subtitleClassName, actionsClassName, backClassName.

Sanctioned combinations#

CombinationProducesWhere usedWhy
Icon + title + subtitle + titleExtra + actionsA list page's full header: identity, copy, an inline status toggle, and a trailing action clusterwalmart-mvp/frontend/src/pages/Jobs.tsx:829-879The richest real header in either app; every slot but back and avatar in one call
title as a renderNode-styled string, back as a real <Link>, actions as a button clusterA detail page's header with a return pathspeedway/app/routes/workspaces/jobs/run.tsx:163-166, speedway/app/routes/admin/team.tsx:288-297The detail-page-with-back preset from canon doc 9 §B
title as JSX (InlineEdit wrapped in an <h1> carrying the row's own title classes)A title the user can rename in place, styled identically to a plain string titlespeedway/app/routes/workspaces/jobs/job.tsx:256-272Demonstrates the "JSX must dress itself" rule with the exact class string page-title.tsx:89 uses
back={<span />} with no other back content, alongside a manually-skeletoned title beneath itReserves the back row's height while the real back link's destination is still loadingspeedway/app/routes/account/accounts.tsx:76-82The page's PageTitle itself is NOT in skeleton mode here; only a sibling <div className="skeleton ..."> bones the title text, while back holds an empty node so the layout doesn't jump once the real link resolves
avatar={<Glyph size={18} />}A module's own glyph in the identity disc instead of a stock iconspeedway/app/routes/workspaces/modules.tsx:684, 786avatar wins over Icon for module-identity pages where the glyph IS the identity

Banned combinations#

Do not use a second PageTitle as a section heading inside a page. A page has one PageTitle and one h1; a heading for a block of content is a Card title or a Kicker. A second PageTitle mounts a second h1 and a second identity disc where the page wanted a label.

Do not put a status clock or "Updated" text in actions. It belongs in meta, in the correct muted ink, where it sits under the title instead of competing with the buttons for width.

Do not write a policy sentence as the subtitle. The clamp keeps the header from growing, but a subtitle is a one-line description of the page, not a paragraph.

Do not hand-build the back-link arrow or its hover styling. back is a slot for the link's content only; page-title.tsx:62 supplies the LeftArrow icon and the muted-to-solid hover transition itself. A caller that renders its own arrow before the link duplicates kit chrome.

Do not reach for skeleton to blank a title that is already known. As shown above, real boot-skeleton pages render PageTitle with the actual static title and subtitle text, never the slot-aware placeholder, because the page's identity is fixed at build time even when its data is not (canon doc 4 §A3).

Do not style a JSX title expecting renderNode to do it. Only strings, numbers, and booleans get wrapped and styled (internal/render-node.tsx:16-19); a JSX node passes through untouched, so its className is the caller's responsibility, per the job.tsx case above.

Before you adopt this#

Five questions to answer before reaching for PageTitle.

  1. Does the shell, a parent layout, or a global provider already render this? Not applicable, PageTitle is a fixed step in the page's own composition order.
  2. Does this app already ship a local implementation of the same thing? Not applicable, no local PageTitle clone is documented in either app.
  3. Does this app's kit pin reach the version this component or prop landed in? meta, the subtitle clamp and the wrapping actions bar arrive with the next kit bump after 0.5.0; a ^0.2.x app gets none of them until it upgrades. On upgrade an app moves its "Updated" text from actions to meta; nothing else is required.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? back supplies only link content; the kit adds the arrow and hover styling itself.
  5. Which canon §A rules bind this surface, and which does the composition break? §A2 reads the same PAGE_META entry as the sidebar; icon and label can't drift.

Travels with#

PAGE_META, the app-level single source for a page's icon and label (canon doc 8 §A2). PageTitle's Icon and title are meant to read from the same map the sidebar and breadcrumbs use, not a separately hand-typed icon/string pair.

Card and StatTile, which follow PageTitle in the fixed composition order (canon doc 9 §A4): PageTitle is pinned shrink-0 above them on scrolling pages.

The per-route boot-skeleton module (routeSkeletons.tsx), which renders the real PageTitle inside an otherwise-placeholder page (canon doc 4 §A10).

Snippet#

import { Button, PageTitle, RefreshControl, StatusPill } from "@versable-git/ui";
// Adapted from walmart-mvp/frontend/src/pages/Jobs.tsx:829-879. The app's own
// inline "Updated 23:15:04" clock and text Refresh button are replaced by
// RefreshControl: a round icon button whose tooltip carries the freshness.
<PageTitle
Icon={appIcon.jobs}
title="Jobs"
subtitle="Every batch you've imported and exactly where it is in the pipeline. Click a row for the stage-by-stage view."
titleExtra={
jobs && jobs.some((j) => j.status === "in_review") ? (
<button
type="button"
onClick={() => setReviewOnly((v) => !v)}
aria-pressed={reviewOnly}
aria-label="Show only jobs in review"
className={`cursor-pointer rounded-full transition-shadow ${reviewOnly ? "ring-primary/35 ring-2 ring-offset-1" : ""}`}
>
<StatusPill kind="review" size="sm" count={jobs.filter((j) => j.status === "in_review").length}>
in review
</StatusPill>
</button>
) : undefined
}
actions={
<div className="flex items-center gap-3">
<RefreshControl updatedAt={lastFetchedAt} loading={jobsSWR.revalidating} onRefresh={refreshNow} />
<Button color="success" size="sm" Icon="Plus" content="New job" onClick={() => navigate("/import")} />
</div>
}
/>
@versable-git/ui · reference, canon, and method, read in place