@terpjs/react-core 0.7.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.
Files changed (94) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Breadcrumbs.test.tsx +24 -0
  8. package/src/Breadcrumbs.tsx +9 -32
  9. package/src/ConfirmDialog.tsx +13 -44
  10. package/src/EmptyState.tsx +8 -36
  11. package/src/ErrorState.tsx +8 -36
  12. package/src/Field.test.tsx +57 -0
  13. package/src/Field.tsx +46 -22
  14. package/src/HubPage.test.tsx +22 -13
  15. package/src/HubPage.tsx +25 -97
  16. package/src/LoadingState.tsx +3 -24
  17. package/src/LoginView.tsx +22 -75
  18. package/src/ModuleNav.test.tsx +19 -0
  19. package/src/ModuleNav.tsx +10 -35
  20. package/src/Page.test.tsx +9 -6
  21. package/src/Page.tsx +15 -39
  22. package/src/PageActions.tsx +5 -10
  23. package/src/ProfileView.test.tsx +15 -0
  24. package/src/ProfileView.tsx +8 -33
  25. package/src/ResourceList.tsx +13 -24
  26. package/src/UserMenu.test.tsx +12 -5
  27. package/src/UserMenu.tsx +33 -62
  28. package/src/admin/AuditLogAdmin.tsx +1 -10
  29. package/src/admin/GroupCreate.tsx +1 -1
  30. package/src/admin/GroupDetail.tsx +2 -2
  31. package/src/admin/UserCreate.tsx +1 -1
  32. package/src/admin/admin.test.tsx +31 -0
  33. package/src/dataview/DataView.test.tsx +109 -5
  34. package/src/dataview/DataView.tsx +41 -23
  35. package/src/dataview/DataViewCardList.tsx +14 -60
  36. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  37. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  38. package/src/dataview/DataViewPagination.tsx +2 -32
  39. package/src/dataview/DataViewRowActions.tsx +13 -33
  40. package/src/dataview/DataViewTable.tsx +16 -103
  41. package/src/dataview/DataViewToolbar.tsx +53 -76
  42. package/src/dataview/README.md +6 -0
  43. package/src/dataview/index.ts +1 -0
  44. package/src/dataview/internal.tsx +4 -1
  45. package/src/dataview/types.ts +13 -0
  46. package/src/download.test.tsx +153 -0
  47. package/src/download.tsx +132 -0
  48. package/src/feedback.test.tsx +26 -0
  49. package/src/files.test.tsx +18 -0
  50. package/src/files.tsx +15 -15
  51. package/src/icons.test.tsx +10 -6
  52. package/src/icons.tsx +9 -37
  53. package/src/index.ts +7 -4
  54. package/src/layout.test.tsx +24 -9
  55. package/src/layout.tsx +24 -21
  56. package/src/layoutContract.test.tsx +95 -0
  57. package/src/locale.tsx +24 -4
  58. package/src/markers.test.ts +354 -25
  59. package/src/routeSearch.ts +73 -0
  60. package/src/routeTypes.ts +50 -6
  61. package/src/router.test.tsx +191 -1
  62. package/src/router.tsx +81 -18
  63. package/src/sso.test.tsx +6 -3
  64. package/src/ssr.test.tsx +1 -3
  65. package/src/styles.test.ts +855 -6
  66. package/src/styles.ts +3049 -153
  67. package/src/theme.tsx +24 -3
  68. package/src/toast.tsx +35 -71
  69. package/src/ui/Alert.test.tsx +12 -0
  70. package/src/ui/Alert.tsx +15 -43
  71. package/src/ui/Badge.test.tsx +14 -3
  72. package/src/ui/Badge.tsx +9 -28
  73. package/src/ui/Button.test.tsx +19 -4
  74. package/src/ui/Button.tsx +10 -63
  75. package/src/ui/Card.test.tsx +6 -2
  76. package/src/ui/Card.tsx +11 -39
  77. package/src/ui/Checkbox.tsx +2 -19
  78. package/src/ui/Combobox.test.tsx +22 -0
  79. package/src/ui/Combobox.tsx +31 -80
  80. package/src/ui/DatePicker.test.tsx +131 -4
  81. package/src/ui/DatePicker.tsx +158 -106
  82. package/src/ui/Input.tsx +6 -19
  83. package/src/ui/Markdown.test.tsx +26 -0
  84. package/src/ui/Markdown.tsx +28 -2
  85. package/src/ui/Menu.test.tsx +38 -4
  86. package/src/ui/Menu.tsx +50 -52
  87. package/src/ui/Popover.tsx +53 -19
  88. package/src/ui/Radio.tsx +5 -30
  89. package/src/ui/Select.tsx +7 -30
  90. package/src/ui/Switch.tsx +2 -20
  91. package/src/ui/Tabs.tsx +4 -28
  92. package/src/ui/Textarea.tsx +6 -17
  93. package/src/ui/Tooltip.tsx +9 -21
  94. package/src/ui/controlStyles.ts +0 -9
