@terpjs/react-core 0.9.0 → 0.10.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 (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. package/src/unwrap.ts +118 -32
@@ -0,0 +1,278 @@
1
+ /**
2
+ * A channel into a running app, for the tool that is showing it.
3
+ *
4
+ * A development tool that embeds a running app in an iframe cannot see into it — a cross-origin
5
+ * frame is opaque by design, and deliberately so. Which leaves someone looking at a button they
6
+ * want changed with no way to say WHICH button, except in prose.
7
+ *
8
+ * This package does not know or name who is asking. It answers a protocol, not a product.
9
+ *
10
+ * The hook this uses already exists: every sanctioned component stamps `data-terp` on its root,
11
+ * and the marker inventory is a pinned, gated list. So "which component is this?" is a question
12
+ * the DOM can already answer; what was missing is someone to ask it.
13
+ *
14
+ * FOUR DECISIONS, and each is the reason a bridge like this is usually a bad idea.
15
+ *
16
+ * **It exists only in a development build.** The whole module is behind `import.meta.env.DEV`,
17
+ * which a production build folds to `false` and strips — the same mechanism the template uses for
18
+ * its dev sign-in credentials, and for the same reason. A deployed app has no listener, so none of
19
+ * what follows is a production attack surface. It is not a setting that can be left on.
20
+ *
21
+ * **The tool speaks first, and the FIRST one wins.** The app never volunteers anything: it
22
+ * records the origin and the window of the first well-formed handshake and answers that one
23
+ * alone, thereafter — a second party cannot take the conversation over, and cannot start one.
24
+ * First-come rather than validated, because there is nothing to validate against: a tool
25
+ * embedding an app has no way to configure this package, and the two are on different origins by
26
+ * design (an embedded preview sharing its host would carry that host's session cookie into app
27
+ * code). An opaque origin is refused, since `"null"` is what a sandboxed or `file://` document
28
+ * reports and it matches every other one.
29
+ *
30
+ * The window is kept as well as the origin. `window.parent` is the frame's embedder only when
31
+ * there IS an embedder — open the app in a tab and `window.parent` is the app itself, so a reply
32
+ * addressed there talks to nobody while looking like it worked.
33
+ *
34
+ * **Three things leave, and they are named exhaustively.** A reply carries `data-terp` markers,
35
+ * element tag names, and the route path the page is on. Never text, never values, never
36
+ * attributes — nothing an app renders data into. An app under development is an app with real
37
+ * data in it, and a channel that could read the page would be a way out for that data, dev-only
38
+ * or not.
39
+ *
40
+ * The route path is the one of the three that is not purely structural, and it is listed rather
41
+ * than glossed: `/records/42` carries an identifier. It is sent because "which component" is not
42
+ * a useful answer without "on which screen", it is the same string already visible in the address
43
+ * bar of the frame the asker is displaying, and it is bounded below. An earlier version of this
44
+ * docstring said "structure, never content" and sent it anyway, which is the kind of sentence
45
+ * this file exists to not write.
46
+ *
47
+ * A bounding rectangle used to leave too. Nothing consumed it, so it does not.
48
+ *
49
+ * **The app draws its own highlight.** Nothing outside a cross-origin iframe can paint over it,
50
+ * so pointing at something has to happen on this side. One outline, from the token layer, removed
51
+ * with the mode.
52
+ *
53
+ * ADR 0006's quadruple is not fully satisfied and the missing halves are named in ADR 0101 §5:
54
+ * there is a typed protocol with a safe default (off, and absent in production) and a fail-closed
55
+ * runtime check (every message is validated and a stranger is ignored), but no build-time rule and
56
+ * no escape hatch — a lint rule would have nothing to check, since the module is not something an
57
+ * app writes, and there is nothing to opt out of in a build where it does not exist.
58
+ */
59
+
60
+ /**
61
+ * The protocol name, carried on every message in both directions.
62
+ *
63
+ * Versioned in the name rather than in a field: a tool and an app can be from different releases,
64
+ * and a version mismatch has to be silence rather than a half-understood conversation. An app that
65
+ * does not recognise the name ignores the message, which is exactly what an older app does with a
66
+ * newer tool and the reverse.
67
+ */
68
+ export const PREVIEW_BRIDGE_PROTOCOL = "terp.preview.1";
69
+
70
+ /** One step of the chain from the clicked element up to the page root. */
71
+ export interface PreviewBridgeStep {
72
+ /** The sanctioned component's marker, e.g. "button" or "dataview". */
73
+ readonly marker: string;
74
+ /** The element it was stamped on, lowercased. */
75
+ readonly tag: string;
76
+ }
77
+
78
+ /** What the app answers with when something is picked. */
79
+ export interface PreviewBridgeSelection {
80
+ /** Innermost first: the marker chain from what was clicked up to the page. */
81
+ readonly path: readonly PreviewBridgeStep[];
82
+ /** The route the preview is on, so a tool can say where the component was found. */
83
+ readonly path_name: string;
84
+ }
85
+
86
+ /**
87
+ * The shape a marker may have: the shape every name in the pinned inventory has.
88
+ *
89
+ * Checked rather than trusted, even though this code runs inside the app it is describing.
90
+ * `getAttribute("data-terp")` returns whatever is on the element, and an app under development
91
+ * is allowed to put anything anywhere — so an unbounded string here is an app writing arbitrary
92
+ * text into whatever the asking tool does with it. Bounded in length for the same reason.
93
+ */
94
+ const MARKER_RE = /^[a-z0-9-]{1,64}$/;
95
+
96
+ /** A tag name, bounded the same way. Custom elements are hyphenated ASCII. */
97
+ const TAG_RE = /^[a-z0-9-]{1,64}$/;
98
+
99
+ /** A route path, bounded. Long enough for any real route, short enough not to be a payload. */
100
+ const MAX_PATH_NAME = 512;
101
+
102
+ /** The attribute the highlight is styled from — an attribute, like every other marker here. */
103
+ const HIGHLIGHT_ATTRIBUTE = "data-terp-preview-pick";
104
+
105
+ const STYLE_ID = "terp-preview-bridge";
106
+
107
+ const HIGHLIGHT_CSS = `
108
+ [${HIGHLIGHT_ATTRIBUTE}] {
109
+ outline: 2px solid var(--color-brand-primary, #2563eb);
110
+ outline-offset: 1px;
111
+ cursor: crosshair;
112
+ }
113
+ `;
114
+
115
+ interface Incoming {
116
+ protocol?: unknown;
117
+ kind?: unknown;
118
+ on?: unknown;
119
+ }
120
+
121
+ function isIncoming(data: unknown): data is Incoming & { protocol: string; kind: string } {
122
+ return (
123
+ typeof data === "object" &&
124
+ data !== null &&
125
+ (data as Incoming).protocol === PREVIEW_BRIDGE_PROTOCOL &&
126
+ typeof (data as Incoming).kind === "string"
127
+ );
128
+ }
129
+
130
+ /**
131
+ * The chain of sanctioned components from *element* up to the document.
132
+ *
133
+ * A step whose marker or tag is not the shape a marker has is DROPPED rather than sent. The
134
+ * attribute is whatever is on the element, and this code cannot tell a component's marker from
135
+ * a string an app put there — so the reply carries only what looks like the closed vocabulary the
136
+ * asker is expecting, and never becomes a way to write arbitrary text into that asker's tools.
137
+ */
138
+ function markerPath(element: Element | null): PreviewBridgeStep[] {
139
+ const path: PreviewBridgeStep[] = [];
140
+ for (let node: Element | null = element; node !== null; node = node.parentElement) {
141
+ const marker = node.getAttribute("data-terp");
142
+ const tag = node.tagName.toLowerCase();
143
+ if (marker !== null && MARKER_RE.test(marker) && TAG_RE.test(tag)) {
144
+ path.push({ marker, tag });
145
+ }
146
+ }
147
+ return path;
148
+ }
149
+
150
+ /** The nearest ancestor that a sanctioned component stamped, including *element* itself. */
151
+ function nearestMarked(element: Element | null): Element | null {
152
+ for (let node: Element | null = element; node !== null; node = node.parentElement) {
153
+ if (node.getAttribute("data-terp")) return node;
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * Listen for a tool asking about this app, and answer it.
160
+ *
161
+ * Returns a function that removes every listener, the style element and the highlight — so a test
162
+ * can run this twice without leaking, and so a caller that ever wants to stop can.
163
+ *
164
+ * Safe to call with no `window` (server rendering): it does nothing and returns a no-op.
165
+ */
166
+ export function installPreviewBridge(): () => void {
167
+ if (typeof window === "undefined" || typeof document === "undefined") {
168
+ return () => {};
169
+ }
170
+
171
+ /**
172
+ * Who said hello: the origin to address a reply to, and the window to send it to.
173
+ *
174
+ * Both, because they answer different questions. The origin is what stops another site
175
+ * reading a reply; the window is what makes the reply arrive at all — `window.parent` is the
176
+ * embedder only when the app IS embedded, and is the app itself when it is open in a tab.
177
+ */
178
+ let asker: { origin: string; window: MessageEventSource } | null = null;
179
+ /**
180
+ * Who turned select mode on, or `null` when it is off.
181
+ *
182
+ * The asker rather than a boolean, and that is what removes the last unreachable branch from
183
+ * this module: "select mode is on" and "there is someone to answer" are the same fact, so a
184
+ * click handler holding this holds a destination and `reply` has no null case to guard. Written
185
+ * as a boolean first, which left a `if (asker === null) return` in `reply` that no test could
186
+ * reach — dead defensive code in the one module where dead code is least welcome.
187
+ */
188
+ let selecting: { origin: string; window: MessageEventSource } | null = null;
189
+ let highlighted: Element | null = null;
190
+
191
+ const reply = (to: { origin: string; window: MessageEventSource }, message: object) => {
192
+ (to.window as Window).postMessage(
193
+ { protocol: PREVIEW_BRIDGE_PROTOCOL, ...message },
194
+ to.origin,
195
+ );
196
+ };
197
+
198
+ const clearHighlight = () => {
199
+ highlighted?.removeAttribute(HIGHLIGHT_ATTRIBUTE);
200
+ highlighted = null;
201
+ };
202
+
203
+ const onPointerOver = (event: Event) => {
204
+ if (selecting === null) return;
205
+ const target = nearestMarked(event.target as Element | null);
206
+ if (target === highlighted) return;
207
+ clearHighlight();
208
+ if (target !== null) {
209
+ target.setAttribute(HIGHLIGHT_ATTRIBUTE, "");
210
+ highlighted = target;
211
+ }
212
+ };
213
+
214
+ const onClick = (event: MouseEvent) => {
215
+ const to = selecting;
216
+ if (to === null) return;
217
+ // Captured and cancelled: in select mode a click picks a component rather than doing what it
218
+ // normally does. Letting it through would navigate away from the thing being pointed at.
219
+ event.preventDefault();
220
+ event.stopPropagation();
221
+ const target = nearestMarked(event.target as Element | null);
222
+ if (target === null) return;
223
+ reply(to, {
224
+ kind: "selected",
225
+ selection: {
226
+ path: markerPath(target),
227
+ path_name: window.location.pathname.slice(0, MAX_PATH_NAME),
228
+ } satisfies PreviewBridgeSelection,
229
+ });
230
+ };
231
+
232
+ const setSelecting = (to: { origin: string; window: MessageEventSource } | null) => {
233
+ selecting = to;
234
+ if (to === null) clearHighlight();
235
+ if (to !== null && document.getElementById(STYLE_ID) === null) {
236
+ const style = document.createElement("style");
237
+ style.id = STYLE_ID;
238
+ // textContent, never innerHTML: no HTML-injection sink is touched, which is the same rule
239
+ // the component stylesheet's own injector follows.
240
+ style.textContent = HIGHLIGHT_CSS;
241
+ document.head.appendChild(style);
242
+ }
243
+ };
244
+
245
+ const onMessage = (event: MessageEvent) => {
246
+ if (!isIncoming(event.data)) return;
247
+ if (asker === null && event.data.kind === "hello") {
248
+ // `"null"` is what a sandboxed iframe, a `file://` page and a data: URL all report, so it
249
+ // identifies nobody and matches everybody — adopting it would make the next such document
250
+ // the asker too. A source is required for the same reason: without one there is no window
251
+ // to answer.
252
+ if (event.origin === "null" || event.origin === "" || event.source === null) return;
253
+ asker = { origin: event.origin, window: event.source };
254
+ }
255
+ if (asker === null || event.origin !== asker.origin || event.source !== asker.window) return;
256
+ if (event.data.kind === "hello") {
257
+ reply(asker, { kind: "ready" });
258
+ return;
259
+ }
260
+ if (event.data.kind === "select") {
261
+ setSelecting(event.data.on === true ? asker : null);
262
+ return;
263
+ }
264
+ };
265
+
266
+ window.addEventListener("message", onMessage);
267
+ document.addEventListener("pointerover", onPointerOver, true);
268
+ document.addEventListener("click", onClick, true);
269
+
270
+ return () => {
271
+ window.removeEventListener("message", onMessage);
272
+ document.removeEventListener("pointerover", onPointerOver, true);
273
+ document.removeEventListener("click", onClick, true);
274
+ selecting = null;
275
+ clearHighlight();
276
+ document.getElementById(STYLE_ID)?.remove();
277
+ };
278
+ }
package/src/raw.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  /**
2
- * Minimal ambient declarations for the source-scanning tests only — the package keeps
3
- * `"types": []` so component source never sees ambient Node globals.
2
+ * Minimal ambient declarations for the two things this package reads off `import.meta` — the
3
+ * package keeps `"types": []` so component source never sees ambient Node globals.
4
+ *
5
+ * Safe to declare here even though every consuming app already has `vite/client`: nothing
6
+ * imports this file, so it is part of THIS package's program and not of a consumer's, and the
7
+ * two `ImportMeta` augmentations never meet.
4
8
  *
5
9
  * Both live here rather than inline in the tests that use them: a global augmentation
6
10
  * repeated in two files is a TS2717 the moment the copies disagree, and they disagree
@@ -18,4 +22,12 @@ interface ImportMeta {
18
22
  pattern: string,
19
23
  options: { query: "?raw"; import: "default"; eager: true },
20
24
  ) => Record<string, string>;
25
+ /**
26
+ * The build-mode flag, `false` in a production build.
27
+ *
28
+ * Written out verbatim at its one call site rather than read through a helper or a cast: a
29
+ * bundler folds the expression `import.meta.env.DEV` TEXTUALLY, so anything else leaves the
30
+ * branch — and the module behind it — in a production bundle.
31
+ */
32
+ readonly env: { readonly DEV: boolean };
21
33
  }
@@ -0,0 +1,272 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { Checkbox } from "./ui/Checkbox";
6
+ import { Radio } from "./ui/Radio";
7
+ import { Switch } from "./ui/Switch";
8
+ import { Popover } from "./ui/Popover";
9
+ import { Tabs } from "./ui/Tabs";
10
+ import { TERP_STYLES_CSS } from "./styles";
11
+
12
+ // Defects the phases 1-4 review found, gated.
13
+ //
14
+ // Every one of these shipped past four browser lanes and a full unit suite, so an assertion that
15
+ // merely restates the fix would be worth nothing. Each row names the mutation that turns it red
16
+ // and was checked against it.
17
+ //
18
+ // Several read SOURCE TEXT or the stylesheet rather than behaviour, which is unusual here and
19
+ // deliberate rather than lazy. Each of those is a fact about something this suite cannot execute:
20
+ // what a DIFFERENT React major serializes, what an entry point no test mounts forwards, or which
21
+ // rule wins a cascade jsdom does not compute. A behavioural assertion would pass under the bug in
22
+ // every one of those cases. Where behaviour CAN see it — the frozen controls, the tabpanel's
23
+ // name, the popover's focus return — the test renders and asserts on the result instead.
24
+ //
25
+ // No count here on purpose: an earlier version of this comment said "two of the four" and was
26
+ // wrong by the time the file had ten. A citation keeps; a tally rots.
27
+ //
28
+ // One trap has now appeared twice in gates written for this very review, so it is worth stating
29
+ // where the next reader will meet it: a `toContain` on a CSS selector is satisfied by any LONGER
30
+ // selector containing it. `:root[data-density="x"]` contains `[data-density="x"]`, and the pager's
31
+ // own disabled rule contains the shared one. Both mutations came back GREEN until the assertions
32
+ // were anchored to the start of a line with a regex.
33
+
34
+ const sources = import.meta.glob("./**/*.tsx", {
35
+ query: "?raw",
36
+ import: "default",
37
+ eager: true,
38
+ }) as Record<string, string>;
39
+
40
+ /** The package manifest as text, so the peer range can be asserted without importing it. */
41
+ const packageJson = import.meta.glob("../package.json", {
42
+ query: "?raw",
43
+ import: "default",
44
+ eager: true,
45
+ }) as Record<string, string>;
46
+
47
+ afterEach(() => {
48
+ cleanup();
49
+ });
50
+
51
+ describe("defects the phases 1-4 review found", () => {
52
+ it("warns when a checked control is given no onChange, instead of freezing silently", () => {
53
+ // React's own guard is the only thing that would tell an author they pinned `checked` and
54
+ // forgot the handler. Passing `onChange` unconditionally suppresses it, so the control looked
55
+ // operable, never changed, and said nothing — the exact failure `Select` documents at its own
56
+ // call site (ui/Select.tsx) and avoids. Mutation: restore the unconditional handler in any of
57
+ // the three and the count drops.
58
+ const seen: string[] = [];
59
+ const spy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
60
+ seen.push(args.map(String).join(" "));
61
+ });
62
+ try {
63
+ render(
64
+ <>
65
+ <Checkbox label="frozen box" checked />
66
+ <Switch label="frozen switch" checked />
67
+ <Radio name="group" value="a" label="frozen radio" checked />
68
+ </>,
69
+ );
70
+ } finally {
71
+ spy.mockRestore();
72
+ }
73
+ // React de-duplicates this warning per component type, so three distinct components warn
74
+ // three times. Matched loosely because the exact wording differs across React majors.
75
+ const complaints = seen.filter((line) => /without an .?onChange.? handler/.test(line));
76
+ expect(complaints).toHaveLength(3);
77
+ });
78
+
79
+ it("does not repaint an invalid field's border on hover", () => {
80
+ // Both rules live in terp.state, so the cascade is decided by specificity alone: the hover
81
+ // selector weighed (0,4,0) against the danger border's (0,2,0) and won. Pointing at a field
82
+ // that had just failed validation removed its error border for as long as the pointer rested
83
+ // there — and the pointer rests there precisely because the user is about to fix it.
84
+ // Mutation: drop the :not([aria-invalid="true"]).
85
+ expect(TERP_STYLES_CSS).toContain(
86
+ '[data-terp="input"]:hover:not(:disabled):not(:focus):not([aria-invalid="true"])',
87
+ );
88
+ // And the unnarrowed aggressor must not exist anywhere, in any form.
89
+ expect(TERP_STYLES_CSS).not.toContain(
90
+ '[data-terp="input"]:hover:not(:disabled):not(:focus) {',
91
+ );
92
+ });
93
+
94
+ it("does not promise a React major on which the drawer's containment does not exist", () => {
95
+ // The drawer inerts the page column and marks it aria-hidden. Measured with
96
+ // renderToStaticMarkup against both renderers:
97
+ //
98
+ // spelling React 18.3.1 React 19.2.8
99
+ // inert={true} DROPPED (warns) inert=""
100
+ // inert="" inert="" DROPPED (warns: treated as false)
101
+ //
102
+ // So under the old `^18.3.0 || ^19.0.0` peer range, an app on 18.3 got the worst half of
103
+ // the pair — a subtree announced as hidden to assistive technology with every control in it
104
+ // still focusable and clickable — and no spelling is correct and quiet on both. The review
105
+ // that found this proposed `inert=""`, which would have broken React 19 instead; the
106
+ // measurement is the only reason that did not ship. The defect is the promise, so the fix
107
+ // is the range. Mutation: widen the peer range back to include ^18.3.0.
108
+ const manifest = JSON.parse(
109
+ (packageJson as Record<string, string>)["../package.json"] ?? "{}",
110
+ ) as { peerDependencies?: Record<string, string> };
111
+ const peers = manifest.peerDependencies ?? {};
112
+ for (const name of ["react", "react-dom"] as const) {
113
+ expect(peers[name], `${name} peer range`).toBeDefined();
114
+ expect(peers[name]).not.toMatch(/18\./);
115
+ }
116
+ // And the attribute stays the spelling React 19 actually renders.
117
+ const shell = sources["./AppShell.tsx"] ?? "";
118
+ expect(shell, "AppShell.tsx is not in the scanned sources").not.toBe("");
119
+ expect(shell).toContain("inert={isMobile && drawerOpen ? true : undefined}");
120
+ });
121
+
122
+ it("gives a tabpanel an accessible name even when the tab value contains whitespace", () => {
123
+ // `Tabs` built element ids by interpolating the caller's `value`. A value is caller data and
124
+ // an id is an IDREF, so a value with a space turned `aria-labelledby` into a list of two
125
+ // tokens, neither of which resolves — and the tabpanel silently lost its name. Nothing
126
+ // reported it: axe sees a well-formed reference to nothing, and the visible tab still reads
127
+ // correctly. Ids key on the tab's index now.
128
+ // Mutation: interpolate `tab.value` / `selectedTab.value` back into the two ids.
129
+ render(
130
+ <Tabs
131
+ label="Sections"
132
+ tabs={[
133
+ { value: "my tab", label: "My tab", content: <p>first</p> },
134
+ { value: "other one", label: "Other one", content: <p>second</p> },
135
+ ]}
136
+ />,
137
+ );
138
+ expect(screen.getByRole("tabpanel", { name: "My tab" })).toBeInTheDocument();
139
+ });
140
+
141
+ it("wins the density tie against the contract's own :root declarations", () => {
142
+ // The shell's density attribute sets the same custom properties the contract declares on
143
+ // :root, both unlayered. A bare [data-density="..."] on <html> weighs (0,1,0) — exactly what
144
+ // :root weighs — so the two tied and only the order the sheets happened to load decided it.
145
+ // A production build extracts tokens.css to a <link> that precedes the injected sheet, so
146
+ // react-core won by construction and the exposure was the dev server and any host loading
147
+ // the tokens late. The :root-qualified copy is (0,2,0) and wins outright.
148
+ // Mutation: drop either :root-qualified selector.
149
+ for (const density of ["comfortable", "compact"] as const) {
150
+ expect(TERP_STYLES_CSS).toContain(`:root[data-density="${density}"]`);
151
+ // The unqualified copy stays, for a density island on a subtree where there is no :root.
152
+ // Anchored to the start of a line, because `:root[data-density="x"]` CONTAINS
153
+ // `[data-density="x"]` — a bare toContain here would be satisfied by the qualified copy
154
+ // alone and could never notice the unqualified one being deleted.
155
+ expect(TERP_STYLES_CSS).toMatch(
156
+ new RegExp(`^\\[data-density="${density}"\\]`, "m"),
157
+ );
158
+ }
159
+ });
160
+
161
+ it("forwards logoDark from both sanctioned entry points", () => {
162
+ // The third slot to exist on the shell and be unreachable from the entry points every app
163
+ // uses, after `headerActions` — which ADR 0097's own Context complains about. This one was
164
+ // worse: the project template instructs every new app to pass `logoDark` to `renderTerpApp`
165
+ // (template/project/frontend/src/main.tsx.jinja), so the documented example did not
166
+ // typecheck. Mutation: delete either forwarding line.
167
+ //
168
+ // Matched as "the option appears in what the prop is given" rather than as one exact
169
+ // expression, because the exact expression changed the day `shell.brand` landed and the
170
+ // invariant did not: the slot now prefers a declared PATH and falls back to the option, and
171
+ // the old `toContain("logoDark={options.logoDark}")` failed on a router that still forwards
172
+ // it perfectly. An assertion on a spelling fails for the wrong reason, which is only one
173
+ // step better than passing for the wrong reason.
174
+ const router = sources["./router.tsx"] ?? "";
175
+ const bootstrap = sources["./bootstrap.tsx"] ?? "";
176
+ expect(router, "router.tsx is not in the scanned sources").not.toBe("");
177
+ expect(bootstrap, "bootstrap.tsx is not in the scanned sources").not.toBe("");
178
+ expect(router).toMatch(/logoDark=\{[^}]*options\.logoDark/);
179
+ // `logo=` alone would also match inside `logoDark=`; the negated class stops at the
180
+ // first `}`, so this reads the whole expression the slot is given.
181
+ expect(router).toMatch(/[^a-zA-Z]logo=\{[^}]*options\.logo}/);
182
+ expect(bootstrap).toContain("logoDark: options.logoDark,");
183
+ expect(bootstrap).toMatch(/logoDark\?: ReactNode;/);
184
+ });
185
+
186
+ it("returns focus to the trigger when Tab leaves a popover panel", () => {
187
+ // The panel is portalled to the END of document.body, so with no Tab branch the
188
+ // sequential-navigation starting point stayed inside a node at the wrong end of the
189
+ // document: Tab out landed past every piece of page content, and Shift+Tab landed on the
190
+ // last focusable element on the page rather than back on the control that opened it. `Menu`
191
+ // has implemented the APG contract for this all along and documents the exact failure;
192
+ // `Popover` carried the same portal with an Escape-only handler.
193
+ // Mutation: delete the Tab branch from the panel's onKeyDown.
194
+ render(
195
+ <Popover trigger={<button type="button">Open</button>}>
196
+ {() => (
197
+ <button type="button">Inside</button>
198
+ )}
199
+ </Popover>,
200
+ );
201
+ const trigger = screen.getByRole("button", { name: "Open" });
202
+ fireEvent.click(trigger);
203
+ const inside = screen.getByRole("button", { name: "Inside" });
204
+ inside.focus();
205
+ expect(document.activeElement).toBe(inside);
206
+ fireEvent.keyDown(inside, { key: "Tab" });
207
+ // Closed, and focus is back on the trigger — from which the browser's own Tab default
208
+ // moves on to whatever genuinely follows it in the document.
209
+ expect(screen.queryByRole("button", { name: "Inside" })).not.toBeInTheDocument();
210
+ expect(document.activeElement).toBe(trigger);
211
+ });
212
+
213
+ it("keeps a pager button focusable when its own click disables it", () => {
214
+ // Each of the pager's four buttons has a bound condition recomputed from what its own click
215
+ // just changed, so pressing "next" until the last page disabled the control the user was
216
+ // operating — and a disabled element cannot hold focus, so the browser dropped it to
217
+ // <body>. A keyboard user paging to the end lost their place at the moment they arrived.
218
+ // Mutation: change aria-disabled back to disabled on the next/last pair.
219
+ const pagination = sources["./dataview/DataViewPagination.tsx"] ?? "";
220
+ expect(pagination, "DataViewPagination.tsx is not in the scanned sources").not.toBe("");
221
+ expect(pagination, "the native disabled attribute drops focus to <body>").not.toContain(
222
+ "disabled={atFirst}",
223
+ );
224
+ expect(pagination).not.toContain("disabled={atLast}");
225
+ expect(pagination).toContain("aria-disabled={atLast || undefined}");
226
+ expect(pagination).toContain("aria-disabled={atFirst || undefined}");
227
+ // And the sheet must paint the announced state exactly as it painted the native one, or the
228
+ // fix trades a focus bug for a visual one.
229
+ // Both anchored to the start of a line. The pager rule CONTAINS the shared selector as a
230
+ // substring, so a bare toContain on the shared one is satisfied by the pager rule alone
231
+ // and cannot notice it being deleted — which is exactly what the mutation showed.
232
+ expect(TERP_STYLES_CSS).toMatch(
233
+ /^\[data-terp="iconbutton"\]\[aria-disabled="true"\]/m,
234
+ );
235
+ expect(TERP_STYLES_CSS).toMatch(
236
+ /^\[data-terp="dataview-pager"\] > \[data-terp="iconbutton"\]\[aria-disabled="true"\]/m,
237
+ );
238
+ });
239
+
240
+ it("caps Markdown's blocks in a measured shell, past its own boxless wrapper", () => {
241
+ // Markdown is display: contents, so it generates no box — and a non-inherited property on a
242
+ // boxless element is dropped. The measured-width rule is a child-star selector on the page's
243
+ // body children, so it MATCHED the markdown wrapper and then had nothing to apply a width
244
+ // to: prose ran the full width of a measured shell, on the one component whose entire
245
+ // purpose is long-form text. The sheet's own comment beside the wrapper asserted that no
246
+ // child-star selector existed in the sheet, which the measured-width rule had already
247
+ // falsified. Mutation: delete the reach-through rule.
248
+ expect(TERP_STYLES_CSS).toContain('[data-terp="page"] > [data-terp="markdown"] > *');
249
+ });
250
+
251
+ it("paints the account menu from the sidebar family it actually sits on", () => {
252
+ // The trigger renders inside the sidebar (and inside the header group under
253
+ // navPlacement="header", which takes the sidebar surface), so its ink against that
254
+ // background is a pairing in play — but it read the NEUTRAL family, which the contrast gate
255
+ // has no sidebar-scoped pairing for. Reading the sidebar family instead puts it under
256
+ // `sidebar-text` and `sidebar-muted-text`, both already declared, so the gate covers it with
257
+ // no new entries. Provably zero-diff: the two families are byte-equal in all five themes.
258
+ //
259
+ // The role marker renders TWICE — trigger and portalled panel — so it is scoped rather than
260
+ // swapped; the panel is in document.body and the sidebar palette does not apply there.
261
+ // Mutation: put --color-neutral-900 back on the trigger.
262
+ const menuRule = TERP_STYLES_CSS.slice(
263
+ TERP_STYLES_CSS.indexOf('[data-terp="user-menu"] [data-terp="menu-trigger"] {'),
264
+ ).slice(0, 900);
265
+ expect(menuRule).toContain("color: var(--color-sidebar-fg)");
266
+ expect(menuRule).not.toContain("color: var(--color-neutral-900)");
267
+ const roleRule = TERP_STYLES_CSS.slice(
268
+ TERP_STYLES_CSS.indexOf('[data-terp="user-menu"] [data-terp="user-menu-role"] {'),
269
+ ).slice(0, 120);
270
+ expect(roleRule).toContain("color: var(--color-sidebar-muted)");
271
+ });
272
+ });