@terpjs/react-core 0.6.1 → 0.8.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 (79) hide show
  1. package/README.md +12 -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/Breadcrumbs.test.tsx +24 -0
  6. package/src/Breadcrumbs.tsx +9 -32
  7. package/src/ConfirmDialog.tsx +13 -44
  8. package/src/EmptyState.tsx +8 -36
  9. package/src/ErrorState.tsx +8 -36
  10. package/src/Field.test.tsx +57 -0
  11. package/src/Field.tsx +46 -22
  12. package/src/HubPage.test.tsx +22 -13
  13. package/src/HubPage.tsx +25 -97
  14. package/src/LoadingState.tsx +3 -24
  15. package/src/ModuleNav.tsx +1 -1
  16. package/src/PageActions.tsx +5 -10
  17. package/src/UserMenu.test.tsx +12 -5
  18. package/src/UserMenu.tsx +33 -62
  19. package/src/dataview/DataView.test.tsx +109 -5
  20. package/src/dataview/DataView.tsx +41 -23
  21. package/src/dataview/DataViewCardList.tsx +14 -60
  22. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  23. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  24. package/src/dataview/DataViewPagination.tsx +2 -32
  25. package/src/dataview/DataViewRowActions.tsx +13 -33
  26. package/src/dataview/DataViewTable.tsx +16 -103
  27. package/src/dataview/DataViewToolbar.tsx +53 -76
  28. package/src/dataview/README.md +6 -0
  29. package/src/dataview/index.ts +1 -0
  30. package/src/dataview/internal.tsx +4 -1
  31. package/src/dataview/types.ts +13 -0
  32. package/src/feedback.test.tsx +26 -0
  33. package/src/files.test.tsx +18 -0
  34. package/src/files.tsx +13 -4
  35. package/src/icons.test.tsx +10 -6
  36. package/src/icons.tsx +33 -37
  37. package/src/index.ts +0 -3
  38. package/src/layout.test.tsx +24 -9
  39. package/src/layout.tsx +24 -21
  40. package/src/layoutContract.test.tsx +95 -0
  41. package/src/locale.tsx +27 -4
  42. package/src/markers.test.ts +468 -0
  43. package/src/raw.d.ts +15 -1
  44. package/src/router.tsx +6 -9
  45. package/src/ssr.test.tsx +1 -3
  46. package/src/styles.test.ts +823 -6
  47. package/src/styles.ts +2699 -153
  48. package/src/theme.test.tsx +39 -0
  49. package/src/theme.themes.test.ts +124 -0
  50. package/src/theme.tsx +62 -14
  51. package/src/toast.tsx +35 -71
  52. package/src/tokens.guard.test.ts +3 -12
  53. package/src/ui/Alert.test.tsx +12 -0
  54. package/src/ui/Alert.tsx +15 -43
  55. package/src/ui/Badge.test.tsx +14 -3
  56. package/src/ui/Badge.tsx +13 -25
  57. package/src/ui/Button.test.tsx +17 -4
  58. package/src/ui/Button.tsx +10 -63
  59. package/src/ui/Card.test.tsx +6 -2
  60. package/src/ui/Card.tsx +11 -39
  61. package/src/ui/Checkbox.tsx +2 -19
  62. package/src/ui/Combobox.test.tsx +22 -0
  63. package/src/ui/Combobox.tsx +31 -80
  64. package/src/ui/DatePicker.test.tsx +131 -4
  65. package/src/ui/DatePicker.tsx +158 -106
  66. package/src/ui/Input.tsx +6 -19
  67. package/src/ui/Markdown.test.tsx +26 -0
  68. package/src/ui/Markdown.tsx +28 -2
  69. package/src/ui/Menu.test.tsx +38 -4
  70. package/src/ui/Menu.tsx +50 -52
  71. package/src/ui/Popover.tsx +53 -19
  72. package/src/ui/Radio.tsx +5 -30
  73. package/src/ui/Select.tsx +7 -30
  74. package/src/ui/Switch.tsx +2 -20
  75. package/src/ui/Tabs.tsx +4 -28
  76. package/src/ui/Textarea.tsx +6 -17
  77. package/src/ui/Tooltip.tsx +9 -21
  78. package/src/uiText.tsx +9 -0
  79. package/src/ui/controlStyles.ts +0 -9
package/README.md CHANGED
@@ -20,7 +20,17 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
20
20
  or `useRealtimeChannel()` for typed SSE/WebSocket subscriptions. The hook mints a
21
21
  one-use connection ticket through the generated client; bearer tokens never enter URLs.
22
22
  - **Design tokens, not inline colours** — style with the CSS variables from
