@terpjs/react-core 0.1.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 (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. package/vitest.setup.ts +58 -0
@@ -0,0 +1,72 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { DetailList, Stack } from "./layout";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("Stack", () => {
10
+ it("renders a flex column with a token gap by default", () => {
11
+ render(
12
+ <Stack data-testid="stack">
13
+ <span>a</span>
14
+ <span>b</span>
15
+ </Stack>,
16
+ );
17
+ const el = screen.getByTestId("stack");
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)");
22
+ });
23
+
24
+ it("renders the requested element with direction, gap, alignment and wrap", () => {
25
+ render(
26
+ <Stack data-testid="row" as="section" direction="row" gap={4} align="center" justify="space-between" wrap>
27
+ <span>a</span>
28
+ </Stack>,
29
+ );
30
+ const el = screen.getByTestId("row");
31
+ expect(el.tagName).toBe("SECTION");
32
+ expect(el.style.flexDirection).toBe("row");
33
+ expect(el.style.gap).toBe("var(--space-4)");
34
+ expect(el.style.alignItems).toBe("center");
35
+ expect(el.style.justifyContent).toBe("space-between");
36
+ expect(el.style.flexWrap).toBe("wrap");
37
+ });
38
+
39
+ it("works as a form (submit handler fires)", () => {
40
+ let submitted = false;
41
+ render(
42
+ <Stack
43
+ as="form"
44
+ data-testid="form"
45
+ onSubmit={(event) => {
46
+ event.preventDefault();
47
+ submitted = true;
48
+ }}
49
+ >
50
+ <button type="submit">go</button>
51
+ </Stack>,
52
+ );
53
+ screen.getByText("go").click();
54
+ expect(submitted).toBe(true);
55
+ });
56
+ });
57
+
58
+ describe("DetailList", () => {
59
+ it("renders label/value pairs as a definition list", () => {
60
+ render(
61
+ <DetailList
62
+ items={[
63
+ { label: "Owner", value: "Ada" },
64
+ { label: { id: "detail.purchased", message: "Purchased" }, value: "2023-01-01" },
65
+ ]}
66
+ />,
67
+ );
68
+ expect(screen.getByText(/Owner/).tagName).toBe("DT");
69
+ expect(screen.getByText("Ada").tagName).toBe("DD");
70
+ expect(screen.getByText(/Purchased/)).toBeInTheDocument();
71
+ });
72
+ });
package/src/layout.tsx ADDED
@@ -0,0 +1,90 @@
1
+ import type { CSSProperties, ElementType, HTMLAttributes, ReactNode } from "react";
2
+
3
+ import { useUiText } from "./uiText";
4
+ import type { UiText } from "./uiText";
5
+
6
+ /** The spacing scale — indexes into the `--space-*` design tokens (no arbitrary pixel gaps). */
7
+ export type SpaceToken = 0 | 1 | 2 | 3 | 4 | 6 | 8;
8
+
9
+ export interface StackProps extends Omit<HTMLAttributes<HTMLElement>, "style"> {
10
+ /** The rendered element (`"div"` by default; use `"form"`, `"section"`, `"ul"`, …). */
11
+ as?: ElementType;
12
+ /** Main axis: `"column"` (default) stacks, `"row"` lines up. */
13
+ direction?: "column" | "row";
14
+ /** Gap between children, as a step on the token spacing scale (default `2`). */
15
+ gap?: SpaceToken;
16
+ /** Cross-axis alignment (e.g. `"center"`, `"start"`, `"end"`, `"stretch"`). */
17
+ align?: CSSProperties["alignItems"];
18
+ /** Main-axis distribution (e.g. `"space-between"`, `"center"`, `"end"`). */
19
+ justify?: CSSProperties["justifyContent"];
20
+ /** Allow row items to wrap onto new lines (rows of tags, toolbars). */
21
+ wrap?: boolean;
22
+ children?: ReactNode;
23
+ }
24
+
25
+ /**
26
+ * The layout primitive: a flex container whose gap comes from the token spacing scale, so
27
+ * app modules compose layout **without writing CSS or `style={}`** (the boundary lint refuses
28
+ * the `style` attribute in module code). A vertical `Stack` lays out a form; a `row` Stack
29
+ * with `justify="space-between"` is a toolbar. Anything more bespoke belongs in a react-core
30
+ * component, not ad-hoc styles in a module.
31
+ */
32
+ export function Stack({
33
+ as: Component = "div",
34
+ direction = "column",
35
+ gap = 2,
36
+ align,
37
+ justify,
38
+ wrap = false,
39
+ ...rest
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} />;
51
+ }
52
+
53
+ export interface DetailItem {
54
+ /** The item's label (rendered as `<dt>`). */
55
+ label: UiText;
56
+ /** The item's value (rendered as `<dd>`). */
57
+ value: ReactNode;
58
+ }
59
+
60
+ export interface DetailListProps extends Omit<HTMLAttributes<HTMLDListElement>, "style"> {
61
+ /** The label/value pairs to render, in order. */
62
+ items: readonly DetailItem[];
63
+ }
64
+
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
+ /**
74
+ * Token-styled label/value pairs as a semantic `<dl>` — record metadata on a detail page,
75
+ * an expanded row's summary. Centralizes the "Label: value" pattern so modules never
76
+ * hand-style definition lists.
77
+ */
78
+ export function DetailList({ items, ...rest }: DetailListProps) {
79
+ const text = useUiText();
80
+ return (
81
+ <dl {...rest} data-terp="detail-list" style={detailListStyle}>
82
+ {items.map((item, index) => (
83
+ <div key={index}>
84
+ <dt style={detailTermStyle}>{text(item.label)}: </dt>
85
+ <dd style={detailValueStyle}>{item.value}</dd>
86
+ </div>
87
+ ))}
88
+ </dl>
89
+ );
90
+ }
@@ -0,0 +1,179 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, render, screen, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { Component } from "react";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+
7
+ // The lint-side source of the contract table (spec-as-data in @terpjs/eslint-boundaries);
8
+ // react-core carries a TS mirror because a published runtime package cannot depend on a
9
+ // lint package. This parity test keeps the two identical, so the data cannot drift.
10
+ // @ts-expect-error — monorepo-relative untyped JS import, test-only
11
+ import * as lintLayouts from "../../eslint-boundaries/src/layouts.js";
12
+
13
+ import { DetailPage } from "./DetailPage";
14
+ import { HubCard, HubPage } from "./HubPage";
15
+ import {
16
+ LAYOUT_CONTRACTS,
17
+ LayoutContractContext,
18
+ slotViolationMessage,
19
+ verifySlotChildren,
20
+ } from "./layoutContract";
21
+ import { OverviewPage } from "./OverviewPage";
22
+ import { Page } from "./Page";
23
+ import { DetailList, Stack } from "./layout";
24
+
25
+ afterEach(cleanup);
26
+
27
+ class CatchBoundary extends Component<
28
+ { children: ReactNode },
29
+ { message: string | null }
30
+ > {
31
+ state = { message: null };
32
+ static getDerivedStateFromError(error: Error) {
33
+ return { message: error.message };
34
+ }
35
+ render() {
36
+ return this.state.message === null ? (
37
+ this.props.children
38
+ ) : (
39
+ <p data-testid="refused">{this.state.message}</p>
40
+ );
41
+ }
42
+ }
43
+
44
+ function underContract(children: ReactNode, contract: string | null = "standard") {
45
+ return render(
46
+ <CatchBoundary>
47
+ <LayoutContractContext.Provider value={contract}>
48
+ {children}
49
+ </LayoutContractContext.Provider>
50
+ </CatchBoundary>,
51
+ );
52
+ }
53
+
54
+ describe("layout contract parity (docs/data can't drift)", () => {
55
+ it("mirrors the eslint-boundaries contract table byte-for-byte", () => {
56
+ expect(JSON.parse(JSON.stringify(LAYOUT_CONTRACTS))).toEqual(
57
+ JSON.parse(JSON.stringify(lintLayouts.LAYOUT_CONTRACTS)),
58
+ );
59
+ });
60
+
61
+ it("phrases the identical directive message on both halves", () => {
62
+ expect(slotViolationMessage("standard", "HubPage", "<div>")).toBe(
63
+ lintLayouts.slotViolationMessage("standard", "HubPage", "<div>"),
64
+ );
65
+ });
66
+ });
67
+
68
+ describe("runtime slot enforcement", () => {
69
+ it("refuses a non-HubCard child in a HubPage grid, fail closed, with the directive message", async () => {
70
+ underContract(
71
+ <HubPage title="Home">
72
+ {/* not a HubCard — a rogue list item */}
73
+ <li>rogue</li>
74
+ </HubPage>,
75
+ );
76
+ await waitFor(() => {
77
+ expect(screen.getByTestId("refused").textContent).toBe(
78
+ slotViolationMessage("standard", "HubPage", "<li>"),
79
+ );
80
+ });
81
+ });
82
+
83
+ it("passes a conforming HubPage of HubCards", async () => {
84
+ underContract(
85
+ <HubPage title="Home">
86
+ <HubCard to="/a" title="Area A" />
87
+ <HubCard to="/b" title="Area B" />
88
+ </HubPage>,
89
+ );
90
+ await new Promise((resolve) => setTimeout(resolve, 20));
91
+ expect(screen.queryByTestId("refused")).toBeNull();
92
+ expect(screen.getByText("Area A")).toBeDefined();
93
+ });
94
+
95
+ it("refuses bespoke content in an OverviewPage body and names the found element", async () => {
96
+ underContract(
97
+ <OverviewPage title="Records">
98
+ <div>hand-rolled listing</div>
99
+ </OverviewPage>,
100
+ );
101
+ await waitFor(() => {
102
+ expect(screen.getByTestId("refused").textContent).toBe(
103
+ slotViolationMessage("standard", "OverviewPage", "<div>"),
104
+ );
105
+ });
106
+ });
107
+
108
+ it("passes an OverviewPage whose body is allowed components", async () => {
109
+ underContract(
110
+ <OverviewPage title="Records">
111
+ <Stack>
112
+ <span>toolbar content lives inside allowed containers</span>
113
+ </Stack>
114
+ </OverviewPage>,
115
+ );
116
+ await new Promise((resolve) => setTimeout(resolve, 20));
117
+ expect(screen.queryByTestId("refused")).toBeNull();
118
+ });
119
+
120
+ it("passes a DetailPage of record sections and refuses a rogue one", async () => {
121
+ underContract(
122
+ <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
123
+ <Stack>
124
+ <DetailList items={[{ label: "Status", value: "open" }]} />
125
+ </Stack>
126
+ </DetailPage>,
127
+ );
128
+ await new Promise((resolve) => setTimeout(resolve, 20));
129
+ expect(screen.queryByTestId("refused")).toBeNull();
130
+ cleanup();
131
+
132
+ underContract(
133
+ <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
134
+ <table />
135
+ </DetailPage>,
136
+ );
137
+ await waitFor(() => {
138
+ expect(screen.getByTestId("refused").textContent).toBe(
139
+ slotViolationMessage("standard", "DetailPage", "<table>"),
140
+ );
141
+ });
142
+ });
143
+
144
+ it("leaves the plain Page unconstrained (the contract's bespoke pressure valve)", async () => {
145
+ underContract(
146
+ <Page title="Bespoke">
147
+ <div>anything goes here</div>
148
+ </Page>,
149
+ );
150
+ await new Promise((resolve) => setTimeout(resolve, 20));
151
+ expect(screen.queryByTestId("refused")).toBeNull();
152
+ });
153
+
154
+ it("skips the check while the archetype shows the loading / error frame", async () => {
155
+ underContract(
156
+ <OverviewPage title="Records" isLoading>
157
+ <div>never rendered</div>
158
+ </OverviewPage>,
159
+ );
160
+ await new Promise((resolve) => setTimeout(resolve, 20));
161
+ expect(screen.queryByTestId("refused")).toBeNull();
162
+ });
163
+
164
+ it("does nothing without an opted-in contract (backwards compatible)", async () => {
165
+ underContract(
166
+ <OverviewPage title="Records">
167
+ <div>legacy body</div>
168
+ </OverviewPage>,
169
+ null,
170
+ );
171
+ await new Promise((resolve) => setTimeout(resolve, 20));
172
+ expect(screen.queryByTestId("refused")).toBeNull();
173
+ });
174
+
175
+ it("verifySlotChildren returns null for an ungoverned contract/slot", () => {
176
+ expect(verifySlotChildren("ghost", "HubPage", [])).toBeNull();
177
+ expect(verifySlotChildren("standard", "Page", [])).toBeNull();
178
+ });
179
+ });
@@ -0,0 +1,137 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /**
4
+ * The runtime half of the slot-typed layout contract control (ADR 0079) — the layout
5
+ * analog of ./pageMarker.ts. When an app opts into a contract
6
+ * (`renderTerpApp({ layoutContract })` / `buildAppRouter(..., { layoutContract })`),
7
+ * each governed page archetype verifies after mount that its body slot's rendered DOM
8
+ * children are components the contract allows there — every sanctioned component stamps
9
+ * a `data-terp` marker on its root — and refuses the view, fail closed, with the same
10
+ * agent-directive message the `terp/layout-contract` lint rule phrases.
11
+ *
12
+ * This table is the TypeScript mirror of the spec-as-data source in
13
+ * `@terpjs/eslint-boundaries/src/layouts.js` (react-core ships standalone, so it cannot
14
+ * import a lint package); the parity test in ./layoutContract.test.tsx keeps the two
15
+ * identical, so the data cannot drift.
16
+ */
17
+
18
+ /** One governed slot: allowed component names mapped to their `data-terp` root markers. */
19
+ export interface LayoutSlotSpec {
20
+ readonly components: Readonly<Record<string, string>>;
21
+ }
22
+
23
+ /** One named layout contract: a description and its per-archetype slot specs. */
24
+ export interface LayoutContractSpec {
25
+ readonly description: string;
26
+ readonly slots: Readonly<Record<string, LayoutSlotSpec>>;
27
+ }
28
+
29
+ /** Every layout contract, keyed by id (mirror of the eslint-boundaries source table). */
30
+ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
31
+ standard: {
32
+ description:
33
+ "The standard three-level shape: hub bodies are card grids (HubCard only), " +
34
+ "overview bodies are data collections (DataView / ResourceList + framework " +
35
+ "states), detail bodies are record sections (DetailList / Stack / Tabs + " +
36
+ "framework states). A bespoke screen composes the plain Page, which the " +
37
+ "contract deliberately leaves unconstrained.",
38
+ slots: {
39
+ HubPage: {
40
+ components: { HubCard: "hubcard" },
41
+ },
42
+ OverviewPage: {
43
+ components: {
44
+ DataView: "dataview",
45
+ ResourceList: "resource-list",
46
+ ModuleNav: "module-nav",
47
+ Stack: "stack",
48
+ EmptyState: "empty-state",
49
+ ErrorState: "error-state",
50
+ LoadingState: "loading-state",
51
+ Alert: "alert",
52
+ ConfirmDialog: "dialog",
53
+ },
54
+ },
55
+ DetailPage: {
56
+ components: {
57
+ DetailList: "detail-list",
58
+ Stack: "stack",
59
+ Tabs: "tabs",
60
+ ModuleNav: "module-nav",
61
+ DataView: "dataview",
62
+ EmptyState: "empty-state",
63
+ ErrorState: "error-state",
64
+ LoadingState: "loading-state",
65
+ Alert: "alert",
66
+ ConfirmDialog: "dialog",
67
+ },
68
+ },
69
+ },
70
+ },
71
+ };
72
+
73
+ /**
74
+ * The one agent-directive violation message both enforcement halves phrase: the
75
+ * contract, the slot, what was found, what is allowed, and the concrete fix.
76
+ * (Byte-identical to the eslint-boundaries builder; parity-tested.)
77
+ */
78
+ export function slotViolationMessage(
79
+ contractId: string,
80
+ slotOwner: string,
81
+ found: string,
82
+ ): string {
83
+ const allowed = Object.keys(LAYOUT_CONTRACTS[contractId]!.slots[slotOwner]!.components);
84
+ return (
85
+ `Layout contract "${contractId}": the ${slotOwner} body slot accepts only ` +
86
+ `${allowed.join(" / ")}; found ${found}. Compose the body from those react-core ` +
87
+ "components (recipe: terp guide layouts), move bespoke content to a plain Page, " +
88
+ "or opt out on this line with a justified // terp-allow-layout-contract: <reason> " +
89
+ "marker (counted by the escape-hatch budget)."
90
+ );
91
+ }
92
+
93
+ /** The active contract id for the current routed view, or null (no contract = no checks). */
94
+ export const LayoutContractContext = createContext<string | null>(null);
95
+
96
+ /** Read the active layout contract id (null outside an opted-in app). */
97
+ export function useLayoutContract(): string | null {
98
+ return useContext(LayoutContractContext);
99
+ }
100
+
101
+ /**
102
+ * The body-slot owner the enclosing archetype declared for its `Page` (set by
103
+ * `OverviewPage` / `DetailPage`; `Page` resets it to null around its own children so a
104
+ * nested tree is never judged by an ancestor's slot).
105
+ */
106
+ export const LayoutSlotContext = createContext<string | null>(null);
107
+
108
+ /** How a rendered DOM child is described in a violation message. */
109
+ function describeElement(element: Element): string {
110
+ const marker = element.getAttribute("data-terp");
111
+ const tag = element.tagName.toLowerCase();
112
+ return marker !== null ? `<${tag} data-terp="${marker}">` : `<${tag}>`;
113
+ }
114
+
115
+ /**
116
+ * Verify a slot's rendered DOM children against the active contract: every child must
117
+ * carry the `data-terp` marker of an allowed component. Returns the directive violation
118
+ * message, or null when the slot conforms (or the contract/slot is not governed).
119
+ */
120
+ export function verifySlotChildren(
121
+ contractId: string,
122
+ slotOwner: string,
123
+ children: readonly Element[],
124
+ ): string | null {
125
+ const slot = LAYOUT_CONTRACTS[contractId]?.slots[slotOwner];
126
+ if (slot === undefined) {
127
+ return null;
128
+ }
129
+ const allowed = new Set(Object.values(slot.components));
130
+ for (const child of children) {
131
+ const marker = child.getAttribute("data-terp");
132
+ if (marker === null || !allowed.has(marker)) {
133
+ return slotViolationMessage(contractId, slotOwner, describeElement(child));
134
+ }
135
+ }
136
+ return null;
137
+ }
@@ -0,0 +1,97 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import { LOCALE_EN, LOCALE_NL, LOCALE_STORAGE_KEY, LanguageSwitcher, LocaleProvider } from "./locale";
6
+ import { DEFAULT_STRINGS, useStrings } from "./uiText";
7
+
8
+ afterEach(() => {
9
+ cleanup();
10
+ window.localStorage.clear();
11
+ });
12
+
13
+ function SignOutLabel() {
14
+ return <p>{useStrings().signOut}</p>;
15
+ }
16
+
17
+ const NL = { label: "Nederlands", strings: { signOut: "Uitloggen", language: "Taal" } };
18
+
19
+ describe("LocaleProvider + LanguageSwitcher", () => {
20
+ it("feeds the active catalog's overrides through the UiText seam", () => {
21
+ render(
22
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: NL }} defaultLocale="nl">
23
+ <SignOutLabel />
24
+ </LocaleProvider>,
25
+ );
26
+ expect(screen.getByText("Uitloggen")).toBeInTheDocument();
27
+ });
28
+
29
+ it("switches locale via the LanguageSwitcher and persists the choice", () => {
30
+ render(
31
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: NL }}>
32
+ <LanguageSwitcher />
33
+ <SignOutLabel />
34
+ </LocaleProvider>,
35
+ );
36
+ expect(screen.getByText("Sign out")).toBeInTheDocument();
37
+ fireEvent.click(screen.getByRole("button", { name: "Language" }));
38
+ fireEvent.click(screen.getByRole("menuitemradio", { name: "Nederlands" }));
39
+ expect(screen.getByText("Uitloggen")).toBeInTheDocument();
40
+ expect(window.localStorage.getItem(LOCALE_STORAGE_KEY)).toBe("nl");
41
+ // The switcher itself follows the active catalog too.
42
+ expect(screen.getByLabelText("Taal")).toBeInTheDocument();
43
+ });
44
+
45
+ it("restores a persisted locale and lists native names", () => {
46
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, "nl");
47
+ render(
48
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: NL }}>
49
+ <LanguageSwitcher />
50
+ </LocaleProvider>,
51
+ );
52
+ fireEvent.click(screen.getByRole("button", { name: "Taal" }));
53
+ expect(screen.getByRole("menuitemradio", { name: "English" })).toBeInTheDocument();
54
+ expect(screen.getByRole("menuitemradio", { name: "Nederlands" })).toHaveAttribute("aria-checked", "true");
55
+ });
56
+
57
+ it("ignores a persisted locale the app no longer declares", () => {
58
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, "fr");
59
+ render(
60
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: NL }}>
61
+ <SignOutLabel />
62
+ </LocaleProvider>,
63
+ );
64
+ expect(screen.getByText("Sign out")).toBeInTheDocument();
65
+ });
66
+
67
+ it("renders no switcher with a single locale, or outside a provider", () => {
68
+ render(
69
+ <LocaleProvider locales={{ en: LOCALE_EN }}>
70
+ <LanguageSwitcher />
71
+ </LocaleProvider>,
72
+ );
73
+ render(<LanguageSwitcher />);
74
+ expect(screen.queryByLabelText("Language")).not.toBeInTheDocument();
75
+ });
76
+
77
+ it("offers an icon-only inline variant for the shell header", () => {
78
+ render(
79
+ <LocaleProvider locales={{ en: LOCALE_EN, nl: NL }}>
80
+ <LanguageSwitcher variant="inline" />
81
+ </LocaleProvider>,
82
+ );
83
+ expect(screen.getByRole("button", { name: "Language" })).toBeInTheDocument();
84
+ // No visible label text in the inline variant.
85
+ expect(screen.queryByText("Language")).not.toBeInTheDocument();
86
+ });
87
+ });
88
+
89
+ describe("LOCALE_NL", () => {
90
+ it("translates every framework string (completeness drift-guard)", () => {
91
+ // A new TerpStrings key without a Dutch translation fails here, so the
92
+ // bundled catalog can never silently fall back to English for new chrome.
93
+ expect(Object.keys(LOCALE_NL.strings ?? {}).sort()).toEqual(
94
+ Object.keys(DEFAULT_STRINGS).sort(),
95
+ );
96
+ });
97
+ });