@terpjs/react-core 0.8.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 (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  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.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. package/src/unwrap.ts +118 -32
package/src/router.tsx CHANGED
@@ -7,12 +7,14 @@ import {
7
7
  useNavigate,
8
8
  useParams,
9
9
  useRouter,
10
+ useRouterState,
11
+ useSearch,
10
12
  type AnyRoute,
11
13
  type RouterHistory,
12
14
  } from "@tanstack/react-router";
13
15
  import type { ComponentType, ReactNode } from "react";
14
- import { useEffect, useRef, useState } from "react";
15
- import type { ModuleManifest } from "@terpjs/contract";
16
+ import { useCallback, useEffect, useRef, useState } from "react";
17
+ import type { ModuleManifest, NavGroup } from "@terpjs/contract";
16
18
 
17
19
  import { AppShell } from "./AppShell";
18
20
  import { ProfileView } from "./ProfileView";
@@ -21,11 +23,24 @@ import type {
21
23
  TerpRouteParamName,
22
24
  TerpRouteParams,
23
25
  TerpRoutePath,
26
+ TerpRouteSearch,
24
27
  } from "./routeTypes";
25
28
  import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
26
- import { visibleNav } from "./nav";
29
+ import {
30
+ BRAND_FIELDS,
31
+ resolveLayoutDeclaration,
32
+ type LayoutDeclaration,
33
+ } from "./layoutDeclaration";
34
+ import { isDeclarationVisible, visibleNav } from "./nav";
27
35
  import { NavLinkContext } from "./navLink";
36
+ import type { NavLinkRenderer } from "./navLink";
28
37
  import { PageMarkerContext } from "./pageMarker";
38
+ import {
39
+ RouteSearchContext,
40
+ declaredSearchKeys,
41
+ indexSearchKeys,
42
+ useRouteSearchIndex,
43
+ } from "./routeSearch";
29
44
  import { useAuth } from "./TerpProvider";
30
45
  import { UserMenu } from "./UserMenu";
31
46
  import { useStrings } from "./uiText";
@@ -169,9 +184,56 @@ export function useTerpNavigate(): (target: TerpNavigateTarget) => Promise<void>
169
184
  // runtime TanStack types `params` as a reducer (or `true`), and merging over the
170
185
  // previous params is also the honest semantic for an in-place param change.
171
186
  params: (previous: Record<string, unknown>) => ({ ...previous, ...(target.params ?? {}) }),
187
+ // Search is REPLACED, not merged (ADR 0096). Merging reads as convenient and is the
188
+ // wrong default for the case this exists to serve: clearing a filter means sending
189
+ // the key as undefined, and a merge would keep the old value instead — so "clear"
190
+ // would silently not clear. A screen that wants to keep other keys passes them,
191
+ // which is also the only form that stays checkable against the declared key set.
192
+ search: dropUndefined(target.search),
172
193
  });
173
194
  }
174
195
 
