@terpjs/react-core 0.9.0 → 0.11.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.
Files changed (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
@@ -19,6 +19,17 @@ export interface EmptyStateProps {
19
19
  description?: ReactNode;
20
20
  /** Optional call to action (typically a `Button`). */
21
21
  action?: ReactNode;
22
+ /**
23
+ * `"compact"` for an empty block that is not the whole screen.
24
+ *
25
+ * The default is sized to be the only thing on a page — generous padding, a 2rem
26
+ * glyph, centred. That is right for an empty list and wrong the moment a screen has two
27
+ * of them: stacked, they were 480px of chrome repeating a sentence, and the emptiness of
28
+ * one section is not the page's headline. Compact keeps the frame and the wording and
29
+ * takes back the space — tighter padding, a smaller glyph, left-aligned, because a
30
+ * block that is one item among several reads as a row rather than a poster.
31
+ */
32
+ size?: "default" | "compact";
22
33
  }
23
34
 
24
35
  /**
@@ -27,15 +38,24 @@ export interface EmptyStateProps {
27
38
  * UX platform-wide tells the user "this is not an error — there is just nothing to show",
28
39
  * and the `action` slot turns the dead end into the obvious next step.
29
40
  */
30
- export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
41
+ export function EmptyState({
42
+ icon,
43
+ title,
44
+ description,
45
+ action,
46
+ size = "default",
47
+ }: EmptyStateProps) {
31
48
  const resolve = useUiText();
49
+ const compact = size === "compact";
32
50
  const leading = icon ?? (
33
51
  <span data-terp="empty-state-icon">
34
- <Icon name="inbox" size="2rem" />
52
+ <Icon name="inbox" size={compact ? "1.25rem" : "2rem"} />
35
53
  </span>
36
54
  );
55
+ // Stamped only for `compact`: the full-page block's geometry IS the base rule, the same
56
+ // shape `Button`'s sizes and the shell's density take.
37
57
  return (
38
- <div data-terp="empty-state">
58
+ <div data-terp="empty-state" data-size={compact ? "compact" : undefined}>
39
59
  {leading}
40
60
  <p data-terp="empty-state-title">{resolve(title)}</p>
41
61
  {description !== undefined && <div data-terp="empty-state-description">{description}</div>}
@@ -22,6 +22,36 @@ describe("Field", () => {
22
22
  expect(screen.getByText("required")).toBeInTheDocument();
23
23
  });
24
24
 
25
+ it("exposes the error as an alert, and nothing else in the field", () => {
26
+ // `aria-describedby` is read when focus reaches the control. That covers an error which was
27
+ // already there and covers nothing about one that arrives on submit, when focus has left the
28
+ // field and the only thing that changed is a span nobody is pointed at. The two channels fire
29
+ // at different moments, and a submit-time rejection only has the second one.
30
+ //
31
+ // The length assertion is the half with teeth: `role="alert"` on the hint as well would
32
+ // satisfy a bare `getByRole` while training the user to ignore the channel the error needs.
33
+ // Mutation: drop `role="alert"` from the span and the lookup finds nothing.
34
+ render(
35
+ <Field label="Email" hint="we never share it" error="required">
36
+ <Input defaultValue="" />
37
+ </Field>,
38
+ );
39
+ expect(screen.getAllByRole("alert")).toHaveLength(1);
40
+ expect(screen.getByRole("alert")).toHaveTextContent("required");
41
+ });
42
+
43
+ it("raises no alert when there is nothing wrong", () => {
44
+ // An alert that is present on every render is an alert that means nothing. The span is
45
+ // conditional, so it enters the accessibility tree exactly when the error appears, which is
46
+ // the event the role exists to report.
47
+ render(
48
+ <Field label="Email" hint="we never share it">
49
+ <Input defaultValue="" />
50
+ </Field>,
51
+ );
52
+ expect(screen.queryByRole("alert")).toBeNull();
53
+ });
54
+
25
55
  it("renders no error node when error is null", () => {
26
56
  render(
27
57
  <Field label="Name" error={null}>
package/src/Field.tsx CHANGED
@@ -31,6 +31,14 @@ export interface FieldProps {
31
31
  * declares either keeps its own value; the field adds to `aria-describedby` rather than replacing
32
32
  * it. `Input`, `Select`, `Textarea` and `Combobox` all spread their props onto the DOM element, so
33
33
  * the attributes land where assistive tech reads them.
34
+ *
35
+ * The error also carries `role="alert"`, and `aria-describedby` is why it has to. A description is
36
+ * read when focus reaches the control, which covers an error that was already there and covers
37
+ * nothing about one that appears on submit — by then focus has left the field, or the button, and
38
+ * the only thing that changed is a span nobody is pointed at. The two are not redundant: they
39
+ * announce at different moments, and a submit-time rejection only has the second one. Because the
40
+ * span is conditional, it enters the accessibility tree exactly when the error appears, which is
41
+ * the event `alert` exists to report.
34
42
  */
35
43
  export function Field({ label, children, error, hint }: FieldProps) {
36
44
  const resolve = useUiText();
@@ -39,17 +47,35 @@ export function Field({ label, children, error, hint }: FieldProps) {
39
47
  const hintId = hint !== undefined ? `${baseId}-hint` : undefined;
40
48
  const errorId = hasError ? `${baseId}-error` : undefined;
41
49
  const described = [hintId, errorId].filter((id) => id !== undefined).join(" ");
50
+ const labelId = `${baseId}-label`;
42
51
 
43
- // Only a single element child can be described — which is the documented contract ("the
44
- // control"). Anything else is passed through untouched rather than guessed at.
45
- const control = described.length > 0 && isValidElement<{
52
+ // Only a single element child can be named and described — which is the documented contract
53
+ // ("the control"). Anything else is passed through untouched rather than guessed at.
54
+ const control = isValidElement<{
46
55
  "aria-describedby"?: string;
47
56
  "aria-invalid"?: boolean | "true" | "false";
57
+ "aria-label"?: string;
58
+ "aria-labelledby"?: string;
48
59
  }>(children)
49
60
  ? cloneElement(children, {
50
- "aria-describedby": [children.props["aria-describedby"], described]
51
- .filter((id) => id !== undefined && id !== "")
52
- .join(" "),
61
+ // The control is named by the label TEXT, not by the label element's subtree, and that
62
+ // distinction is the whole reason this exists. A wrapping label takes its name from
63
+ // everything inside it, so a control that renders an adornment of its own — the password
64
+ // reveal is the first — hands its own button's name to the field: Chromium computes
65
+ // "Password Show password" for that input, which is a WCAG 2.5.3 failure and a sentence
66
+ // no voice-control user can see to say. Pointing at the span makes the name exact.
67
+ //
68
+ // A caller that named the control itself keeps their name; this never overrides one.
69
+ "aria-labelledby":
70
+ children.props["aria-label"] === undefined
71
+ ? (children.props["aria-labelledby"] ?? labelId)
72
+ : undefined,
73
+ "aria-describedby":
74
+ described.length > 0
75
+ ? [children.props["aria-describedby"], described]
76
+ .filter((id) => id !== undefined && id !== "")
77
+ .join(" ")
78
+ : children.props["aria-describedby"],
53
79
  "aria-invalid": children.props["aria-invalid"] ?? (hasError ? true : undefined),
54
80
  })
55
81
  : children;
@@ -57,7 +83,9 @@ export function Field({ label, children, error, hint }: FieldProps) {
57
83
  return (
58
84
  <div data-terp="field">
59
85
  <label data-terp="field-label">
60
- <span data-terp="field-label-text">{resolve(label)}</span>
86
+ <span id={labelId} data-terp="field-label-text">
87
+ {resolve(label)}
88
+ </span>
61
89
  {control}
62
90
  </label>
63
91
  {hint !== undefined && (
@@ -66,7 +94,7 @@ export function Field({ label, children, error, hint }: FieldProps) {
66
94
  </span>
67
95
  )}
68
96
  {hasError && (
69
- <span id={errorId} data-terp="field-error">
97
+ <span id={errorId} role="alert" data-terp="field-error">
70
98
  {error}
71
99
  </span>
72
100
  )}
@@ -0,0 +1,54 @@
1
+ import type { BreadcrumbItem } from "./Breadcrumbs";
2
+ import { LayoutSlotContext } from "./layoutContract";
3
+ import { Page } from "./Page";
4
+ import type { PageProps } from "./Page";
5
+
6
+ export interface FormPageProps extends Omit<PageProps, "breadcrumbs"> {
7
+ /**
8
+ * The layers this form sits under, outermost first — the list it was reached from, and
9
+ * anything above that.
10
+ *
11
+ * Required, and for the reason `DetailPage`'s is: a create or edit screen is always reached
12
+ * from somewhere, and a form with no way back is a dead end with unsaved work in it.
13
+ */
14
+ parents: readonly (BreadcrumbItem & { to: string })[];
15
+ }
16
+
17
+ /**
18
+ * The form archetype: one create-or-edit screen, reached from the collection it writes into.
19
+ *
20
+ * It is a `Page` with two things fixed. The frame is `measure="narrow"` by default — header
21
+ * included — because a form is a single column of controls and a Save button a screen-width
22
+ * from its last field is worse than one sitting over it. And the body slot admits the shape a
23
+ * form actually has rather than the shape a record screen has.
24
+ *
25
+ * **The body is a container, so `Field` is deliberately not admitted directly.** The slot takes
26
+ * the form container, plus `Grid` for a two-column arrangement inside a wide form, `Card` for a
27
+ * grouped section, `Divider` and `Text` between them, and the framework states.
28
+ *
29
+ * What that refusal does and does not buy is worth stating exactly, because the obvious claim is
30
+ * too strong. It keeps fields out of the top level, so a form body is always a container — which
31
+ * is the shape the archetype is for. It does **not** guarantee the form can be submitted: both
32
+ * halves of the contract match on `data-terp` markers, and `Stack` renders the same marker
33
+ * whether or not it was given `as="form"`. So `<Stack><Field/></Stack>` passes and is still
34
+ * unsubmittable by Enter. Closing that would mean a second marker for the form case, which is
35
+ * six more names describing the same DOM — the `Section` trade 4b already declined.
36
+ *
37
+ * It renders no element of its own — the slot context and `Page`, nothing between them, because
38
+ * a wrapper around the body would become the sole entry in `article.children` and fail every
39
+ * governed page closed (ADR 0079).
40
+ */
41
+ export function FormPage({ parents, measure = "narrow", ...page }: FormPageProps) {
42
+ return (
43
+ <LayoutSlotContext.Provider value="FormPage">
44
+ {/* Spread FIRST, then the archetype's own props — `HubPage`'s order, not
45
+ `DetailPage`'s. `Omit<PageProps, "breadcrumbs">` removes the key from the type but not
46
+ from a runtime object, and a JSX spread gets no excess-property check: a wrapper
47
+ forwarding `{...props}` with a present-but-undefined `breadcrumbs` would otherwise
48
+ overwrite `parents` and silently drop the trail this archetype requires.
49
+ (`DetailPage` and `OverviewPage` still spread last. Same latent shape, pre-existing,
50
+ and left for a change that can re-record their baselines rather than this one.) */}
51
+ <Page {...page} breadcrumbs={parents} measure={measure} />
52
+ </LayoutSlotContext.Provider>
53
+ );
54
+ }
@@ -51,7 +51,39 @@ describe("LoginView dev credentials", () => {
51
51
  </TerpProvider>,
52
52
  );
53
53
  fireEvent.click(await screen.findByRole("button", { name: "Fill dev credentials" }));
54
- expect(screen.getByPlaceholderText("Email")).toHaveValue("admin@example.test");
55
- expect(screen.getByPlaceholderText("Password")).toHaveValue("correct horse battery staple");
54
+ // By label, not by placeholder. Reaching for a placeholder here was itself a symptom:
55
+ // it was the only handle these inputs had.
56
+ expect(screen.getByLabelText("Email")).toHaveValue("admin@example.test");
57
+ expect(screen.getByLabelText("Password")).toHaveValue("correct horse battery staple");
58
+ });
59
+ });
60
+
61
+ describe("LoginView accessible names", () => {
62
+ it("labels both credentials so they survive typing and can be addressed by name", async () => {
63
+ // The first screen of every Terp app, and it was labelled by placeholder alone. A
64
+ // placeholder is not an accessible name and it disappears the moment someone types, so
65
+ // the field a user is halfway through filling had nothing identifying it (WCAG 3.3.2)
66
+ // — and `getByLabel` could not find either input, which made the one screen every app
67
+ // ships the one screen its own tests could not address by name.
68
+ stubFetch();
69
+ render(
70
+ <TerpProvider baseUrl="https://api.test">
71
+ <LoginView />
72
+ </TerpProvider>,
73
+ );
74
+ await screen.findByRole("heading", { name: "Sign in" });
75
+
76
+ const email = screen.getByLabelText("Email");
77
+ const password = screen.getByLabelText("Password");
78
+ expect(email).toHaveAttribute("type", "email");
79
+ expect(password).toHaveAttribute("type", "password");
80
+
81
+ // The name has to survive typing, which is the whole difference from a placeholder.
82
+ fireEvent.change(email, { target: { value: "someone@example.test" } });
83
+ expect(screen.getByLabelText("Email")).toHaveValue("someone@example.test");
84
+
85
+ // And the autocomplete tokens stay, so a password manager still offers to fill.
86
+ expect(email).toHaveAttribute("autocomplete", "username");
87
+ expect(password).toHaveAttribute("autocomplete", "current-password");
56
88
  });
57
89
  });
package/src/LoginView.tsx CHANGED
@@ -5,6 +5,7 @@ import { TerpMark } from "./icons";
5
5
  import { useAuth, useSso } from "./TerpProvider";
6
6
  import { useStrings } from "./uiText";
7
7
  import { Button } from "./ui/Button";
8
+ import { Field } from "./Field";
8
9
  import { Input } from "./ui/Input";
9
10
  import type { SsoProvider } from "./sso";
10
11
 
@@ -37,9 +38,16 @@ export interface DevCredentials {
37
38
  *
38
39
  * It renders no inline styles: the full-viewport page, the card, the brand row, both button
39
40
  * groups, the separator and the error line take their geometry and ink from the injected
40
- * react-core sheet (ADR 0094). The buttons fill their group through a rule on the group
41
- * rather than a prop on `Button` — `Button` declares `width: fit-content`, so a grid does not
42
- * stretch them for free, and a `block` prop for one internal caller is API this does not need.
41
+ * react-core sheet (ADR 0094).
42
+ *
43
+ * Two things here used to be worked around and are not any more, which is worth recording
44
+ * because the workarounds were both reasonable while they lasted. The buttons filled their
45
+ * group through a rule on the GROUP, because `Button` declares `width: fit-content` — a
46
+ * definite width, so a grid does not stretch them for free — and a prop for one internal
47
+ * caller was API the package did not need. `Button` has `fullWidth` now, so the rule retires
48
+ * and the four call sites say what they mean. And the submit button hand-rolled a busy state
49
+ * out of `disabled` plus a swapped label; `loading` is that state, and it adds the spinner and
50
+ * the `aria-busy` the hand-rolled version never had.
43
51
  */
44
52
  export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps = {}) {
45
53
  const auth = useAuth();
@@ -85,27 +93,43 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
85
93
  <h1 data-terp="login-title">{strings.signIn}</h1>
86
94
  </div>
87
95
  <form data-terp="login-form" onSubmit={onSubmit}>
88
- <Input
89
- type="email"
90
- placeholder={strings.email}
91
- value={email}
92
- onChange={(event) => setEmail(event.target.value)}
93
- required
94
- />
95
- <Input
96
- type="password"
97
- placeholder={strings.password}
98
- value={password}
99
- onChange={(event) => setPassword(event.target.value)}
100
- required
101
- />
102
- <Button type="submit" disabled={busy}>
96
+ {/* Labelled through `Field`, not by placeholder. A placeholder is not an
97
+ accessible name and it disappears the moment someone types, so the field a
98
+ user is halfway through filling has nothing identifying it — WCAG 3.3.2 asks
99
+ for a label that survives typing. It also made these two inputs unreachable
100
+ by `getByLabel`, so every app's very first screen was the one place its own
101
+ tests could not address by name. The framework's own answer was already here:
102
+ `Field` wraps the control in a `<label>`, which is why it needs no id wiring.
103
+ The comment below is left because it dates the omission — an autocomplete
104
+ token was considered for these fields and a label was not. */}
105
+ <Field label={strings.email}>
106
+ <Input
107
+ type="email"
108
+ // Neither field declared an autocomplete token, so no password manager offered to
109
+ // fill or save this form — the one place in the framework where that matters most.
110
+ autoComplete="username"
111
+ value={email}
112
+ onChange={(event) => setEmail(event.target.value)}
113
+ required
114
+ />
115
+ </Field>
116
+ <Field label={strings.password}>
117
+ <Input
118
+ type="password"
119
+ autoComplete="current-password"
120
+ value={password}
121
+ onChange={(event) => setPassword(event.target.value)}
122
+ required
123
+ />
124
+ </Field>
125
+ <Button type="submit" fullWidth loading={busy}>
103
126
  {busy ? strings.signingIn : strings.signIn}
104
127
  </Button>
105
128
  {devCredentials ? (
106
129
  <Button
107
130
  type="button"
108
131
  variant="secondary"
132
+ fullWidth
109
133
  disabled={busy}
110
134
  onClick={() => {
111
135
  setEmail(devCredentials.email);
@@ -130,6 +154,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
130
154
  key={provider.name}
131
155
  type="button"
132
156
  variant="secondary"
157
+ fullWidth
133
158
  disabled={busy}
134
159
  onClick={() => void onSso(provider)}
135
160
  >
@@ -61,16 +61,16 @@ describe("ModuleNav", () => {
61
61
  );
62
62
  expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current");
63
63
 
64
- // The styling key is data-active, and the point is that it is NOT the aria-current
65
- // beside it. A router Link merges its own active props last, so aria-current on this
66
- // element has a second author — keying the accent edge on it would be the breadcrumb
67
- // defect again the moment the two disagreed about "active", which they can, in BOTH
68
- // directions: the router also demands an exact query-string match (so it is narrower
69
- // on a filtered URL), and it compares paths through removeTrailingSlash while this
70
- // component compares them raw (so it is broader on a trailing slash, and then the
71
- // accent edge goes missing on a tab the router calls current). The second is a real
72
- // defect, older than the marker the inline styling read the same isActive and it
73
- // is the navigation model's to fix. Neither case is reachable from this specimen.
64
+ // The styling key is data-active, and the point is that it is NOT the aria-current beside
65
+ // it: a router Link merges its own active props last, so that attribute has a second author.
66
+ //
67
+ // The divergence this comment used to describe is fixed (ADR 0097 §6, amended in 4e). Both
68
+ // authors now compute the same thing by construction rather than by coincidence. The
69
+ // component asks `activeNavPath` over the whole strip; the Link is pinned to
70
+ // `exact: true, includeSearch: false`, so the router volunteers `aria-current` only when the
71
+ // path equals the URL the longest possible match, hence always the tab the predicate
72
+ // picked. It can agree or stay silent; it cannot name a different tab. Which is why the
73
+ // assertion below can now demand exactly one `aria-current` in the strip.
74
74
  const active = screen.getByRole("link", { name: "Projects" });
75
75
  const inactive = screen.getByRole("link", { name: "Overview" });
76
76
  expect(active).toHaveAttribute("data-terp", "module-nav-link");
@@ -79,6 +79,13 @@ describe("ModuleNav", () => {
79
79
  expect(inactive).not.toHaveAttribute("data-active");
80
80
  expect(active.getAttribute("style")).toBeNull();
81
81
  expect(inactive.getAttribute("style")).toBeNull();
82
+
83
+ // Exactly one, over the whole strip. Before 4e the Link was left prefix-matching, so at
84
+ // /tickets/projects the router added its own aria-current to the /tickets tab as well and a
85
+ // screen reader announced two current pages.
86
+ expect(
87
+ document.querySelectorAll('[data-terp="module-nav"] [aria-current="page"]'),
88
+ ).toHaveLength(1);
82
89
  });
83
90
 
84
91
  it("returns nothing for an empty tab list", () => {
package/src/ModuleNav.tsx CHANGED
@@ -1,13 +1,24 @@
1
1
  import { Link, useRouterState } from "@tanstack/react-router";
2
2
 
3
+ import { activeNavPath } from "./navActive";
3
4
  import { useStrings, useUiText } from "./uiText";
4
5
  import type { UiText } from "./uiText";
5
6
 
6
7
  export interface ModuleNavTab {
7
8
  /** Display label. */
8
9
  label: UiText;
9
- /** Router path for an exact sub-page route. */
10
+ /** Router path for the sub-page route. */
10
11
  to: string;
12
+ /**
13
+ * Match this tab's path exactly rather than as a segment-aligned prefix.
14
+ *
15
+ * The default is the prefix, so a tab stays current on the pages beneath it — a detail route
16
+ * under `/records/mapping` keeps "Mapping" lit instead of blanking the strip. Set this on a
17
+ * landing tab that also has siblings deeper in the same strip, where the prefix would keep it
18
+ * lit alongside them; the shared predicate resolves that case by longest match anyway, so this
19
+ * is for the narrower job of a tab owning only itself.
20
+ */
21
+ exact?: boolean;
11
22
  }
12
23
 
13
24
  export interface ModuleNavProps {
@@ -32,6 +43,12 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
32
43
  const strings = useStrings();
33
44
  const resolve = useUiText();
34
45
  const pathname = useRouterState({ select: (state) => state.location.pathname });
46
+ // Resolved over the whole strip, not per tab, and through the same function the sidebar uses
47
+ // (ADR 0097 §6, amended in 4e). Two tabs where one path prefixes the other would otherwise
48
+ // both be current, and this component previously compared `pathname === item.to` raw — which
49
+ // diverged from its own Link in both directions, as the sheet's comment on the active rule
50
+ // says. Longest match wins, so `/records` does not steal from `/records/mapping`.
51
+ const activeTo = activeNavPath(pathname, items);
35
52
 
36
53
  if (items.length === 0) {
37
54
  return null;
@@ -42,12 +59,27 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
42
59
  <ul data-terp="module-nav-list">
43
60
  {items.map((item) => {
44
61
  const label = resolve(item.label);
45
- const isActive = pathname === item.to;
62
+ const isActive = item.to === activeTo;
46
63
  return (
47
64
  <li key={item.to}>
48
65
  <Link
49
66
  to={item.to}
50
- activeOptions={{ exact: true }}
67
+ // `exact` is ALWAYS true here, whatever the tab asked for, and the asymmetry is
68
+ // the mechanism rather than an oversight. The tab's own `exact` governs the
69
+ // component's predicate above; this governs when the ROUTER volunteers its own
70
+ // `aria-current`, and the router decides per link with no knowledge of siblings.
71
+ // Left non-exact it prefix-matches, so at `/tickets/projects` it would mark the
72
+ // `/tickets` tab current too — a second current item the component never chose.
73
+ // Exact matching means "the router thinks this is active" implies the path equals
74
+ // the URL, which is the longest possible match, which is always the tab the
75
+ // predicate picked. The router can only agree.
76
+ //
77
+ // includeSearch: false for the other half. It defaults to true and on an exact
78
+ // link demands a full query-string match, so at `/tickets/projects?page=2` the
79
+ // router's own `data-status` said inactive while this component said active.
80
+ // Nothing paints from `data-status`, which is exactly why that could sit there
81
+ // unnoticed.
82
+ activeOptions={{ exact: true, includeSearch: false }}
51
83
  aria-current={isActive ? "page" : undefined}
52
84
  data-terp="module-nav-link"
53
85
  data-active={isActive ? "true" : undefined}
package/src/Page.tsx CHANGED
@@ -23,6 +23,23 @@ export interface PageProps {
23
23
  renderLink?: RenderBreadcrumbLink;
24
24
  /** Optional page-level actions, rendered on the heading row (e.g. a primary `Button`). */
25
25
  actions?: ReactNode;
26
+ /**
27
+ * Cap the whole frame — header included — at a readable measure (default `"full"`).
28
+ *
29
+ * `"narrow"` is the single-column-of-controls shape: a create/edit form, a settings screen.
30
+ * The header is capped WITH the body here, unlike the shell's own content measure, and that
31
+ * asymmetry is the point rather than an inconsistency. A wide page with a narrow column wants
32
+ * its title and actions spanning the full track, because the band is what tells you the page
33
+ * is wider than its text. A form does not: a Save button floating a screen-width away from
34
+ * the field it saves is worse than one sitting over it.
35
+ *
36
+ * `data-measure` is the same attribute name `Text` uses for the same concept, keyed per
37
+ * marker, so there is one vocabulary for "measure" rather than two.
38
+ *
39
+ * `FormPage` and `SettingsPage` default it on; every other archetype leaves it `"full"`,
40
+ * which stamps nothing.
41
+ */
42
+ measure?: "full" | "narrow";
26
43
  /** Show the loading state instead of the body (the header stays for orientation). */
27
44
  isLoading?: boolean;
28
45
  /** Loading slot; defaults to the standard {@link LoadingState} spinner block. */
@@ -61,6 +78,7 @@ export function Page({
61
78
  breadcrumbs,
62
79
  renderLink,
63
80
  actions,
81
+ measure = "full",
64
82
  isLoading,
65
83
  loadingState,
66
84
  error,
@@ -107,8 +125,12 @@ export function Page({
107
125
  ) : (
108
126
  children
109
127
  );
128
+ // Hoisted, the density/collapsed idiom: the default stamps nothing, so the expression has a
129
+ // branch, and a conditional written at the attribute is the form the marker scanner reads
130
+ // every string literal out of.
131
+ const measureAttribute = measure === "narrow" ? "narrow" : undefined;
110
132
  return (
111
- <article ref={articleRef} data-terp="page">
133
+ <article ref={articleRef} data-terp="page" data-measure={measureAttribute}>
112
134
  {/* A <header> ELEMENT, and it has to stay one. The slot check above drops the header
113
135
  from the body set by tagName, so re-rendering this as a marked <div> would put it
114
136
  back in and fail every governed OverviewPage / DetailPage closed. The marker is
@@ -76,7 +76,7 @@ describe("ProfileView", () => {
76
76
  // jsdom computes no cascade. The address marker matters most of the three — its
77
77
  // overflow-wrap has no specimen, since the mock session's address is short, so this is
78
78
  // the only gate that the element a long address would need is still the marked one.
79
- const avatar = document.querySelector('[data-terp="profile-avatar"]');
79
+ const avatar = document.querySelector('[data-terp="avatar"]');
80
80
  expect(avatar).not.toBeNull();
81
81
  expect(avatar?.textContent).toBe("JD");
82
82
  expect(avatar?.getAttribute("style")).toBeNull();
@@ -3,7 +3,7 @@ import { useAuth } from "./TerpProvider";
3
3
  import { LanguageSwitcher } from "./locale";
4
4
  import { Stack, DetailList } from "./layout";
5
5
  import { ThemeToggle } from "./theme";
6
- import { userInitials } from "./UserMenu";
6
+ import { Avatar } from "./ui/Avatar";
7
7
  import { Button } from "./ui/Button";
8
8
  import { useStrings } from "./uiText";
9
9
 
@@ -29,9 +29,7 @@ export function ProfileView() {
29
29
  <Stack gap={4}>
30
30
  <div data-terp="profile-card">
31
31
  <Stack direction="row" gap={3} align="center">
32
- <span aria-hidden="true" data-terp="profile-avatar">
33
- {userInitials(user.email)}
34
- </span>
32
+ <Avatar from={user.email} />
35
33
  <Stack gap={0}>
36
34
  <strong data-terp="profile-email">{user.email}</strong>
37
35
  <p data-terp="profile-role">{user.role_name}</p>
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { BreadcrumbItem } from "./Breadcrumbs";
4
+ import { LayoutSlotContext } from "./layoutContract";
5
+ import { Page } from "./Page";
6
+ import type { PageProps } from "./Page";
7
+
8
+ export interface SettingsPageProps extends Omit<PageProps, "breadcrumbs"> {
9
+ /**
10
+ * Optional parent layers, for a settings screen nested under an area rather than reached
11
+ * from the account menu.
12
+ *
13
+ * Optional where `FormPage`'s is required, and the difference is real: a form is always
14
+ * reached from the thing it writes into, while a preferences screen is often a destination in
15
+ * its own right — the built-in profile page has no parent at all.
16
+ */
17
+ parents?: readonly (BreadcrumbItem & { to: string })[];
18
+ }
19
+
20
+ /**
21
+ * The settings archetype: preferences and account screens — a stack of titled sections, each
22
+ * owning a few controls.
23
+ *
24
+ * `measure="narrow"` by default, header included, for the reason `FormPage` has it: this is a
25
+ * single column of controls, not a data surface. `ProfileView`'s own card carries exactly this
26
+ * measure today, and 4b already named that card as a page measure wearing a card's clothes — so
27
+ * this is the mechanism it was hand-rolling.
28
+ *
29
+ * **The body is `Card` sections and nothing that holds a collection.** No `DataView`, no
30
+ * `DetailList`, no `Tabs`: a settings screen whose body is a table is an overview, and one with
31
+ * tabs is a hub with the wrong chrome. `Card` is how a titled region is owned here — which is
32
+ * what 4b decided when it refused to ship `Section`, and this slot is the first archetype to
33
+ * depend on that decision rather than merely be compatible with it.
34
+ *
35
+ * Renders no element of its own, for the reason every non-hub archetype does not: a wrapper
36
+ * around the body becomes the sole entry in `article.children` and fails every governed page
37
+ * closed (ADR 0079).
38
+ */
39
+ export function SettingsPage({
40
+ parents,
41
+ measure = "narrow",
42
+ ...page
43
+ }: SettingsPageProps): ReactNode {
44
+ return (
45
+ <LayoutSlotContext.Provider value="SettingsPage">
46
+ {/* Spread first, then the archetype's own props — see `FormPage` for why. */}
47
+ <Page {...page} breadcrumbs={parents} measure={measure} />
48
+ </LayoutSlotContext.Provider>
49
+ );
50
+ }