@terpjs/react-core 0.8.0 → 0.9.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
@@ -63,6 +63,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
63
63
  | Export | Use |
64
64
  |---|---|
65
65
  | `Authorized`, `useCan` | Gate UI on `can(module, action)` — write buttons, admin panels. |
66
+ | `usePermissions`, `useHasPermission` | The caller's **named** grants from `GET /me` (ADR 0096), for a screen whose write needs `definitions.publish` rather than a rank. `Authorized` takes an optional `permission` alongside `action`; both must pass, as the server's own guard does. Display only — the backend re-checks. |
66
67
  | `canPerform`, `DEFAULT_RANK_THRESHOLDS` | The role-rank predicate behind the gate. |
67
68
  | `visibleNav` | Filter nav items to what the current user may see. |
68
69
 
@@ -85,6 +86,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
85
86
  | `useRouteParam` | Read one route param, fail closed: the declared param comes back as a string, an undeclared name throws a directive error instead of silently yielding `undefined`. Replaces the unchecked `useParams({ strict: false }) as {…}` cast (ADR 0092). Checked against the generated route table when the app has one. |
86
87
  | `useRouteParams` | Read a whole declared route's params, typed exactly: `const { recordId } = useRouteParams("/records/:recordId")`. With a generated route table, a typo in the path *or* a param name is a typecheck error. |
87
88
  | `useTerpNavigate` | Navigate by manifest path: `navigate({ to: "/records/:recordId", params: { recordId } })`. An undeclared path is a typecheck error and a parameterised route requires its params — a typo'd path used to be a dead link that shipped green. Takes the manifest's `:id` spelling and translates to the router's `$id`. |
89
+ | `useRouteSearch` | Read a declared route's query-string keys, typed: `const { status, page } = useRouteSearch("/records")` (ADR 0096). Every value is `string | undefined`, and an undeclared key is a typecheck error — so a filtered list screen stays inside the checked seam instead of reaching for the router's own `useSearch`. Declare them in the manifest: `search: ["status", "page"]`. |
88
90
  | `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
89
91
  | `PageActions` | Primary action + overflow menu for a page header. |
90
92
 
@@ -112,7 +114,8 @@ declare module "@terpjs/react-core" {
112
114
  ```
113
115
 
114
116
  From then on `useRouteParams("/records/:recordId")` is exact, `useRouteParam` refuses a
115
- param no route declares, and `useTerpNavigate` refuses an undeclared path. Regenerate
117
+ param no route declares, `useRouteSearch` is keyed to the route's declared query-string
118
+ keys, and `useTerpNavigate` refuses an undeclared path (or an undeclared `search` key). Regenerate
116
119
  after changing a manifest route — `terp verify`'s `routes-drift` check refuses a stale
117
120
  table and names the command (it runs before the typecheck, so a stale table reads as
118
121
  "regenerate", not as errors in your own screens). A route whose `path` is not a plain
@@ -158,6 +161,7 @@ marker, counted by the escape-hatch budget.
158
161
  | `ResourceList` | The standard simple CRUD list screen: titled section, write-gated create form, loading/error/empty states. Composable — screens needing more render their own React. |
159
162
  | `unwrap`, `unwrapOptional`, `ApiError` | Turn a generated-client result into data-or-throw; `ApiError` carries the envelope's `code` / `status` / `requestId`. `unwrapOptional` returns `null` on a 404 instead — for resources whose absence is a normal state (a `/latest` snapshot not yet published), the client-side analog of `BaseService.find` beside `get`. |
160
163
  | `FileUpload`, `useFileDownload` | The files-capability surface (ADR 0056/0057): a token-styled attachment picker that uploads through the typed client, and an authenticated download helper (a raw `<a href>` would carry no bearer token). |
164
+ | `useEndpointDownload`, `saveBlob` | Download an artifact the backend **generates** — an evidence bundle, a CSV export — which has no stored file id (ADR 0096). Goes through the session client, so it carries the base URL and bearer token and rejects a non-2xx instead of saving the error body under the intended filename. |
161
165
 
162
166
  ## Feedback & states
163
167
 
@@ -168,7 +172,7 @@ marker, counted by the escape-hatch budget.
168
172
  | `ErrorState`, `describeError` | Human-readable failure block for a caught error. |
169
173
  | `ErrorMessagesProvider`, `useErrorMessage`, `DEFAULT_ERROR_MESSAGES` | Map stable backend error codes to copy; falls back to the envelope `detail`. |
