@terpjs/react-core 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Breadcrumbs.test.tsx +24 -0
  8. package/src/Breadcrumbs.tsx +9 -32
  9. package/src/ConfirmDialog.tsx +13 -44
  10. package/src/EmptyState.tsx +8 -36
  11. package/src/ErrorState.tsx +8 -36
  12. package/src/Field.test.tsx +57 -0
  13. package/src/Field.tsx +46 -22
  14. package/src/HubPage.test.tsx +22 -13
  15. package/src/HubPage.tsx +25 -97
  16. package/src/LoadingState.tsx +3 -24
  17. package/src/LoginView.tsx +22 -75
  18. package/src/ModuleNav.test.tsx +19 -0
  19. package/src/ModuleNav.tsx +10 -35
  20. package/src/Page.test.tsx +9 -6
  21. package/src/Page.tsx +15 -39
  22. package/src/PageActions.tsx +5 -10
  23. package/src/ProfileView.test.tsx +15 -0
  24. package/src/ProfileView.tsx +8 -33
  25. package/src/ResourceList.tsx +13 -24
  26. package/src/UserMenu.test.tsx +12 -5
  27. package/src/UserMenu.tsx +33 -62
  28. package/src/admin/AuditLogAdmin.tsx +1 -10
  29. package/src/admin/GroupCreate.tsx +1 -1
  30. package/src/admin/GroupDetail.tsx +2 -2
  31. package/src/admin/UserCreate.tsx +1 -1
  32. package/src/admin/admin.test.tsx +31 -0
  33. package/src/dataview/DataView.test.tsx +109 -5
  34. package/src/dataview/DataView.tsx +41 -23
  35. package/src/dataview/DataViewCardList.tsx +14 -60
  36. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  37. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  38. package/src/dataview/DataViewPagination.tsx +2 -32
  39. package/src/dataview/DataViewRowActions.tsx +13 -33
  40. package/src/dataview/DataViewTable.tsx +16 -103
  41. package/src/dataview/DataViewToolbar.tsx +53 -76
  42. package/src/dataview/README.md +6 -0
  43. package/src/dataview/index.ts +1 -0
  44. package/src/dataview/internal.tsx +4 -1
  45. package/src/dataview/types.ts +13 -0
  46. package/src/download.test.tsx +153 -0
  47. package/src/download.tsx +132 -0
  48. package/src/feedback.test.tsx +26 -0
  49. package/src/files.test.tsx +18 -0
  50. package/src/files.tsx +15 -15
  51. package/src/icons.test.tsx +10 -6
  52. package/src/icons.tsx +9 -37
  53. package/src/index.ts +7 -4
  54. package/src/layout.test.tsx +24 -9
  55. package/src/layout.tsx +24 -21
  56. package/src/layoutContract.test.tsx +95 -0
  57. package/src/locale.tsx +24 -4
  58. package/src/markers.test.ts +354 -25
  59. package/src/routeSearch.ts +73 -0
  60. package/src/routeTypes.ts +50 -6
  61. package/src/router.test.tsx +191 -1
  62. package/src/router.tsx +81 -18
  63. package/src/sso.test.tsx +6 -3
  64. package/src/ssr.test.tsx +1 -3
  65. package/src/styles.test.ts +855 -6
  66. package/src/styles.ts +3049 -153
  67. package/src/theme.tsx +24 -3
  68. package/src/toast.tsx +35 -71
  69. package/src/ui/Alert.test.tsx +12 -0
  70. package/src/ui/Alert.tsx +15 -43
  71. package/src/ui/Badge.test.tsx +14 -3
  72. package/src/ui/Badge.tsx +9 -28
  73. package/src/ui/Button.test.tsx +19 -4
  74. package/src/ui/Button.tsx +10 -63
  75. package/src/ui/Card.test.tsx +6 -2
  76. package/src/ui/Card.tsx +11 -39
  77. package/src/ui/Checkbox.tsx +2 -19
  78. package/src/ui/Combobox.test.tsx +22 -0
  79. package/src/ui/Combobox.tsx +31 -80
  80. package/src/ui/DatePicker.test.tsx +131 -4
  81. package/src/ui/DatePicker.tsx +158 -106
  82. package/src/ui/Input.tsx +6 -19
  83. package/src/ui/Markdown.test.tsx +26 -0
  84. package/src/ui/Markdown.tsx +28 -2
  85. package/src/ui/Menu.test.tsx +38 -4
  86. package/src/ui/Menu.tsx +50 -52
  87. package/src/ui/Popover.tsx +53 -19
  88. package/src/ui/Radio.tsx +5 -30
  89. package/src/ui/Select.tsx +7 -30
  90. package/src/ui/Switch.tsx +2 -20
  91. package/src/ui/Tabs.tsx +4 -28
  92. package/src/ui/Textarea.tsx +6 -17
  93. package/src/ui/Tooltip.tsx +9 -21
  94. package/src/ui/controlStyles.ts +0 -9
