@terpjs/react-core 0.9.0 → 0.11.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 (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
@@ -64,4 +64,212 @@ describe("renderTerpApp", () => {
64
64
  );
65
65
  root.remove();
66
66
  });
67
+
68
+ it("forwards the layout declaration to buildAppRouter", () => {
69
+ // Same shape as the navGroups test below, for the same reason: the forwarding is one line in
70
+ // a hand-written enumeration, and a missing line leaves every gate on the declaration green
71
+ // while the one-call entry point — the one apps actually use — ignores the file entirely.
72
+ //
73
+ // Gated through a refusal only `buildAppRouter` can raise, and that took a second attempt.
74
+ // The first version asserted the resolver's enum refusal — which `renderTerpApp` now raises
75
+ // ITSELF, from its own resolve call, before `buildAppRouter` is reached. So it threw with
76
+ // the forwarding line deleted and reported green: a test that passes with the bug. An
77
+ // UNKNOWN CONTRACT is refused only inside `buildAppRouter`, against its own table, so the
78
+ // throw here observes the one line under test and nothing else.
79
+ //
80
+ // Mutation: delete `layout: options.layout` from the buildAppRouter call, and this stops
81
+ // throwing.
82
+ const root = document.createElement("div");
83
+ document.body.appendChild(root);
84
+ try {
85
+ expect(() =>
86
+ renderTerpApp({
87
+ title: "Test",
88
+ modules: { "./modules/notes/module.tsx": notesModule },
89
+ layout: { contract: "bespoke" },
90
+ rootElement: root,
91
+ }),
92
+ ).toThrow(/Unknown layout contract "bespoke"/);
93
+ } finally {
94
+ root.remove();
95
+ }
96
+ });
97
+
98
+ it("opens on the palette the declaration names, and on the option when it does not", async () => {
99
+ // The forwarding tests above gate their line through a THROW, because a signed-out mount
100
+ // renders no shell to read. The palette is the exception and gets the stronger assertion:
101
+ // `ThemeProvider` wraps `RequireAuth`, so it mounts signed out, and the attribute it writes
102
+ // on <html> is the actual observable effect an app ships. A throw here would only prove the
103
+ // file was validated, not that the value reached anything.
104
+ //
105
+ // Mutations, both red, and both by reading NULL rather than "midnight": `"system"` is the
106
+ // provider's default and it REMOVES the attribute, so neither mutation can produce the
107
+ // string. (An earlier version of this comment said the first one reads "system", which is
108
+ // not a value `data-theme` ever holds.) Pass `options.defaultTheme` to `ThemeProvider`
109
+ // instead of `layout.defaultTheme`, or drop `defaultTheme` from the explicit set handed to
110
+ // the resolver, and the corresponding assertion reads null.
111
+ for (const [label, options] of [
112
+ ["from the file", { layout: { defaultTheme: "midnight" } }],
113
+ ["from the option", { defaultTheme: "midnight" as const }],
114
+ ] as const) {
115
+ // A stored choice outranks the default — that is the whole point of a *default* — so the
116
+ // assertion would be about localStorage rather than about the declaration without this.
117
+ window.localStorage.clear();
118
+ document.documentElement.removeAttribute("data-theme");
119
+ const root = document.createElement("div");
120
+ document.body.appendChild(root);
121
+ try {
122
+ renderTerpApp({
123
+ title: "Test",
124
+ modules: { "./modules/notes/module.tsx": notesModule },
125
+ rootElement: root,
126
+ ...options,
127
+ });
128
+ await waitFor(() =>
129
+ expect(
130
+ document.documentElement.getAttribute("data-theme"),
131
+ `${label}: the declared palette must reach <html>`,
132
+ ).toBe("midnight"),
133
+ );
134
+ } finally {
135
+ root.remove();
136
+ document.documentElement.removeAttribute("data-theme");
137
+ }
138
+ }
139
+ });
140
+
141
+ it("refuses the palette named in the file and passed as an option", () => {
142
+ // Non-exclusive per ADR 0009 — either source alone is complete, as the test above shows —
143
+ // but not both, and not even when they agree. Reachable only through `renderTerpApp`,
144
+ // because `buildAppRouter` has no palette option to conflict with.
145
+ const root = document.createElement("div");
146
+ document.body.appendChild(root);
147
+ try {
148
+ expect(() =>
149
+ renderTerpApp({
150
+ title: "Test",
151
+ modules: { "./modules/notes/module.tsx": notesModule },
152
+ layout: { defaultTheme: "midnight" },
153
+ defaultTheme: "midnight",
154
+ rootElement: root,
155
+ }),
156
+ ).toThrow(/both declare "defaultTheme" \(file: "midnight", code: "midnight"\)/);
157
+ } finally {
158
+ root.remove();
159
+ }
160
+ });
161
+
162
+ it("names every doubly-declared key at once, groups included", async () => {
163
+ // The property the resolver's `conflicts.join("; ")` exists for, asserted through the entry
164
+ // point apps use — and it was broken here. `renderTerpApp` resolves the declaration itself
165
+ // (the palette mounts outside the router) and its explicit set omitted `navGroups`, so a
166
+ // groups conflict was invisible from this side: the author fixed the density conflict,
167
+ // re-ran, and hit a second, different refusal. Mutation: drop `navGroups: options.navGroups`
168
+ // from the resolve call at the top of `renderTerpApp` and this names only the density.
169
+ const root = document.createElement("div");
170
+ document.body.appendChild(root);
171
+ let message = "";
172
+ try {
173
+ renderTerpApp({
174
+ title: "Test",
175
+ modules: { "./modules/notes/module.tsx": notesModule },
176
+ layout: {
177
+ shell: {
178
+ density: "comfortable",
179
+ navGroups: [{ id: "work", label: "Workspace" }],
180
+ },
181
+ },
182
+ density: "compact",
183
+ navGroups: [{ id: "admin", label: "Admin" }],
184
+ rootElement: root,
185
+ });
186
+ } catch (error) {
187
+ message = (error as Error).message;
188
+ } finally {
189
+ root.remove();
190
+ }
191
+ expect(message).toContain('"shell.density"');
192
+ expect(message).toContain('"shell.navGroups"');
193
+ });
194
+
195
+ it("refuses the navigation groups named in the file and passed as an option", () => {
196
+ // The array key travelling the same seam as the scalars, end to end: the resolver sees the
197
+ // file, `buildAppRouter` hands the resolved list to the shell, and declaring the groups in
198
+ // both places is one fact declared twice. Named by id, because a list of objects stringified
199
+ // into an error message tells the reader nothing they can match up.
200
+ const root = document.createElement("div");
201
+ document.body.appendChild(root);
202
+ try {
203
+ expect(() =>
204
+ renderTerpApp({
205
+ title: "Test",
206
+ modules: { "./modules/notes/module.tsx": notesModule },
207
+ layout: { shell: { navGroups: [{ id: "work", label: "Workspace" }] } },
208
+ navGroups: [{ id: "admin", label: "Admin" }],
209
+ rootElement: root,
210
+ }),
211
+ ).toThrow(/both declare "shell\.navGroups" \(file: "work", code: "admin"\)/);
212
+ } finally {
213
+ root.remove();
214
+ }
215
+ });
216
+
217
+ it("refuses duplicate ids declared in the file, through the router's own check", () => {
218
+ // The check lives in `buildAppRouter` and now reads the RESOLVED list, so this is the proof
219
+ // that a duplicate reaches it from the file and not only from the option. Mutation: point
220
+ // the check back at `options.navGroups` and this stops throwing while the option-side test
221
+ // above stays green.
222
+ const root = document.createElement("div");
223
+ document.body.appendChild(root);
224
+ try {
225
+ expect(() =>
226
+ renderTerpApp({
227
+ title: "Test",
228
+ modules: { "./modules/notes/module.tsx": notesModule },
229
+ layout: {
230
+ shell: {
231
+ navGroups: [
232
+ { id: "work", label: "Workspace" },
233
+ { id: "work", label: "Again" },
234
+ ],
235
+ },
236
+ },
237
+ rootElement: root,
238
+ }),
239
+ ).toThrow(/duplicate id\(s\): work/);
240
+ } finally {
241
+ root.remove();
242
+ }
243
+ });
244
+
245
+ it("forwards navGroups to buildAppRouter", () => {
246
+ // `renderTerpApp` hands its options to `buildAppRouter` through a hand-written enumeration of
247
+ // names, and nothing covered it — `navPlacement`, `contentWidth` and `density` all travel that
248
+ // same list with no assertion that any of them arrives. A missing line there leaves every
249
+ // other gate on groups green while the feature does nothing for every app using the one-call
250
+ // entry point, which is the entry point apps actually use.
251
+ //
252
+ // Gated through the duplicate-id refusal rather than through a rendered label, and that is
253
+ // the point of doing it this way: this test mounts the SIGNED-OUT app, so the shell never
254
+ // renders and there is no nav to read. The throw only happens if the option was forwarded, so
255
+ // it observes the one line under test and nothing else. Mutation: delete
256
+ // `navGroups: options.navGroups` from the buildAppRouter call, and this stops throwing.
257
+ const root = document.createElement("div");
258
+ document.body.appendChild(root);
259
+ try {
260
+ expect(() =>
261
+ renderTerpApp({
262
+ title: "Test",
263
+ modules: { "./modules/notes/module.tsx": notesModule },
264
+ navGroups: [
265
+ { id: "work", label: "Werkruimte" },
266
+ { id: "work", label: "Weer werkruimte" },
267
+ ],
268
+ rootElement: root,
269
+ }),
270
+ ).toThrow(/duplicate id\(s\): work/);
271
+ } finally {
272
+ root.remove();
273
+ }
274
+ });
67
275
  });
