@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
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Handing bytes to the browser as a named download (ADR 0096).
3
+ *
4
+ * `useFileDownload` covers the stored-file case: a `FileMeta` id, fetched from the files
5
+ * capability's content endpoint. What it does not cover is the other half of the same
6
+ * need — **an artifact the backend generates on demand** ("download this revision as
7
+ * proof", a CSV export, a signed evidence bundle). Those have no stored file id, and the
8
+ * only ways to reach them were a raw `fetch` (refused: one typed egress path) or a raw
9
+ * `<a href>` (which carries no bearer token, so it 401s or, worse, silently downloads an
10
+ * error page). The observed outcome was the feature being dropped rather than built.
11
+ *
12
+ * So the blob-to-anchor dance lives here once, and `useEndpointDownload` reaches any
13
+ * authorized GET through the session client. `path` is a plain string, deliberately and
14
+ * unusually: the generated client is keyed by the app's own schema, which this package
15
+ * cannot see, and a byte-stream route is app-specific by nature. Everything else that
16
+ * matters — the base URL, the bearer token, cookie credentials, the refusal on a non-2xx —
17
+ * still comes from the client, which is what the raw alternatives threw away.
18
+ */
19
+
20
+ import { useCallback } from "react";
21
+
22
+ import { useTerpClient } from "./TerpProvider";
23
+ import type { TerpClient } from "@terpjs/contract";
24
+
25
+ /** What to download: where the bytes come from, and what the file should be called. */
26
+ export interface DownloadTarget {
27
+ /** API path, e.g. `/api/v1/revisions/{id}/evidence` with `params` filling `{id}`. */
28
+ path: string;
29
+ /** Filename offered to the browser (extension included). */
30
+ filename: string;
31
+ /** Path placeholders to substitute, e.g. `{ id: revision.id }`. */
32
+ params?: Record<string, string>;
33
+ /** Query string to append; `undefined` values are omitted. */
34
+ query?: Record<string, string | undefined>;
35
+ }
36
+
37
+ /**
38
+ * Save *blob* as a named download.
39
+ *
40
+ * Exported because it is the one piece a screen cannot avoid re-implementing when it
41
+ * already holds the bytes (a client-side CSV, a canvas export) — and every hand-rolled
42
+ * copy leaks the object URL, which is why the revoke is in a `finally` here.
43
+ */
44
+ export function saveBlob(blob: Blob, filename: string): void {
45
+ const url = URL.createObjectURL(blob);
46
+ try {
47
+ const anchor = document.createElement("a");
48
+ anchor.href = url;
49
+ anchor.download = filename;
50
+ document.body.appendChild(anchor);
51
+ anchor.click();
52
+ anchor.remove();
53
+ } finally {
54
+ URL.revokeObjectURL(url);
55
+ }
56
+ }
57
+
58
+ /** Fill `{placeholder}` segments and append the query string, dropping unset values. */
59
+ export function downloadUrl(target: DownloadTarget): string {
60
+ let path = target.path;
61
+ for (const [name, value] of Object.entries(target.params ?? {})) {
62
+ path = path.replaceAll(`{${name}}`, encodeURIComponent(value));
63
+ }
64
+ const unfilled = /\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(path);
65
+ if (unfilled !== null) {
66
+ // Fail closed rather than requesting a literal `{id}`: the server would 404 or, on a
67
+ // permissive route, hand back somebody else's bytes.
68
+ throw new Error(
69
+ `Download path "${target.path}" still contains the placeholder "${unfilled[0]}" — ` +
70
+ `pass it in \`params\` (e.g. params: { ${unfilled[1]}: row.id }).`,
71
+ );
72
+ }
73
+ const query = new URLSearchParams();
74
+ for (const [name, value] of Object.entries(target.query ?? {})) {
75
+ if (value !== undefined) {
76
+ query.append(name, value);
77
+ }
78
+ }
79
+ const suffix = query.toString();
80
+ return suffix.length > 0 ? `${path}?${suffix}` : path;
81
+ }
82
+
83
+ /**
84
+ * Download a generated artifact from an authorized endpoint (ADR 0096).
85
+ *
86
+ * ```tsx
87
+ * const download = useEndpointDownload();
88
+ * void download({
89
+ * path: "/api/v1/revisions/{revisionId}/evidence",
90
+ * params: { revisionId: revision.id },
91
+ * filename: `revision-${revision.number}.json`,
92
+ * });
93
+ * ```
94
+ *
95
+ * Rejects on a non-2xx response, so a caller can surface the failure — a raw anchor would
96
+ * have saved the error body under the intended filename instead.
97
+ */
98
+ export function useEndpointDownload(): (target: DownloadTarget) => Promise<void> {
99
+ const client = useTerpClient();
100
+ return useCallback(
101
+ async (target: DownloadTarget) => {
102
+ const blob = await fetchDownload(client as unknown as TerpClient, target);
103
+ saveBlob(blob, target.filename);
104
+ },
105
+ [client],
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Fetch a download target's bytes through the session client.
111
+ *
112
+ * Separate from the hook so a caller that wants the blob for something *other* than
113
+ * saving it (a preview, a checksum) does not have to save it first.
114
+ */
115
+ export async function fetchDownload(client: TerpClient, target: DownloadTarget): Promise<Blob> {
116
+ const url = downloadUrl(target);
117
+ const { data, error, response } = await (
118
+ client as unknown as {
119
+ GET: (
120
+ path: string,
121
+ init: { parseAs: "blob" },
122
+ ) => Promise<{ data?: unknown; error?: unknown; response: Response }>;
123
+ }
124
+ ).GET(url, { parseAs: "blob" });
125
+ if (error !== undefined || !response.ok) {
126
+ throw new Error(
127
+ `Download of ${url} failed with HTTP ${response.status}. The endpoint must be a GET ` +
128
+ "the current session is authorized for.",
129
+ );
130
+ }
131
+ return data as Blob;
132
+ }
@@ -82,6 +82,32 @@ describe("ConfirmDialog", () => {
82
82
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
83
83
  });
84
84
 
85
+ it("links the consequence text to the dialog, and only when there is one", () => {
86
+ const { rerender } = render(
87
+ <ConfirmDialog
88
+ open
89
+ onOpenChange={() => {}}
90
+ onConfirm={() => {}}
91
+ title="Delete this link?"
92
+ description="Its mappings and its run history are removed with it."
93
+ />,
94
+ );
95
+ // A modal announces its name and its description on open. Unlinked body copy is reached
96
+ // only by manual exploration — the wrong ergonomics for the one screen whose whole job is
97
+ // telling someone what a destructive action will do.
98
+ const dialog = screen.getByRole("dialog");
99
+ const describedBy = dialog.getAttribute("aria-describedby");
100
+ expect(describedBy).not.toBeNull();
101
+ expect(document.getElementById(describedBy!)?.textContent).toBe(
102
+ "Its mappings and its run history are removed with it.",
103
+ );
104
+ // No description means no attribute, rather than an idref pointing at nothing.
105
+ rerender(
106
+ <ConfirmDialog open onOpenChange={() => {}} onConfirm={() => {}} title="Delete this link?" />,
107
+ );
108
+ expect(screen.getByRole("dialog")).not.toHaveAttribute("aria-describedby");
109
+ });
110
+
85
111
  it("confirms and cancels through the labelled buttons", () => {
86
112
  const onConfirm = vi.fn();
87
113
  const onOpenChange = vi.fn();
@@ -71,6 +71,24 @@ describe("FileUpload", () => {
71
71
  await waitFor(() => expect(onUploaded).toHaveBeenCalledWith(STORED));
72
72
  });
73
73
 
74
+ it("hides the picker input with the hidden attribute, not an inline style", () => {
75
+ render(
76
+ <TerpProvider baseUrl="https://api.test">
77
+ <FileUpload />
78
+ </TerpProvider>,
79
+ );
80
+ // The visible control is the button; this input exists only so a programmatic click can
81
+ // open the native dialog. `hidden` is the platform's own way to say that, and it keeps the
82
+ // file free of a style object that would read as unmigrated styling.
83
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
84
+ expect(input).not.toBeNull();
85
+ expect(input.hidden).toBe(true);
86
+ expect(input.getAttribute("style")).toBeNull();
87
+ // Belt and braces, kept deliberately: both survive an author rule that resurrects the box.
88
+ expect(input).toHaveAttribute("aria-hidden", "true");
89
+ expect(input.tabIndex).toBe(-1);
90
+ });
91
+
74
92
  it("reports a failed upload through onError", async () => {
75
93
  stubFetch(async (request) => {
76
94
  if (request.url.endsWith("/api/v1/files/") && request.method === "POST") {
package/src/files.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
- import type { ChangeEvent, CSSProperties } from "react";
2
+ import type { ChangeEvent } from "react";
3
3
 
4
+ import { saveBlob } from "./download";
4
5
  import { useTerpClient } from "./TerpProvider";
5
6
  import { Button } from "./ui/Button";
6
7
  import { useStrings, useUiText, type UiText } from "./uiText";
@@ -91,24 +92,12 @@ export function useFileDownload(): (file: Pick<FileMeta, "id" | "filename">) =>
91
92
  return useCallback(
92
93
  async (file: Pick<FileMeta, "id" | "filename">) => {
93
94
  const blob = await fetchFileContent(client as unknown as TerpClient, file.id);
94
- const url = URL.createObjectURL(blob);
95
- try {
96
- const anchor = document.createElement("a");
97
- anchor.href = url;
98
- anchor.download = file.filename;
99
- document.body.appendChild(anchor);
100
- anchor.click();
101
- anchor.remove();
102
- } finally {
103
- URL.revokeObjectURL(url);
104
- }
95
+ saveBlob(blob, file.filename);
105
96
  },
106
97
  [client],
107
98
  );
108
99
  }
109
100
 
110
- const hiddenInputStyle: CSSProperties = { display: "none" };
111
-
112
101
  export interface FileUploadProps {
113
102
  /** Called with the stored metadata after a successful upload. */
114
103
  onUploaded?: (file: FileMeta) => void;
@@ -152,11 +141,22 @@ export function FileUpload({ onUploaded, onError, label, accept, disabled }: Fil
152
141
 
153
142
  return (
154
143
  <>
144
+ {/* The platform attribute, not an inline display:none.
145
+
146
+ This input is the file picker's plumbing: the visible control is the Button below,
147
+ and this element exists only so `.click()` can open the native dialog. So it is not
148
+ a styled surface at all, and the alternatives both misdescribe it — giving it a
149
+ marker and a `display: none` rule would put a component with no visual design into
150
+ the sheet, and keeping the inline object would leave the one declaration in the file
151
+ looking like styling that had not been migrated yet. `hidden` is what HTML provides
152
+ for "not relevant", and a programmatic click still opens the dialog. The
153
+ `aria-hidden` and `tabIndex` become redundant beside it and are kept anyway: they
154
+ cost nothing and they survive an author rule that resurrects the box. */}
155
155
  <input
156
156
  ref={inputRef}
157
157
  type="file"
158
158
  accept={accept}
159
- style={hiddenInputStyle}
159
+ hidden
160
160
  onChange={(event) => void onChange(event)}
161
161
  aria-hidden="true"
162
162
  tabIndex={-1}
@@ -11,7 +11,10 @@ describe("NavIcon", () => {
11
11
  const { container } = render(<NavIcon name="users" label="Users" />);
12
12
  const slot = container.querySelector('[data-terp="nav-icon"]');
13
13
  const svg = container.querySelector("svg");
14
- expect(slot).toHaveStyle({ width: "1.25rem", height: "1.25rem", flex: "0 0 1.25rem" });
14
+ // The rail slot's fixed 1.25rem box is a sheet rule now (ADR 0094); what this test
15
+ // owns is that the named glyph resolved and stayed decorative.
16
+ expect(slot).not.toBeNull();
17
+ expect(slot?.getAttribute("style")).toBeNull();
15
18
  expect(svg).not.toBeNull();
16
19
  expect(svg).toHaveAttribute("aria-hidden", "true");
17
20
  });
@@ -19,11 +22,12 @@ describe("NavIcon", () => {
19
22
  it("falls back to the label's initial for an unknown or missing name", () => {
20
23
  const { container } = render(<NavIcon name="no-such-glyph" label="widgets" />);
21
24
  expect(screen.getByText("W")).toBeInTheDocument();
22
- expect(container.querySelector('[data-terp="nav-icon"]')).toHaveStyle({
23
- width: "1.25rem",
24
- height: "1.25rem",
25
- flex: "0 0 1.25rem",
26
- });
25
+ // The fallback tile is its own marker, so the rail can style "an initial in a tile"
26
+ // separately from "a glyph" — which is the distinction a theme cares about.
27
+ expect(
28
+ container.querySelector('[data-terp="nav-icon-fallback"]'),
29
+ "the initial renders in the fallback tile",
30
+ ).not.toBeNull();
27
31
  render(<NavIcon label="records" />);
28
32
  expect(screen.getByText("R")).toBeInTheDocument();
29
33
  });
package/src/icons.tsx CHANGED
@@ -1,4 +1,8 @@
1
- import type { CSSProperties, ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
+
3
+ import { injectTerpStyles } from "./styles";
4
+
5
+ injectTerpStyles();
2
6
 
3
7
  /**
4
8
  * The dependency-free icon layer: a small set of inline SVG glyphs the shell's
@@ -431,31 +435,6 @@ export const ICON_GLYPHS: Record<string, ReactNode> = {
431
435
  ),
432
436
  };
433
437
 
434
- const navIconStyle: CSSProperties = {
435
- display: "inline-flex",
436
- alignItems: "center",
437
- justifyContent: "center",
438
- width: "1.25rem",
439
- height: "1.25rem",
440
- flex: "0 0 1.25rem",
441
- fontSize: "1rem",
442
- lineHeight: 1,
443
- };
444
-
445
- const fallbackStyle: CSSProperties = {
446
- display: "inline-flex",
447
- alignItems: "center",
448
- justifyContent: "center",
449
- width: "100%",
450
- height: "100%",
451
- borderRadius: "var(--radius-sm, 4px)",
452
- background: "var(--color-brand-primary-soft, var(--color-neutral-200))",
453
- color: "var(--color-brand-primary, var(--color-neutral-700))",
454
- fontSize: "0.7em",
455
- fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
456
- lineHeight: 1,
457
- };
458
-
459
438
  export interface NavIconProps {
460
439
  /** Glyph name (a `NavItem.icon` value); unknown / missing falls back to the initial. */
461
440
  name?: string;
@@ -470,9 +449,9 @@ export interface NavIconProps {
470
449
  export function NavIcon({ name, label }: NavIconProps) {
471
450
  const glyph = name !== undefined ? ICON_GLYPHS[name] : undefined;
472
451
  return (
473
- <span aria-hidden="true" data-terp="nav-icon" style={navIconStyle}>
452
+ <span aria-hidden="true" data-terp="nav-icon">
474
453
  {glyph ?? (
475
- <span style={fallbackStyle}>
454
+ <span data-terp="nav-icon-fallback">
476
455
  {(label[0] ?? "?").toUpperCase()}
477
456
  </span>
478
457
  )}
@@ -511,15 +490,8 @@ export function Icon({ name, size = "1em", title }: IconProps) {
511
490
  aria-hidden={labelled ? undefined : true}
512
491
  role={labelled ? "img" : undefined}
513
492
  aria-label={labelled ? title : undefined}
514
- style={{
515
- display: "inline-flex",
516
- alignItems: "center",
517
- justifyContent: "center",
518
- width: px,
519
- height: px,
520
- lineHeight: 1,
521
- color: "inherit",
522
- }}
493
+ data-terp="icon"
494
+ style={{ width: px, height: px }}
523
495
  >
524
496
  {glyph}
525
497
  </span>
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ export { canPerform, DEFAULT_RANK_THRESHOLDS } from "./capabilities";
8
8
  export type { RankThresholds } from "./capabilities";
9
9
  export { createAuthClient } from "./createAuthClient";
10
10
  export type { AuthClientOptions, TokenGetter } from "./createAuthClient";
11
- export { Authorized, useCan } from "./Authorized";
11
+ export { Authorized, useCan, useHasPermission, usePermissions } from "./Authorized";
12
12
  export type { AuthorizedProps } from "./Authorized";
13
13
  export { useResource } from "./useResource";
14
14
  export type { Resource, ResourceSource } from "./useResource";
@@ -30,9 +30,6 @@ export type { RequireAuthProps } from "./RequireAuth";
30
30
  export { visibleNav } from "./nav";
31
31
  export {
32
32
  AppShell,
33
- NAV_LINK_STYLE,
34
- NAV_LINK_COLLAPSED_STYLE,
35
- NAV_LINK_ACTIVE_STYLE,
36
33
  SIDEBAR_STORAGE_KEY,
37
34
  } from "./AppShell";
38
35
  export type {
@@ -112,6 +109,8 @@ export { Alert } from "./ui/Alert";
112
109
  export type { AlertProps, AlertTone } from "./ui/Alert";
113
110
  export { Markdown } from "./ui/Markdown";
114
111
  export type { MarkdownProps } from "./ui/Markdown";
112
+ export { saveBlob, useEndpointDownload, fetchDownload, downloadUrl } from "./download";
113
+ export type { DownloadTarget } from "./download";
115
114
  export { Field } from "./Field";
116
115
  export type { FieldProps } from "./Field";
117
116
  export { Stack, DetailList } from "./layout";
@@ -122,6 +121,7 @@ export {
122
121
  PROFILE_PATH,
123
122
  useRouteParam,
124
123
  useRouteParams,
124
+ useRouteSearch,
125
125
  useTerpNavigate,
126
126
  } from "./router";
127
127
  export type { BuildAppRouterOptions } from "./router";
@@ -132,6 +132,9 @@ export type {
132
132
  TerpRouteParamName,
133
133
  TerpRouteParams,
134
134
  TerpRoutePath,
135
+ TerpRouteSearch,
136
+ TerpRouteSearchKey,
137
+ TerpRouteSearchTable,
135
138
  TerpRouteTable,
136
139
  } from "./routeTypes";
137
140
  export { LoginView } from "./LoginView";
@@ -7,7 +7,7 @@ import { DetailList, Stack } from "./layout";
7
7
  afterEach(cleanup);
8
8
 
9
9
  describe("Stack", () => {
10
- it("renders a flex column with a token gap by default", () => {
10
+ it("names a column at the default gap, with no inline styling", () => {
11
11
  render(
12
12
  <Stack data-testid="stack">
13
13
  <span>a</span>
@@ -16,24 +16,39 @@ describe("Stack", () => {
16
16
  );
17
17
  const el = screen.getByTestId("stack");
18
18
  expect(el.tagName).toBe("DIV");
19
- expect(el.style.display).toBe("flex");
20
- expect(el.style.flexDirection).toBe("column");
21
- expect(el.style.gap).toBe("var(--space-2)");
19
+ expect(el).toHaveAttribute("data-direction", "column");
20
+ expect(el).toHaveAttribute("data-gap", "2");
21
+ expect(el).not.toHaveAttribute("data-wrap");
22
+ expect(el.getAttribute("style")).toBeNull();
22
23
  });
23
24
 
24
- it("renders the requested element with direction, gap, alignment and wrap", () => {
25
+ it("renders the requested element and names direction, gap and wrap", () => {
25
26
  render(
26
- <Stack data-testid="row" as="section" direction="row" gap={4} align="center" justify="space-between" wrap>
27
+ <Stack data-testid="row" as="section" direction="row" gap={4} wrap>
27
28
  <span>a</span>
28
29
  </Stack>,
29
30
  );
30
31
  const el = screen.getByTestId("row");
31
32
  expect(el.tagName).toBe("SECTION");
32
- expect(el.style.flexDirection).toBe("row");
33
- expect(el.style.gap).toBe("var(--space-4)");
33
+ expect(el).toHaveAttribute("data-direction", "row");
34
+ expect(el).toHaveAttribute("data-gap", "4");
35
+ expect(el).toHaveAttribute("data-wrap", "true");
36
+ expect(el.getAttribute("style")).toBeNull();
37
+ });
38
+
39
+ it("keeps alignment inline, because it is an open set of CSS keywords", () => {
40
+ // align/justify accept any alignment keyword, so they cannot become a rule per value
41
+ // without inventing a vocabulary CSS already has (ADR 0094). They stay inline, and
42
+ // the geometry attributes stay attributes — the two halves of the same element.
43
+ render(
44
+ <Stack data-testid="aligned" align="center" justify="space-between">
45
+ <span>a</span>
46
+ </Stack>,
47
+ );
48
+ const el = screen.getByTestId("aligned");
34
49
  expect(el.style.alignItems).toBe("center");
35
50
  expect(el.style.justifyContent).toBe("space-between");
36
- expect(el.style.flexWrap).toBe("wrap");
51
+ expect(el.style.display).toBe("");
37
52
  });
38
53
 
39
54
  it("works as a form (submit handler fires)", () => {
package/src/layout.tsx CHANGED
@@ -38,16 +38,27 @@ export function Stack({
38
38
  wrap = false,
39
39
  ...rest
40
40
  }: StackProps) {
41
- const style: CSSProperties = {
42
- display: "flex",
43
- flexDirection: direction,
44
- gap: `var(--space-${gap})`,
45
- margin: 0,
46
- ...(align !== undefined ? { alignItems: align } : undefined),
47
- ...(justify !== undefined ? { justifyContent: justify } : undefined),
48
- ...(wrap ? { flexWrap: "wrap" } : undefined),
49
- };
50
- return <Component {...rest} data-terp="stack" style={style} />;
41
+ // `direction`, `gap` and `wrap` are closed sets, so they are attributes the sheet keys
42
+ // on. `align` and `justify` take any alignment keyword CSS accepts, so they stay inline
43
+ // rather than turning an open vocabulary into a rule per value (ADR 0094). Undefined on
44
+ // both means no style attribute at all.
45
+ const alignment: CSSProperties | undefined =
46
+ align === undefined && justify === undefined
47
+ ? undefined
48
+ : {
49
+ ...(align !== undefined ? { alignItems: align } : undefined),
50
+ ...(justify !== undefined ? { justifyContent: justify } : undefined),
51
+ };
52
+ return (
53
+ <Component
54
+ {...rest}
55
+ data-terp="stack"
56
+ data-direction={direction}
57
+ data-gap={String(gap)}
58
+ data-wrap={wrap ? "true" : undefined}
59
+ style={alignment}
60
+ />
61
+ );
51
62
  }
52
63
 
53
64
  export interface DetailItem {
@@ -62,14 +73,6 @@ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>,
62
73
  items: readonly DetailItem[];
63
74
  }
64
75
 
65
- const detailListStyle: CSSProperties = {
66
- margin: 0,
67
- display: "grid",
68
- gap: "var(--space-1)",
69
- };
70
- const detailTermStyle: CSSProperties = { display: "inline", fontWeight: "var(--font-weight-medium)" };
71
- const detailValueStyle: CSSProperties = { display: "inline", margin: 0 };
72
-
73
76
  /**
74
77
  * Token-styled label/value pairs as a semantic `<dl>` — record metadata on a detail page,
75
78
  * an expanded row's summary. Centralizes the "Label: value" pattern so modules never
@@ -78,11 +81,11 @@ const detailValueStyle: CSSProperties = { display: "inline", margin: 0 };
78
81
  export function DetailList({ items, ...rest }: DetailListProps) {
79
82
  const text = useUiText();
80
83
  return (
81
- <dl {...rest} data-terp="detail-list" style={detailListStyle}>
84
+ <dl {...rest} data-terp="detail-list">
82
85
  {items.map((item, index) => (
83
86
  <div key={index}>
84
- <dt style={detailTermStyle}>{text(item.label)}: </dt>
85
- <dd style={detailValueStyle}>{item.value}</dd>
87
+ <dt data-terp="detail-list-term">{text(item.label)}: </dt>
88
+ <dd data-terp="detail-list-value">{item.value}</dd>
86
89
  </div>
87
90
  ))}
88
91
  </dl>
@@ -21,6 +21,9 @@ import {
21
21
  import { OverviewPage } from "./OverviewPage";
22
22
  import { Page } from "./Page";
23
23
  import { DetailList, Stack } from "./layout";
24
+ import { PageActions } from "./PageActions";
25
+ import { ThemeProvider, ThemeToggle } from "./theme";
26
+ import { Button } from "./ui/Button";
24
27
  import { Card } from "./ui/Card";
25
28
 
26
29
  afterEach(cleanup);
@@ -179,3 +182,95 @@ describe("runtime slot enforcement", () => {
179
182
  expect(verifySlotChildren("standard", "Page", [])).toBeNull();
180
183
  });
181
184
  });
185
+
186
+ // The styling migration (ADR 0094) is renaming rendered roots: components that used to emit
187
+ // an unmarked <div>, or to borrow Popover's `popover`, now name themselves. Every one of those
188
+ // names is read by this check, because the check IS the marker join — so the question is not
189
+ // whether the names look sensible but whether any slot's verdict moved.
190
+ //
191
+ // It did not, and the reason is worth stating rather than re-derived: verifySlotChildren
192
+ // refuses a direct body-slot child whose data-terp is not in the slot's allow table, and a
193
+ // missing attribute is refused too (`marker === null`). None of the new names is in any table,
194
+ // and neither was the unmarked element each replaced. So each of these was refused before the
195
+ // migration and is refused after it. What changed is the MESSAGE — describeElement now reports
196
+ // a named component instead of a bare tag, which is strictly more useful to the person reading
197
+ // the refusal, and is also a string a test could have pinned.
198
+ describe("layout contract survives the roots the styling migration renames", () => {
199
+ it("still refuses a chrome menu in a governed body, and now names it", async () => {
200
+ underContract(
201
+ // The provider renders no element of its own, so the slot's direct child is still the
202
+ // toggle. Without it ThemeToggle returns null and the body is empty — which passes for
203
+ // no reason at all.
204
+ <ThemeProvider>
205
+ <OverviewPage title="Records">
206
+ <ThemeToggle variant="stacked" />
207
+ </OverviewPage>
208
+ </ThemeProvider>,
209
+ );
210
+ await waitFor(() => {
211
+ expect(screen.getByTestId("refused").textContent).toBe(
212
+ slotViolationMessage("standard", "OverviewPage", '<div data-terp="theme-toggle">'),
213
+ );
214
+ });
215
+ });
216
+
217
+ it("still refuses an action cluster in a governed body, and now names it", async () => {
218
+ underContract(
219
+ <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
220
+ <PageActions primary={<Button>Publish</Button>} />
221
+ </DetailPage>,
222
+ );
223
+ await waitFor(() => {
224
+ expect(screen.getByTestId("refused").textContent).toBe(
225
+ slotViolationMessage("standard", "DetailPage", '<div data-terp="page-actions">'),
226
+ );
227
+ });
228
+ });
229
+
230
+ it("keeps every marker the allow tables name out of the migration's way", () => {
231
+ // The tables are the audit surface: a rename of one of THESE would widen or close a slot
232
+ // silently, so they are the markers the migration may not touch without changing both
233
+ // halves of the contract. Asserted as a set so an addition has to be deliberate.
234
+ const named = new Set(
235
+ Object.values(LAYOUT_CONTRACTS.standard!.slots).flatMap((slot) =>
236
+ Object.values(slot.components),
237
+ ),
238
+ );
239
+ expect([...named].sort()).toEqual([
240
+ "alert",
241
+ "card",
242
+ "dataview",
243
+ "detail-list",
244
+ "dialog",
245
+ "empty-state",
246
+ "error-state",
247
+ "hubcard",
248
+ "loading-state",
249
+ "module-nav",
250
+ "resource-list",
251
+ "stack",
252
+ "tabs",
253
+ ]);
254
+ });
255
+
256
+ it("passes a governed body whose children are still allowed components", async () => {
257
+ // The other direction: the migration must not have widened anything either. A Stack and a
258
+ // Card are allowed, and they stay allowed with their markers unchanged.
259
+ underContract(
260
+ <ThemeProvider>
261
+ <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
262
+ <Card title="A section">body</Card>
263
+ <Stack>
264
+ <ThemeToggle variant="stacked" />
265
+ </Stack>
266
+ </DetailPage>
267
+ </ThemeProvider>,
268
+ );
269
+ await new Promise((resolve) => setTimeout(resolve, 20));
270
+ // And the toggle really did render, or this asserts nothing.
271
+ expect(document.querySelector('[data-terp="theme-toggle"]')).not.toBeNull();
272
+ // Nested inside an allowed container is sanctioned composition — the check reads direct
273
+ // children only, which is what makes a marked root safe to put anywhere below one.
274
+ expect(screen.queryByTestId("refused")).toBeNull();
275
+ });
276
+ });