@terpjs/react-core 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,7 +77,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
77
77
  | Export | Use |
78
78
  |---|---|
79
79
  | `AppShell` | The responsive level-1 frame: a home-linked brand, icon/label nav and account footer. Desktop collapses to a persisted, scrollbar-free rail with one fixed icon slot; mobile becomes a scroll-locking drawer. The sticky header holds the sidebar toggle and icon-only preferences. Router-agnostic link renderers receive framework-owned expanded/collapsed geometry. |
80
- | Shell geometry (tokens, not props) | `--shell-sidebar-width-expanded` / `-collapsed`, `--shell-header-height`, `--shell-content-max-width` and `--shell-brand-size` are published contract tokens: an app moves them from its own unlayered `theme.css` with no prop at all, and the Studio gets them from the manifest. |
80
+ | Shell geometry (tokens, not props) | `--shell-sidebar-width-expanded` / `-collapsed`, `--shell-header-height`, `--shell-content-max-width`, `--shell-brand-size` and `--shell-gutter` (the content column's inline gutter, read by the app header, `main`, the footer and the page band, and tightened one step below the mobile breakpoint) are published contract tokens: an app moves them from its own unlayered `theme.css` with no prop at all, and the Studio gets them from the manifest. |
81
81
  | `logo` / `logoDark` (shell) | The brand mark, rendered in a box of `--shell-brand-size` so an asset larger than the 4rem icon rail is scaled rather than clipped — which is why there is no separate collapsed-mark slot: `logo` is the mark, `title` is the wordmark, and the rail already hides the second. `logoDark` is for a fixed-colour mark that cannot survive a dark theme; both render and the stylesheet shows one, switched on `--appearance-show-light` / `--appearance-show-dark`, which the token build emits from each theme's declared `appearance`. Resolving it in React would be wrong: the theme is `<html data-theme>` and an app may set it with no provider mounted. |
82
82
  | `density` (shell) | App-wide `"comfortable"` or `"compact"`, stamped on the shell root; every control height and cell padding follows by token inheritance. **No default** — omitting it stamps nothing, so an app's own `data-density` on `<html>` still wins. A subtree may override it: a `DataView density="comfortable"` inside a compact shell really is comfortable, because comfortable now has named tokens and a rule rather than being the absence of an attribute. |
83
83
  | Skip to content | The shell renders the skip link and owns the landmarks it skips: `main` carries `tabIndex={-1}` and a per-instance id, so activating the link *moves focus* rather than only scrolling and two shells on one page get two distinct targets. It is not rendered while the mobile drawer is open — the drawer is `aria-modal` and its target sits inside the `inert` column. |
@@ -89,7 +89,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
89
89
  | `navPlacement` (shell) | Where the primary navigation lives on desktop: `"sidebar"` (default, stamps nothing) or `"header"` — a horizontal row in the header with no sidebar at all, for an app whose destinations are few enough that 15rem of permanent chrome is a tax. The header then *becomes* the sidebar surface, so every `--color-sidebar-*` an app themes carries over and no property is overridden. Below the mobile breakpoint both placements are the drawer. `defaultCollapsed` is `never` under `"header"`: with no sidebar there is nothing to collapse. Available on `AppShell`, `buildAppRouter` and `renderTerpApp`. |
90
90
  | `contentWidth` (shell) | `"measured"` caps routed content at `--shell-content-max-width` while each page's own header keeps the full track — the subheader band. Default `"full"` stamps no attribute, so nothing moves until an app asks. Available on `AppShell`, `buildAppRouter` and `renderTerpApp`. |
91
91
  | `NavIcon`, `Icon`, `TerpMark`, `ICON_GLYPHS`, `ICON_NAMES` | The dependency-free icon layer. `ICON_NAMES` is published by `@terpjs/contract` as data and `IconName` is derived from it, so `NavItem.icon` and `Icon`'s `name` are **checked names** — a typo is a typecheck error, not a picture of nothing. `NavIcon`'s `name` stays a plain `string` on purpose: its unknown-name behaviour is a designed, visible fallback (the label's initial in a tile), while `Icon` rendered an empty box. The glyph table is held to the name set exhaustively in both directions by `satisfies`, so a glyph without a name or a name without a glyph fails to compile. The bundled catalogue covers common UI, action, object and status glyphs (home, list, folder, users, plus, edit, trash, search, check, x, chevron-{left,right,down}, arrow-left, external, logout, user, bell, key, globe, lock, tag, mail, refresh, filter, download, upload, star, heart, database, code, truck, cart, wallet, map-pin, clock, link, grid, book, briefcase, building, clipboard, layers, send, phone, image, video, music, wrench, zap, …). `TerpMark` is the placeholder brand mark until an app passes its own `logo`. |
92
- | `Page` | The base routed screen: optional breadcrumb row, then one compact `h1` + intrinsic-width actions row (title-first on narrow layouts), then the body with loading/error slots. |
92
+ | `Page` | The base routed screen: one band carrying the breadcrumb trail whose leaf **is** the view's single `h1` plus `badges`, a truncated `description` lead line and the `actions` cluster, then the body with loading/error slots. Every page renders a trail, even one of a single crumb, so an overview's title sits where a detail's does. Inside a shell the band bleeds to the content column's edge and takes `--shell-header-height`; a `measure="narrow"` frame keeps the row and drops the chrome. |
93
93
  | `HubPage`, `HubCard` | Responsive `auto-fit` landing grid. Cards share equal outer and internal tracks even when descriptions/stats differ; nested hubs use the ordinary breadcrumb contract via `parents`. |
94
94
  | `OverviewPage` | A module's top-level listing screen (level 2); detail pages crumb back to it. |
95
95
  | `DetailPage` | One record's screen (level 3); breadcrumb trail = ancestors + record title. |
@@ -234,9 +234,9 @@ token spacing scale, so spacing is themed centrally):
234
234
  |---|---|
235
235
  | `Stack` | The layout primitive: a flex container with a token gap. Vertical by default (forms, sections); `direction="row"` + `justify` for toolbars; `as="form"` etc. for semantics. `padding` insets on the same token scale; `direction` and `gap` also take a `{ narrow, wide }` pair, which changes over at the one viewport cutover the shell and the DataView already use. |
236
236
  | `Grid` | The two-dimensional primitive, and the one that lifts a real ceiling — a two-column form could not be expressed at all before it. `columns` takes a fixed 1–4 or `"auto"` (the default), which reflows to whatever the **container** can hold with no breakpoint anywhere; `minColumn` is the track floor for `auto`; `gap` indexes the spacing scale; `align` is a closed four. Renders no inline style. No `span`, and therefore no twelve-column option — a span system needs a child component to carry it. |
237
- | `Card` | A token-styled surface (border + background + padding) grouping one block of a page — the sanctioned visual separation between sections. Optional header row: `title` (semantic `<h3>`), muted `description`, `actions` slot. `variant="plain"` keeps the heading and drops the box, for a titled region inside something that is already a surface — a section whose body is a `DataView` gets a border inside a border otherwise. There is no separate `Section` or `Surface`: both are this element with declarations removed. |
237
+ | `Card` | A token-styled frame (a border, a radius and padding — no fill, so the page's canvas shows through) grouping one block of a page — the sanctioned visual separation between sections. Optional header row: `title` (semantic `<h3>`), muted `description`, `actions` slot — and the slot stays on the title's line whether or not there is a description, which took a rule: a content-sized heading is as wide as its longest line, and flex breaks lines on that width before it shrinks anything. `variant="plain"` keeps the heading and drops the box, for a titled region inside something that is already a surface — a section whose body is a `DataView` gets a border inside a border otherwise. There is no separate `Section` or `Surface`: both are this element with declarations removed. |
238
238
  | `Divider` | A rule between groups, as a semantic `<hr>` so the separation reaches the accessibility tree — `Separator` under its other name, shipped once. `orientation="vertical"` takes its height from its flex or grid line rather than inventing one, so it works between the items of a row `Stack` and is zero-height in a block parent. |
239
- | `DetailList` | Token-styled label/value pairs as a semantic `<dl>` (record metadata, expanded-row summaries). A real grid: `layout="aligned"` puts every label in a shared column so the values line up, `"stacked"` puts the label above its value, and `columns` takes two pairs per row. Tracks are floored at zero and long values wrap, so a 64-character digest no longer pushes the list past its container. |
239
+ | `DetailList` | Token-styled label/value pairs as a semantic `<dl>` (record metadata, expanded-row summaries). A real grid: `layout="aligned"` puts every label in a shared column so the values line up, `"stacked"` puts the label above its value, and `columns` takes two pairs per row. Tracks are floored at zero and long values wrap, so a 64-character digest no longer pushes the list past its container. Both non-inline layouts mute their label to the small, regular step — at the value's own size and weight a card of labelled values is a wall of bold text — and space their rows so a pair groups; `gap` moves that distance on the spacing scale, and it is the **row** gap, since the column gap is the label-to-value distance. Below the framework's viewport cutover it reflows to one column, where `Grid` deliberately does not for a fixed count: `Grid` publishes `columns="auto"` as its responsive answer and this component's closed one-or-two has none, so the reflow has to be its own. |
240
240
  | `Heading` | A section heading inside a page body — `h2`–`h4`, with `size` a **separate** choice from `level` so a visually small `h2` is expressible without picking the wrong element. No level 1: `Page` renders the single `h1` of every routed view. |
241
241
  | `Text` | Body copy with themeable ink — `tone` (default / muted / subtle), `size`, and an enumerable `measure` that caps the line length in `ch`. What a bare `<p>` in a module cannot be, since a bare element carries no marker for a rule to reach. |
242
242
  | `Code` | An identifier or a snippet in the mono family. `block` wraps it in a focusable `<pre>`, which is what preserves the whitespace and what makes a long line scrollable by keyboard. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
6
6
  "exports": {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@tanstack/react-router": "^1.170.16",
17
- "@terpjs/contract": "^0.13.1"
17
+ "@terpjs/contract": "^0.15.0"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "react": "^19.0.0",
package/src/AppShell.tsx CHANGED
@@ -104,7 +104,15 @@ interface AppShellBaseProps {
104
104
  *
105
105
  * "Full width" means the full width of the article's own track. `appshell-main`'s padding
106
106
  * sits outside it, so this is a measure within the content column rather than a bleed to the
107
- * window edge — which would need a negative margin, and therefore an inline site.
107
+ * window edge.
108
+ *
109
+ * The page's header does reach past that padding, to the content column's edge, and it does
110
+ * it with a negative margin of one `--shell-gutter` per side — so a band and the app
111
+ * header above it read as one piece of chrome. This paragraph used to say such a bleed
112
+ * "would need a negative margin, and therefore an inline site"; the margin is right and the
113
+ * inline site is not, because the sheet knows both viewport variants and the gutter is a
114
+ * published token. ADR 0097 §2 carries the amendment. Nothing here needs the app's help:
115
+ * the band is `Page`'s own header, not something `contentWidth` switches on.
108
116
  */
109
117
  contentWidth?: "full" | "measured";
110
118
  /**
@@ -43,6 +43,36 @@ describe("Breadcrumbs", () => {
43
43
  expect(screen.getByText("Section")).not.toHaveAttribute("aria-current");
44
44
  });
45
45
 
46
+ it("renders the current crumb as the page heading when asked, and only then", () => {
47
+ // The page band's case: the trail IS the title, so its leaf is the view's single h1
48
+ // rather than a second copy of the same string sitting under the trail. The element
49
+ // changes and the accessible current-ness does not.
50
+ const { container } = render(
51
+ <Breadcrumbs
52
+ items={[{ label: "Tasks", to: "/tasks" }, { label: "Fix the door" }]}
53
+ currentAs="h1"
54
+ />,
55
+ );
56
+
57
+ const heading = screen.getByRole("heading", { level: 1, name: "Fix the door" });
58
+ expect(heading).toHaveAttribute("aria-current", "page");
59
+ // The marker moves with the element, because the two mean different things to the sheet:
60
+ // a trail's end, versus a heading that happens to sit at the trail's end.
61
+ expect(heading).toHaveAttribute("data-terp", "page-title");
62
+ expect(container.querySelector('[data-terp="breadcrumbs-current"]')).toBeNull();
63
+ // The ancestor is untouched: currentAs describes the LEAF only.
64
+ expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
65
+ });
66
+
67
+ it("defaults to a span, so a standalone trail mints no heading", () => {
68
+ // The default matters as much as the option. A wayfinding trail rendered anywhere on a
69
+ // page must not introduce an h1 competing with that page's own.
70
+ render(<Breadcrumbs items={[{ label: "Tasks", to: "/tasks" }, { label: "Here" }]} />);
71
+
72
+ expect(screen.queryByRole("heading")).not.toBeInTheDocument();
73
+ expect(screen.getByText("Here")).toHaveAttribute("data-terp", "breadcrumbs-current");
74
+ });
75
+
46
76
  it("marks only the final crumb as current, on a marker of its own", () => {
47
77
  // The current-crumb styling deliberately does NOT key on aria-current. A router's Link
48
78
  // stamps aria-current="page" on every link whose path is a prefix of the current one —
@@ -21,6 +21,21 @@ export type RenderBreadcrumbLink = (item: { label: string; to: string }) => Reac
21
21
  export interface BreadcrumbsProps {
22
22
  /** The trail, outermost first; the last item is the current page. */
23
23
  items: readonly BreadcrumbItem[];
24
+ /**
25
+ * What element the current (last) crumb renders as. Default `"span"`.
26
+ *
27
+ * `"h1"` is the page band's case (see `Page`): there the trail IS the page title, so its
28
+ * leaf is the view's single heading rather than a second copy of the same string sitting
29
+ * under the trail. It carries `data-terp="page-title"` instead of `breadcrumbs-current`,
30
+ * because the two mean different things to the sheet: one is a trail's end, the other is a
31
+ * heading that happens to sit at the trail's end and is styled as such.
32
+ *
33
+ * The heading lands inside the list item, which is deliberate rather than convenient. `li`
34
+ * takes flow content, so it is valid; it keeps the separator logic in one place instead of
35
+ * asking the caller to draw a chevron; and it leaves `aria-current="page"` on the same node
36
+ * a screen reader reads as the heading.
37
+ */
38
+ currentAs?: "span" | "h1";
24
39
  /**
25
40
  * Link renderer for ancestor crumbs. Defaults to the surrounding router's `Link`
26
41
  * (published by `buildAppRouter`), falling back to a plain `<a href>` only outside a
@@ -56,7 +71,7 @@ const anchorRenderLink: RenderBreadcrumbLink = (item) => <a href={item.to}>{item
56
71
  * list, and `aria-current="page"` on the final crumb. Router-agnostic — `renderLink` turns
57
72
  * an ancestor crumb into the active stack's link, exactly like `AppShell`'s `renderLink`.
58
73
  */
59
- export function Breadcrumbs({ items, renderLink }: BreadcrumbsProps) {
74
+ export function Breadcrumbs({ items, renderLink, currentAs = "span" }: BreadcrumbsProps) {
60
75
  const navLink = useNavLink();
61
76
  const renderCrumbLink =
62
77
  renderLink ??
@@ -75,13 +90,26 @@ export function Breadcrumbs({ items, renderLink }: BreadcrumbsProps) {
75
90
  <li key={`${index}-${label}`}>
76
91
  {!isLast && item.to !== undefined ? (
77
92
  renderCrumbLink({ label, to: item.to })
78
- ) : (
79
- <span
80
- aria-current={isLast ? "page" : undefined}
81
- data-terp={isLast ? "breadcrumbs-current" : undefined}
82
- >
93
+ ) : isLast && currentAs === "h1" ? (
94
+ // Two spelled-out branches rather than one element with a computed tag and a
95
+ // computed marker, and the marker scanner is the reason rather than taste: it
96
+ // reads string literals out of a data-terp attribute, so a ternary there
97
+ // published "h1" as a marker and hid breadcrumbs-current from the inventory.
98
+ // The sheet records the same idiom for the same scanner ("a conditional
99
+ // written at the attribute is the form the marker scanner reads every string
100
+ // literal out of"); spelling both out keeps one literal per element.
101
+ <h1 aria-current="page" data-terp="page-title">
102
+ {label}
103
+ </h1>
104
+ ) : isLast ? (
105
+ <span aria-current="page" data-terp="breadcrumbs-current">
83
106
  {label}
84
107
  </span>
108
+ ) : (
109
+ // An ancestor with no `to`: plain text, never a dead link, and never the
110
+ // heading either — `currentAs` describes the LEAF, and an intermediate layer
111
+ // that happens to be unlinked is not the page.
112
+ <span>{label}</span>
85
113
  )}
86
114
  {!isLast && (
87
115
  <span aria-hidden="true" data-terp="breadcrumbs-separator">
@@ -15,15 +15,26 @@ describe("HubPage", () => {
15
15
  </HubPage>,
16
16
  );
17
17
 
18
- expect(screen.getByRole("heading", { level: 1, name: "Administration" })).toBeInTheDocument();
19
- expect(screen.queryByRole("navigation", { name: "Breadcrumb" })).not.toBeInTheDocument();
18
+ const heading = screen.getByRole("heading", { level: 1, name: "Administration" });
19
+ expect(heading).toBeInTheDocument();
20
+ // A parentless hub is a trail of ONE, not a bare heading, so its title sits exactly where
21
+ // an overview's and a detail's do and does not move when you descend. The trail is
22
+ // therefore present and has nothing to link to yet.
23
+ expect(heading).toHaveAttribute("data-terp", "page-title");
24
+ expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
20
25
  expect(screen.getAllByText("Administration")).toHaveLength(1);
21
26
  // The marker, not the declaration. jsdom does not compute the cascade, so toHaveStyle
22
27
  // can only ever see an inline style — asserting gridAutoRows here was asserting that the
23
28
  // grid is styled from a style object, which is the thing ADR 0094 removes. What a test
24
29
  // should assert is the fact the sheet keys on; the geometry is gated by styles.test.ts
25
30
  // (the rule exists) and by the hub-page baselines (it does what it says).
26
- expect(screen.getByRole("list")).toHaveAttribute("data-terp", "hubpage-grid");
31
+ // getByRole("list") would be ambiguous now that the trail renders an <ol> of its own, and
32
+ // the ambiguity is the assertion's own fault rather than the trail's: what it means is
33
+ // "the grid is the marked element", so it asks for the grid.
34
+ expect(
35
+ screen.getAllByRole("list").find((list) => list.dataset.terp === "hubpage-grid"),
36
+ "the card grid must be the marked list",
37
+ ).toBeDefined();
27
38
  expect(screen.getByRole("link", { name: /Users/ })).toBeInTheDocument();
28
39
  expect(screen.getByRole("link", { name: /Roles/ })).toBeInTheDocument();
29
40
  expect(screen.getByText("Manage accounts")).toBeInTheDocument();
package/src/Page.test.tsx CHANGED
@@ -33,11 +33,37 @@ describe("Page", () => {
33
33
  expect(screen.getByText("body")).toBeInTheDocument();
34
34
  });
35
35
 
36
- it("does not repeat a root page title as a current-page-only breadcrumb", () => {
37
- render(<Page title="Tasks">x</Page>);
38
-
39
- expect(screen.queryByRole("navigation", { name: "Breadcrumb" })).not.toBeInTheDocument();
36
+ it("renders a parentless page as a trail of one, in the same boxes as a deeper page", () => {
37
+ // The overview and the detail have to put the title in the SAME place, or the name moves
38
+ // the moment you open a record. So a parentless page is a trail of one rather than a bare
39
+ // heading: same nav, same list, same leaf, no ancestors in front of it yet.
40
+ const root = render(<Page title="Tasks">x</Page>);
41
+ const rootLeaf = screen.getByRole("heading", { level: 1, name: "Tasks" });
42
+ expect(rootLeaf).toHaveAttribute("data-terp", "page-title");
43
+ // Still exactly once: the leaf IS the heading, so there is nothing to repeat.
40
44
  expect(screen.getAllByText("Tasks")).toHaveLength(1);
45
+ // The ancestry of the heading is the shape that must not differ between the two.
46
+ const chain = (node: HTMLElement) => {
47
+ const tags: string[] = [];
48
+ for (let at = node.parentElement; at !== null; at = at.parentElement) {
49
+ tags.push(at.tagName);
50
+ if (at.dataset.terp === "page-heading") break;
51
+ }
52
+ return tags;
53
+ };
54
+ const rootChain = chain(rootLeaf);
55
+ expect(rootChain).toEqual(["LI", "OL", "NAV", "DIV"]);
56
+ root.unmount();
57
+
58
+ const deep = render(
59
+ <Page title="Fix the door" breadcrumbs={[{ label: "Tasks", to: "/tasks" }]}>
60
+ x
61
+ </Page>,
62
+ );
63
+ expect(chain(screen.getByRole("heading", { level: 1, name: "Fix the door" }))).toEqual(
64
+ rootChain,
65
+ );
66
+ deep.unmount();
41
67
  });
42
68
 
43
69
  it("appends its own crumb to the supplied trail", () => {
@@ -49,10 +75,47 @@ describe("Page", () => {
49
75
 
50
76
  expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
51
77
  expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
52
- expect(screen.getByText("Fix the door", { selector: "span" })).toHaveAttribute(
53
- "aria-current",
54
- "page",
78
+ // The leaf of the trail IS the h1 now, so the page's name appears ONCE. It used to
79
+ // appear twice: Page built its trail as [...breadcrumbs, { label: title }] and then
80
+ // rendered <h1>{title}</h1>, so every DetailPage printed its own name as the leaf crumb
81
+ // and again a couple of dozen pixels below it. That duplication was the whole reason the
82
+ // header collapsed into one band, and this is the assertion that holds it.
83
+ const heading = screen.getByRole("heading", { level: 1, name: "Fix the door" });
84
+ expect(heading).toHaveAttribute("aria-current", "page");
85
+ expect(heading).toHaveAttribute("data-terp", "page-title");
86
+ expect(screen.getAllByText("Fix the door")).toHaveLength(1);
87
+ });
88
+
89
+ it("renders badges and a lead line on the band, and neither when absent", () => {
90
+ const { container, unmount } = render(
91
+ <Page title="Fix the door" badges={<span>Open</span>} description="Reported yesterday">
92
+ x
93
+ </Page>,
94
+ );
95
+
96
+ expect(screen.getByText("Open")).toBeInTheDocument();
97
+ expect(screen.getByText("Reported yesterday")).toBeInTheDocument();
98
+ expect(container.querySelector('[data-terp="page-badges"]')).not.toBeNull();
99
+ expect(container.querySelector('[data-terp="page-description"]')).not.toBeNull();
100
+ unmount();
101
+
102
+ // Absent is not an empty row: a band that reserved space for slots no page filled would
103
+ // put a gap next to every title in every app that passes neither.
104
+ const bare = render(<Page title="Fix the door">x</Page>);
105
+ expect(bare.container.querySelector('[data-terp="page-badges"]')).toBeNull();
106
+ expect(bare.container.querySelector('[data-terp="page-description"]')).toBeNull();
107
+ });
108
+
109
+ it("takes several badges as one row, not one row each", () => {
110
+ const { container } = render(
111
+ <Page title="Fix the door" badges={[<span key="a">Open</span>, <span key="b">Urgent</span>]}>
112
+ x
113
+ </Page>,
55
114
  );
115
+
116
+ expect(container.querySelectorAll('[data-terp="page-badges"]')).toHaveLength(1);
117
+ expect(screen.getByText("Open")).toBeInTheDocument();
118
+ expect(screen.getByText("Urgent")).toBeInTheDocument();
56
119
  });
57
120
 
58
121
  it("replaces the body with the loading state while keeping the header", () => {
@@ -116,11 +179,15 @@ describe("Page", () => {
116
179
  });
117
180
 
118
181
  describe("OverviewPage", () => {
119
- it("is a root-level Page with one title and no redundant breadcrumb", () => {
182
+ it("puts its title where a detail page puts its own: the trail's leaf", () => {
120
183
  render(<OverviewPage title="Tasks">list</OverviewPage>);
121
184
 
122
- expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toBeInTheDocument();
123
- expect(screen.queryByRole("navigation", { name: "Breadcrumb" })).not.toBeInTheDocument();
185
+ const leaf = screen.getByRole("heading", { level: 1, name: "Tasks" });
186
+ expect(leaf).toHaveAttribute("data-terp", "page-title");
187
+ expect(leaf).toHaveAttribute("aria-current", "page");
188
+ // A trail of one has no ancestors to link to, which is the whole difference between an
189
+ // overview and the detail beneath it.
190
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
124
191
  expect(screen.getAllByText("Tasks")).toHaveLength(1);
125
192
  expect(screen.getByText("list")).toBeInTheDocument();
126
193
  });
@@ -140,8 +207,9 @@ describe("DetailPage", () => {
140
207
 
141
208
  expect(screen.getByRole("navigation", { name: "Breadcrumb" })).toBeInTheDocument();
142
209
  expect(screen.getByRole("link", { name: "Tasks" })).toHaveAttribute("href", "/tasks");
143
- expect(screen.getByRole("heading", { level: 1, name: "Fix the door" })).toBeInTheDocument();
144
- expect(screen.getByText("Fix the door", { selector: "span" })).toHaveAttribute(
210
+ const leaf = screen.getByRole("heading", { level: 1, name: "Fix the door" });
211
+ expect(screen.getAllByText("Fix the door")).toHaveLength(1);
212
+ expect(leaf).toHaveAttribute(
145
213
  "aria-current",
146
214
  "page",
147
215
  );
package/src/Page.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import { useContext, useEffect, useRef, useState } from "react";
2
+ import { Fragment, useContext, useEffect, useRef, useState } from "react";
3
3
 
4
4
  import { Breadcrumbs } from "./Breadcrumbs";
5
5
  import type { BreadcrumbItem, RenderBreadcrumbLink } from "./Breadcrumbs";
@@ -11,8 +11,8 @@ import {
11
11
  } from "./layoutContract";
12
12
  import { LoadingState } from "./LoadingState";
13
13
  import { usePageMarker } from "./pageMarker";
14
- import { useUiText } from "./uiText";
15
- import type { UiText } from "./uiText";
14
+ import { resolveUiTextNode, useUiText } from "./uiText";
15
+ import type { UiText, UiTextNode } from "./uiText";
16
16
 
17
17
  export interface PageProps {
18
18
  /** The page heading (rendered as the single `h1`). */
@@ -21,8 +21,31 @@ export interface PageProps {
21
21
  breadcrumbs?: readonly BreadcrumbItem[];
22
22
  /** Link renderer for ancestor crumbs; defaults to the surrounding router's `Link` (see {@link Breadcrumbs}). */
23
23
  renderLink?: RenderBreadcrumbLink;
24
- /** Optional page-level actions, rendered on the heading row (e.g. a primary `Button`). */
24
+ /** Optional page-level actions, rendered at the band's right edge (e.g. a primary `Button`). */
25
25
  actions?: ReactNode;
26
+ /**
27
+ * Status pill(s) for the page's subject, shown next to the title — an entity's state, a
28
+ * visibility marker. Pass a `Badge` (or several) so the tone is the caller's choice; a
29
+ * bare string is not accepted, because "which tone" is a decision this frame cannot make.
30
+ *
31
+ * Keep it to a handful of short labels. This is a badge row on a bounded band, not a place
32
+ * for prose — that is what `description` is, and neither is a place for a paragraph.
33
+ */
34
+ badges?: ReactNode | readonly ReactNode[];
35
+ /**
36
+ * One short line about the page, shown after the badges and truncated to a single line.
37
+ *
38
+ * It is chrome, not content: the band has a height, so a lead line that would wrap is
39
+ * clipped rather than allowed to set it. An explanation that does not fit is describing the
40
+ * body and belongs in the body.
41
+ *
42
+ * `UiTextNode`, like `EmptyState`'s own description, rather than a bare `ReactNode`: this is
43
+ * user-facing prose, so it goes through the localization seam. Typed as `ReactNode` it took
44
+ * a plain string that never reached the resolver, which is the failure that does not
45
+ * announce itself — every other string on the band translates and the lead line stays in the
46
+ * source language.
47
+ */
48
+ description?: UiTextNode;
26
49
  /**
27
50
  * Cap the whole frame — header included — at a readable measure (default `"full"`).
28
51
  *
@@ -58,11 +81,27 @@ export interface PageProps {
58
81
  }
59
82
 
60
83
  /**
61
- * The base content-page frame: every routed view is constructed the same way — one
62
- * header holding the breadcrumb trail (when there is a path back up through the
63
- * layers), then one row with the single `h1` title on the left and the page's
64
- * `actions` slot on the right, then the body. A root page omits the redundant
65
- * current-page-only crumb. Title-first DOM order keeps narrow layouts natural.
84
+ * The base content-page frame: every routed view is constructed the same way — one band
85
+ * carrying the page's identity and its actions, then the body.
86
+ *
87
+ * **The band is one row, the row is the title, and the title is the trail's leaf.** Every
88
+ * page renders a trail: its ancestors link up through the layers and its leaf is the view's
89
+ * single `h1` (see `Breadcrumbs`' `currentAs`). An overview with no parents is a trail of
90
+ * one, which is deliberately the same node in the same boxes as a detail's leaf — so
91
+ * opening a record slides the name right behind its new parents instead of moving it between
92
+ * two different layouts.
93
+ *
94
+ * The page's name therefore appears exactly ONCE, which is the defect this shape fixed: the
95
+ * frame used to append `title` to the trail *and* render it as an `h1`, so every `DetailPage`
96
+ * printed its own name twice, a couple of dozen pixels apart.
97
+ *
98
+ * `badges` and `description` sit after the title and `actions` at the right edge, all on that
99
+ * one line. Inside a shell the band bleeds to the content column's edge and takes the app
100
+ * header's own height and a bottom border, so the two read as one piece of chrome; standalone
101
+ * — the workbench, the unit tests — it is the same row without the bleed. A `measure="narrow"`
102
+ * frame (`FormPage`, `SettingsPage`) keeps the row and drops the chrome, because a form is
103
+ * capped with its header (ADR 0098 §3).
104
+ *
66
105
  * `OverviewPage` and `DetailPage` specialise it for the standard overview -> detail
67
106
  * layering; a bespoke screen composes `Page` directly.
68
107
  *
@@ -78,6 +117,8 @@ export function Page({
78
117
  breadcrumbs,
79
118
  renderLink,
80
119
  actions,
120
+ badges,
121
+ description,
81
122
  measure = "full",
82
123
  isLoading,
83
124
  loadingState,
@@ -115,8 +156,30 @@ export function Page({
115
156
  if (slotViolation !== null) {
116
157
  throw new Error(slotViolation);
117
158
  }
118
- const hasAncestors = breadcrumbs !== undefined && breadcrumbs.length > 0;
119
- const trail: BreadcrumbItem[] = hasAncestors ? [...breadcrumbs, { label: title }] : [];
159
+ // ALWAYS a trail, even of one, and that is the point rather than a simplification.
160
+ // An overview's title has to be the same node in the same boxes as a detail's, or the name
161
+ // moves the moment you open a record: one path rendered a bare h1 and the other rendered
162
+ // the h1 inside nav > ol > li, so the text sat at two slightly different places and the
163
+ // transition showed it. A trail of one is the overview's title; append an ancestor and the
164
+ // same leaf slides right behind its parents, which is the only motion there should be.
165
+ const trail: BreadcrumbItem[] = [...(breadcrumbs ?? []), { label: title }];
166
+ // Normalised rather than branched at the site, so one badge and several take the same path
167
+ // and the row exists or does not exist for one reason.
168
+ //
169
+ // Filtered on RENDERABILITY, not on undefined, and the difference is the whole bug it fixes:
170
+ // the idiomatic call is `badges={isPublished && <Badge/>}`, which hands this `false` when the
171
+ // condition fails. Testing `!== undefined` let `[false]` through, so the row existed, was
172
+ // empty, and put a gap beside the title — the exact "absent is not an empty row"
173
+ // invariant the band promises. `false`, `null`, `undefined` and `""` all mean absent here.
174
+ const badgeList: readonly ReactNode[] = ([] as ReactNode[])
175
+ .concat(badges ?? [])
176
+ .filter((badge) => badge !== false && badge !== null && badge !== undefined && badge !== "");
177
+ // Same test for the lead line, for the same reason: `description={error && error.message}`.
178
+ const hasDescription =
179
+ description !== undefined &&
180
+ description !== null &&
181
+ description !== false &&
182
+ description !== "";
120
183
  const body =
121
184
  error !== null && error !== undefined ? (
122
185
  (errorState ?? <ErrorState error={error} />)
@@ -138,15 +201,23 @@ export function Page({
138
201
  wrapper of its own — not even a display: contents one, since article.children is a
139
202
  DOM traversal and would see it. */}
140
203
  <header data-terp="page-header">
141
- {hasAncestors && (
142
- <div data-terp="page-breadcrumbs">
143
- <Breadcrumbs items={trail} renderLink={renderLink} />
144
- </div>
145
- )}
146
204
  <div data-terp="page-heading">
147
- <h1 data-terp="page-title">{resolve(title)}</h1>
148
- {actions}
205
+ {/* The trail carries the h1 as its leaf. No wrapper of its own any more: the crumb
206
+ row it used to sit in existed to hold a 2rem floor above the title row, and
207
+ there is no title row to be above. */}
208
+ <Breadcrumbs items={trail} renderLink={renderLink} currentAs="h1" />
209
+ {badgeList.length > 0 && (
210
+ <div data-terp="page-badges">
211
+ {badgeList.map((badge, index) => (
212
+ <Fragment key={index}>{badge}</Fragment>
213
+ ))}
214
+ </div>
215
+ )}
216
+ {hasDescription && (
217
+ <p data-terp="page-description">{resolveUiTextNode(description, resolve)}</p>
218
+ )}
149
219
  </div>
220
+ {actions}
150
221
  </header>
151
222
  {/* Reset the slot for the body's own subtree, so nested content is never judged
152
223
  by an ancestor archetype's slot. */}
@@ -259,6 +259,42 @@ describe("DetailList", () => {
259
259
  expect(el).toHaveAttribute("data-columns", "2");
260
260
  });
261
261
 
262
+ it("stamps a gap only when one was asked for, because the default is the layout's", () => {
263
+ // Unlike Stack's and Grid's, this attribute is conditional, and the condition is the point:
264
+ // the default row gap differs per layout — --space-3 for aligned and stacked, --space-1 for
265
+ // inline, where a pair is one line of a paragraph rather than a block. There is no single
266
+ // default to compare against, so an unset gap must stamp NOTHING and leave the layout rule
267
+ // standing. Stamping a default here would silently flatten all three back to one value,
268
+ // which is the grouping defect the layouts' own gap was added to fix.
269
+ const { rerender } = render(
270
+ <DetailList data-testid="dl" layout="aligned" items={[{ label: "Owner", value: "Ada" }]} />,
271
+ );
272
+ expect(screen.getByTestId("dl").hasAttribute("data-gap")).toBe(false);
273
+ rerender(
274
+ <DetailList
275
+ data-testid="dl"
276
+ layout="aligned"
277
+ gap={6}
278
+ items={[{ label: "Owner", value: "Ada" }]}
279
+ />,
280
+ );
281
+ expect(screen.getByTestId("dl")).toHaveAttribute("data-gap", "6");
282
+ // Step 0 is a real step and must survive the conditional — a falsy-check rather than an
283
+ // undefined-check would drop it, and "no gap between pairs" would silently render the
284
+ // layout's default instead.
285
+ rerender(
286
+ <DetailList
287
+ data-testid="dl"
288
+ layout="aligned"
289
+ gap={0}
290
+ items={[{ label: "Owner", value: "Ada" }]}
291
+ />,
292
+ );
293
+ expect(screen.getByTestId("dl")).toHaveAttribute("data-gap", "0");
294
+ // And still no inline style: every step is a rule the sheet keys on (ADR 0097).
295
+ expect(screen.getByTestId("dl").getAttribute("style")).toBeNull();
296
+ });
297
+
262
298
  it("puts no colon in the markup, because two layouts must not have one", () => {
263
299
  // The colon is a rule on the inline layout, not a text node — `aligned` and `stacked` must
264
300
  // not carry one, and no rule can withdraw a text node. It is decorative either way: the
package/src/layout.tsx CHANGED
@@ -241,6 +241,22 @@ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>,
241
241
  layout?: DetailListLayout;
242
242
  /** Pairs per row (default `1`). */
243
243
  columns?: 1 | 2;
244
+ /**
245
+ * Distance **between pairs**, as a step on the token spacing scale.
246
+ *
247
+ * Defaults to the layout's own: `--space-3` for `aligned` and `stacked`, `--space-1` for
248
+ * `inline`, where a pair is one line of a paragraph rather than a block of its own.
249
+ *
250
+ * It sets the ROW gap only, and that is a guarantee rather than an implementation detail. The
251
+ * column gap is the label-to-value distance in `aligned` and the space between pair groups at
252
+ * `columns={2}`, so a caller who could set it would be reaching past the layout into its
253
+ * internals; a `gap` shorthand from here would reset both.
254
+ *
255
+ * The prop exists because its absence was the reason the value was unreachable: `Stack`,
256
+ * `Grid` and `Card` all take a `gap` on this scale, and app modules may write neither `style`
257
+ * nor `className` (ADR 0059), so a detail list needing looser rows had nowhere to say so.
258
+ */
259
+ gap?: SpaceToken;
244
260
  }
245
261
 
246
262
  /**
@@ -263,11 +279,30 @@ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>,
263
279
  * layout alone — `aligned` and `stacked` must not have one, and a text node cannot be
264
280
  * withdrawn by a rule. It is decorative either way: the `<dt>` / `<dd>` pairing is what carries
265
281
  * the relationship to assistive tech.
282
+ *
283
+ * Three later corrections are worth knowing, because each was measured rather than reasoned:
284
+ *
285
+ * - **The label is muted, and `aligned` shares that rule with `stacked`.** It did not, and the
286
+ * gap was the whole legibility complaint: an aligned `<dt>` rendered at the value's own size,
287
+ * weight and ink, so a card of five labelled values was a wall of bold text with nothing
288
+ * saying which half to read first. `inline` keeps its plain term deliberately — there the
289
+ * label is half a sentence.
290
+ * - **Rows are `--space-3` apart in both non-inline layouts.** At the old `--space-1` the
291
+ * distance within a pair equalled the distance between pairs, so nothing grouped. {@link
292
+ * DetailListProps.gap} makes it a caller's choice on the same scale as every other primitive.
293
+ * - **It reflows to one column below the framework's viewport cutover**, where `Grid`
294
+ * deliberately does not for a fixed `columns` count. The asymmetry is a decision: `Grid`
295
+ * publishes `columns="auto"` as its responsive answer and this component's closed `1 | 2` has
296
+ * no such escape, so the reflow has to be its own. Above the cutover the shape is what it
297
+ * was, bar a label column now capped at `max-content` rather than floored at min-content;
298
+ * below it, four tracks in a phone's width met `overflow-wrap: anywhere` — correct for an
299
+ * unbreakable digest, wrong as a way to fit a label — and broke ordinary values mid-word.
266
300
  */
267
301
  export function DetailList({
268
302
  items,
269
303
  layout = "inline",
270
304
  columns = 1,
305
+ gap,
271
306
  ...rest
272
307
  }: DetailListProps) {
273
308
  const text = useUiText();
@@ -278,6 +313,10 @@ export function DetailList({
278
313
  // `inline` and one column are the base rule, so neither stamps an attribute.
279
314
  data-layout={layout === "inline" ? undefined : layout}
280
315
  data-columns={columns === 1 ? undefined : String(columns)}
316
+ // No default to compare against: the default row gap is the LAYOUT's, so an unset gap
317
+ // must stamp no attribute at all and leave the layout rule standing. Stack's and Grid's
318
+ // gaps stamp unconditionally because their defaults are one value, not three.
319
+ data-gap={gap === undefined ? undefined : String(gap)}
281
320
  >
282
321
  {items.map((item, index) => (
283
322
  <div key={index} data-terp="detail-list-row">
@@ -198,7 +198,8 @@ const MARKERS = [
198
198
  "nav-icon-fallback",
199
199
  "page",
200
200
  "page-actions",
201
- "page-breadcrumbs",
201
+ "page-badges",
202
+ "page-description",
202
203
  "page-header",
203
204
  "page-heading",
204
205
  "page-title",