package/src/bootstrap.tsx CHANGED
@@ -2,7 +2,7 @@ import { RouterProvider } from "@tanstack/react-router";
2
2
  import { StrictMode } from "react";
3
3
  import type { ComponentType, ReactNode } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import type { ModuleManifest } from "@terpjs/contract";
5
+ import type { ModuleManifest, NavGroup } from "@terpjs/contract";
6
6
 
7
7
  import { LoginView } from "./LoginView";
8
8
  import type { DevCredentials } from "./LoginView";
@@ -10,7 +10,10 @@ import { RequireAuth } from "./RequireAuth";
10
10
  import { TerpProvider } from "./TerpProvider";
11
11
  import { AdminHub } from "./admin/AdminHub";
12
12
  import { adminModule } from "./admin/module";
13
+ import { resolveLayoutDeclaration } from "./layoutDeclaration";
14
+ import type { LayoutDeclaration } from "./layoutDeclaration";
13
15
  import { LocaleProvider } from "./locale";
16
+ import { installPreviewBridge } from "./previewBridge";
14
17
  import type { LocaleCatalog } from "./locale";
15
18
  import { buildAppRouter } from "./router";
16
19
  import type { SsoProvider } from "./sso";
@@ -97,8 +100,61 @@ export interface RenderTerpAppOptions {
97
100
  modules: Record<string, unknown>;
98
101
  /** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
99
102
  logo?: ReactNode;
103
+ /**
104
+ * The dark-theme brand mark ({@link AppShell.logoDark}); the stylesheet picks per appearance,
105
+ * with no code of the app's involved.
106
+ *
107
+ * Forwarded because it was not, and the template already told every new app to pass it — so
108
+ * the documented example did not typecheck. Third instance of the shape this ADR's Context
109
+ * names for `headerActions`: a slot that exists on the shell and cannot be reached from the
110
+ * one-call bootstrap.
111
+ */
112
+ logoDark?: ReactNode;
113
+ /**
114
+ * Extra header content, rendered before the theme / language controls.
115
+ *
116
+ * `AppShell` has had this slot all along and `renderTerpApp` did not pass it, so the only way
117
+ * to reach it was to abandon the one-call bootstrap for `TerpProvider` + `buildAppRouter` —
118
+ * a slot that existed and was unreachable from the entry point every app uses.
119
+ */
120
+ headerActions?: ReactNode;
100
121
  /** Footer line under the content; default: a muted line with the app title. */
101
122
  footer?: ReactNode;
123
+ /**
124
+ * Cap routed content at the published measure, with each page's header on the full track
125
+ * ({@link AppShell.contentWidth}); default `"full"`, which changes nothing. Move the measure
126
+ * itself from an app's own `theme.css`: `--shell-content-max-width`.
127
+ */
128
+ contentWidth?: "full" | "measured";
129
+ /**
130
+ * App-wide density — one attribute on the shell root, from which every control height and cell
131
+ * padding follows by token inheritance.
132
+ *
133
+ * **No default.** Omitting it stamps nothing, so an app setting `data-density` on `<html>`
134
+ * (ADR 0094 §4's app-wide case) still reaches everything; a shell default would silently win
135
+ * against it.
136
+ */
137
+ density?: "comfortable" | "compact";
138
+ /**
139
+ * Where the primary navigation lives on desktop ({@link AppShell.navPlacement}): the
140
+ * full-height `"sidebar"` (default, and what every shell renders today) or `"header"`, a
141
+ * horizontal row in the header with no sidebar at all. Below the mobile breakpoint both are
142
+ * the drawer.
143
+ */
144
+ navPlacement?: "sidebar" | "header";
145
+ /**
146
+ * The app's navigation groups ({@link AppShell.navGroups}), referenced by manifest items
147
+ * through `NavItem.group`. Omit for the flat, unlabelled sidebar every app renders today.
148
+ *
149
+ * This is the app's half of the model and there is no module-side equivalent: a group spans
150
+ * modules, so its label and its position cannot belong to any one of them. A duplicate id is
151
+ * refused when the router is built.
152
+ *
153
+ * Prefer {@link RenderTerpAppOptions.layout}: `shell.navGroups` in the app's own
154
+ * `frontend/layout-contract.json` says the same thing in the one document a tool can read and
155
+ * rewrite. Declaring the groups in both places is refused rather than silently resolved.
156
+ */
157
+ navGroups?: readonly NavGroup[];
102
158
  /**
103
159
  * Ship the packaged admin area (default `true`): the admin-gated sidebar entry, the
104
160
  * `/admin` hub, and the users / groups / audit screens over the base-profile
@@ -131,15 +187,40 @@ export interface RenderTerpAppOptions {
131
187
  locales?: Record<string, LocaleCatalog>;
132
188
  /** Starting locale when the user has not chosen one; default: the first `locales` key. */
133
189
  defaultLocale?: string;
134
- /** Starting theme when the user has not chosen one; default "system" (OS preference). */
190
+ /**
191
+ * Starting theme when the user has not chosen one; default `"system"` (OS preference).
192
+ *
193
+ * Prefer {@link RenderTerpAppOptions.layout}: `"defaultTheme"` in the app's own
194
+ * `frontend/layout-contract.json` says the same thing in the one document a tool can read and
195
+ * rewrite, which is the whole reason the declaration exists. Declaring it in both places is
196
+ * refused rather than silently resolved.
197
+ */
135
198
  defaultTheme?: Theme;
136
199
  /**
137
200
  * Opt into a slot-typed layout contract (ADR 0079), e.g. `"standard"`: every routed
138
201
  * archetype's body slot then accepts only the components the contract allows there,
139
- * verified at runtime (fail closed). Keep it in sync with the app's checked-in
140
- * `layout-contract.json` (the `terp/layout-contract` lint half).
202
+ * verified at runtime (fail closed).
203
+ *
204
+ * Prefer {@link RenderTerpAppOptions.layout} — importing the app's own
205
+ * `frontend/layout-contract.json` declares this once for both halves. This option used to
206
+ * carry the instruction "keep it in sync with the checked-in file", which is a defect
207
+ * written as advice: the lint rule reads the file, this read the option, and nothing
208
+ * compared them.
141
209
  */
142
210
  layoutContract?: string;
211
+ /**
212
+ * The app's checked-in layout declaration: `import layout from "../layout-contract.json"`.
213
+ *
214
+ * One file declaring the layout contract for both the lint rule and the runtime check, plus
215
+ * the palette the app opens on and the shell's own shape — density, navigation placement,
216
+ * content measure, and the navigation groups a module's items name by id. Declaring a key
217
+ * here and passing the matching option is refused rather than silently resolved.
218
+ *
219
+ * The authoritative list is `TOP_LEVEL_KEYS` and `SHELL_KEYS` in
220
+ * {@link ./layoutDeclaration}, and the published `layout.manifest.json` beside them. This
221
+ * sentence is a restatement and has already drifted once.
222
+ */
223
+ layout?: LayoutDeclaration;
143
224
  /** Mount point; default `document.getElementById("root")`. */
144
225
  rootElement?: HTMLElement | null;
145
226
  }
