@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
package/src/layout.tsx CHANGED
@@ -6,13 +6,45 @@ import type { UiText } from "./uiText";
6
6
  /** The spacing scale — indexes into the `--space-*` design tokens (no arbitrary pixel gaps). */
7
7
  export type SpaceToken = 0 | 1 | 2 | 3 | 4 | 6 | 8;
8
8
 
9
+ /**
10
+ * A value, or one value below the framework's viewport cutover and another above it.
11
+ *
12
+ * There is one cutover and not a scale of them, deliberately: `wide` is the complement of the
13
+ * width at which the shell becomes a drawer and the DataView becomes cards, so a responsive
14
+ * `Stack` changes over at exactly the moment the chrome around it does. A second breakpoint is
15
+ * a rule per value per breakpoint in the sheet, and nothing has asked for one — the same reason
16
+ * the density scale has two steps rather than five.
17
+ */
18
+ export type Responsive<T> = T | { narrow: T; wide: T };
19
+
20
+ /** Split a possibly-responsive prop into its two halves. */
21
+ function responsive<T>(value: Responsive<T>): { narrow: T; wide: T | undefined } {
22
+ return value !== null && typeof value === "object" && "narrow" in value
23
+ ? { narrow: value.narrow, wide: value.wide }
24
+ : { narrow: value as T, wide: undefined };
25
+ }
26
+
9
27
  export interface StackProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
10
28
  /** The rendered element (`"div"` by default; use `"form"`, `"section"`, `"ul"`, …). */
11
29
  as?: ElementType;
12
- /** Main axis: `"column"` (default) stacks, `"row"` lines up. */
13
- direction?: "column" | "row";
30
+ /**
31
+ * Main axis: `"column"` (default) stacks, `"row"` lines up.
32
+ *
33
+ * Takes a {@link Responsive} pair for the toolbar case — `{ narrow: "column", wide: "row" }`
34
+ * is a row of controls that stacks below the cutover, which was previously inexpressible
35
+ * without a `style` an app module may not write.
36
+ */
37
+ direction?: Responsive<"column" | "row">;
14
38
  /** Gap between children, as a step on the token spacing scale (default `2`). */
15
- gap?: SpaceToken;
39
+ gap?: Responsive<SpaceToken>;
40
+ /**
41
+ * Inset around the children, as a step on the token spacing scale (default none).
42
+ *
43
+ * The dimension the diagnosis named first — "no padding" — and the one that made a padded
44
+ * region reachable only through a `Card`, whose border and background came with it whether
45
+ * they were wanted or not.
46
+ */
47
+ padding?: SpaceToken;
16
48
  /** Cross-axis alignment (e.g. `"center"`, `"start"`, `"end"`, `"stretch"`). */
17
49
  align?: CSSProperties["alignItems"];
18
50
  /** Main-axis distribution (e.g. `"space-between"`, `"center"`, `"end"`). */