23
- `@terpjs/contract` (`var(--color-*)`, `var(--space-*)`, `var(--font-*)`).
23
+ `@terpjs/contract` (`var(--color-*)`, `var(--space-*)`, `var(--font-*)`). The full list,
24
+ with each token's per-theme values and the foreground/background pairings the contrast gate
25
+ enforces, is published as `@terpjs/contract/tokens.manifest.json` — read it rather than
26
+ inferring names from the compiled sheet.
27
+ - **The accent is two tokens, and mixing them up is a contrast bug.**
28
+ `--color-brand-primary` is the accent as a *filled surface*, and the only thing that may sit
29
+ on it is `--color-brand-primary-contrast`. `--color-fg-accent` is the accent as *ink or a
30
+ boundary* against one of the app's own surfaces — accent text, a selected-tab underline, a
31
+ focus ring, a checkbox's `accent-color`. One token cannot do both: in a dark theme the
32
+ surface use needs a value dark enough to hold a white label and the ink use needs one light
33
+ enough to read on a dark canvas, and there is no value satisfying both.
24
34
  - **User-facing text is `UiText`** — every text prop accepts a plain string or an
25
35
  `{id, message}` descriptor, so apps can localize via `UiTextProvider` without
26
36
  react-core taking an i18n dependency.
@@ -43,7 +53,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
43
53
  | `LoginView` | The standard sign-in screen: username/password, plus optional SSO provider buttons via `ssoProviders` and a dev-only credential-fill button via `devCredentials` (gate it on `import.meta.env.DEV`). |
44
54
  | `useSso`, `parseSsoCallback`, `fetchSsoAuthorizationUrl`, `completeSsoCallback` | The SSO login seam (ADR 0058): `useSso().begin(provider)` opens an OIDC flow; `TerpProvider` completes the `/auth/callback/{provider}` redirect landing into a normal session on boot. `renderTerpApp({ ssoProviders })` wires the buttons in one line. |
45
55
  | `RequireAuth` | Renders children only with a session; pairs with the router so the app mounts only when signed in. |
46
- | `ThemeProvider`, `ThemeToggle`, `useTheme` | Light/dark/system theming: applies `data-theme` on `<html>` (the token stylesheet carries both palettes) and persists the choice. `renderTerpApp` mounts it for every app; the shell header uses an icon-only, token-themed `variant="inline"` menu. |
56
+ | `ThemeProvider`, `ThemeToggle`, `useTheme` | Theming over the five shipped palettes — `light`, `dark`, `midnight`, `twilight`, `contrast` — plus `system` to follow the OS preference. Applies `data-theme` on `<html>` (the token stylesheet carries every palette) and persists the choice. `defaultTheme` is how an app ships on a named theme. `renderTerpApp` mounts it for every app; the shell header uses an icon-only, token-themed `variant="inline"` menu. |
47
57
  | `LocaleProvider`, `LanguageSwitcher`, `useLocale`, `LOCALE_EN`, `LOCALE_NL` | The language seam over `UiTextProvider`: per-locale string catalogs, a persisted active locale, and an icon-only, token-themed menu in the shell header once an app declares a second locale. English and Dutch catalogs ship complete; `renderTerpApp({ locales })` wires them. |
48
58
  | `UserMenu`, `userInitials` | The signed-in user's menu, pinned by `buildAppRouter` to the bottom of the sidebar: an initials avatar trigger opening the identity block, **Settings** (the built-in profile page) and sign-out. Collapses to the avatar in the icon rail. |
49
59
  | `ProfileView` | The built-in profile / settings page (`/profile`): the server-validated identity, theme + language preferences, and sign-out. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.6.1",
3
+ "version": "0.8.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.6.1"
16
+ "@terpjs/contract": "^0.8.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-brand-primary)",
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
  );
@@ -42,4 +42,28 @@ describe("Breadcrumbs", () => {
42
42
  expect(screen.queryByRole("link")).not.toBeInTheDocument();
43
43
  expect(screen.getByText("Section")).not.toHaveAttribute("aria-current");
44
44
  });
45
+
46
+ it("marks only the final crumb as current, on a marker of its own", () => {
47
+ // The current-crumb styling deliberately does NOT key on aria-current. A router's Link
48
+ // stamps aria-current="page" on every link whose path is a prefix of the current one —
49
+ // which every ancestor crumb is — so borrowing that attribute painted the whole trail as
50
+ // the current page. The marker says what this component means, not what a router infers.
51
+ const { container } = render(
52
+ <Breadcrumbs
53
+ items={[{ label: "Tasks", to: "/tasks" }, { label: "Open", to: "/tasks/open" }, { label: "Here" }]}
54
+ renderLink={(item) => (
55
+ <a href={item.to} aria-current="page">
56
+ {item.label}
57
+ </a>
58
+ )}
59
+ />,
60
+ );
61
+
62
+ const current = container.querySelectorAll('[data-terp="breadcrumbs-current"]');
63
+ expect(current).toHaveLength(1);
64
+ expect(current[0]).toHaveTextContent("Here");
65
+ // Both ancestors claim aria-current here, which is exactly the router behaviour that
66
+ // made the old selector wrong — and none of them may pick up the current styling.
67
+ expect(container.querySelectorAll('[aria-current="page"]')).toHaveLength(3);
68
+ });
45
69
  });