170
174
  | `ToastProvider`, `useToast` | Transient success/error feedback (no toast library). |
171
- | `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. |
175
+ | `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. A modal is for a confirmation or an explicit post-action moment — an edit form or a detail view belongs in a routed page, or in an expanded row beside the thing it edits (ADR 0096 §4). |
172
176
 
173
177
  ## Forms & primitives
174
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@tanstack/react-router": "^1.170.16",
16
- "@terpjs/contract": "^0.8.0"
16
+ "@terpjs/contract": "^0.9.0"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^18.3.0 || ^19.0.0",
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
3
3
  import { useEffect } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
 
6
- import { Authorized } from "./Authorized";
6
+ import { Authorized, useHasPermission, usePermissions } from "./Authorized";
7
7
  import { TerpProvider, useAuth } from "./TerpProvider";
8
8
 
9
9
  function jsonResponse(body: unknown): Response {
@@ -57,4 +57,66 @@ describe("Authorized", () => {
57
57
  expect(screen.getByText("no-admin")).toBeInTheDocument();
58
58
  expect(screen.queryByText("admin-only")).not.toBeInTheDocument();
59
59
  });
60
+
61
+ it("gates on a named grant, not only rank (ADR 0096)", async () => {
62
+ // The hole this closes: rank was all the wire carried, so a screen whose write needs
63
+ // `definitions.publish` hid by rank as a proxy and handled the 403 anyway.
64
+ vi.stubGlobal(
65
+ "fetch",
66
+ vi.fn<typeof fetch>(async (input) => {
67
+ const url = (input as Request).url;
68
+ if (url.endsWith("/api/v1/auth/login")) {
69
+ return jsonResponse({ access_token: "token", token_type: "bearer" });
70
+ }
71
+ return jsonResponse({
72
+ id: "u1",
73
+ email: "editor@example.com",
74
+ role_rank: 20,
75
+ role_name: "editor",
76
+ permissions: ["definitions.manage"],
77
+ });
78
+ }),
79
+ );
80
+
81
+ render(
82
+ <TerpProvider baseUrl="https://api.test">
83
+ <LogInOnMount />
84
+ <Authorized action="write" permission="definitions.manage">
85
+ <span>held</span>
86
+ </Authorized>
87
+ <Authorized action="write" permission="definitions.publish">
88
+ <span>not held</span>
89
+ </Authorized>
90
+ {/* Rank alone still fails closed even when the grant is held. */}
91
+ <Authorized action="admin" permission="definitions.manage">
92
+ <span>rank too low</span>
93
+ </Authorized>
94
+ </TerpProvider>,
95
+ );
96
+
97
+ await waitFor(() => expect(screen.getByText("held")).toBeInTheDocument());
98
+ expect(screen.queryByText("not held")).toBeNull();
99
+ expect(screen.queryByText("rank too low")).toBeNull();
100
+ });
101
+
102
+ it("reports no permissions when signed out, and none for an app that grants none", async () => {
103
+ vi.stubGlobal(
104
+ "fetch",
105
+ vi.fn<typeof fetch>(async () => new Response("", { status: 401 })),
106
+ );
107
+
108
+ function Probe() {
109
+ return (
110
+ <span>{`n=${usePermissions().length} has=${String(useHasPermission("anything"))}`}</span>
111
+ );
112
+ }
113
+ render(
114
+ <TerpProvider baseUrl="https://api.test">
115
+ <Probe />
116
+ </TerpProvider>,
117
+ );
118
+
119
+ // Empty rather than undefined, so a screen can read it without guarding first.
120
+ await waitFor(() => expect(screen.getByText("n=0 has=false")).toBeInTheDocument());
121
+ });
60
122
  });
@@ -8,14 +8,47 @@ export function useCan(action: Action): boolean {
8
8
  return useAuth().can(action);
9
9
  }
10
10
 