package/README.md CHANGED
@@ -63,6 +63,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
63
63
  | Export | Use |
64
64
  |---|---|
65
65
  | `Authorized`, `useCan` | Gate UI on `can(module, action)` — write buttons, admin panels. |
66
+ | `usePermissions`, `useHasPermission` | The caller's **named** grants from `GET /me` (ADR 0096), for a screen whose write needs `definitions.publish` rather than a rank. `Authorized` takes an optional `permission` alongside `action`; both must pass, as the server's own guard does. Display only — the backend re-checks. |
66
67
  | `canPerform`, `DEFAULT_RANK_THRESHOLDS` | The role-rank predicate behind the gate. |
67
68
  | `visibleNav` | Filter nav items to what the current user may see. |
68
69
 
@@ -85,6 +86,7 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
85
86
  | `useRouteParam` | Read one route param, fail closed: the declared param comes back as a string, an undeclared name throws a directive error instead of silently yielding `undefined`. Replaces the unchecked `useParams({ strict: false }) as {…}` cast (ADR 0092). Checked against the generated route table when the app has one. |
86
87
  | `useRouteParams` | Read a whole declared route's params, typed exactly: `const { recordId } = useRouteParams("/records/:recordId")`. With a generated route table, a typo in the path *or* a param name is a typecheck error. |
87
88
  | `useTerpNavigate` | Navigate by manifest path: `navigate({ to: "/records/:recordId", params: { recordId } })`. An undeclared path is a typecheck error and a parameterised route requires its params — a typo'd path used to be a dead link that shipped green. Takes the manifest's `:id` spelling and translates to the router's `$id`. |
89
+ | `useRouteSearch` | Read a declared route's query-string keys, typed: `const { status, page } = useRouteSearch("/records")` (ADR 0096). Every value is `string | undefined`, and an undeclared key is a typecheck error — so a filtered list screen stays inside the checked seam instead of reaching for the router's own `useSearch`. Declare them in the manifest: `search: ["status", "page"]`. |
88
90
  | `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
89
91
  | `PageActions` | Primary action + overflow menu for a page header. |
90
92
 
@@ -112,7 +114,8 @@ declare module "@terpjs/react-core" {
112
114
  ```
113
115
 
114
116
  From then on `useRouteParams("/records/:recordId")` is exact, `useRouteParam` refuses a
115
- param no route declares, and `useTerpNavigate` refuses an undeclared path. Regenerate
117
+ param no route declares, `useRouteSearch` is keyed to the route's declared query-string
118
+ keys, and `useTerpNavigate` refuses an undeclared path (or an undeclared `search` key). Regenerate
116
119
  after changing a manifest route — `terp verify`'s `routes-drift` check refuses a stale
117
120
  table and names the command (it runs before the typecheck, so a stale table reads as
118
121
  "regenerate", not as errors in your own screens). A route whose `path` is not a plain
@@ -158,6 +161,7 @@ marker, counted by the escape-hatch budget.
158
161
  | `ResourceList` | The standard simple CRUD list screen: titled section, write-gated create form, loading/error/empty states. Composable — screens needing more render their own React. |
159
162
  | `unwrap`, `unwrapOptional`, `ApiError` | Turn a generated-client result into data-or-throw; `ApiError` carries the envelope's `code` / `status` / `requestId`. `unwrapOptional` returns `null` on a 404 instead — for resources whose absence is a normal state (a `/latest` snapshot not yet published), the client-side analog of `BaseService.find` beside `get`. |
160
163
  | `FileUpload`, `useFileDownload` | The files-capability surface (ADR 0056/0057): a token-styled attachment picker that uploads through the typed client, and an authenticated download helper (a raw `<a href>` would carry no bearer token). |
164
+ | `useEndpointDownload`, `saveBlob` | Download an artifact the backend **generates** — an evidence bundle, a CSV export — which has no stored file id (ADR 0096). Goes through the session client, so it carries the base URL and bearer token and rejects a non-2xx instead of saving the error body under the intended filename. |
161
165
 