@@ -33,6 +65,7 @@ export function Stack({
33
65
  as: Component = "div",
34
66
  direction = "column",
35
67
  gap = 2,
68
+ padding,
36
69
  align,
37
70
  justify,
38
71
  wrap = false,
@@ -49,18 +82,140 @@ export function Stack({
49
82
  ...(align !== undefined ? { alignItems: align } : undefined),
50
83
  ...(justify !== undefined ? { justifyContent: justify } : undefined),
51
84
  };
85
+ // A responsive prop is two attributes, and the wide half is stamped only when it was asked
86
+ // for — so a non-responsive Stack renders exactly the attributes it always did and every
87
+ // existing baseline is untouched by construction.
88
+ const directions = responsive(direction);
89
+ const gaps = responsive(gap);
52
90
  return (
53
91
  <Component
54
92
  {...rest}
55
93
  data-terp="stack"
56
- data-direction={direction}
57
- data-gap={String(gap)}
94
+ data-direction={directions.narrow}
95
+ data-direction-wide={directions.wide}
96
+ data-gap={String(gaps.narrow)}
97
+ data-gap-wide={gaps.wide === undefined ? undefined : String(gaps.wide)}
98
+ data-padding={padding === undefined ? undefined : String(padding)}
58
99
  data-wrap={wrap ? "true" : undefined}
59
100
  style={alignment}
60
101
  />
61
102
  );
62
103
  }
63
104
 
105
+ /** Fixed column count, or `"auto"` — as many columns as fit above `minColumn`. */
106
+ export type GridColumns = 1 | 2 | 3 | 4 | "auto";
107
+
108
+ /** Track floor for an `"auto"` grid: the width below which a column stops being one. */
109
+ export type GridMinColumn = "xs" | "sm" | "md" | "lg";
110
+
111
+ export interface GridProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
112
+ /** The rendered element (`"div"` by default; use `"ul"`, `"section"`, `"dl"`, …). */
113
+ as?: ElementType;
114
+ /**
115
+ * Columns: a fixed count, or `"auto"` (the default) for as many as fit above
116
+ * {@link GridProps.minColumn}.
117
+ *
118
+ * `"auto"` is the responsive answer and it takes no breakpoint: the track floor makes the
119
+ * grid reflow to whatever its **container** can hold, which is what a caller almost always
120
+ * means and is more nearly right than a viewport query — a grid inside a narrow panel
121
+ * should go one-column whatever the window is doing.
122
+ */
123
+ columns?: GridColumns;
124
+ /** Track floor for an `"auto"` grid (default `"sm"`); ignored at a fixed count. */
125
+ minColumn?: GridMinColumn;
126
+ /** Gap between cells, as a step on the token spacing scale (default `4`). */
127
+ gap?: SpaceToken;
128
+ /** Inset around the cells, as a step on the token spacing scale (default none). */
129
+ padding?: SpaceToken;
130
+ /**
131
+ * Block alignment of each cell within its row (default `"stretch"`).
132
+ *
133
+ * A closed set of four, unlike `Stack`'s `align` — see the note on {@link Grid}.
134
+ */
135
+ align?: "start" | "center" | "end" | "stretch";
136
+ children?: ReactNode;
137
+ }
138
+
139
+ /**
140
+ * The two-dimensional layout primitive, and the one that lifts a real ceiling: with `Stack`
141
+ * as the entire vocabulary, a two-column form could not be expressed at all, so a fifteen-field
142
+ * form shipped as one long vertical run. App modules may not write `style` or `className`
143
+ * (ADR 0059), so that was not awkwardness — it was unbuildable.
144
+ *
145
+ * Every prop is a closed set and becomes a `data-*` attribute with a rule each, so `Grid`
146
+ * renders **no inline style** and stays out of the inline-style ledger (ADR 0097). Three
147
+ * consequences of that are worth knowing, because each was a choice:
148
+ *
149
+ * - **`align` takes four values, where `Stack`'s takes any alignment keyword.** The divergence
150
+ * is deliberate rather than an oversight. `Stack`'s is open because it was already inline
151
+ * when ADR 0094 drew the line, and an open vocabulary stays inline; a *new* component picks
152
+ * the closed set so it needs no `style` at all. `justify` is absent for the same reason it
153
+ * would be least useful: a grid's tracks already fill their container, so distributing them
154
+ * is a question about the tracks rather than the cells, and no consumer has asked.
155
+ * - **`minColumn` is a scale, not a length.** `Icon`'s `size` takes any CSS length and stays
156
+ * inline for it; a grid's column floor could have gone the same way. It is a scale instead
157
+ * for the reason `Stack`'s `gap` is a token index: so there are no arbitrary widths. An app
158
+ * wanting 17.5rem cannot have it, which is the same trade `gap` already makes.
159
+ * - **There is no `span`,** and therefore no twelve-column option. A span system needs a child
160
+ * component to carry it, and a `columns={12}` with no way to span is a grid of twelve narrow
161
+ * cells rather than a layout system — worse than not offering it.
162
+ */
163
+ export function Grid({
164
+ as: Component = "div",
165
+ columns = "auto",
166
+ minColumn = "sm",
167
+ gap = 4,
168
+ padding,
169
+ align = "stretch",
170
+ ...rest
171
+ }: GridProps) {
172
+ return (
173
+ <Component
174
+ {...rest}
175
+ data-terp="grid"
176
+ // The defaults are the base rule, so their attributes match nothing and are left off —
177
+ // the shape density and Button's `md` already use. String(columns) rather than the
178
+ // number, because a `data-` attribute is text and `columns={2}` must produce "2".
179
+ data-columns={columns === "auto" ? undefined : String(columns)}
180
+ data-min-column={columns === "auto" && minColumn !== "sm" ? minColumn : undefined}
181
+ data-gap={String(gap)}
182
+ data-padding={padding === undefined ? undefined : String(padding)}
183
+ data-align={align === "stretch" ? undefined : align}
184
+ />
185
+ );
186
+ }
187
+
188
+ export interface DividerProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
189
+ /** `"horizontal"` (default) rules across a column; `"vertical"` separates a row. */
190
+ orientation?: "horizontal" | "vertical";
191
+ }
192
+
193
+ /**
194
+ * A rule between two groups of content — `Separator` under its other common name, shipped
195
+ * once rather than twice.
196
+ *
197
+ * An `<hr>`, so the separation is in the accessibility tree rather than only in the pixels; a
198
+ * bordered `<div>` is what a module reaches for when it has no primitive, and it says nothing
199
+ * to a screen reader. The vertical form carries `aria-orientation`, which `<hr>` does not
200
+ * imply.
201
+ *
202
+ * The vertical case needs a height from somewhere and deliberately does not invent one: it
203
+ * stretches to its flex or grid line, so it works between the items of a row `Stack` and is
204
+ * zero-height in a plain block parent. That is the one thing about it worth knowing before
205
+ * reaching for it, and the reason its specimen renders inside a fixed-height row.
206
+ */
207
+ export function Divider({ orientation = "horizontal", ...rest }: DividerProps) {
208
+ return (
209
+ <hr
210
+ {...rest}
211
+ data-terp="divider"
212
+ // Horizontal is the base rule, so only the vertical case carries an attribute.
213
+ data-orientation={orientation === "vertical" ? "vertical" : undefined}
214
+ aria-orientation={orientation === "vertical" ? "vertical" : undefined}
215
+ />
216
+ );
217
+ }
218
+
64
219
  export interface DetailItem {
65
220
  /** The item's label (rendered as `<dt>`). */
66
221
  label: UiText;
@@ -68,23 +223,65 @@ export interface DetailItem {
68
223
  value: ReactNode;
69
224
  }
70
225
 
226
+ /** How a pair is arranged. */
227
+ export type DetailListLayout = "inline" | "aligned" | "stacked";
228
+
71
229
  export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>, "style"> {
72
230
  /** The label/value pairs to render, in order. */
73
231
  items: readonly DetailItem[];
232
+ /**
233
+ * How each pair reads (default `"inline"` — `Label: value` on one line).
234
+ *
235
+ * `"aligned"` puts every label in a shared left column, so the values line up; `"stacked"`
236
+ * puts the label above its value, which is what a narrow column or a long value wants.
237
+ *
238
+ * The default is the old behaviour on purpose: this component is in the `standard` layout
239
+ * contract's detail-page slot, so every governed detail screen already renders one.
240
+ */
241
+ layout?: DetailListLayout;
242
+ /** Pairs per row (default `1`). */
243
+ columns?: 1 | 2;
74
244
  }
75
245
 
76
246
  /**
77
247
  * Token-styled label/value pairs as a semantic `<dl>` — record metadata on a detail page,
78
- * an expanded row's summary. Centralizes the "Label: value" pattern so modules never
79
- * hand-style definition lists.
248
+ * an expanded row's summary.
249
+ *
250
+ * A real grid rather than an inline run, which is the thing the diagnosis was pointing at: nine
251
+ * pairs including two 64-character digests, "through an inline run 4px apart with no
252
+ * alignment". Two fixes rather than one, and the second was the actual defect:
253
+ *
254
+ * - `layout="aligned"` gives the labels a shared column. The row wrapper becomes
255
+ * `display: contents` so the `<dt>` and `<dd>` are grid items of the `<dl>` itself — no DOM
256
+ * change, and the only way to align across rows without one.
257
+ * - **The tracks are floored at zero.** An implicit grid column is `auto`, which floors at
258
+ * *min-content* — so a 64-character digest with nothing to break on widened the column and
259
+ * pushed the list past its container. `minmax(0, 1fr)` is what stops that, and it is the same
260
+ * declaration `Grid`'s fixed counts need for the same reason.
261
+ *
262
+ * The colon lives in the sheet rather than in the markup, because it belongs to the inline
263
+ * layout alone — `aligned` and `stacked` must not have one, and a text node cannot be
264
+ * withdrawn by a rule. It is decorative either way: the `<dt>` / `<dd>` pairing is what carries
265
+ * the relationship to assistive tech.
80
266
  */
81
- export function DetailList({ items, ...rest }: DetailListProps) {
267
+ export function DetailList({
268
+ items,
269
+ layout = "inline",
270
+ columns = 1,
271
+ ...rest
272
+ }: DetailListProps) {
82
273
  const text = useUiText();
83
274
  return (
84
- <dl {...rest} data-terp="detail-list">
275
+ <dl
276
+ {...rest}
277
+ data-terp="detail-list"
278
+ // `inline` and one column are the base rule, so neither stamps an attribute.
279
+ data-layout={layout === "inline" ? undefined : layout}
280
+ data-columns={columns === 1 ? undefined : String(columns)}
281
+ >
85
282
  {items.map((item, index) => (
86
- <div key={index}>
87
- <dt data-terp="detail-list-term">{text(item.label)}: </dt>
283
+ <div key={index} data-terp="detail-list-row">
284
+ <dt data-terp="detail-list-term">{text(item.label)}</dt>
88
285
  <dd data-terp="detail-list-value">{item.value}</dd>
89
286
  </div>
90
287
  ))}
@@ -1,5 +1,5 @@
1
1
  // @vitest-environment jsdom
2
- import { cleanup, render, screen, waitFor } from "@testing-library/react";
2
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
3
3
  import type { ReactNode } from "react";
4
4
  import { Component } from "react";
5
5
  import { afterEach, describe, expect, it } from "vitest";
@@ -11,6 +11,11 @@ import { afterEach, describe, expect, it } from "vitest";
11
11
  import * as lintLayouts from "../../eslint-boundaries/src/layouts.js";
12
12
 
13
13
  import { DetailPage } from "./DetailPage";
14
+ import { FormPage } from "./FormPage";
15
+ import { SettingsPage } from "./SettingsPage";
16
+ import { SplitPage, SplitPane } from "./SplitPage";
17
+ import { Input } from "./ui/Input";
18
+ import { Field } from "./Field";
14
19
  import { HubCard, HubPage } from "./HubPage";
15
20
  import {
16
21
  LAYOUT_CONTRACTS,
@@ -19,8 +24,17 @@ import {
19
24
  verifySlotChildren,
20
25
  } from "./layoutContract";
21
26
  import { OverviewPage } from "./OverviewPage";
27
+
28
+ // The public surface, as source: the archetype-coverage check below derives its list from
29
+ // the entry point's own exports rather than restating one. `raw.d.ts` declares the ambient
30
+ // ImportMeta.glob type this shares with the other scanning tests in the package.
31
+ const entryPoint = Object.values(
32
+ import.meta.glob("./index.ts", { query: "?raw", import: "default", eager: true }),
33
+ )[0] as string;
22
34
  import { Page } from "./Page";
23
- import { DetailList, Stack } from "./layout";
35
+ import { EmptyState } from "./EmptyState";
36
+ import { DetailList, Divider, Grid, Stack } from "./layout";
37
+ import { Text } from "./typography";
24
38
  import { PageActions } from "./PageActions";
25
39
  import { ThemeProvider, ThemeToggle } from "./theme";
26
40
  import { Button } from "./ui/Button";
@@ -55,6 +69,30 @@ function underContract(children: ReactNode, contract: string | null = "standard"
55
69
  );
56
70
  }
57
71
 
72
+ /**
73
+ * Assert that a composition was ACCEPTED — after actually letting the check run.
74
+ *
75
+ * The obvious spelling is a false green, and it was the shape every acceptance test in this file
76
+ * used:
77
+ *
78
+ * await waitFor(() => expect(screen.queryByTestId("refused")).toBeNull());
79
+ *
80
+ * `waitFor` retries until its callback stops throwing, so a callback asserting that something is
81
+ * ABSENT passes on the very first attempt — before `Page`'s `setTimeout(0)` slot check has run at
82
+ * all. It waits for nothing and can never fail. Nine tests claimed to gate the acceptance half of
83
+ * ADR 0079 and gated nothing; verified by putting a `Grid` — which the OverviewPage slot table
84
+ * does not admit — into a governed overview body and watching the suite stay green.
85
+ *
86
+ * Flushing one macrotask inside `act` is what makes the assertion mean something: the check has
87
+ * then either thrown into the boundary or decided the body was legal.
88
+ */
89
+ async function expectAccepted() {
90
+ await act(async () => {
91
+ await new Promise((resolve) => setTimeout(resolve, 0));
92
+ });
93
+ expect(screen.queryByTestId("refused")).toBeNull();
94
+ }
95
+
58
96
  describe("layout contract parity (docs/data can't drift)", () => {
59
97
  it("mirrors the eslint-boundaries contract table byte-for-byte", () => {
60
98
  expect(JSON.parse(JSON.stringify(LAYOUT_CONTRACTS))).toEqual(
@@ -69,6 +107,124 @@ describe("layout contract parity (docs/data can't drift)", () => {
69
107
  });
70
108
  });
71
109
 
110
+ describe("every archetype the package exports is governed, or says it is not", () => {
111
+ // The gap this closes was a green build. `verifySlotChildren` returns null for a slot the
112
+ // table does not name (layoutContract.ts:138-141) and the lint rule early-returns the same
113
+ // way, so an archetype exported WITHOUT a table entry is silently ungoverned by both halves
114
+ // of the control — no error, no warning, and a governed app renders it with the body slot
115
+ // wide open. Nothing asserted the two lists agreed, which means "forgot the table entry"
116
+ // was indistinguishable from "deliberately unconstrained".
117
+ //
118
+ // Derived from the public surface rather than restated as a list, because a list is the
119
+ // thing that was missing: a new `FormPage` export joins this check by existing, and has to
120
+ // either take a slot or come here and say why.
121
+ // Two independent derivations, unioned, because each has a blind spot the other covers.
122
+ //
123
+ // The named-export scan reads `export { X } from` and `export { X as Y } from` — the alias
124
+ // form matters, since the exported name is what a consumer writes. It cannot see
125
+ // `export * from`, and index.ts already contains one (`./dataview`), so on its own this
126
+ // derivation would let an archetype re-exported through a sub-namespace escape all three
127
+ // assertions below INCLUDING the vacuity guard, which is built from the same scan.
128
+ //
129
+ // The filename scan covers exactly that: an archetype lives in `<Name>Page.tsx` by
130
+ // convention, wherever in the tree it sits. It cannot see an archetype declared inside some
131
+ // other file — which the export scan can.
132
+ const namedExports = [...entryPoint.matchAll(/^export \{([^}]*)\} from/gm)]
133
+ .flatMap((match) => match[1]!.split(","))
134
+ // `A as B` exports B; a bare name exports itself.
135
+ .map((entry) => entry.trim().split(/\s+as\s+/).pop()!.trim());
136
+ const archetypeFiles = Object.keys(
137
+ import.meta.glob("./**/*Page.tsx", { query: "?raw", import: "default", eager: true }),
138
+ )
139
+ .filter((file) => !file.includes(".test."))
140
+ .map((file) => file.split("/").pop()!.replace(/\.tsx$/, ""));
141
+ const exported = [
142
+ ...new Set(
143
+ [...namedExports, ...archetypeFiles]
144
+ // `endsWith` rather than a `\w*Page$` regex, which cannot match "Page" itself:
145
+ // after the leading capital there is no "Page" left to match. The vacuity guard below
146
+ // caught that on the first run, which is the whole reason it is there.
147
+ .filter((name) => /^[A-Z]\w*$/.test(name) && name.endsWith("Page")),
148
+ ),
149
+ ].sort();
150
+
151
+ /**
152
+ * Archetypes with no slot entry, and the reason each is unconstrained.
153
+ *
154
+ * One entry, and it is the contract's own pressure valve rather than an omission: the
155
+ * plain `Page` is what a bespoke screen composes when no archetype fits, and the
156
+ * contract's description says so in as many words. Anything else added here is a claim
157
+ * that a screen shape has no vocabulary, which is the claim the table exists to refuse.
158
+ */
159
+ const DELIBERATELY_UNCONSTRAINED: Record<string, string> = {
160
+ Page: "the bespoke pressure valve — a screen no archetype fits composes this, and the contract leaves it open by design",
161
+ };
162
+
163
+ it("finds the archetype exports it is meant to check", () => {
164
+ // Vacuity guard: a derivation that matched nothing would make every assertion below pass.
165
+ expect(exported).toEqual([
166
+ "DetailPage",
167
+ "FormPage",
168
+ "HubPage",
169
+ "OverviewPage",
170
+ "Page",
171
+ "SettingsPage",
172
+ "SplitPage",
173
+ ]);
174
+ });
175
+
176
+ it("pins the namespace re-exports, which neither derivation can look inside", () => {
177
+ // `export * from "./x"` is opaque to both scans: the filename scan only sees files named
178
+ // `*Page.tsx`, and the named-export scan sees no names at all. So the set of namespace
179
+ // re-exports is pinned instead — adding one is the moment to check it carries no archetype,
180
+ // and this assertion is what forces that look.
181
+ const namespaces = [...entryPoint.matchAll(/^export \* from "([^"]+)"/gm)].map(
182
+ (match) => match[1]!,
183
+ );
184
+ expect(namespaces).toEqual(["./dataview"]);
185
+ });
186
+
187
+ it("keeps no excuse for an archetype that is no longer exported", () => {
188
+ // The reverse-direction test filters these keys out of its comparison, so a stale excuse
189
+ // changes nothing there and would sit in the file for ever — quietly claiming that a
190
+ // component nobody exports is deliberately unconstrained.
191
+ for (const excused of Object.keys(DELIBERATELY_UNCONSTRAINED)) {
192
+ expect(
193
+ exported,
194
+ `${excused} is excused from the slot table but is not an exported archetype — the excuse is stale`,
195
+ ).toContain(excused);
196
+ }
197
+ });
198
+
199
+ it("gives every exported archetype a slot, or a named reason for having none", () => {
200
+ const slots = LAYOUT_CONTRACTS.standard!.slots;
201
+ for (const archetype of exported) {
202
+ const governed = archetype in slots;
203
+ const excused = archetype in DELIBERATELY_UNCONSTRAINED;
204
+ expect(
205
+ governed || excused,
206
+ `${archetype} is exported but has no LAYOUT_CONTRACTS.standard.slots entry, so both ` +
207
+ "halves of the layout contract silently skip it. Add the slot (in this file AND in " +
208
+ "eslint-boundaries/src/layouts.js), or add it to DELIBERATELY_UNCONSTRAINED with the " +
209
+ "reason its body has no vocabulary.",
210
+ ).toBe(true);
211
+ expect(
212
+ governed && excused,
213
+ `${archetype} is both governed and excused — the excuse is stale, delete it`,
214
+ ).toBe(false);
215
+ }
216
+ });
217
+
218
+ it("names no slot that no archetype exports", () => {
219
+ // The other direction: a slot for an archetype that was renamed or withdrawn is a table
220
+ // entry nothing can ever satisfy, and the message it phrases names a component that is
221
+ // no longer there.
222
+ expect(Object.keys(LAYOUT_CONTRACTS.standard!.slots).sort()).toEqual(
223
+ exported.filter((name) => !(name in DELIBERATELY_UNCONSTRAINED)),
224
+ );
225
+ });
226
+ });
227
+
72
228
  describe("runtime slot enforcement", () => {
73
229
  it("refuses a non-HubCard child in a HubPage grid, fail closed, with the directive message", async () => {
74
230
  underContract(
@@ -242,14 +398,18 @@ describe("layout contract survives the roots the styling migration renames", ()
242
398
  "dataview",
243
399
  "detail-list",
244
400
  "dialog",
401
+ "divider",
245
402
  "empty-state",
246
403
  "error-state",
404
+ "grid",
247
405
  "hubcard",
248
406
  "loading-state",
249
407
  "module-nav",
250
408
  "resource-list",
409
+ "splitpane",
251
410
  "stack",
252
411
  "tabs",
412
+ "text",
253
413
  ]);
254
414
  });
255
415
 
@@ -274,3 +434,152 @@ describe("layout contract survives the roots the styling migration renames", ()
274
434
  expect(screen.queryByTestId("refused")).toBeNull();
275
435
  });
276
436
  });
437
+
438
+ describe("what 4b's widening does and does not admit", () => {
439
+ it("accepts a Grid in a detail body, which is the two-column form case", async () => {
440
+ // The diagnosis's headline evidence was a fifteen-field form shipped as one vertical run.
441
+ // Shipping `Grid` without this widening would have shipped a component no governed page
442
+ // could use — and only a copier-generated project has the contract switched on, so the
443
+ // example app could not have detected that either.
444
+ underContract(
445
+ <DetailPage title="Record" parents={[{ label: "Records", to: "/records" }]}>
446
+ <Grid columns={2}>
447
+ <span>one</span>
448
+ <span>two</span>
449
+ </Grid>
450
+ </DetailPage>,
451
+ );
452
+ await expectAccepted();
453
+ });
454
+
455
+ it("still refuses a Grid in an overview body, because a grid there is a hub", () => {
456
+ // The asymmetry IS the decision, so it is pinned rather than left to the table. An
457
+ // overview body is a data collection; a grid of summary cards is a hub, and the contract
458
+ // has a hub archetype for that. Widening both slots identically would have been the easy
459
+ // move and would have dissolved the distinction the three levels exist to carry.
460
+ underContract(
461
+ <OverviewPage title="Records">
462
+ <Grid columns={2}>
463
+ <span>one</span>
464
+ </Grid>
465
+ </OverviewPage>,
466
+ );
467
+ return waitFor(() => {
468
+ expect(screen.getByTestId("refused").textContent).toBe(
469
+ slotViolationMessage("standard", "OverviewPage", '<div data-terp="grid">'),
470
+ );
471
+ });
472
+ });
473
+
474
+ it("refuses a bare Field at the top of a form body, because that form cannot submit", () => {
475
+ // The one refusal in FormPage's slot that is a decision rather than an omission. A run of
476
+ // bare fields looks like a finished form and cannot be submitted: Enter does nothing
477
+ // without a <form>, and the house spelling of one is `Stack as="form"`. So the slot takes
478
+ // the container and not the fields — which is the opposite of what a reader would guess
479
+ // from "a form page's body is fields".
480
+ underContract(
481
+ <FormPage title="New sync" parents={[{ label: "Syncs", to: "/syncs" }]}>
482
+ <Field label="Name">
483
+ <Input />
484
+ </Field>
485
+ </FormPage>,
486
+ );
487
+ return waitFor(() => {
488
+ expect(screen.getByTestId("refused")).toHaveTextContent(
489
+ /the FormPage body slot accepts only Stack \/ Grid \/ Card/,
490
+ );
491
+ });
492
+ });
493
+
494
+ it("accepts a form body that is a form container with sections beside it", () => {
495
+ underContract(
496
+ <FormPage title="New sync" parents={[{ label: "Syncs", to: "/syncs" }]}>
497
+ <Text>Every definition needs a source and a target.</Text>
498
+ <Stack as="form">
499
+ <Field label="Name">
500
+ <Input />
501
+ </Field>
502
+ </Stack>
503
+ </FormPage>,
504
+ );
505
+ return waitFor(() => {
506
+ expect(screen.queryByTestId("refused")).not.toBeInTheDocument();
507
+ });
508
+ });
509
+
510
+ it("refuses a collection in a settings body, because that screen is an overview", () => {
511
+ // SettingsPage's slot is Card sections and nothing that holds a collection. A settings
512
+ // screen whose body is a table is an overview with the wrong chrome, and the contract is
513
+ // where that distinction is enforceable rather than advisory.
514
+ underContract(
515
+ <SettingsPage title="Preferences">
516
+ <DetailList items={[{ label: "Theme", value: "Dark" }]} />
517
+ </SettingsPage>,
518
+ );
519
+ return waitFor(() => {
520
+ expect(screen.getByTestId("refused")).toHaveTextContent(
521
+ /the SettingsPage body slot accepts only Card \/ Stack/,
522
+ );
523
+ });
524
+ });
525
+
526
+ it("refuses anything but a SplitPane inside a split, and accepts the two panes", async () => {
527
+ // The split governs its PANE ROW rather than the page body, which is HubPage's shape and
528
+ // not DetailPage's — so this is the assertion that the choice actually took effect. A
529
+ // Card dropped straight into the split is the realistic mistake.
530
+ underContract(
531
+ <SplitPage title="Syncs">
532
+ <Card title="Not a pane" />
533
+ </SplitPage>,
534
+ );
535
+ await waitFor(() => {
536
+ expect(screen.getByTestId("refused")).toHaveTextContent(
537
+ /the SplitPage body slot accepts only SplitPane/,
538
+ );
539
+ });
540
+ cleanup();
541
+ underContract(
542
+ <SplitPage title="Syncs">
543
+ <SplitPane role="list" label="Definitions">
544
+ <DetailList items={[{ label: "Customer master", value: "ok" }]} />
545
+ </SplitPane>
546
+ <SplitPane role="detail" label="Selected definition">
547
+ <Card title="Customer master" />
548
+ </SplitPane>
549
+ </SplitPage>,
550
+ );
551
+ await waitFor(() => {
552
+ expect(screen.queryByTestId("refused")).not.toBeInTheDocument();
553
+ });
554
+ });
555
+
556
+ it("accepts a Divider and a lead paragraph in both governed bodies", async () => {
557
+ // A rule between sections and a paragraph above them — the two things a body wanted that
558
+ // needed no container. `Heading` is deliberately NOT admitted: a heading in a governed body
559
+ // must own its section, and `Card` (plain or boxed) is how a section is owned. A bare
560
+ // heading with siblings after it is a grouping the contract cannot see.
561
+ for (const page of [
562
+ <OverviewPage key="o" title="Records">
563
+ <Text>Lead paragraph.</Text>
564
+ <Divider />
565
+ <EmptyState title="No records yet" />
566
+ </OverviewPage>,
567
+ <DetailPage key="d" title="Record" parents={[{ label: "Records", to: "/records" }]}>
568
+ <Text>Lead paragraph.</Text>
569
+ <Divider />
570
+ <DetailList items={[{ label: "Owner", value: "Ada" }]} />
571
+ </DetailPage>,
572
+ ]) {
573
+ cleanup();
574
+ underContract(page);
575
+ // Per iteration, not once after the loop. Two separate faults met here and only the
576
+ // second one mattered. `Page` schedules its check as a setTimeout(0) and clears it on
577
+ // unmount while RTL's `cleanup` unmounts synchronously, so a single trailing assertion
578
+ // cancelled the OverviewPage timer and only ever reached the DetailPage. But moving the
579
+ // assertion inside the loop changed nothing on its own, because the assertion itself
580
+ // could not fail — see `expectAccepted`. Verified together: an illegal Grid in the
581
+ // OverviewPage body above is green under either old form and red under this one.
582
+ await expectAccepted();
583
+ }
584
+ });
585
+ });