11
+ /**
12
+ * The caller's effective permission names, from `GET /me` (ADR 0096).
13
+ *
14
+ * `useCan` compares role *rank*, which is all the wire used to carry. A screen whose write
15
+ * needs a **named** grant (`definitions.publish`) therefore had nothing to ask: it hid by
16
+ * rank as a proxy and handled the 403 anyway — showing a button it knew might fail, or
17
+ * hiding one the user was entitled to. This is the same set the server's guard enforces,
18
+ * projected for display.
19
+ *
20
+ * Empty when signed out, and empty for an app that mounts no grant capability (it has no
21
+ * named permissions). A *display* input only: the server re-checks every request, and a
22
+ * client that treats this as authority has moved the gate to the wrong side of the wire.
23
+ */
24
+ export function usePermissions(): readonly string[] {
25
+ return useAuth().currentUser()?.permissions ?? [];
26
+ }
27
+
28
+ /** Whether the current user holds the named permission grant (the UI gate). */
29
+ export function useHasPermission(permission: string): boolean {
30
+ return usePermissions().includes(permission);
31
+ }
32
+
11
33
  export interface AuthorizedProps {
12
34
  action: Action;
13
35
  children: ReactNode;
36
+ /**
37
+ * Also require this named permission grant.
38
+ *
39
+ * Both must pass, which is deliberately what the server does: a `Policy` carrying a
40
+ * `Permission` enforces the permission's role floor **and** the grant, so a UI that
41
+ * checked only one would disagree with the endpoint in one direction or the other.
42
+ */
43
+ permission?: string;
14
44
  /** Rendered when the user may not perform `action` (default: nothing). */
15
45
  fallback?: ReactNode;
16
46
  }
17
47
 
18
48
  /** Render `children` only when the current user may perform `action`, else `fallback`. */
19
- export function Authorized({ action, children, fallback = null }: AuthorizedProps) {
20
- return <>{useCan(action) ? children : fallback}</>;
49
+ export function Authorized({ action, permission, children, fallback = null }: AuthorizedProps) {
50
+ const allowedByRank = useCan(action);
51
+ const permissions = usePermissions();
52
+ const allowed = allowedByRank && (permission === undefined || permissions.includes(permission));
53
+ return <>{allowed ? children : fallback}</>;
21
54
  }
package/src/LoginView.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useState } from "react";
2
- import type { CSSProperties, FormEvent } from "react";
2
+ import type { FormEvent } from "react";
3
3
 
4
4
  import { TerpMark } from "./icons";
5
5
  import { useAuth, useSso } from "./TerpProvider";
@@ -8,67 +8,6 @@ import { Button } from "./ui/Button";
8
8
  import { Input } from "./ui/Input";
9
9
  import type { SsoProvider } from "./sso";
10
10
 