196
+ /**
197
+ * Drop `undefined` values so a cleared filter leaves the URL instead of appearing as
198
+ * `?status=undefined`, and an all-cleared search yields a bare path.
199
+ */
200
+ function dropUndefined(
201
+ search: Record<string, string | undefined> | undefined,
202
+ ): Record<string, string> {
203
+ const kept: Record<string, string> = {};
204
+ for (const [key, value] of Object.entries(search ?? {})) {
205
+ if (value !== undefined) {
206
+ kept[key] = value;
207
+ }
208
+ }
209
+ return kept;
210
+ }
211
+
212
+ /**
213
+ * Read the current route's declared query-string keys (ADR 0096).
214
+ *
215
+ * ```tsx
216
+ * const { status, page } = useRouteSearch("/records");
217
+ * ```
218
+ *
219
+ * Every key is `string | undefined`, because a query parameter is text and is absent
220
+ * until someone sets it — so a screen destructures with defaults rather than branching on
221
+ * a bag of `unknown`. Reading a key the route did not declare is a typecheck error once
222
+ * `terp routes` has generated; before that the shape is loose, exactly like the params
223
+ * helpers. Undeclared keys present in the URL are **not** returned: the declaration is the
224
+ * surface, so a stray key someone hand-typed cannot leak into a screen's logic.
225
+ */
226
+ export function useRouteSearch<P extends TerpRoutePath>(path: P): TerpRouteSearch<P> {
227
+ const search = useSearch({ strict: false }) as Record<string, unknown>;
228
+ const declared = declaredSearchKeys(useRouteSearchIndex(), path);
229
+ const resolved: Record<string, string | undefined> = {};
230
+ for (const name of declared) {
231
+ const value = search[name];
232
+ resolved[name] = typeof value === "string" ? value : undefined;
233
+ }
234
+ return resolved as TerpRouteSearch<P>;
235
+ }
236
+
175
237
  export interface BuildAppRouterOptions {
176
238
  /** Maps a manifest route's `view` id to the component that renders it. */
177
239
  views: Record<string, ComponentType>;
@@ -179,8 +241,51 @@ export interface BuildAppRouterOptions {
179
241
  title: string;
180
242
  /** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
181
243
  logo?: ReactNode;
244
+ /**
245
+ * The dark-theme brand mark ({@link AppShell.logoDark}); the stylesheet picks per appearance.
246
+ *
247
+ * Forwarded because it was not, which made it the third slot to exist on the shell and be
248
+ * unreachable from the entry points every app uses — after `headerActions`, which this ADR's
249
+ * own Context complains about. This one was worse than unreachable: the project template
250
+ * instructs every new app to pass `logoDark` to `renderTerpApp`, so the documented example
251
+ * did not typecheck.
252
+ */
253
+ logoDark?: ReactNode;
254
+ /** Extra header content, rendered before the theme / language controls. */
255
+ headerActions?: ReactNode;
182
256
  /** Footer line under the content; default: a muted line with the app title. */
183
257
  footer?: ReactNode;
258
+ /**
259
+ * Cap routed content at the published measure, with each page's header on the full track
260
+ * ({@link AppShell.contentWidth}); default `"full"`, which changes nothing.
261
+ */
262
+ contentWidth?: "full" | "measured";
263
+ /**
264
+ * App-wide density ({@link AppShell.density}). **No default** — omitting it stamps nothing, so
265
+ * an app's own `data-density` on `<html>` still reaches the tree.
266
+ */
267
+ density?: "comfortable" | "compact";
268
+ /**
269
+ * Where the primary navigation lives on desktop ({@link AppShell.navPlacement}): the
270
+ * full-height `"sidebar"` (default, and what every shell renders today) or `"header"`, a
271
+ * horizontal row in the header with no sidebar at all. Below the mobile breakpoint both are
272
+ * the drawer.
273
+ */
274
+ navPlacement?: "sidebar" | "header";
275
+ /**
276
+ * The app's navigation groups ({@link AppShell.navGroups}), which manifest items reference by
277
+ * `NavItem.group`. Omit for the flat, unlabelled sidebar every app renders today.
278
+ *
279
+ * Prefer {@link BuildAppRouterOptions.layout}: `shell.navGroups` in the app's own
280
+ * `frontend/layout-contract.json` says the same thing in the one document a tool can read and
281
+ * rewrite. Declaring the groups in both places is refused rather than silently resolved.
282
+ *
283
+ * A duplicate id is refused here rather than tolerated: it is an authoring error with no
284
+ * legitimate transient form, and composition time is where it can be reported once instead of
285
+ * on every render. An item naming an *undeclared* group is the opposite case and is not an
286
+ * error at all — modules ship independently of the app, so `groupNav` lets it fall open.
287
+ */
288
+ navGroups?: readonly NavGroup[];
184
289
  /** Role-name -> minimum rank; an unknown role is denied (fail closed). */
185
290
  roleRanks?: Record<string, number>;
186
291
  /** Rendered when the current user may not access a route (default: a simple message). */
@@ -189,10 +294,32 @@ export interface BuildAppRouterOptions {
189
294
  * Opt into a slot-typed layout contract (ADR 0079), e.g. `"standard"`: every routed
190
295
  * archetype's body slot then accepts only the components the contract allows there,
191
296
  * verified at runtime (fail closed) with the same directive message the
192
- * `terp/layout-contract` lint rule phrases. Keep it in sync with the app's checked-in
193
- * `layout-contract.json` (the lint half). Omit for today's archetype-only behavior.
297
+ * `terp/layout-contract` lint rule phrases.
298
+ *
299
+ * Prefer {@link BuildAppRouterOptions.layout}. This option used to carry the instruction
300
+ * "keep it in sync with the app's checked-in `layout-contract.json`", which is a defect
301
+ * written as advice and, since this function began reading that file, worse than stale:
302
+ * doing what the sentence said is now refused as one fact declared twice, even when the two
303
+ * agree. Omit both for today's archetype-only behavior.
194
304
  */
195
305
  layoutContract?: string;
306
+ /**
307
+ * The app's checked-in layout declaration (`frontend/layout-contract.json`, imported).
308
+ *
309
+ * The file the `terp/layout-contract` lint rule already reads, now read by the runtime half
310
+ * too, so `contract` is declared once instead of once per half. It also carries the palette
311
+ * the app opens on and the shell's own shape — `density`, `navPlacement`, `contentWidth` and
312
+ * the `navGroups` a module's `NavItem.group` names by id — all of which shipped as options
313
+ * here and were therefore out of reach of anything that edits files rather than code.
314
+ *
315
+ * The authoritative list is `TOP_LEVEL_KEYS` and `SHELL_KEYS` in
316
+ * {@link ./layoutDeclaration}, and the published `layout.manifest.json` beside them. This
317
+ * sentence is a restatement and has already drifted once.
318
+ *
319
+ * Declaring a key here AND passing the matching option is refused — see
320
+ * {@link resolveLayoutDeclaration}.
321
+ */
322
+ layout?: LayoutDeclaration;
196
323
  /** Router history (e.g. `createMemoryHistory`); omit for the browser history. */
197
324
  history?: RouterHistory;
198
325
  }
@@ -226,13 +353,33 @@ function DefaultUnauthorized() {
226
353
  * built-in {@link ProfileView} mounts at {@link PROFILE_PATH} unless a manifest claims that
227
354
  * path. Wrap the returned router in `<TerpProvider><RouterProvider router={router}/></TerpProvider>`.
228
355
  */
356
+ /**
357
+ * A declared path as the element the shell's slot takes.
358
+ *
359
+ * `alt=""` because the mark is decorative here: the shell renders the app's title beside it,
360
+ * so a name on the image would have a screen reader announce the app twice. That is also what
361
+ * the template's own commented example does, and what an app hand-writing the option should do.
362
+ */
363
+ function brandMark(path: string | undefined) {
364
+ return path === undefined ? undefined : <img src={path} alt="" />;
365
+ }
366
+
229
367
  export function buildAppRouter(
230
368
  manifests: readonly ModuleManifest[],
231
369
  options: BuildAppRouterOptions,
232
370
  ) {
233
371
  const roleRanks = options.roleRanks ?? DEFAULT_ROLE_RANKS;
234
372
  const Unauthorized = options.unauthorized ?? DefaultUnauthorized;
235
- const layoutContract = options.layoutContract ?? null;
373
+ // The declaration and the options collapse into one set before anything reads them, so
374
+ // every consumer below sees a single answer per key and cannot pick a different precedence.
375
+ const layout = resolveLayoutDeclaration(options.layout, {
376
+ contract: options.layoutContract,
377
+ density: options.density,
378
+ navPlacement: options.navPlacement,
379
+ contentWidth: options.contentWidth,
380
+ navGroups: options.navGroups,
381
+ });
382
+ const layoutContract = layout.contract ?? null;
236
383
  if (layoutContract !== null && LAYOUT_CONTRACTS[layoutContract] === undefined) {
237
384
  throw new Error(
238
385
  `Unknown layout contract "${layoutContract}"; known contracts: ` +
@@ -240,6 +387,40 @@ export function buildAppRouter(
240
387
  ".",
241
388
  );
242
389
  }
390
+ // An authoring error with no legitimate transient form, so it is refused here rather than
391
+ // absorbed. `groupNav` itself stays total — first declaration wins — because it runs on every
392
+ // render and a render must not be able to throw; this runs once, when the app is composed.
393
+ // Deliberately NOT symmetrical with an item naming an undeclared group, which is not an error
394
+ // at all: a module ships on its own schedule, so that is the normal state of an app mid-adoption
395
+ // and it falls open.
396
+ // Over the RESOLVED list, so one refusal covers a group declared in the file and a group
397
+ // passed as an option alike — which is also why `resolveLayoutDeclaration` does not restate it.
398
+ // The brand's two slots, each declarable as a checked-in PATH or passed as a rendered
399
+ // element. Refused together for the reason every other doubly-declared key is, and refused
400
+ // HERE rather than in the resolver because a path and an element are not comparable values:
401
+ // the resolver's message names both, and there is nothing to name on the code side but the
402
+ // fact that something was passed.
403
+ const brandConflicts = BRAND_FIELDS.filter(
404
+ (slot) => layout.brand?.[slot] !== undefined && options[slot] !== undefined,
405
+ );
406
+ if (brandConflicts.length > 0) {
407
+ throw new Error(
408
+ `frontend/layout-contract.json declares shell.brand.${brandConflicts.join(" and shell.brand.")}` +
409
+ ` and the bootstrap options pass ${brandConflicts.join(" and ")}. Declare each mark in ` +
410
+ "one place: the file is what a tool can read and rewrite, so prefer it and drop the option.",
411
+ );
412
+ }
413
+
414
+ const duplicateGroups = (layout.navGroups ?? [])
415
+ .map((group) => group.id)
416
+ .filter((id, index, ids) => ids.indexOf(id) !== index);
417
+ if (duplicateGroups.length > 0) {
418
+ throw new Error(
419
+ "Terp navGroups declare duplicate id(s): " +
420
+ [...new Set(duplicateGroups)].join(", ") +
421
+ ". Each group id is referenced by NavItem.group and must be declared once.",
422
+ );
423
+ }
243
424
  const missingViews = manifests.flatMap((manifest) =>
244
425
  manifest.routes
245
426
  .filter((route) => options.views[route.view] === undefined)
@@ -251,29 +432,86 @@ export function buildAppRouter(
251
432
  );
252
433
  }
253
434
 
435
+ // The runtime half of the search declaration (ADR 0096): the generated table is types
436
+ // only, so `useRouteSearch` reads the keys from the manifests this router was built from,
437
+ // published per router through a context (never a module-level table, which every router
438
+ // in the process would share).
439
+ const searchKeys = new Map(indexSearchKeys(manifests));
440
+ if (!searchKeys.has(PROFILE_PATH)) {
441
+ searchKeys.set(PROFILE_PATH, []);
442
+ }
443
+
254
444
  function Shell() {
255
445
  const router = useRouter();
256
- const rank = useAuth().currentUser()?.role_rank ?? null;
257
- const nav = visibleNav(manifests, (role) => allows(roleRanks, rank, role));
446
+ const user = useAuth().currentUser();
447
+ const rank = user?.role_rank ?? null;
448
+ const nav = visibleNav(manifests, {
449
+ canSeeRole: (role) => allows(roleRanks, rank, role),
450
+ permissions: user?.permissions ?? [],
451
+ });
452
+ // The shell decides which nav item is current and needs the path to do it. Selected, so the
453
+ // subscription re-renders only when the pathname itself changes — the same mechanism
454
+ // ModuleNav already uses.
455
+ const pathname = useRouterState({ select: (state) => state.location.pathname });
456
+ // Memoised, and this is a fix for a regression the line above introduces rather than
457
+ // tidying. `Shell` used to re-render only when `useAuth()` changed; it now re-renders on
458
+ // every navigation. `Outlet` is memoised with no props, so a re-render alone bails out at
459
+ // that boundary and the routed subtree is untouched — but a CONTEXT VALUE punches straight
460
+ // through a memo bailout, and this value is used as a component (Breadcrumbs and HubCard
461
+ // render it through useNavLink), so an unstable identity is worse than a re-render: it
462
+ // remounts every in-app link in the tree on each navigation.
463
+ const renderNavLink = useCallback<NavLinkRenderer>(
464
+ ({ to, children, attributes }) => (
465
+ <Link to={to} {...attributes}>
466
+ {children}
467
+ </Link>
468
+ ),
469
+ [],
470
+ );
258
471
  return (
259
472
  // Publish the router's Link so every layout component that renders an in-app link
260
473
  // (Breadcrumbs, HubCard) navigates client-side by default. Forgetting `renderLink`
261
474
  // used to degrade the app silently: a raw anchor, a full page reload, no error.
262
- <NavLinkContext.Provider value={({ to, children }) => <Link to={to}>{children}</Link>}>
475
+ <NavLinkContext.Provider value={renderNavLink}>
263
476
  <AppShell
264
477
  title={options.title}
265
- logo={options.logo}
478
+ logo={brandMark(layout.brand?.logo) ?? options.logo}
479
+ logoDark={brandMark(layout.brand?.logoDark) ?? options.logoDark}
480
+ headerActions={options.headerActions}
266
481
  footer={options.footer}
482
+ contentWidth={layout.contentWidth}
483
+ density={layout.density}
484
+ navPlacement={layout.navPlacement}
485
+ activePath={pathname}
267
486
  nav={nav}
487
+ navGroups={layout.navGroups}
268
488
  renderBrandLink={({ to, children }) => (
269
489
  <Link to={to} data-terp="appshell-brand">
270
490
  {children}
271
491
  </Link>
272
492
  )}
273
- // No style objects and no activeProps: the shell's stylesheet owns the link
274
- // geometry and keys the active route on aria-current="page", which Link sets.
275
- renderLink={(item, children) => (
276
- <Link to={item.to} activeOptions={{ exact: item.to === "/" }}>
493
+ // No style objects and no activeProps: the shell's stylesheet owns the link geometry
494
+ // and keys the active route on aria-current="page".
495
+ //
496
+ // The shell supplies that attribute now, and `exact: true` is what makes the router
497
+ // agree instead of arguing. Two facts combine. `aria-current` is not among the props
498
+ // useLinkProps destructures, so a value passed here survives into the rendered anchor;
499
+ // and the router's own active props are spread LAST, but only when it considers the link
500
+ // active. With exact matching, "the router considers it active" implies the link's path
501
+ // equals the URL — which is the longest possible match, so it is always the same item
502
+ // the shell picked. The router can therefore only ever agree, never add a second
503
+ // current item.
504
+ //
505
+ // Prefix matching is what broke that: it marked every ancestor active, so `/settings`
506
+ // and `/settings/users` were both current at `/settings/users`. The old
507
+ // `exact: item.to === "/"` was a workaround for the same thing at the root, and it is
508
+ // gone because the shell's predicate matches on segments and `/` claims nothing else.
509
+ renderLink={(item, children, { active }) => (
510
+ <Link
511
+ to={item.to}
512
+ activeOptions={{ exact: true }}
513
+ aria-current={active ? "page" : undefined}
514
+ >
277
515
  {children}
278
516
  </Link>
279
517
  )}
@@ -295,12 +533,21 @@ export function buildAppRouter(
295
533
  function guardedRoute(
296
534
  path: string,
297
535
  View: ComponentType,
298
- role: string | undefined,
536
+ declaration: { role?: string; permission?: string },
299
537
  viewName: string,
300
538
  ): AnyRoute {
301
539
  function RouteComponent() {
302
- const rank = useAuth().currentUser()?.role_rank ?? null;
303
- const allowed = allows(roleRanks, rank, role);
540
+ const user = useAuth().currentUser();
541
+ const rank = user?.role_rank ?? null;
542
+ // The same resolution the sidebar uses, and using it here is what stops `permission` from
543
+ // becoming a cosmetic gate: hiding a link while leaving its route reachable by URL is not
544
+ // a weaker version of authorization, it is the appearance of it. `role` has never had that
545
+ // asymmetry — it is declared on both NavItem and ModuleRoute — so `permission` does not
546
+ // get to introduce one.
547
+ const allowed = isDeclarationVisible(declaration, {
548
+ canSeeRole: (role) => allows(roleRanks, rank, role),
549
+ permissions: user?.permissions ?? [],
550
+ });
304
551
  // The runtime half of the "every routed view is a page archetype" control: Page
305
552
  // (composed by OverviewPage / DetailPage / HubPage) marks the render; a routed view
306
553
  // that mounted without any archetype in its tree is refused, fail closed. The check
@@ -330,15 +577,17 @@ export function buildAppRouter(
330
577
  return <Unauthorized />;
331
578
  }
332
579
  return (
333
- <LayoutContractContext.Provider value={layoutContract}>
334
- <PageMarkerContext.Provider
335
- value={() => {
336
- marked.current = true;
337
- }}
338
- >
339
- <View />
340
- </PageMarkerContext.Provider>
341
- </LayoutContractContext.Provider>
580
+ <RouteSearchContext.Provider value={searchKeys}>
581
+ <LayoutContractContext.Provider value={layoutContract}>
582
+ <PageMarkerContext.Provider
583
+ value={() => {
584
+ marked.current = true;
585
+ }}
586
+ >
587
+ <View />
588
+ </PageMarkerContext.Provider>
589
+ </LayoutContractContext.Provider>
590
+ </RouteSearchContext.Provider>
342
591
  );
343
592
  }
344
593
  return createRoute({
@@ -350,14 +599,14 @@ export function buildAppRouter(
350
599
 
351
600
  const childRoutes: AnyRoute[] = manifests.flatMap((manifest) =>
352
601
  manifest.routes.map((route) =>
353
- guardedRoute(route.path, options.views[route.view]!, route.role, route.view),
602
+ guardedRoute(route.path, options.views[route.view]!, route, route.view),
354
603
  ),
355
604
  );
356
605
  const profileClaimed = manifests.some((manifest) =>
357
606
  manifest.routes.some((route) => route.path === PROFILE_PATH),
358
607
  );
359
608
  if (!profileClaimed) {
360
- childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, undefined, "profile"));
609
+ childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, {}, "profile"));
361
610
  }
362
611
 
363
612
  const routeTree = rootRoute.addChildren(childRoutes);
package/src/sso.test.tsx CHANGED
@@ -120,9 +120,12 @@ describe("SSO login (ADR 0058)", () => {
120
120
  </TerpProvider>,
121
121
  );
122
122
 
123
- await waitFor(() =>
124
- expect(screen.getByText("Single sign-on failed. Try again.")).toBeInTheDocument(),
125
- );
123
+ // Announced, not merely displayed. This is the one screen whose user is not signed in
124
+ // yet, so a failure they cannot see leaves them with no signal at all that the sign-in
125
+ // did not happen — the form simply sits there. ResourceList's error has carried
126
+ // role="alert" since it existed; this one carried nothing.
127
+ const failure = await screen.findByRole("alert");
128
+ expect(failure).toHaveTextContent("Single sign-on failed. Try again.");
126
129
  expect(window.location.pathname).toBe("/");
127
130
  });
128
131
  });