package/src/HubPage.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
 
4
4
  import { Page } from "./Page";
@@ -19,22 +19,15 @@ export type HubPageProps = Omit<
19
19
  parents?: PageProps["breadcrumbs"];
20
20
  };
21
21
 
22
- const gridStyle: CSSProperties = {
23
- display: "grid",
24
- gridTemplateColumns: "repeat(auto-fit, minmax(min(16rem, 100%), 1fr))",
25
- gridAutoRows: "1fr",
26
- gap: "var(--space-4)",
27
- alignItems: "stretch",
28
- listStyle: "none",
29
- margin: 0,
30
- padding: 0,
31
- };
32
-
33
22
  /**
34
23
  * The landing / hub page archetype: a `Page` whose body is a responsive grid of
35
24
  * {@link HubCard} links into the sub-areas of a domain. Use it as a module index that
36
25
  * adds discovery value (each card can carry a live `stat`, making the hub a lightweight
37
26
  * dashboard) — never as a mandatory speed-bump in front of a single frequently-used list.
27
+ *
28
+ * It renders no inline styles: the grid and every part of a card take their geometry from
29
+ * the injected react-core sheet, matched on the `data-terp` markers stamped below
30
+ * (ADR 0094).
38
31
  */
39
32
  export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProps) {
40
33
  // The runtime half of the slot-typed layout contract control (ADR 0079) for the hub
@@ -61,7 +54,7 @@ export function HubPage({ children, parents, breadcrumbs, ...page }: HubPageProp
61
54
  }
62
55
  return (
63
56
  <Page {...page} breadcrumbs={parents ?? breadcrumbs}>
64
- <ul ref={gridRef} style={gridStyle}>
57
+ <ul ref={gridRef} data-terp="hubpage-grid">
65
58
  {children}
66
59
  </ul>
67
60
  </Page>
@@ -89,86 +82,23 @@ export interface HubCardProps {
89
82
  renderLink?: RenderHubCardLink;
90
83
  }
91
84
 
92
- const cardStyle: CSSProperties = {
93
- height: "100%",
94
- minHeight: 0,
95
- };
96
-
97
- const cardBodyStyle: CSSProperties = {
98
- display: "grid",
99
- gridTemplateRows: "auto minmax(3rem, 1fr) auto",
100
- gap: "var(--space-2)",
101
- height: "100%",
102
- minHeight: "10rem",
103
- padding: "var(--space-4)",
104
- border: "1px solid var(--color-neutral-200)",
105
- borderRadius: "var(--radius-lg)",
106
- background: "var(--color-neutral-0)",
107
- color: "var(--color-neutral-900)",
108
- boxSizing: "border-box",
109
- };
110
-
111
- const cardTitleRowStyle: CSSProperties = {
112
- margin: 0,
113
- display: "flex",
114
- alignItems: "center",
115
- gap: "var(--space-3)",
116
- };
117
-
118
- const iconTileStyle: CSSProperties = {
119
- display: "inline-flex",
120
- alignItems: "center",
121
- justifyContent: "center",
122
- width: "2.25rem",
123
- height: "2.25rem",
124
- flexShrink: 0,
125
- borderRadius: "var(--radius-md)",
126
- background: "var(--color-brand-primary-soft)",
127
- color: "var(--color-fg-accent)",
128
- };
129
-
130
- const titleTextStyle: CSSProperties = {
131
- color: "var(--color-neutral-900)",
132
- fontSize: "var(--font-size-base)",
133
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
134
- transition: "color 150ms ease",
135
- };
136
-
137
- const descriptionStyle: CSSProperties = {
138
- margin: 0,
139
- color: "var(--color-neutral-600)",
140
- fontSize: "var(--font-size-sm)",
141
- lineHeight: 1.5,
142
- };
143
-
144
- const emptyDescriptionStyle: CSSProperties = { ...descriptionStyle, visibility: "hidden" };
145
-
146
- const statStyle: CSSProperties = {
147
- color: "var(--color-neutral-900)",
148
- fontSize: "var(--font-size-sm)",
149
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
150
- };
151
- const emptyStatStyle: CSSProperties = { ...statStyle, visibility: "hidden" };
152
-
153
- const linkStyle: CSSProperties = {
154
- textDecoration: "none",
155
- color: "inherit",
156
- display: "block",
157
- height: "100%",
158
- minHeight: 0,
159
- };
160
-
161
85
  const anchorRenderLink: RenderHubCardLink = ({ to, children }) => (
162
- <a href={to} data-terp="hubcard-link" style={linkStyle}>
86
+ <a href={to} data-terp="hubcard-link">
163
87
  {children}
164
88
  </a>
165
89
  );
166
90
 
167
91
  /**
168
- * A single navigable card inside a {@link HubPage}: icon + title, a short description of
92
+ * A single navigable card inside a {@link HubPage}: icon + title, a short explanation of
169
93
  * the area, and an optional live `stat`. The whole card is one link, rendered through
170
94
  * `renderLink` so the hub stays router-agnostic — defaulting to the surrounding router's
171
95
  * `Link`, and to a plain anchor only outside a Terp router.
96
+ *
97
+ * The description and stat rows always render, carrying a non-breaking space when the prop
98
+ * is absent, so a card's three grid rows keep their heights and a bare card stays flush
99
+ * with a full one in the same row. `data-empty` is what hides the placeholder — an
100
+ * attribute rather than a second style object, because "is this row a placeholder" is a
101
+ * fact about the element that a rule can read.
172
102
  */
173
103
  export function HubCard({
174
104
  to,
@@ -184,27 +114,26 @@ export function HubCard({
184
114
  (navLink === null
185
115
  ? anchorRenderLink
186
116
  : ({ to: href, children }: { to: string; children: ReactNode }) => (
187
- <span data-terp="hubcard-link" style={linkStyle}>
188
- {navLink({ to: href, children })}
189
- </span>
117
+ <span data-terp="hubcard-link">{navLink({ to: href, children })}</span>
190
118
  ));
191
119
  const resolve = useUiText();
192
120
  return (
193
- <li data-terp="hubcard" style={cardStyle}>
121
+ <li data-terp="hubcard">
194
122
  {renderCardLink({
195
123
  to,
196
124
  children: (
197
- <span data-terp="hubcard-body" style={cardBodyStyle}>
198
- <span style={cardTitleRowStyle}>
199
- {icon !== undefined && <span style={iconTileStyle}>{icon}</span>}
200
- <strong data-terp="hubcard-title" style={titleTextStyle}>
201
- {resolve(title)}
202
- </strong>
125
+ <span data-terp="hubcard-body">
126
+ <span data-terp="hubcard-heading">
127
+ {icon !== undefined && <span data-terp="hubcard-icon">{icon}</span>}
128
+ <strong data-terp="hubcard-title">{resolve(title)}</strong>
203
129
  </span>
204
- <span data-terp="hubcard-description" style={description === undefined ? emptyDescriptionStyle : descriptionStyle}>
130
+ <span
131
+ data-terp="hubcard-description"
132
+ data-empty={description === undefined ? "true" : undefined}
133
+ >
205
134
  {description === undefined ? " " : resolve(description)}
206
135
  </span>
207
- <span data-terp="hubcard-stat" style={stat === undefined ? emptyStatStyle : statStyle}>
136
+ <span data-terp="hubcard-stat" data-empty={stat === undefined ? "true" : undefined}>
208
137
  {stat ?? " "}
209
138
  </span>
210
139
  </span>
@@ -213,4 +142,3 @@ export function HubCard({
213
142
  </li>
214
143
  );
215
144
  }
216
-
@@ -1,5 +1,3 @@
1
- import type { CSSProperties } from "react";
2
-
3
1
  import { injectTerpStyles } from "./styles";
4
2
  import { useStrings, useUiText } from "./uiText";
5
3
  import type { UiText } from "./uiText";
@@ -23,13 +21,7 @@ export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
23
21
  <span
24
22
  aria-hidden="true"
25
23
  data-terp="spinner-ring"
26
- style={{
27
- display: "inline-block",
28
- verticalAlign: "middle",
29
- width: size,
30
- height: size,
31
- lineHeight: 0,
32
- }}
24
+ style={{ width: size, height: size }}
33
25
  >
34
26
  <svg
35
27
  aria-hidden="true"
@@ -37,7 +29,6 @@ export function InlineSpinner({ size = 16 }: InlineSpinnerProps) {
37
29
  height={size}
38
30
  viewBox="0 0 24 24"
39
31
  fill="none"
40
- style={{ display: "block" }}
41
32
  >
42
33
  <circle
43
34
  cx="12"
@@ -63,18 +54,6 @@ export interface LoadingStateProps {
63
54
  label?: UiText;
64
55
  }
65
56
 
66
- const wrapStyle: CSSProperties = {
67
- display: "flex",
68
- alignItems: "center",
69
- justifyContent: "center",
70
- gap: "var(--space-2)",
71
- padding: "var(--space-6)",
72
- color: "var(--color-neutral-500)",
73
- fontSize: "var(--font-size-sm)",
74
- };
75
-
76
- const spinnerColorStyle: CSSProperties = { color: "var(--color-fg-accent)" };
77
-
78
57
  /**
79
58
  * Standard inline loading indicator: a spinner plus a short label, announced
80
59
  * as a `status` live region. Use it when a query is pending and the page shell
@@ -85,8 +64,8 @@ export function LoadingState({ label }: LoadingStateProps) {
85
64
  const strings = useStrings();
86
65
  const resolve = useUiText();
87
66
  return (
88
- <div role="status" data-terp="loading-state" style={wrapStyle}>
89
- <span style={spinnerColorStyle}>
67
+ <div role="status" data-terp="loading-state">
68
+ <span data-terp="loading-state-spinner">
90
69
  <InlineSpinner size={20} />
91
70
  </span>
92
71
  <span>{resolve(label ?? strings.loading)}</span>
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>
@@ -1,9 +1,12 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
 
3
+ import { injectTerpStyles } from "./styles";
3
4
  import { Menu, MenuItem } from "./ui/Menu";
4
5
  import { useStrings } from "./uiText";
5
6
  import type { UiText } from "./uiText";
6
7
 
8
+ injectTerpStyles();
9
+
7
10
  export interface OverflowAction {
8
11
  /** Display label for the menu item. */
9
12
  label: UiText;
@@ -25,14 +28,6 @@ export interface PageActionsProps {
25
28
  className?: string;
26
29
  }
27
30
 
28
- const clusterStyle: CSSProperties = {
29
- display: "flex",
30
- flexWrap: "wrap",
31
- alignItems: "center",
32
- justifyContent: "flex-end",
33
- gap: "var(--space-2)",
34
- };
35
-
36
31
  /** Standard right-aligned action cluster for page headers. */
37
32
  export function PageActions({ primary, secondary, overflow, className }: PageActionsProps) {
38
33
  const strings = useStrings();
@@ -43,7 +38,7 @@ export function PageActions({ primary, secondary, overflow, className }: PageAct
43
38
  }
44
39
 
45
40
  return (
46
- <div className={className} style={clusterStyle}>
41
+ <div className={className} data-terp="page-actions">
47
42
  {hasOverflow && (
48
43
  <Menu trigger="⋯" triggerLabel={strings.moreActions}>
49
44
  {({ close }) => (
@@ -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();