11
- const pageStyle: CSSProperties = {
12
- minHeight: "100vh",
13
- display: "grid",
14
- placeItems: "center",
15
- padding: "var(--space-6)",
16
- background: "var(--color-neutral-50)",
17
- fontFamily: "var(--font-family-sans)",
18
- color: "var(--color-neutral-900)",
19
- };
20
-
21
- const cardStyle: CSSProperties = {
22
- width: "100%",
23
- maxWidth: "24rem",
24
- display: "grid",
25
- gap: "var(--space-4)",
26
- padding: "var(--space-6)",
27
- background: "var(--color-neutral-0)",
28
- border: "1px solid var(--color-neutral-200)",
29
- borderRadius: "var(--radius-lg)",
30
- boxShadow: "var(--shadow-md)",
31
- };
32
-
33
- const brandStyle: CSSProperties = {
34
- display: "flex",
35
- alignItems: "center",
36
- gap: "var(--space-2)",
37
- color: "var(--color-neutral-900)",
38
- };
39
-
40
- const brandTitleStyle: CSSProperties = {
41
- margin: 0,
42
- fontSize: "var(--font-size-xl)",
43
- fontWeight: "var(--font-weight-bold)" as CSSProperties["fontWeight"],
44
- letterSpacing: 0,
45
- };
46
-
47
- const formStyle: CSSProperties = { display: "grid", gap: "var(--space-3)" };
48
-
49
- const fullWidthStyle: CSSProperties = { width: "100%" };
50
-
51
- const separatorStyle: CSSProperties = {
52
- display: "flex",
53
- alignItems: "center",
54
- gap: "var(--space-2)",
55
- color: "var(--color-neutral-500)",
56
- fontSize: "var(--font-size-xs)",
57
- textTransform: "uppercase",
58
- letterSpacing: "0.06em",
59
- };
60
-
61
- const separatorRuleStyle: CSSProperties = {
62
- flex: 1,
63
- borderTop: "1px solid var(--color-neutral-200)",
64
- };
65
-
66
- const errorStyle: CSSProperties = {
67
- margin: 0,
68
- color: "var(--color-status-danger)",
69
- fontSize: "var(--font-size-sm)",
70
- };
71
-
72
11
  export interface LoginViewProps {
73
12
  /**
74
13
  * SSO providers to offer alongside the credentials form (ADR 0058). Each renders a
@@ -95,6 +34,12 @@ export interface DevCredentials {
95
34
  * The default signed-out screen: collects credentials and calls the auth session. Used by
96
35
  * {@link renderTerpApp}/`RequireAuth` unless an app supplies its own login view. Pass
97
36
  * {@link LoginViewProps.ssoProviders} to add SSO provider buttons under the form.
37
+ *
38
+ * It renders no inline styles: the full-viewport page, the card, the brand row, both button
39
+ * 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.
98
43
  */
99
44
  export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps = {}) {
100
45
  const auth = useAuth();
@@ -133,13 +78,13 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
133
78
  const ssoError = sso.error !== null && sso.error !== undefined ? strings.ssoFailed : null;
134
79
 
135
80
  return (
136
- <main style={pageStyle}>
137
- <div style={cardStyle}>
138
- <div style={brandStyle}>
81
+ <main data-terp="login-view">
82
+ <div data-terp="login-card">
83
+ <div data-terp="login-brand">
139
84
  <TerpMark />
140
- <h1 style={brandTitleStyle}>{strings.signIn}</h1>
85
+ <h1 data-terp="login-title">{strings.signIn}</h1>
141
86
  </div>
142
- <form style={formStyle} onSubmit={onSubmit}>
87
+ <form data-terp="login-form" onSubmit={onSubmit}>
143
88
  <Input
144
89
  type="email"
145
90
  placeholder={strings.email}
@@ -154,7 +99,7 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
154
99
  onChange={(event) => setPassword(event.target.value)}
155
100
  required
156
101
  />
157
- <Button type="submit" disabled={busy} style={fullWidthStyle}>
102
+ <Button type="submit" disabled={busy}>
158
103
  {busy ? strings.signingIn : strings.signIn}
159
104
  </Button>
160
105
  {devCredentials ? (
@@ -162,7 +107,6 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
162
107
  type="button"
163
108
  variant="secondary"
164
109
  disabled={busy}
165
- style={fullWidthStyle}
166
110
  onClick={() => {
167
111
  setEmail(devCredentials.email);
168
112
  setPassword(devCredentials.password);
@@ -175,19 +119,18 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
175
119
  </form>
176
120
  {ssoProviders.length > 0 ? (
177
121
  <>
178
- <div style={separatorStyle} aria-hidden="true">
179
- <span style={separatorRuleStyle} />
122
+ <div data-terp="login-separator" aria-hidden="true">
123
+ <span data-terp="login-separator-rule" />
180
124
  <span>{strings.orSeparator}</span>
181
- <span style={separatorRuleStyle} />
125
+ <span data-terp="login-separator-rule" />
182
126
  </div>
183
- <div style={formStyle}>
127
+ <div data-terp="login-sso">
184
128
  {ssoProviders.map((provider) => (
185
129
  <Button
186
130
  key={provider.name}
187
131
  type="button"
188
132
  variant="secondary"
189
133
  disabled={busy}
190
- style={fullWidthStyle}
191
134
  onClick={() => void onSso(provider)}
192
135
  >
193
136
  {`${strings.continueWith} ${provider.label ?? provider.name}`}
@@ -196,7 +139,11 @@ export function LoginView({ ssoProviders = [], devCredentials }: LoginViewProps
196
139
  </div>
197
140
  </>
198
141
  ) : null}
199
- {error ?? ssoError ? <p style={errorStyle}>{error ?? ssoError}</p> : null}
142
+ {error ?? ssoError ? (
143
+ <p role="alert" data-terp="login-error">
144
+ {error ?? ssoError}
145
+ </p>
146
+ ) : null}
200
147
  </div>
201
148
  </main>
202
149
  );
@@ -60,6 +60,25 @@ describe("ModuleNav", () => {
60
60
  "page",
61
61
  );
62
62
  expect(screen.getByRole("link", { name: "Overview" })).not.toHaveAttribute("aria-current");
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.
74
+ const active = screen.getByRole("link", { name: "Projects" });
75
+ const inactive = screen.getByRole("link", { name: "Overview" });
76
+ expect(active).toHaveAttribute("data-terp", "module-nav-link");
77
+ expect(active).toHaveAttribute("data-active", "true");
78
+ expect(inactive).toHaveAttribute("data-terp", "module-nav-link");
79
+ expect(inactive).not.toHaveAttribute("data-active");
80
+ expect(active.getAttribute("style")).toBeNull();
81
+ expect(inactive.getAttribute("style")).toBeNull();
63
82
  });
64
83
 
65
84
  it("returns nothing for an empty tab list", () => {
package/src/ModuleNav.tsx CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Link, useRouterState } from "@tanstack/react-router";
2
- import type { CSSProperties } from "react";
3
2
 
4
3
  import { useStrings, useUiText } from "./uiText";
5
4
  import type { UiText } from "./uiText";
@@ -17,38 +16,17 @@ export interface ModuleNavProps {
17
16
  ariaLabel?: UiText;
18
17
  }
19
18
 
20
- const navStyle: CSSProperties = {
21
- borderBottom: "1px solid var(--color-neutral-200)",
22
- };
23
-
24
- const listStyle: CSSProperties = {
25
- listStyle: "none",
26
- margin: 0,
27
- padding: 0,
28
- display: "flex",
29
- flexWrap: "wrap",
30
- gap: "var(--space-3)",
31
- };
32
-
33
- const linkStyle: CSSProperties = {
34
- display: "inline-flex",
35
- alignItems: "center",
36
- padding: "var(--space-2) 0",
37
- color: "var(--color-neutral-600)",
38
- textDecoration: "none",
39
- borderBottom: "2px solid transparent",
40
- };
41
-
42
- const activeLinkStyle: CSSProperties = {
43
- color: "var(--color-neutral-900)",
44
- borderBottomColor: "var(--color-fg-accent)",
45
- };
46
-
47
19
  /**
48
20
  * Secondary horizontal navigation for intra-module sub-pages.
49
21
  *
50
22
  * Each tab links to a real TanStack Router route so sub-pages keep their own URL,
51
23
  * lazy loading, and back-button behavior. The active tab is matched exactly.
24
+ *
25
+ * It renders no inline styles: the strip, the list and the links take their geometry and
26
+ * ink from the injected react-core sheet (ADR 0094). The active tab is styled from
27
+ * `data-active`, which this component alone writes, rather than from the `aria-current` it
28
+ * also sets — a router `Link` merges its own `aria-current` last, so that attribute has a
29
+ * second author and would be the breadcrumb mistake again.
52
30
  */
53
31
  export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
54
32
  const strings = useStrings();
@@ -60,12 +38,8 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
60
38
  }
61
39
 
62
40
  return (
63
- <nav
64
- aria-label={resolve(ariaLabel ?? strings.moduleNavigationLabel)}
65
- data-terp="module-nav"
66
- style={navStyle}
67
- >
68
- <ul style={listStyle}>
41
+ <nav aria-label={resolve(ariaLabel ?? strings.moduleNavigationLabel)} data-terp="module-nav">
42
+ <ul data-terp="module-nav-list">
69
43
  {items.map((item) => {
70
44
  const label = resolve(item.label);
71
45
  const isActive = pathname === item.to;
@@ -75,7 +49,8 @@ export function ModuleNav({ items, ariaLabel }: ModuleNavProps) {
75
49
  to={item.to}
76
50
  activeOptions={{ exact: true }}
77
51
  aria-current={isActive ? "page" : undefined}
78
- style={{ ...linkStyle, ...(isActive ? activeLinkStyle : undefined) }}
52
+ data-terp="module-nav-link"
53
+ data-active={isActive ? "true" : undefined}
79
54
  >
80
55
  {label}
81
56
  </Link>
package/src/Page.test.tsx CHANGED
@@ -18,13 +18,16 @@ describe("Page", () => {
18
18
  </Page>,
19
19
  );
20
20
 
21
- expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toBeInTheDocument();
22
- expect(screen.getByRole("heading", { level: 1, name: "Tasks" })).toHaveStyle({
23
- fontSize: "var(--font-size-lg)",
24
- letterSpacing: "0",
25
- });
26
- expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
27
21
  const title = screen.getByRole("heading", { level: 1, name: "Tasks" });
22
+ expect(title).toBeInTheDocument();
23
+ // The marker, not the declaration. jsdom computes no cascade, so toHaveStyle could only
24
+ // ever see an inline style — asserting the title's font size was asserting that Page
25
+ // styles itself from a style object, which is the thing ADR 0094 removes. What a unit
26
+ // test can hold is the fact the sheet keys on; the type is gated by styles.test.ts for
27
+ // existence and by the page-header baseline for what it renders as.
28
+ expect(title).toHaveAttribute("data-terp", "page-title");
29
+ expect(title.getAttribute("style")).toBeNull();
30
+ expect(screen.getByRole("button", { name: "New" })).toBeInTheDocument();
28
31
  const action = screen.getByRole("button", { name: "New" });
29
32
  expect(title.compareDocumentPosition(action) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
30
33
  expect(screen.getByText("body")).toBeInTheDocument();
package/src/Page.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
  import { useContext, useEffect, useRef, useState } from "react";
3
3
 
4
4
  import { Breadcrumbs } from "./Breadcrumbs";
@@ -40,39 +40,6 @@ export interface PageProps {
40
40
  children: ReactNode;
41
41
  }
42
42
 
43
- const pageStyle: CSSProperties = {
44
- display: "grid",
45
- gridTemplateColumns: "minmax(0, 1fr)",
46
- gap: "var(--space-4)",
47
- alignContent: "start",
48
- minWidth: 0,
49
- };
50
-
51
- const headerStyle: CSSProperties = { display: "grid", gap: "var(--space-2)" };
52
-
53
- const breadcrumbRowStyle: CSSProperties = {
54
- display: "flex",
55
- alignItems: "center",
56
- minHeight: "2rem",
57
- };
58
-
59
- const titleRowStyle: CSSProperties = {
60
- display: "flex",
61
- alignItems: "center",
62
- justifyContent: "space-between",
63
- gap: "var(--space-3)",
64
- flexWrap: "wrap",
65
- };
66
-
67
- const titleStyle: CSSProperties = {
68
- margin: 0,
69
- fontSize: "var(--font-size-lg)",
70
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
71
- letterSpacing: 0,
72
- color: "var(--color-neutral-900)",
73
- lineHeight: 1.3,
74
- };
75
-
76
43
  /**
77
44
  * The base content-page frame: every routed view is constructed the same way — one
78
45
  * header holding the breadcrumb trail (when there is a path back up through the
@@ -85,6 +52,9 @@ const titleStyle: CSSProperties = {
85
52
  * The frame also owns the async body states: `error` (which wins, so a failed
86
53
  * query never hides behind a spinner) then `isLoading` replace the body while the
87
54
  * header stays put, so the user keeps their place in the layers.
55
+ *
56
+ * It renders no inline styles: the frame's geometry and the title's type come from the
57
+ * injected react-core sheet, matched on the `data-terp` markers stamped below (ADR 0094).
88
58
  */
89
59
  export function Page({
90
60
  title,
@@ -138,15 +108,21 @@ export function Page({
138
108
  children
139
109
  );
140
110
  return (
141
- <article ref={articleRef} style={pageStyle}>
142
- <header style={headerStyle}>
111
+ <article ref={articleRef} data-terp="page">
112
+ {/* A <header> ELEMENT, and it has to stay one. The slot check above drops the header
113
+ from the body set by tagName, so re-rendering this as a marked <div> would put it
114
+ back in and fail every governed OverviewPage / DetailPage closed. The marker is
115
+ additive; the tag is load-bearing. For the same reason the body below takes no
116
+ wrapper of its own — not even a display: contents one, since article.children is a
117
+ DOM traversal and would see it. */}
118
+ <header data-terp="page-header">
143
119
  {hasAncestors && (
144
- <div style={breadcrumbRowStyle}>
120
+ <div data-terp="page-breadcrumbs">
145
121
  <Breadcrumbs items={trail} renderLink={renderLink} />
146
122
  </div>
147
123
  )}
148
- <div style={titleRowStyle}>
149
- <h1 style={titleStyle}>{resolve(title)}</h1>
124
+ <div data-terp="page-heading">
125
+ <h1 data-terp="page-title">{resolve(title)}</h1>
150
126
  {actions}
151
127
  </div>
152
128
  </header>
@@ -69,6 +69,21 @@ describe("ProfileView", () => {
69
69
  );
70
70
  expect(screen.getAllByText("jane.doe@example.com").length).toBeGreaterThanOrEqual(1);
71
71
  expect(screen.getByText("editor (20)")).toBeInTheDocument();
72
+
73
+ // The identity block, which nothing asserted before: the initials tile was entirely
74
+ // unreached by any test, and is aria-hidden so axe skips it too. Markers plus the
75
+ // absence of a style attribute, because the geometry is a sheet rule now (ADR 0094) and
76
+ // jsdom computes no cascade. The address marker matters most of the three — its
77
+ // overflow-wrap has no specimen, since the mock session's address is short, so this is
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"]');
80
+ expect(avatar).not.toBeNull();
81
+ expect(avatar?.textContent).toBe("JD");
82
+ expect(avatar?.getAttribute("style")).toBeNull();
83
+ const email = document.querySelector('[data-terp="profile-email"]');
84
+ expect(email?.textContent).toBe("jane.doe@example.com");
85
+ expect(email?.getAttribute("style")).toBeNull();
86
+ expect(document.querySelectorAll('[data-terp="profile-card"]')).toHaveLength(2);
72
87
  // The stacked preference controls live here (settings surface).
73
88
  expect(screen.getByLabelText("Theme")).toBeInTheDocument();
74
89
  expect(screen.getByLabelText("Language")).toBeInTheDocument();
@@ -1,5 +1,3 @@
1
- import type { CSSProperties } from "react";
2
-
3
1
  import { Page } from "./Page";
4
2
  import { useAuth } from "./TerpProvider";
5
3
  import { LanguageSwitcher } from "./locale";
@@ -9,38 +7,15 @@ import { userInitials } from "./UserMenu";
9
7
  import { Button } from "./ui/Button";
10
8
  import { useStrings } from "./uiText";
11
9
 
12
- const avatarStyle: CSSProperties = {
13
- display: "inline-flex",
14
- alignItems: "center",
15
- justifyContent: "center",
16
- width: "3.5rem",
17
- height: "3.5rem",
18
- flexShrink: 0,
19
- borderRadius: "var(--radius-full)",
20
- background: "var(--color-brand-primary)",
21
- color: "var(--color-brand-primary-contrast)",
22
- fontSize: "var(--font-size-lg)",
23
- fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
24
- };
25
-
26
- const mutedStyle: CSSProperties = { margin: 0, color: "var(--color-neutral-600)" };
27
-
28
- const cardStyle: CSSProperties = {
29
- display: "grid",
30
- gap: "var(--space-4)",
31
- padding: "var(--space-4)",
32
- maxWidth: "32rem",
33
- background: "var(--color-neutral-0)",
34
- border: "1px solid var(--color-neutral-200)",
35
- borderRadius: "var(--radius-lg)",
36
- };
37
-
38
10
  /**
39
11
  * The built-in profile / settings page the {@link UserMenu}'s Settings item opens.
40
12
  * `buildAppRouter` mounts it at `/profile` in every app (an app manifest claiming
41
13
  * that path wins): the signed-in identity (avatar, email, role — the server-validated
42
14
  * `/me` session, not token claims), the standard theme + language preferences, and
43
15
  * sign-out. A `Page` archetype, so it satisfies the routed-view frame control.
16
+ *
17
+ * It renders no inline styles: both cards, the avatar tile and the two identity lines take
18
+ * their geometry and ink from the injected react-core sheet (ADR 0094).
44
19
  */
45
20
  export function ProfileView() {
46
21
  const auth = useAuth();
@@ -52,14 +27,14 @@ export function ProfileView() {
52
27
  return (
53
28
  <Page title={strings.profile}>
54
29
  <Stack gap={4}>
55
- <div style={cardStyle}>
30
+ <div data-terp="profile-card">
56
31
  <Stack direction="row" gap={3} align="center">
57
- <span aria-hidden="true" style={avatarStyle}>
32
+ <span aria-hidden="true" data-terp="profile-avatar">
58
33
  {userInitials(user.email)}
59
34
  </span>
60
35
  <Stack gap={0}>
61
- <strong style={{ overflowWrap: "anywhere" }}>{user.email}</strong>
62
- <p style={mutedStyle}>{user.role_name}</p>
36
+ <strong data-terp="profile-email">{user.email}</strong>
37
+ <p data-terp="profile-role">{user.role_name}</p>
63
38
  </Stack>
64
39
  </Stack>
65
40
  <DetailList
@@ -69,7 +44,7 @@ export function ProfileView() {
69
44
  ]}
70
45
  />
71
46
  </div>
72
- <div style={cardStyle}>
47
+ <div data-terp="profile-card">
73
48
  <ThemeToggle />
74
49
  <LanguageSwitcher />
75
50
  <div>