162
166
  ## Feedback & states
163
167
 
@@ -168,7 +172,7 @@ marker, counted by the escape-hatch budget.
168
172
  | `ErrorState`, `describeError` | Human-readable failure block for a caught error. |
169
173
  | `ErrorMessagesProvider`, `useErrorMessage`, `DEFAULT_ERROR_MESSAGES` | Map stable backend error codes to copy; falls back to the envelope `detail`. |
170
174
  | `ToastProvider`, `useToast` | Transient success/error feedback (no toast library). |
171
- | `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. |
175
+ | `ConfirmDialog` | Accessible confirmation modal (native `<dialog>`); use before any destructive action. A modal is for a confirmation or an explicit post-action moment — an edit form or a detail view belongs in a routed page, or in an expanded row beside the thing it edits (ADR 0096 §4). |
172
176
 
173
177
  ## Forms & primitives
174
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@tanstack/react-router": "^1.170.16",
16
- "@terpjs/contract": "^0.7.0"
16
+ "@terpjs/contract": "^0.9.0"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^18.3.0 || ^19.0.0",
@@ -21,9 +21,7 @@ function renderShell(extra?: Partial<Parameters<typeof AppShell>[0]>) {
21
21
  <AppShell
22
22
  title="Terp"
23
23
  nav={nav}
24
- renderLink={(item, children, context) => (
25
- <a href={item.to} style={context.style}>{children}</a>
26
- )}
24
+ renderLink={(item, children) => <a href={item.to}>{children}</a>}
27
25
  navFooter={<p>pinned footer</p>}
28
26
  {...extra}
29
27
  >
@@ -74,12 +72,18 @@ describe("AppShell", () => {
74
72
  expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
75
73
  expect(screen.getByText("U")).toBeInTheDocument(); // Users' fallback initial tile
76
74
  const navigation = screen.getByRole("navigation", { name: "Primary" });
77
- expect(navigation).toHaveAttribute("data-collapsed", "true");
75
+ // data-collapsed is on the SIDEBAR, not on the nav. One fact, one owner: the rail decides
76
+ // the sidebar's width, the brand's centring, the nav's scrollbar and both hidden labels, so
77
+ // every rule that reads it descends from the element that owns it.
78
+ const sidebar = navigation.closest('[data-terp="appshell-sidebar"]');
79
+ expect(sidebar).toHaveAttribute("data-collapsed", "true");
78
80
  expect(navigation.querySelectorAll('[data-terp="nav-icon"]')).toHaveLength(2);
79
- expect(screen.getByRole("link", { name: "Notes" })).toHaveStyle({
80
- justifyContent: "center",
81
- width: "100%",
82
- });
81
+ // The link's collapsed geometry is a rule now, keyed on that attribute — asserting
82
+ // toHaveStyle here asserted that the shell hands a style object to the caller's link
83
+ // renderer, which is the thing this migration removed. jsdom computes no cascade, so the
84
+ // fact is what a unit test can hold; the geometry is gated by styles.test.ts and by the
85
+ // app-shell-collapsed baseline.
86
+ expect(screen.getByRole("link", { name: "Notes" })).not.toHaveAttribute("style");
83
87
  expect(screen.getByText("rail")).toBeInTheDocument();
84
88
  expect(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)).toBe("collapsed");
85
89
  expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument();
@@ -89,13 +93,30 @@ describe("AppShell", () => {
89
93
  window.localStorage.setItem(SIDEBAR_STORAGE_KEY, "collapsed");
90
94
  renderShell();
91
95
  expect(screen.getByRole("link", { name: "Notes" })).toBeInTheDocument();
92
- expect(screen.getByRole("navigation", { name: "Primary" })).toHaveAttribute(
93
- "data-collapsed",
94
- "true",
95
- );
96
+ expect(
97
+ screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
98
+ ).toHaveAttribute("data-collapsed", "true");
96
99
  expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeInTheDocument();
97
100
  });
98
101
 
102
+ it("starts collapsed on defaultCollapsed, and a stored choice still wins", () => {
103
+ // The rail was internal state with no way in, which is why four rules that apply only to it
104
+ // were painted by nothing. Reading the key with `=== "collapsed"` also treated an absent key
105
+ // and an explicit "expanded" as the same thing, so the fallback had to become a null check.
106
+ renderShell({ defaultCollapsed: true });
107
+ expect(
108
+ screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
109
+ ).toHaveAttribute("data-collapsed", "true");
110
+ cleanup();
111
+
112
+ window.localStorage.setItem(SIDEBAR_STORAGE_KEY, "expanded");
113
+ renderShell({ defaultCollapsed: true });
114
+ expect(
115
+ screen.getByRole("navigation", { name: "Primary" }).closest('[data-terp="appshell-sidebar"]'),
116
+ "an explicit stored choice must beat defaultCollapsed, in both directions",
117
+ ).not.toHaveAttribute("data-collapsed");
118
+ });
119
+
99
120
  it("renders a custom logo and footer in their slots", () => {
100
121
  renderShell({ logo: <span>MyMark</span>, footer: <span>v1.2.3</span> });
101
122
  expect(screen.getByText("MyMark")).toBeInTheDocument();
package/src/AppShell.tsx CHANGED
@@ -1,12 +1,11 @@
1
1
  import type { NavItem } from "@terpjs/contract";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
- import type { CSSProperties, ReactNode } from "react";
3
+ import type { ReactNode } from "react";
4
4
 
5
5
  import { Icon, NavIcon, TerpMark } from "./icons";
6
6
  import { LanguageSwitcher } from "./locale";
7
7
  import { injectTerpStyles } from "./styles";
8
8
  import { ThemeToggle } from "./theme";
9
- import { CONTROL_TEXT_STYLE } from "./ui/controlStyles";
10
9
  import { useStrings, useUiText } from "./uiText";
11
10
  import type { UiText } from "./uiText";
12
11
 
@@ -17,16 +16,18 @@ export interface AppShellSlotContext {
17
16
  collapsed: boolean;
18
17
  }
19
18
 
20
- export interface AppShellLinkContext extends AppShellSlotContext {
21
- style: CSSProperties;
22
- activeStyle: CSSProperties;
23
- }
19
+ /**
20
+ * What a link renderer is told about the shell it is rendering into.
21
+ *
22
+ * It used to carry `style` and `activeStyle` for the caller to spread, which made the
23
+ * shell's link geometry a style object handed across a public boundary — unthemeable by
24
+ * an app, and duplicated by every stack. The sheet owns that geometry now, keyed on
25
+ * `[data-terp="appshell-nav"] a` and on `aria-current="page"` for the active route, so a
26
+ * renderer needs to return nothing but its stack's link (ADR 0094).
27
+ */
28
+ export type AppShellLinkContext = AppShellSlotContext;
24
29
 
25
- export type RenderBrandLink = (props: {
26
- to: string;
27
- children: ReactNode;
28
- style: CSSProperties;
29
- }) => ReactNode;
30
+ export type RenderBrandLink = (props: { to: string; children: ReactNode }) => ReactNode;
30
31
 
31
32
  export interface AppShellProps {
32
33
  /** Product / app title shown next to the logo at the top of the sidebar. */
@@ -35,10 +36,10 @@ export interface AppShellProps {
35
36
  nav: readonly NavItem[];
36
37
  /**
37
38
  * Turns a nav item into the active stack's link around the shell-styled
38
- * `children` (icon + label), keeping the shell router-agnostic. Spread
39
- * `context.style` (and `context.activeStyle` on the active route) onto the
40
- * link element the shell owns the expanded/collapsed link geometry, so
41
- * every stack's links look identical in both rail states.
39
+ * `children` (icon + label), keeping the shell router-agnostic. Return the
40
+ * stack's link and nothing else: the shell owns the expanded and collapsed
41
+ * link geometry from its stylesheet, and the active route's treatment is
42
+ * keyed on `aria-current="page"`, which every router sets.
42
43
  */
43
44
  renderLink: (item: NavItem, children: ReactNode, context: AppShellLinkContext) => ReactNode;
44
45
  /** Turns the product brand into the home link; defaults to a plain anchor to `/`. */
@@ -51,6 +52,16 @@ export interface AppShellProps {
51
52
  navFooter?: ReactNode | ((context: AppShellSlotContext) => ReactNode);
52
53
  /** Footer line under the content; default: a muted line with the app title. */
53
54
  footer?: ReactNode;
55
+ /**
56
+ * Start with the desktop sidebar collapsed to its icon rail, when no choice has been
57
+ * persisted yet. The user's own toggle still wins and still persists.
58
+ *
59
+ * It exists for the same reason `Menu` and both date pickers take `defaultOpen`: the
60
+ * rail is internal state read from `localStorage`, so without a way in it can be
61
+ * rendered by no specimen and no test, and every rule that only applies to it is
62
+ * unpainted. Four were.
63
+ */
64
+ defaultCollapsed?: boolean;
54
65
  /** The routed page content. */
55
66
  children: ReactNode;
56
67
  }
@@ -61,42 +72,6 @@ export const SIDEBAR_STORAGE_KEY = "terp.sidebar";
61
72
  /** Below this width the sidebar becomes an overlay drawer (matches DataView's card cutover). */
62
73
  const MOBILE_BREAKPOINT = "(max-width: 768px)";
63
74
 
64
- const EXPANDED_WIDTH = "15rem";
65
- const COLLAPSED_WIDTH = "4rem";
66
-
67
- /** Base style for sidebar links — spread onto the stack's link element. */
68
- export const NAV_LINK_STYLE: CSSProperties = {
69
- display: "flex",
70
- alignItems: "center",
71
- gap: "var(--space-2)",
72
- padding: "var(--space-2) var(--space-3)",
73
- borderRadius: "var(--radius-md)",
74
- color: "var(--color-neutral-700)",
75
- fontSize: "var(--font-size-sm)",
76
- fontWeight: "var(--font-weight-medium)" as CSSProperties["fontWeight"],
77
- textDecoration: "none",
78
- whiteSpace: "nowrap",
79
- overflow: "hidden",
80
- boxSizing: "border-box",
81
- minHeight: "2.25rem",
82
- transition: "background-color 150ms ease, color 150ms ease",
83
- };
84
-
85
- /** Collapsed rail geometry: one centered fixed-size icon inside the 2.5rem content track. */
86
- export const NAV_LINK_COLLAPSED_STYLE: CSSProperties = {
87
- justifyContent: "center",
88
- gap: 0,
89
- padding: "var(--space-2)",
90
- width: "100%",
91
- };
92
-
93
- /** Merged over {@link NAV_LINK_STYLE} on the active route's link. */
94
- export const NAV_LINK_ACTIVE_STYLE: CSSProperties = {
95
- background: "var(--color-brand-primary-soft)",
96
- color: "var(--color-fg-accent)",
97
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
98
- };
99
-
100
75
  function useIsMobile(): boolean {
101
76
  const [isMobile, setIsMobile] = useState(
102
77
  () =>
@@ -116,175 +91,27 @@ function useIsMobile(): boolean {
116
91
  return isMobile;
117
92
  }
118
93
 
119
- function readStoredCollapsed(): boolean {
94
+ /**
95
+ * The persisted rail choice, falling back to `defaultCollapsed` when nothing is stored.
96
+ *
97
+ * The null check is the whole point and it is new: reading `=== "collapsed"` treated an
98
+ * absent key and an explicit "expanded" as the same thing, so a `defaultCollapsed` shell
99
+ * could never start collapsed. A stored choice still wins in both directions.
100
+ */
101
+ function readStoredCollapsed(fallback: boolean): boolean {
120
102
  if (typeof window === "undefined") {
121
- return false;
103
+ return fallback;
122
104
  }
123
105
  try {
124
- return window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "collapsed";
106
+ const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
107
+ return stored === null ? fallback : stored === "collapsed";
125
108
  } catch {
126
- return false;
109
+ return fallback;
127
110
  }
128
111
  }
129
112
 
130
- const shellStyle: CSSProperties = {
131
- display: "flex",
132
- alignItems: "stretch",
133
- minHeight: "100vh",
134
- fontFamily: "var(--font-family-sans)",
135
- color: "var(--color-neutral-900)",
136
- background: "var(--color-neutral-50)",
137
- };
138
-
139
- const sidebarStyle: CSSProperties = {
140
- display: "flex",
141
- flexDirection: "column",
142
- gap: "var(--space-4)",
143
- padding: "var(--space-3)",
144
- boxSizing: "border-box",
145
- flexShrink: 0,
146
- position: "sticky",
147
- top: 0,
148
- height: "100vh",
149
- overflowX: "hidden",
150
- background: "var(--color-neutral-0)",
151
- borderRight: "1px solid var(--color-neutral-200)",
152
- transition: "width 150ms ease",
153
- };
154
-
155
- const drawerStyle: CSSProperties = {
156
- ...sidebarStyle,
157
- position: "fixed",
158
- inset: "0 auto 0 0",
159
- height: "100dvh",
160
- width: EXPANDED_WIDTH,
161
- zIndex: 50,
162
- boxShadow: "var(--shadow-lg)",
163
- };
164
-
165
- const backdropStyle: CSSProperties = {
166
- position: "fixed",
167
- inset: 0,
168
- zIndex: 40,
169
- background: "rgb(0 0 0 / 0.4)",
170
- };
171
-
172
- const brandStyle: CSSProperties = {
173
- display: "flex",
174
- alignItems: "center",
175
- gap: "var(--space-2)",
176
- padding: "var(--space-1) var(--space-2)",
177
- minHeight: "2.25rem",
178
- };
179
-
180
- const brandLinkStyle: CSSProperties = {
181
- ...brandStyle,
182
- color: "var(--color-neutral-900)",
183
- textDecoration: "none",
184
- borderRadius: "var(--radius-md)",
185
- boxSizing: "border-box",
186
- };
187
-
188
- const drawerBrandRowStyle: CSSProperties = {
189
- display: "flex",
190
- alignItems: "center",
191
- gap: "var(--space-2)",
192
- };
193
-
194
- const brandTitleStyle: CSSProperties = {
195
- overflow: "hidden",
196
- textOverflow: "ellipsis",
197
- whiteSpace: "nowrap",
198
- fontSize: "var(--font-size-base)",
199
- fontWeight: "var(--font-weight-semibold)" as CSSProperties["fontWeight"],
200
- color: "var(--color-neutral-900)",
201
- letterSpacing: 0,
202
- };
203
-
204
- const navItemLabelStyle: CSSProperties = {
205
- overflow: "hidden",
206
- textOverflow: "ellipsis",
207
- whiteSpace: "nowrap",
208
- };
209
-
210
- const visuallyHiddenStyle: CSSProperties = {
211
- position: "absolute",
212
- width: 1,
213
- height: 1,
214
- padding: 0,
215
- margin: -1,
216
- overflow: "hidden",
217
- clip: "rect(0 0 0 0)",
218
- whiteSpace: "nowrap",
219
- border: 0,
220
- };
221
-
222
- const navStyle: CSSProperties = { flexGrow: 1, overflowY: "auto", minHeight: 0 };
223
-
224
- const listStyle: CSSProperties = {
225
- listStyle: "none",
226
- margin: 0,
227
- padding: 0,
228
- display: "grid",
229
- gap: "var(--space-1)",
230
- };
231
-
232
- const columnStyle: CSSProperties = {
233
- display: "flex",
234
- flexDirection: "column",
235
- flexGrow: 1,
236
- minWidth: 0,
237
- };
238
-
239
- const headerStyle: CSSProperties = {
240
- position: "sticky",
241
- top: 0,
242
- zIndex: 30,
243
- display: "flex",
244
- flexWrap: "wrap",
245
- alignItems: "center",
246
- justifyContent: "space-between",
247
- gap: "var(--space-3)",
248
- padding: "var(--space-2) var(--space-4)",
249
- minHeight: "3rem",
250
- boxSizing: "border-box",
251
- background: "var(--color-neutral-0)",
252
- borderBottom: "1px solid var(--color-neutral-200)",
253
- };
254
-
255
- const headerGroupStyle: CSSProperties = {
256
- display: "flex",
257
- alignItems: "center",
258
- gap: "var(--space-2)",
259
- };
260
-
261
- const mainStyle: CSSProperties = { flexGrow: 1, padding: "var(--space-6)", minWidth: 0 };
262
- const mainMobileStyle: CSSProperties = { ...mainStyle, padding: "var(--space-4)" };
263
-
264
- const footerStyle: CSSProperties = {
265
- padding: "var(--space-3) var(--space-6)",
266
- borderTop: "1px solid var(--color-neutral-200)",
267
- color: "var(--color-neutral-500)",
268
- fontSize: "var(--font-size-xs)",
269
- };
270
-
271
- const toggleStyle: CSSProperties = {
272
- ...CONTROL_TEXT_STYLE,
273
- display: "inline-flex",
274
- alignItems: "center",
275
- justifyContent: "center",
276
- width: "2.25rem",
277
- height: "2.25rem",
278
- padding: 0,
279
- color: "var(--color-neutral-700)",
280
- background: "transparent",
281
- border: "1px solid transparent",
282
- borderRadius: "var(--radius-md)",
283
- cursor: "pointer",
284
- };
285
-
286
- const defaultRenderBrandLink: RenderBrandLink = ({ to, children, style }) => (
287
- <a href={to} data-terp="appshell-brand" style={style}>
113
+ const defaultRenderBrandLink: RenderBrandLink = ({ to, children }) => (
114
+ <a href={to} data-terp="appshell-brand">
288
115
  {children}
289
116
  </a>
290
117
  );
@@ -331,12 +158,13 @@ export function AppShell({
331
158
  headerActions,
332
159
  navFooter,
333
160
  footer,
161
+ defaultCollapsed = false,
334
162
  children,
335
163
  }: AppShellProps) {
336
164
  const resolve = useUiText();
337
165
  const strings = useStrings();
338
166
  const isMobile = useIsMobile();
339
- const [collapsed, setCollapsed] = useState(readStoredCollapsed);
167
+ const [collapsed, setCollapsed] = useState(() => readStoredCollapsed(defaultCollapsed));
340
168
  const [drawerOpen, setDrawerOpen] = useState(false);
341
169
  const drawerRef = useRef<HTMLElement>(null);
342
170
  const drawerCloseRef = useRef<HTMLButtonElement>(null);
@@ -399,24 +227,27 @@ export function AppShell({
399
227
  // The drawer always shows labels; the desktop rail hides them when collapsed.
400
228
  const railCollapsed = !isMobile && collapsed;
401
229
  const context: AppShellSlotContext = { collapsed: railCollapsed };
402
- const linkStyle = railCollapsed
403
- ? { ...NAV_LINK_STYLE, ...NAV_LINK_COLLAPSED_STYLE }
404
- : NAV_LINK_STYLE;
230
+ // Hoisted, the density-attribute idiom: the marker scanner reads a whole expression
231
+ // container, so a conditional written at the attribute reports every literal in it as a
232
+ // marker name.
233
+ const collapsedAttribute = railCollapsed ? "true" : undefined;
234
+ // One attribute for the viewport, on the shell root, and every consequence of it descends
235
+ // from there: the sidebar becomes a drawer, main tightens its padding. The breakpoint stays
236
+ // in one place — this component's media query — rather than being restated as a CSS
237
+ // @media rule that could drift from it.
238
+ const shellVariant = isMobile ? "mobile" : "desktop";
405
239
  const resolvedTitle = resolve(title);
406
240
 
241
+ // The brand takes no style object and needs none: its three looks are the resting one,
242
+ // the collapsed one (reached from the sidebar's data-collapsed) and the mobile one
243
+ // (reached from the drawer's brand row, which only exists on mobile). The DOM already
244
+ // says which it is.
407
245
  const brand = renderBrandLink({
408
246
  to: "/",
409
- style: railCollapsed
410
- ? { ...brandLinkStyle, justifyContent: "center", paddingInline: 0 }
411
- : isMobile
412
- ? { ...brandLinkStyle, flex: 1, minWidth: 0 }
413
- : brandLinkStyle,
414
247
  children: (
415
248
  <>
416
249
  {logo ?? <TerpMark />}
417
- <strong style={railCollapsed ? visuallyHiddenStyle : brandTitleStyle}>
418
- {resolvedTitle}
419
- </strong>
250
+ <strong data-terp="appshell-brand-title">{resolvedTitle}</strong>
420
251
  </>
421
252
  ),
422
253
  });
@@ -429,23 +260,19 @@ export function AppShell({
429
260
  aria-label={isMobile ? strings.primaryNavigationLabel : undefined}
430
261
  tabIndex={isMobile ? -1 : undefined}
431
262
  onKeyDown={isMobile ? onDrawerKeyDown : undefined}
432
- style={
433
- isMobile
434
- ? drawerStyle
435
- : { ...sidebarStyle, width: railCollapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH }
436
- }
263
+ data-terp="appshell-sidebar"
264
+ data-collapsed={collapsedAttribute}
437
265
  >
438
266
  {isMobile && (
439
267
  <span
440
268
  data-terp="drawer-focus-start"
441
269
  tabIndex={0}
442
- style={visuallyHiddenStyle}
443
270
  onFocus={() => focusDrawerEdge("last")}
444
271
  />
445
272
  )}
446
273
  {isMobile ? (
447
274
  <div
448
- style={drawerBrandRowStyle}
275
+ data-terp="appshell-brand-row"
449
276
  onClick={(event) => {
450
277
  if (event.target instanceof Element && event.target.closest("a") !== null) {
451
278
  closeDrawer();
@@ -458,7 +285,6 @@ export function AppShell({
458
285
  type="button"
459
286
  data-terp="iconbutton"
460
287
  aria-label={strings.closeNavigation}
461
- style={toggleStyle}
462
288
  onClick={closeDrawer}
463
289
  >
464
290
  <Icon name="x" size="1.15rem" />
@@ -466,24 +292,20 @@ export function AppShell({
466
292
  </div>
467
293
  ) : brand}
468
294
  <nav
469
- style={navStyle}
470
295
  data-terp="appshell-nav"
471
- data-collapsed={railCollapsed || undefined}
472
296
  aria-label={strings.primaryNavigationLabel}
473
297
  onClick={isMobile ? closeDrawer : undefined}
474
298
  >
475
- <ul style={listStyle}>
299
+ <ul data-terp="appshell-nav-list">
476
300
  {nav.map((item) => (
477
301
  <li key={item.to} title={railCollapsed ? item.label : undefined}>
478
302
  {renderLink(
479
303
  item,
480
304
  <>
481
305
  <NavIcon name={item.icon} label={item.label} />
482
- <span style={railCollapsed ? visuallyHiddenStyle : navItemLabelStyle}>
483
- {item.label}
484
- </span>
306
+ <span data-terp="appshell-nav-label">{item.label}</span>
485
307
  </>,
486
- { collapsed: railCollapsed, style: linkStyle, activeStyle: NAV_LINK_ACTIVE_STYLE },
308
+ { collapsed: railCollapsed },
487
309
  )}
488
310
  </li>
489
311
  ))}
@@ -494,7 +316,6 @@ export function AppShell({
494
316
  <span
495
317
  data-terp="drawer-focus-end"
496
318
  tabIndex={0}
497
- style={visuallyHiddenStyle}
498
319
  onFocus={() => focusDrawerEdge("first")}
499
320
  />
500
321
  )}
@@ -502,13 +323,13 @@ export function AppShell({
502
323
  );
503
324
 
504
325
  return (
505
- <div style={shellStyle}>
326
+ <div data-terp="appshell" data-variant={shellVariant}>
506
327
  {isMobile ? (
507
328
  drawerOpen && (
508
329
  <>
509
330
  {/* Click-away surface only: Escape and the labelled header toggle are the
510
331
  accessible close paths, so the backdrop stays out of the a11y tree. */}
511
- <div aria-hidden="true" style={backdropStyle} onClick={closeDrawer} />
332
+ <div aria-hidden="true" data-terp="appshell-backdrop" onClick={closeDrawer} />
512
333
  {sidebar}
513
334
  </>
514
335
  )
@@ -516,16 +337,15 @@ export function AppShell({
516
337
  sidebar
517
338
  )}
518
339
  <div
519
- style={columnStyle}
340
+ data-terp="appshell-column"
520
341
  inert={isMobile && drawerOpen ? true : undefined}
521
342
  aria-hidden={isMobile && drawerOpen ? true : undefined}
522
343
  >
523
- <header style={headerStyle}>
344
+ <header data-terp="appshell-header">
524
345
  <button
525
346
  ref={toggleRef}
526
347
  type="button"
527
348
  data-terp="iconbutton"
528
- style={toggleStyle}
529
349
  aria-expanded={isMobile ? drawerOpen : !collapsed}
530
350
  aria-label={
531
351
  isMobile
@@ -540,14 +360,14 @@ export function AppShell({
540
360
  >
541
361
  <PanelIcon />
542
362
  </button>
543
- <div style={headerGroupStyle}>
363
+ <div data-terp="appshell-header-group">
544
364
  {headerActions}
545
365
  <ThemeToggle variant="inline" />
546
366
  <LanguageSwitcher variant="inline" />
547
367
  </div>
548
368
  </header>
549
- <main style={isMobile ? mainMobileStyle : mainStyle}>{children}</main>
550
- <footer style={footerStyle}>{footer ?? <small>{resolvedTitle}</small>}</footer>
369
+ <main data-terp="appshell-main">{children}</main>
370
+ <footer data-terp="appshell-footer">{footer ?? <small>{resolvedTitle}</small>}</footer>
551
371
  </div>
552
372
  </div>
553
373
  );