@@ -223,6 +304,27 @@ export function withAdminArea(
223
304
  * stylesheet import. Drop to `TerpProvider` + `buildAppRouter` for full control.
224
305
  */
225
306
  export function renderTerpApp(options: RenderTerpAppOptions): void {
307
+ // Resolved here as well as inside `buildAppRouter`, because the one key mounted OUTSIDE the
308
+ // router is the palette: `ThemeProvider` wraps everything, the router included.
309
+ //
310
+ // Two calls, one answer — but only while both are handed the SAME option set, and that is a
311
+ // standing obligation rather than something the code enforces. It was broken once already,
312
+ // within a day: `navGroups` was added to the router's set and not to this one, so a groups
313
+ // conflict declared through `renderTerpApp` was invisible here and the resolver's
314
+ // report-every-conflict-at-once property quietly became report-some. Anything added to
315
+ // `BuildAppRouterOptions` and passed at the bottom of this function belongs in this call too;
316
+ // `bootstrap.test.tsx` names both conflicts in one message to hold that.
317
+ //
318
+ // `layout` and every option still go down untouched, so a key added to the declaration later
319
+ // reaches the router without being threaded through this function first.
320
+ const layout = resolveLayoutDeclaration(options.layout, {
321
+ contract: options.layoutContract,
322
+ density: options.density,
323
+ navPlacement: options.navPlacement,
324
+ contentWidth: options.contentWidth,
325
+ navGroups: options.navGroups,
326
+ defaultTheme: options.defaultTheme,
327
+ });
226
328
  const collected = collectModules(options.modules);
227
329
  const { manifests, views } = withAdminArea(
228
330
  collected.manifests,
@@ -233,16 +335,30 @@ export function renderTerpApp(options: RenderTerpAppOptions): void {
233
335
  views,
234
336
  title: options.title,
235
337
  logo: options.logo,
338
+ logoDark: options.logoDark,
339
+ headerActions: options.headerActions,
236
340
  footer: options.footer,
341
+ contentWidth: options.contentWidth,
342
+ density: options.density,
343
+ navPlacement: options.navPlacement,
344
+ navGroups: options.navGroups,
237
345
  layoutContract: options.layoutContract,
346
+ layout: options.layout,
238
347
  });
239
348
  const root = options.rootElement ?? document.getElementById("root");
240
349
  if (!root) {
241
350
  throw new Error('renderTerpApp: no root element (add <div id="root"> or pass rootElement).');
242
351
  }
352
+ // The channel a tool showing this app in an iframe can ask it questions through, and it exists
353
+ // ONLY in a development build: `import.meta.env.DEV` folds to false in production and the
354
+ // module goes with it, so a deployed app carries no listener at all. Same mechanism, and the
355
+ // same reason, as the template's dev sign-in credentials. See ./previewBridge.
356
+ if (import.meta.env.DEV) {
357
+ installPreviewBridge();
358
+ }
243
359
  createRoot(root).render(
244
360
  <StrictMode>
245
- <ThemeProvider defaultTheme={options.defaultTheme}>
361
+ <ThemeProvider defaultTheme={layout.defaultTheme}>
246
362
  <LocaleProvider
247
363
  locales={options.locales ?? { en: {} }}
248
364
  defaultLocale={options.defaultLocale}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The one viewport cutover the framework has, in the one place it is written.
3
+ *
4
+ * It was written in three: `AppShell` and `DataView` each declared
5
+ * `const MOBILE_BREAKPOINT = "(max-width: 768px)"` verbatim — the duplication the diagnosis
6
+ * named — and the responsive `Stack` props would have made a third copy, this time in the
7
+ * stylesheet where the first two could not see it.
8
+ *
9
+ * ## Why the value is a literal and not `var(--breakpoint-md)`
10
+ *
11
+ * The contract publishes `--breakpoint-md: 768px`, and neither consumer can read it. CSS
12
+ * forbids a custom property in a media-query condition, and `matchMedia` takes a string, so
13
+ * a component would have to resolve the property off `document.documentElement` at runtime —
14
+ * which is a layout read on every mount, breaks under SSR where there is no document, and
15
+ * turns a static query into a value that can change after first paint.
16
+ *
17
+ * So the literal stays, and the drift it invites is gated instead:
18
+ * `styles.test.ts` reads `--breakpoint-md` out of the contract's token sheet and refuses a
19
+ * mismatch here or in the stylesheet. The token remains the published source of truth about
20
+ * what the number IS; this module is the single place it is spelled for use.
21
+ *
22
+ * ## Why WIDE is a negation
23
+ *
24
+ * `NARROW` and `WIDE` have to partition the viewport exactly: a width that satisfies both
25
+ * would render the shell's drawer beside a row-direction toolbar, and a width satisfying
26
+ * neither would render nothing at all. Two independent queries cannot guarantee that — the
27
+ * conventional pairing is `max-width: 767.98px` with `min-width: 768px`, which works but
28
+ * makes the partition depend on an epsilon somebody chose, and picking one here would also
29
+ * have moved the shell's existing behaviour at exactly 768px.
30
+ *
31
+ * `not all and (max-width: 768px)` is the complement of `NARROW` by construction. The
32
+ * `not all and` spelling rather than the shorter `not (…)` because it is the form every
33
+ * browser has supported since media queries existed, and this is a stylesheet a consumer
34
+ * cannot patch.
35
+ */
36
+
37
+ /** The condition both narrow-viewport components match on (`matchMedia`, so no `@media`). */
38
+ export const NARROW_VIEWPORT = "(max-width: 768px)";
39
+
40
+ /** The stylesheet's complement of {@link NARROW_VIEWPORT} — everything above the cutover. */
41
+ export const WIDE_VIEWPORT_QUERY = "not all and (max-width: 768px)";
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import type { ReactNode } from "react";
3
3
 
4
+ import { NARROW_VIEWPORT } from "../breakpoints";
4
5
  import { EmptyState } from "../EmptyState";
5
6
  import { ErrorState } from "../ErrorState";
6
7
  import type { BadgeTone } from "../ui/Badge";
@@ -26,7 +27,6 @@ import type {
26
27
  ViewStateRepository,
27
28
  } from "./types";
28
29
 
29
- const MOBILE_BREAKPOINT = "(max-width: 768px)";
30
30
  /** Embedded views render all rows; the parent owns paging. */
31
31
  const EMBEDDED_PAGE_SIZE = 10_000;
32
32
 
@@ -49,7 +49,14 @@ interface DataViewBaseProps<T> {
49
49
  *
50
50
  * `"comfortable"` stamps NO attribute, because comfortable IS the token sheet's
51
51
  * `:root` value and an attribute for it would match no rule. The consequence worth
52
- * knowing: inside an already-compact subtree, `density="comfortable"` does not make
52
+ * (Historic note, since the prop's behaviour changed: `"comfortable"` used to stamp
53
+ * nothing, on the grounds that comfortable was the sheet's own `:root` value — which was
54
+ * true until the shell could make an ancestor compact. Both values are stamped now, and
55
+ * both have a rule. Where nothing above is compact the two compute identically, so the
56
+ * change is zero-diff by construction.)
57
+ *
58
+ * What used to be worth knowing: inside an already-compact subtree, `density="comfortable"`
59
+ * did not make
53
60
  * this view comfortable again. Expressing that needs a named comfortable copy of
54
61
  * each live token, which ADR 0094 defers until something asks — nothing can ask
55
62
  * until the shell takes a density of its own.
@@ -129,13 +136,13 @@ function useIsMobile(): boolean {
129
136
  () =>
130
137
  typeof window !== "undefined" &&
131
138
  typeof window.matchMedia === "function" &&
132
- window.matchMedia(MOBILE_BREAKPOINT).matches,
139
+ window.matchMedia(NARROW_VIEWPORT).matches,
133
140
  );
134
141
  useEffect(() => {
135
142
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
136
143
  return;
137
144
  }
138
- const media = window.matchMedia(MOBILE_BREAKPOINT);
145
+ const media = window.matchMedia(NARROW_VIEWPORT);
139
146
  const onChange = () => setIsMobile(media.matches);
140
147
  media.addEventListener("change", onChange);
141
148
  return () => media.removeEventListener("change", onChange);
@@ -147,7 +154,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
147
154
  const { strings, resolve } = useDataViewText();
148
155
  const embedded = props.variant === "embedded";
149
156
  // Only the compact value is expressible as an attribute; see the prop's doc comment.
150
- const densityAttribute = props.density === "compact" ? "compact" : undefined;
157
+ const densityAttribute = props.density;
151
158
  // Hoisted rather than written inline at the two roots, which is what the density
152
159
  // attribute above already does and what keeps the marker scanner out of a trap: it
153
160
  // reads a whole expression container, so every string literal inside one counts as a
@@ -5,7 +5,7 @@ import type { UiText } from "../uiText";
5
5
 
6
6
  import { DataViewExpandToggle } from "./DataViewExpandableRow";
7
7
  import { DataViewRowActions } from "./DataViewRowActions";
8
- import { useDataViewText } from "./internal";
8
+ import { useCellFormatter, useDataViewText } from "./internal";
9
9
  import type { DataViewColumn, DataViewRowAction } from "./types";
10
10
 
11
11
  export interface DataViewCardListProps<T> {
@@ -33,6 +33,7 @@ function slotValue<T>(
33
33
  columns: DataViewColumn<T>[],
34
34
  row: T,
35
35
  slot: "title" | "subtitle" | "status" | "date",
36
+ formatCell: (value: unknown) => ReactNode,
36
37
  ): ReactNode {
37
38
  const column = columns.find((candidate) => candidate.meta?.mobileSlot === slot);
38
39
  if (column === undefined) {
@@ -41,8 +42,7 @@ function slotValue<T>(
41
42
  if (column.cell !== undefined) {
42
43
  return column.cell(row);
43
44
  }
44
- const value = column.accessor?.(row);
45
- return value === null || value === undefined ? null : String(value);
45
+ return formatCell(column.accessor?.(row));
46
46
  }
47
47
 
48
48
  /**
@@ -129,10 +129,11 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
129
129
  }
130
130
 
131
131
  function DefaultCardBody<T>({ row, columns }: { row: T; columns: DataViewColumn<T>[] }) {
132
- const title = slotValue(columns, row, "title");
133
- const subtitle = slotValue(columns, row, "subtitle");
134
- const status = slotValue(columns, row, "status");
135
- const date = slotValue(columns, row, "date");
132
+ const formatCell = useCellFormatter();
133
+ const title = slotValue(columns, row, "title", formatCell);
134
+ const subtitle = slotValue(columns, row, "subtitle", formatCell);
135
+ const status = slotValue(columns, row, "status", formatCell);
136
+ const date = slotValue(columns, row, "date", formatCell);
136
137
  return (
137
138
  <div data-terp="dataview-card-fields">
138
139
  <div data-terp="dataview-card-heading">
@@ -37,14 +37,21 @@ export function DataViewPagination({
37
37
  return (
38
38
  <div data-terp="dataview-pagination">
39
39
  <span>{format(strings.resultsRange, { from, to, total: totalCount })}</span>
40
+ {/* aria-disabled, not disabled, and the difference is where focus goes. Each of these
41
+ four buttons has a bound condition recomputed from what its own click just changed, so
42
+ pressing "next" until the last page disabled the very control the user was operating —
43
+ and a disabled element cannot hold focus, so the browser dropped it to <body>. A
44
+ keyboard user paging to the end lost their place in the document at the exact moment
45
+ they arrived. Kept focusable and announced as disabled instead, with the handler inert
46
+ on the bound; the sheet paints [aria-disabled="true"] identically to :disabled. */}
40
47
  {pageCount > 1 && (
41
48
  <span data-terp="dataview-pager">
42
49
  <span>{format(strings.pageOf, { page: pageIndex + 1, pages: pageCount })}</span>
43
50
  <button
44
51
  type="button"
45
52
  aria-label={format(strings.firstPage, {})}
46
- disabled={atFirst}
47
- onClick={() => goTo(0)}
53
+ aria-disabled={atFirst || undefined}
54
+ onClick={() => (atFirst ? undefined : goTo(0))}
48
55
  data-terp="iconbutton"
49
56
  >
50
57
  <PageFirstGlyph />
@@ -52,8 +59,8 @@ export function DataViewPagination({
52
59
  <button
53
60
  type="button"
54
61
  aria-label={format(strings.previousPage, {})}
55
- disabled={atFirst}
56
- onClick={() => goTo(pageIndex - 1)}
62
+ aria-disabled={atFirst || undefined}
63
+ onClick={() => (atFirst ? undefined : goTo(pageIndex - 1))}
57
64
  data-terp="iconbutton"
58
65
  >
59
66
  <PagePrevGlyph />
@@ -61,8 +68,8 @@ export function DataViewPagination({
61
68
  <button
62
69
  type="button"
63
70
  aria-label={format(strings.nextPage, {})}
64
- disabled={atLast}
65
- onClick={() => goTo(pageIndex + 1)}
71
+ aria-disabled={atLast || undefined}
72
+ onClick={() => (atLast ? undefined : goTo(pageIndex + 1))}
66
73
  data-terp="iconbutton"
67
74
  >
68
75
  <PageNextGlyph />
@@ -70,8 +77,8 @@ export function DataViewPagination({
70
77
  <button
71
78
  type="button"
72
79
  aria-label={format(strings.lastPage, {})}
73
- disabled={atLast}
74
- onClick={() => goTo(pageCount - 1)}
80
+ aria-disabled={atLast || undefined}
81
+ onClick={() => (atLast ? undefined : goTo(pageCount - 1))}
75
82
  data-terp="iconbutton"
76
83
  >
77
84
  <PageLastGlyph />