@terpjs/react-core 0.9.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 (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. package/src/unwrap.ts +118 -32
package/README.md CHANGED
@@ -53,7 +53,7 @@ JSDoc, so your editor shows the same guidance inline. **Never deep-import** from
53
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`). |
54
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. |
55
55
  | `RequireAuth` | Renders children only with a session; pairs with the router so the app mounts only when signed in. |
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. |
56
+ | `ThemeProvider`, `ThemeToggle`, `useTheme` | Theming over the 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 — declare it in `layout-contract.json` so a tool can read and rewrite it, or pass the bootstrap option; both is refused. `renderTerpApp` mounts it for every app; the shell header uses an icon-only, token-themed `variant="inline"` menu. |
57
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. |
58
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. |
59
59
  | `ProfileView` | The built-in profile / settings page (`/profile`): the server-validated identity, theme + language preferences, and sign-out. |
@@ -76,11 +76,25 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
76
76
  | Export | Use |
77
77
  |---|---|
78
78
  | `AppShell` | The responsive level-1 frame: a home-linked brand, icon/label nav and account footer. Desktop collapses to a persisted, scrollbar-free rail with one fixed icon slot; mobile becomes a scroll-locking drawer. The sticky header holds the sidebar toggle and icon-only preferences. Router-agnostic link renderers receive framework-owned expanded/collapsed geometry. |
79
- | `NavIcon`, `Icon`, `TerpMark`, `ICON_GLYPHS` | The dependency-free icon layer: manifest `NavItem.icon` names resolve to bundled inline-SVG glyphs (label-initial fallback), `Icon` renders any glyph by name (`<Icon name="plus" size="1em" />`) — the bundled catalogue covers common UI, action, object, and status glyphs (home, list, folder, users, plus, edit, trash, search, check, x, chevron-{left,right,down}, arrow-left, external, logout, user, bell, key, globe, lock, tag, mail, refresh, filter, download, upload, star, heart, database, code, truck, cart, wallet, map-pin, clock, link, grid, book, briefcase, building, clipboard, layers, send, phone, image, video, music, wrench, zap, …) — and `TerpMark` is the placeholder brand mark until an app passes its own `logo`. |
79
+ | Shell geometry (tokens, not props) | `--shell-sidebar-width-expanded` / `-collapsed`, `--shell-header-height`, `--shell-content-max-width` and `--shell-brand-size` are published contract tokens: an app moves them from its own unlayered `theme.css` with no prop at all, and the Studio gets them from the manifest. |
80
+ | `logo` / `logoDark` (shell) | The brand mark, rendered in a box of `--shell-brand-size` so an asset larger than the 4rem icon rail is scaled rather than clipped — which is why there is no separate collapsed-mark slot: `logo` is the mark, `title` is the wordmark, and the rail already hides the second. `logoDark` is for a fixed-colour mark that cannot survive a dark theme; both render and the stylesheet shows one, switched on `--appearance-show-light` / `--appearance-show-dark`, which the token build emits from each theme's declared `appearance`. Resolving it in React would be wrong: the theme is `<html data-theme>` and an app may set it with no provider mounted. |
81
+ | `density` (shell) | App-wide `"comfortable"` or `"compact"`, stamped on the shell root; every control height and cell padding follows by token inheritance. **No default** — omitting it stamps nothing, so an app's own `data-density` on `<html>` still wins. A subtree may override it: a `DataView density="comfortable"` inside a compact shell really is comfortable, because comfortable now has named tokens and a rule rather than being the absence of an attribute. |
82
+ | Skip to content | The shell renders the skip link and owns the landmarks it skips: `main` carries `tabIndex={-1}` and a per-instance id, so activating the link *moves focus* rather than only scrolling and two shells on one page get two distinct targets. It is not rendered while the mobile drawer is open — the drawer is `aria-modal` and its target sits inside the `inert` column. |
83
+ | `defaultDrawerOpen` (shell) | Opens the mobile drawer on mount. A dev/specimen affordance — an app opening it on load shows every mobile user a menu they did not ask for — and the only way to render the drawer's own geometry and backdrop at all, since below the breakpoint the sidebar exists only while the drawer is open. |
84
+ | `headerActions` (shell) | Extra header content before the theme and language controls. Now forwarded by `renderTerpApp` and `buildAppRouter`; it was previously reachable only by dropping to `TerpProvider` + `buildAppRouter` by hand. |
85
+ | `permission` (nav + route) | A named grant from `CurrentUser.permissions`, **ANDed** with `role` — the same composition `Authorized` ships, because a server `Policy` carrying a `Permission` enforces the permission's role floor *and* the grant. Declared on both `NavItem` and `ModuleRoute`, so a hidden link never leaves a reachable route. Fails closed three ways: signed out, unknown name, and an app with no grant capability (empty list). Not a combinator — the server's own `AuthzRef` is one ref per read and one per write, so an any-of could express a gate no `Policy` can declare. |
86
+ | `activeNavPath` / `isNavItemActive` | Which nav item is current, as one predicate. Longest **segment-aligned** match wins across the set, because "at most one is current" cannot be decided one link at a time — a router marks every prefix-active link, so `/settings` and `/settings/users` are both current at `/settings/users`. `NavItem.exact` narrows a single item to itself. Search and hash are ignored: a tab's identity is its path, so filtering a list must not unhighlight it. |
87
+ | `groupNav` / `NavGroup` | The app declares named sections (`{ id, label, order }`); modules point items at them with `NavItem.group`, and `NavItem.order` sorts within one. A group spans modules, so no module can own its label or position — which is why this is the one part of the navigation model that is not on a module manifest. Additive: declare none and the sidebar is the flat list it is today. Absent `order` is 0 and the sort is stable (CSS `order` semantics), an item naming an undeclared group falls **open** into the ungrouped bucket rather than vanishing, a group left empty by `role`/`permission` filtering renders nothing at all, and the ungrouped bucket is emitted **last** so the packaged `/admin` entry stays where it already is. A `label: null` group renders no heading — pure positioning. Declare the groups in `layout-contract.json` under `shell.navGroups` (where `label: ""` is how a file spells `null`) so a tool can read and rewrite them, or pass the bootstrap option; both is refused. |
88
+ | `navPlacement` (shell) | Where the primary navigation lives on desktop: `"sidebar"` (default, stamps nothing) or `"header"` — a horizontal row in the header with no sidebar at all, for an app whose destinations are few enough that 15rem of permanent chrome is a tax. The header then *becomes* the sidebar surface, so every `--color-sidebar-*` an app themes carries over and no property is overridden. Below the mobile breakpoint both placements are the drawer. `defaultCollapsed` is `never` under `"header"`: with no sidebar there is nothing to collapse. Available on `AppShell`, `buildAppRouter` and `renderTerpApp`. |
89
+ | `contentWidth` (shell) | `"measured"` caps routed content at `--shell-content-max-width` while each page's own header keeps the full track — the subheader band. Default `"full"` stamps no attribute, so nothing moves until an app asks. Available on `AppShell`, `buildAppRouter` and `renderTerpApp`. |
90
+ | `NavIcon`, `Icon`, `TerpMark`, `ICON_GLYPHS`, `ICON_NAMES` | The dependency-free icon layer. `ICON_NAMES` is published by `@terpjs/contract` as data and `IconName` is derived from it, so `NavItem.icon` and `Icon`'s `name` are **checked names** — a typo is a typecheck error, not a picture of nothing. `NavIcon`'s `name` stays a plain `string` on purpose: its unknown-name behaviour is a designed, visible fallback (the label's initial in a tile), while `Icon` rendered an empty box. The glyph table is held to the name set exhaustively in both directions by `satisfies`, so a glyph without a name or a name without a glyph fails to compile. The bundled catalogue covers common UI, action, object and status glyphs (home, list, folder, users, plus, edit, trash, search, check, x, chevron-{left,right,down}, arrow-left, external, logout, user, bell, key, globe, lock, tag, mail, refresh, filter, download, upload, star, heart, database, code, truck, cart, wallet, map-pin, clock, link, grid, book, briefcase, building, clipboard, layers, send, phone, image, video, music, wrench, zap, …). `TerpMark` is the placeholder brand mark until an app passes its own `logo`. |
80
91
  | `Page` | The base routed screen: optional breadcrumb row, then one compact `h1` + intrinsic-width actions row (title-first on narrow layouts), then the body with loading/error slots. |
81
92
  | `HubPage`, `HubCard` | Responsive `auto-fit` landing grid. Cards share equal outer and internal tracks even when descriptions/stats differ; nested hubs use the ordinary breadcrumb contract via `parents`. |
82
93
  | `OverviewPage` | A module's top-level listing screen (level 2); detail pages crumb back to it. |
83
94
  | `DetailPage` | One record's screen (level 3); breadcrumb trail = ancestors + record title. |
95
+ | `FormPage` | A create-or-edit screen. `measure="narrow"` by default, so the whole frame — header, actions and all — caps at 32rem over a single column of controls. Its body slot takes the form container (`Stack as="form"`), plus `Grid` / `Card` / `Divider` / `Text`; a bare `Field` at the top level is refused, because a run of fields has no `<form>` and cannot be submitted. |
96
+ | `SettingsPage` | Preferences and account screens: `Card` sections, also `measure="narrow"`. No `DataView`, `DetailList` or `Tabs` — a settings screen whose body is a collection is an overview with the wrong chrome. `parents` is optional, unlike `FormPage`'s. |
97
+ | `SplitPage`, `SplitPane` | A list beside the record it selects. The archetype owns the pane row and admits **only** `SplitPane` in it (`HubPage`'s shape, not `DetailPage`'s), so the panes are the governed thing. `listWidth` is a step — `sm` / `md` / `lg` — not a length; a draggable divider waits for the preference seam. Each pane is a named `<section>`, and below the mobile breakpoint they stack list-first, so the reading order and the tab order agree at both widths. |
84
98
  | `Breadcrumbs` | The trail itself (used by the archetypes; rarely composed directly). Ancestor crumbs use the router's `Link` by default — `renderLink` is only for rendering outside a Terp router. |
85
99
  | `NavLinkContext`, `useNavLink` | The ambient link renderer `buildAppRouter` publishes (and the layout components default to); provide it yourself in a standalone story/test tree or a bespoke shell. |
86
100
  | `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. |
@@ -127,17 +141,28 @@ a generated file every helper falls back to `string`, so adopting is opt-in: add
127
141
 
128
142
  ### Slot-typed layout contracts (opt-in, ADR 0079)
129
143
 
130
- An app can ratchet the archetype control further with a named **layout contract**:
131
- `renderTerpApp({ layoutContract: "standard" })` (runtime half) plus a checked-in
132
- `layout-contract.json` next to the frontend sources (`{ "contract": "standard" }`, the
133
- `terp/layout-contract` lint half keep the two in sync; the project template generates
134
- both). Each governed archetype's body slot then accepts **only** the contract's
144
+ An app can ratchet the archetype control further with a named **layout contract**,
145
+ declared once in a checked-in `layout-contract.json` next to the frontend sources
146
+ (`{ "contract": "standard" }`) and read by both halves: the `terp/layout-contract` lint
147
+ rule finds the file on disk, and `main.tsx` imports it
148
+ `renderTerpApp({ layout })`. The same file carries the palette the app opens on
149
+ (`defaultTheme`) and the shell's own shape under `shell` (`density`, `navPlacement`,
150
+ `contentWidth`, the `navGroups` a module's `NavItem.group` names by id, and the `brand` marks
151
+ as paths), which is what lets a tool read and rewrite those choices instead of them living
152
+ only in TypeScript. Declaring a key in the file AND
153
+ passing the matching bootstrap option is refused when the router is composed, with both
154
+ sources named; `layoutContract: "standard"` on its own still works for an app that would
155
+ rather write code than check in a file. Each governed archetype's body slot then accepts **only** the contract's
135
156
  components — `standard`: hub bodies hold `HubCard` only; overview bodies hold
136
- `DataView` / `ResourceList` / `ModuleNav` / `Stack` / `Card` plus the framework states
137
- (`EmptyState` / `ErrorState` / `LoadingState` / `Alert`) and `ConfirmDialog`; detail
138
- bodies hold `DetailList` / `Stack` / `Tabs` / `ModuleNav` / `DataView` / `Card` plus
139
- the same states. The plain `Page` stays unconstrained (the sanctioned home for a
140
- bespoke screen). Only the slot's **direct** children are governed an allowed
157
+ `DataView` / `ResourceList` / `ModuleNav` / `Stack` / `Card` / `Divider` / `Text` plus
158
+ the framework states (`EmptyState` / `ErrorState` / `LoadingState` / `Alert`) and
159
+ `ConfirmDialog`; detail bodies hold `DetailList` / `Stack` / `Tabs` / `ModuleNav` /
160
+ `DataView` / `Card` / `Grid` / `Divider` / `Text` plus the same states. The asymmetry is
161
+ deliberate and pinned by tests rather than left to the table: `Grid` joins **detail**
162
+ bodies only (an overview body is a collection, and a grid of summary cards is a hub,
163
+ which has its own archetype), while `Heading` joins **neither** — a heading in a governed
164
+ body must own its section, and `Card` is how a section is owned. The plain `Page` stays
165
+ unconstrained (the sanctioned home for a bespoke screen). Only the slot's **direct** children are governed — an allowed
141
166
  container's own subtree (a `Card` body, a `Stack` of rows) is the app's to compose.
142
167
  Enforcement is two-layer and fail-closed: the lint rule checks static JSX
143
168
  children; the archetypes verify the rendered DOM (sanctioned components stamp a
@@ -145,7 +170,9 @@ children; the archetypes verify the rendered DOM (sanctioned components stamp a
145
170
  message** — contract, slot, what was found, what is allowed, and the fix — so a
146
171
  failing check tells the author (human or agent) exactly how to build the screen.
147
172
  `LAYOUT_CONTRACTS` exports the table; no config means no checks (fully backwards
148
- compatible). The one opt-out is a justified `// terp-allow-layout-contract: <reason>`
173
+ compatible). Every exported archetype must appear in it or name its reason for not
174
+ appearing — an archetype missing from the table is silently ungoverned by both halves,
175
+ which used to be a green build. The one opt-out is a justified `// terp-allow-layout-contract: <reason>`
149
176
  marker, counted by the escape-hatch budget.
150
177
 
151
178
  ## Data
@@ -159,7 +186,8 @@ marker, counted by the escape-hatch budget.
159
186
  | `useRecord` | The singleton counterpart of `useResource` — the one record a detail screen shows: `item` (or `null`) + loading/error + reload + mutate. Deletes the one-element-list wart (`list: async () => [unwrap(…)]` then `items[0]`). |
160
187
  | `useRealtimeChannel` | The sanctioned typed SSE/WebSocket seam for the optional realtime capability: mints a short-lived one-use ticket via the authenticated generated client, validates every inbound JSON payload with the channel's runtime type guard, and exposes connection state / last message / WebSocket send. App modules never touch raw transports. |
161
188
  | `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. |
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`. |
189
+ | `unwrap`, `unwrapOptional`, `ApiError` | Turn a generated-client result into data-or-throw; `ApiError` carries the envelope's `code` / `status` / `requestId`, plus `fields` — the per-field reasons of a 422, keyed by dotted path, ready to hand to `Field`'s `error` prop (`{}` when the failure names no field). `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`. |
190
+ | `useFormatDate`, `useFormatDateTime`, `useFormatNumber` (and locale-explicit `formatDate` / `formatDateTime` / `formatNumber`) | Render a date or a number in the **app's** locale. `toLocaleDateString()` with no argument asks the browser, so an app shipping one language renders its own tables in whatever the visitor's OS is set to. The hooks read `LocaleProvider` and are `useCallback`-stable, so a column list built in a `useMemo` can depend on one. An absent or unparseable value renders as an em dash rather than throwing. |
163
191
  | `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
192
  | `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. |
165
193
 
@@ -178,20 +206,22 @@ marker, counted by the escape-hatch budget.
178
206
 
179
207
  | Export | Use |
180
208
  |---|---|
181
- | `Button` | Token-styled, content-sized command (`variant`: primary / secondary / danger / ghost; optional leading `icon`). Explicit `width: 100%` opts into full width. |
182
- | `Input`, `Select`, `Textarea` | Token-styled controls with stable framework typography, independent of surrounding display text (raw elements are lint-refused). Numeric inputs suppress unthemeable browser steppers. |
209
+ | `Button` | Token-styled command: `variant` (primary / secondary / danger / ghost), `size` (sm / md / lg, composing with density), `loading` (spinner in the icon slot, `aria-busy`, and disabled so a second click cannot start the request twice), `fullWidth` to fill the container instead of the label, and an optional leading `icon`. Content-sized by default. |
210
+ | `Input`, `Select`, `Textarea` | Token-styled controls with stable framework typography, independent of surrounding display text (raw elements are lint-refused). Numeric inputs suppress unthemeable browser steppers. `Input type="password"` grows a reveal toggle — the type decides, so there is no second export and no prop; an app could not add one itself, because the toggle needs a positioned wrapper and module files may use neither `style` nor `className`. |
211
+ | `Select` options (`SelectOption<T>`) | Two forms. Pass `options={[{ value, label, disabled? }]}` with `onValueChange` when the choices are data — `T` is inferred from the list, so a closed enum is checked at both ends and the `event.target.value as Status` cast goes away; `placeholder` renders the disabled empty-valued leading row. Or pass `<option>` children as before. The two are mutually exclusive at the type level, so neither can silently ignore the other. |
183
212
  | `Combobox` | Accessible autocomplete/typeahead single-select: filterable options, controlled or uncontrolled value, loading state, disabled state, and ARIA combobox/listbox keyboard navigation. |
184
213
  | `DatePicker`, `DateRangePicker` | Locale-aware calendar popover controls with keyboard-navigable month grids, min/max bounds, and range selection for ERP date filters. |
185
214
  | `Checkbox` | Labelled checkbox with `checked` / `defaultChecked` and boolean `onChange`. |
186
215
  | `Radio`, `RadioGroup` | Labelled radio and accessible grouped radio options with controlled or uncontrolled value. |
187
216
  | `Switch` | Labelled boolean toggle (`role="switch"`) with `checked` / `defaultChecked` and boolean `onChange`. |
188
217
  | `Tabs` | In-page (non-routed) tab set with `tablist` / `tab` / `tabpanel` roles, arrow-key navigation, and controlled or uncontrolled value. |
218
+ | `Avatar` | The initials tile: `from` (an email or a name) or explicit `initials`, and a closed `size` of `sm` (2rem, an account menu) or `md` (3.5rem, a profile header). `aria-hidden`, because the name it abbreviates is always rendered beside it. No `src`: `/me` carries no avatar URL, so an image slot would be a prop with nothing behind it. |
189
219
  | `Badge` | Small status pill: `<Badge tone="success">Synced</Badge>` (or `label="Synced"`); `tone`: neutral / info / success / warning / danger. |
190
220
  | `Tooltip` | Accessible focus/hover tooltip that describes its trigger with `aria-describedby`. |
191
221
  | `Popover`, `Menu`, `MenuItem` | Shared anchored overlay and dropdown-menu primitives: body-portaled, viewport-aware panels that escape scroll/table clipping, with outside-click/Escape close, focus return, selected-item semantics, and roving keyboard navigation. |
192
222
  | `Alert` | Inline banner for persistent feedback (`tone`: neutral / info / success / warning / danger); warnings and danger announce as `alert`, others as `status`. |
193
223
  | `Markdown` | Safe, dependency-free markdown renderer for headings, paragraphs, bold, italic, inline code, code blocks, lists, and safe links; raw HTML is rendered as text and never passed through. |
194
- | `Field` | Label + control + hint/error wrapper for one form field. |
224
+ | `Field` | Label + control + hint/error wrapper for one form field. The error is announced (`role="alert"`) as well as described, because `aria-describedby` alone is silent for a rejection that arrives after focus has left the control. |
195
225
 
196
226
  ## Layout
197
227
 
@@ -201,9 +231,15 @@ token spacing scale, so spacing is themed centrally):
201
231
 
202
232
  | Export | Use |
203
233
  |---|---|
204
- | `Stack` | The layout primitive: a flex container with a token gap. Vertical by default (forms, sections); `direction="row"` + `justify` for toolbars; `as="form"` etc. for semantics. |
205
- | `Card` | A token-styled surface (border + background + padding) grouping one block of a page the sanctioned visual separation between sections. Optional header row: `title` (semantic `<h3>`), muted `description`, `actions` slot. |
206
- | `DetailList` | Token-styled label/value pairs as a semantic `<dl>` (record metadata, expanded-row summaries). |
234
+ | `Stack` | The layout primitive: a flex container with a token gap. Vertical by default (forms, sections); `direction="row"` + `justify` for toolbars; `as="form"` etc. for semantics. `padding` insets on the same token scale; `direction` and `gap` also take a `{ narrow, wide }` pair, which changes over at the one viewport cutover the shell and the DataView already use. |
235
+ | `Grid` | The two-dimensional primitive, and the one that lifts a real ceiling a two-column form could not be expressed at all before it. `columns` takes a fixed 1–4 or `"auto"` (the default), which reflows to whatever the **container** can hold with no breakpoint anywhere; `minColumn` is the track floor for `auto`; `gap` indexes the spacing scale; `align` is a closed four. Renders no inline style. No `span`, and therefore no twelve-column option — a span system needs a child component to carry it. |
236
+ | `Card` | A token-styled surface (border + background + padding) grouping one block of a page — the sanctioned visual separation between sections. Optional header row: `title` (semantic `<h3>`), muted `description`, `actions` slot. `variant="plain"` keeps the heading and drops the box, for a titled region inside something that is already a surface — a section whose body is a `DataView` gets a border inside a border otherwise. There is no separate `Section` or `Surface`: both are this element with declarations removed. |
237
+ | `Divider` | A rule between groups, as a semantic `<hr>` so the separation reaches the accessibility tree — `Separator` under its other name, shipped once. `orientation="vertical"` takes its height from its flex or grid line rather than inventing one, so it works between the items of a row `Stack` and is zero-height in a block parent. |
238
+ | `DetailList` | Token-styled label/value pairs as a semantic `<dl>` (record metadata, expanded-row summaries). A real grid: `layout="aligned"` puts every label in a shared column so the values line up, `"stacked"` puts the label above its value, and `columns` takes two pairs per row. Tracks are floored at zero and long values wrap, so a 64-character digest no longer pushes the list past its container. |
239
+ | `Heading` | A section heading inside a page body — `h2`–`h4`, with `size` a **separate** choice from `level` so a visually small `h2` is expressible without picking the wrong element. No level 1: `Page` renders the single `h1` of every routed view. |
240
+ | `Text` | Body copy with themeable ink — `tone` (default / muted / subtle), `size`, and an enumerable `measure` that caps the line length in `ch`. What a bare `<p>` in a module cannot be, since a bare element carries no marker for a rule to reach. |
241
+ | `Code` | An identifier or a snippet in the mono family. `block` wraps it in a focusable `<pre>`, which is what preserves the whitespace and what makes a long line scrollable by keyboard. |
242
+ | `Link` | A link with themeable ink, routing in-app paths through the surrounding router and degrading to a plain anchor outside one. An external `newTab` gets `rel="noreferrer"`. The boundary lint refuses a raw in-app `<a href="/…">`; this is the thing to use instead. |
207
243
 
208
244
  ## The packaged admin area
209
245
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.9.0",
3
+ "version": "0.10.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": {
7
- ".": "./src/index.ts"
7
+ ".": "./src/index.ts",
8
+ "./layout.manifest.json": "./src/layout.manifest.json"
8
9
  },
9
10
  "types": "./src/index.ts",
10
11
  "scripts": {
@@ -13,11 +14,11 @@
13
14
  },
14
15
  "dependencies": {
15
16
  "@tanstack/react-router": "^1.170.16",
16
- "@terpjs/contract": "^0.9.0"
17
+ "@terpjs/contract": "^0.10.0"
17
18
  },
18
19
  "peerDependencies": {
19
- "react": "^18.3.0 || ^19.0.0",
20
- "react-dom": "^18.3.0 || ^19.0.0"
20
+ "react": "^19.0.0",
21
+ "react-dom": "^19.0.0"
21
22
  },
22
23
  "devDependencies": {
23
24
  "@testing-library/jest-dom": "^6.9.1",
@@ -117,6 +117,79 @@ describe("AppShell", () => {
117
117
  ).not.toHaveAttribute("data-collapsed");
118
118
  });
119
119
 
120
+ it("stamps no density unless one is asked for, so an app's own html-level choice wins", () => {
121
+ // The regression this exists to prevent shipped for one commit. The prop was defaulted to
122
+ // "comfortable" and stamped unconditionally, which reads as harmless — comfortable is the
123
+ // :root value, after all. It is not: comfortable now has a RULE of its own (that is what
124
+ // makes an island possible), so stamping it on the shell root overrides
125
+ // `data-density="compact"` set on <html>, which ADR 0094 section 4 names as the app-wide
126
+ // case and which an app sets from its own theme.css.
127
+ //
128
+ // So absence has to mean "inherit whatever is above me". The two values mean what they say.
129
+ const { unmount } = renderShell();
130
+ expect(
131
+ document.querySelector('[data-terp="appshell"]'),
132
+ "an unasked-for shell prop must not beat an app-wide choice",
133
+ ).not.toHaveAttribute("data-density");
134
+ unmount();
135
+
136
+ renderShell({ density: "compact" });
137
+ expect(document.querySelector('[data-terp="appshell"]')).toHaveAttribute(
138
+ "data-density",
139
+ "compact",
140
+ );
141
+ cleanup();
142
+
143
+ // And comfortable IS stamped when asked for, because it is now an instruction rather than
144
+ // a no-op: it is how a shell inside something compact says it is not.
145
+ renderShell({ density: "comfortable" });
146
+ expect(document.querySelector('[data-terp="appshell"]')).toHaveAttribute(
147
+ "data-density",
148
+ "comfortable",
149
+ );
150
+ });
151
+
152
+ it("stamps data-content-width only when the measure is asked for", () => {
153
+ // The default has to stamp NOTHING, and that is the assertion rather than a detail: the
154
+ // rule is keyed on `[data-content-width="measured"]`, so an attribute for the full-width
155
+ // case would match nothing while looking like it configured something — and every app on
156
+ // the default must render byte-identically to before the prop existed.
157
+ const { unmount } = renderShell();
158
+ expect(document.querySelector('[data-terp="appshell"]')).not.toHaveAttribute(
159
+ "data-content-width",
160
+ );
161
+ unmount();
162
+
163
+ renderShell({ contentWidth: "full" });
164
+ expect(
165
+ document.querySelector('[data-terp="appshell"]'),
166
+ 'contentWidth="full" is the sheet\'s own behaviour, so it stamps no attribute',
167
+ ).not.toHaveAttribute("data-content-width");
168
+ cleanup();
169
+
170
+ renderShell({ contentWidth: "measured" });
171
+ expect(document.querySelector('[data-terp="appshell"]')).toHaveAttribute(
172
+ "data-content-width",
173
+ "measured",
174
+ );
175
+ });
176
+
177
+ it("keeps the measure attribute on the shell root, above the page it constrains", () => {
178
+ // Ownership, which the selector depends on: the rule descends from the shell root to
179
+ // `[data-terp="page"]`, so the attribute cannot live on `main` or on the page itself. A
180
+ // later refactor moving it one element down would break the measure with no test failing
181
+ // unless this says where it belongs.
182
+ renderShell({ contentWidth: "measured" });
183
+ const root = document.querySelector('[data-terp="appshell"]')!;
184
+ expect(root.getAttribute("data-content-width")).toBe("measured");
185
+ for (const marker of ["appshell-column", "appshell-main", "appshell-header"]) {
186
+ expect(
187
+ document.querySelector(`[data-terp="${marker}"]`),
188
+ `${marker} must not carry the measure attribute — the rule keys on the shell root`,
189
+ ).not.toHaveAttribute("data-content-width");
190
+ }
191
+ });
192
+
120
193
  it("renders a custom logo and footer in their slots", () => {
121
194
  renderShell({ logo: <span>MyMark</span>, footer: <span>v1.2.3</span> });
122
195
  expect(screen.getByText("MyMark")).toBeInTheDocument();
@@ -170,4 +243,245 @@ describe("AppShell", () => {
170
243
  expect(screen.queryByRole("navigation", { name: "Primary" })).not.toBeInTheDocument();
171
244
  expect(document.body.style.overflow).toBe("");
172
245
  });
246
+
247
+ describe("the brand mark", () => {
248
+ it("gives the mark a box of its own, with one child when there is no dark pair", () => {
249
+ renderShell({ logo: <img src="/logo.svg" alt="" /> });
250
+
251
+ const box = document.querySelector('[data-terp="appshell-mark"]');
252
+ expect(box).not.toBeNull();
253
+ expect(box!.children).toHaveLength(1);
254
+ // No attribute in the common case: the switch exists only when there is something to
255
+ // switch between, so an app that passes one asset renders one node and no mechanism.
256
+ expect(box!.querySelector("[data-appearance]")).toBeNull();
257
+ });
258
+
259
+ it("renders both marks when a dark one is given, and labels which is which", () => {
260
+ renderShell({
261
+ logo: <img src="/logo-light.svg" alt="" />,
262
+ logoDark: <img src="/logo-dark.svg" alt="" />,
263
+ });
264
+
265
+ const box = document.querySelector('[data-terp="appshell-mark"]')!;
266
+ // Both are in the DOM and the SHEET picks — the theme is `<html data-theme>`, which an
267
+ // app may set with no provider mounted, so a React branch would be wrong for every shell
268
+ // outside `renderTerpApp`. That is why this asserts presence rather than absence.
269
+ expect(box.querySelector('[data-appearance="light"] img')).toHaveAttribute(
270
+ "src",
271
+ "/logo-light.svg",
272
+ );
273
+ expect(box.querySelector('[data-appearance="dark"] img')).toHaveAttribute(
274
+ "src",
275
+ "/logo-dark.svg",
276
+ );
277
+ });
278
+ });
279
+
280
+ describe('navPlacement="header"', () => {
281
+ it("moves the whole navigation into the header and renders no sidebar", () => {
282
+ renderShell({ navPlacement: "header" });
283
+
284
+ const header = screen.getByRole("banner");
285
+ const navigation = screen.getByRole("navigation", { name: "Primary" });
286
+ // The same nav, in a different parent — the assertion is containment rather than
287
+ // existence, because existence passes in both placements.
288
+ expect(header).toContainElement(navigation);
289
+ expect(header).toContainElement(screen.getByRole("link", { name: "Terp" }));
290
+ // The user menu follows it. Losing it is the failure a placement prop invites, since it
291
+ // is where an app puts sign-out and the sidebar was the only thing holding it.
292
+ expect(header).toContainElement(screen.getByText("pinned footer"));
293
+ expect(document.querySelector('[data-terp="appshell-sidebar"]')).toBeNull();
294
+ expect(document.querySelector('[data-terp="appshell"]')).toHaveAttribute(
295
+ "data-nav-placement",
296
+ "header",
297
+ );
298
+ });
299
+
300
+ it("renders no sidebar toggle, because there is no sidebar to collapse", () => {
301
+ renderShell({ navPlacement: "header" });
302
+
303
+ // Not a tidying assertion: the button carries aria-expanded, so rendering it would
304
+ // announce a state about an element that does not exist.
305
+ expect(screen.queryByRole("button", { name: "Collapse sidebar" })).toBeNull();
306
+ expect(screen.queryByRole("button", { name: "Expand sidebar" })).toBeNull();
307
+ expect(document.querySelector("[aria-expanded]")).toBeNull();
308
+ });
309
+
310
+ it("does not leak a persisted rail choice into the header's link context", () => {
311
+ // The regression this exists for: `collapsed` is persisted, so a user who had collapsed
312
+ // the rail before the app moved its nav would get icon-only links in a header with room
313
+ // for labels — and the sidebar attribute that normally reveals the state lands nowhere.
314
+ window.localStorage.setItem(SIDEBAR_STORAGE_KEY, "collapsed");
315
+ renderShell({
316
+ navPlacement: "header",
317
+ navFooter: ({ collapsed }) => <p>{collapsed ? "rail" : "full"}</p>,
318
+ });
319
+
320
+ expect(screen.getByText("full")).toBeInTheDocument();
321
+ });
322
+
323
+ it("is desktop-only: below the breakpoint it is still the drawer", () => {
324
+ stubMobileViewport();
325
+ renderShell({ navPlacement: "header" });
326
+
327
+ // The attribute is derived from the viewport, not stamped from the prop, which is what
328
+ // lets every rule keyed on it skip a [data-variant] guard.
329
+ expect(document.querySelector('[data-terp="appshell"]')).not.toHaveAttribute(
330
+ "data-nav-placement",
331
+ );
332
+ expect(screen.queryByRole("navigation", { name: "Primary" })).not.toBeInTheDocument();
333
+ fireEvent.click(screen.getByRole("button", { name: "Open navigation" }));
334
+ expect(screen.getByRole("dialog", { name: "Primary" })).toBeInTheDocument();
335
+ });
336
+ });
337
+ });
338
+
339
+ // Navigation groups, as the shell renders them.
340
+ //
341
+ // Every assertion here is on the SHELL'S OUTPUT, never on a fixture literal. That distinction is
342
+ // the one this phase keeps paying for: nine AppShell specimens hand-write `aria-current` in their
343
+ // own renderLink, so no baseline gates the shell's active paint at all. A group label read back
344
+ // from the string that was passed in would be the same mistake.
345
+ //
346
+ // The name queries are `getByRole("list", { name })` rather than `toHaveAttribute`, and that is a
347
+ // gate decision rather than a style one. A dropped or dangling `aria-labelledby` is not a
348
+ // violation any lane in this repo can see — axe files a bad IDREF as `incomplete`, and the a11y
349
+ // lane reads `results.violations` only — so the accessible NAME has to be the thing asserted, and
350
+ // only a name query computes it.
351
+ describe("AppShell navigation groups", () => {
352
+ const grouped: NavItem[] = [
353
+ { label: "Notes", to: "/notes", group: "work" },
354
+ { label: "Reports", to: "/reports", group: "work" },
355
+ { label: "Loose", to: "/loose" },
356
+ ];
357
+
358
+ it("labels each group's list with its own visible label", () => {
359
+ render(
360
+ <AppShell
361
+ title="Terp"
362
+ nav={grouped}
363
+ navGroups={[{ id: "work", label: "Werkruimte" }]}
364
+ renderLink={(item, children) => <a href={item.to}>{children}</a>}
365
+ >
366
+ <p>page content</p>
367
+ </AppShell>,
368
+ );
369
+
370
+ // The accessible name is computed from the rendered span, so this is red on a dropped
371
+ // attribute AND on an id that resolves nowhere.
372
+ const labelled = screen.getByRole("list", { name: "Werkruimte" });
373
+ expect(labelled).toBeInTheDocument();
374
+ expect(
375
+ [...labelled.querySelectorAll("a")].map((anchor) => anchor.getAttribute("href")),
376
+ ).toEqual(["/notes", "/reports"]);
377
+ // The ungrouped bucket is a second list, and it has no name of its own.
378
+ expect(screen.getAllByRole("list")).toHaveLength(2);
379
+ });
380
+
381
+ it("gives two shells on one page distinct label ids", () => {
382
+ // The workbench catalogue renders three shells on one page. With a module-constant id the
383
+ // second shell's `aria-labelledby` resolves into the FIRST shell's span — a wrong accessible
384
+ // name rather than a missing one, which nothing reports: `duplicate-id` is deprecated in axe
385
+ // and does not run, and a resolvable IDREF is not a violation whatever it points at.
386
+ // Mutation: replace `useId()` with a module constant, and the two ids below become equal.
387
+ const shell = (label: string) => (
388
+ <AppShell
389
+ title="Terp"
390
+ nav={[{ label: "Notes", to: "/notes", group: "g" }]}
391
+ navGroups={[{ id: "g", label }]}
392
+ renderLink={(item, children) => <a href={item.to}>{children}</a>}
393
+ >
394
+ <p>page content</p>
395
+ </AppShell>
396
+ );
397
+ render(
398
+ <>
399
+ {shell("Eerste")}
400
+ {shell("Tweede")}
401
+ </>,
402
+ );
403
+
404
+ const ids = screen
405
+ .getAllByRole("list")
406
+ .map((list) => list.getAttribute("aria-labelledby"));
407
+ expect(ids.filter(Boolean)).toHaveLength(2);
408
+ expect(new Set(ids)).toHaveProperty("size", 2);
409
+ // And each one still resolves to its OWN label, which is what the distinct ids are for.
410
+ expect(screen.getByRole("list", { name: "Eerste" })).toBeInTheDocument();
411
+ expect(screen.getByRole("list", { name: "Tweede" })).toBeInTheDocument();
412
+ });
413
+
414
+ it("renders no label element and no aria-labelledby for the default group", () => {
415
+ // The additive case: a shell given no groups at all must render exactly what it renders
416
+ // today, plus the wrapper. Mutation: render a span for a null label.
417
+ const { container } = renderShell();
418
+
419
+ expect(container.querySelectorAll('[data-terp="appshell-nav-group"]')).toHaveLength(1);
420
+ expect(container.querySelectorAll('[data-terp="appshell-nav-group-label"]')).toHaveLength(0);
421
+ expect(
422
+ container.querySelector('[data-terp="appshell-nav-list"]')?.hasAttribute("aria-labelledby"),
423
+ ).toBe(false);
424
+ });
425
+
426
+ it("renders nothing at all for a group no visible item references", () => {
427
+ // Reachable on a first render: `visibleNav` removes the role-gated `/admin` entry for
428
+ // everyone else, so a group holding only it arrives here empty.
429
+ // Mutation: emit the empty section, and the label appears over no links.
430
+ const { container } = render(
431
+ <AppShell
432
+ title="Terp"
433
+ nav={[{ label: "Loose", to: "/loose" }]}
434
+ navGroups={[{ id: "beheer", label: "Beheer" }]}
435
+ renderLink={(item, children) => <a href={item.to}>{children}</a>}
436
+ >
437
+ <p>page content</p>
438
+ </AppShell>,
439
+ );
440
+
441
+ expect(screen.queryByText("Beheer")).not.toBeInTheDocument();
442
+ expect(container.querySelectorAll('[data-terp="appshell-nav-group"]')).toHaveLength(1);
443
+ expect(container.querySelectorAll('[data-terp="appshell-nav-group-label"]')).toHaveLength(0);
444
+ });
445
+
446
+ it("renders no wrapper and no list when every item is gated away", () => {
447
+ // The degenerate case of "a section with no items is not emitted", and a real change: the
448
+ // shell used to render an empty <ul> here. It moves nothing — an empty grid list has no
449
+ // height — and it takes an empty `list` role back out of the accessibility tree, which is
450
+ // why it is pinned rather than worked around. Reachable whenever `visibleNav` removes every
451
+ // item, e.g. a viewer in an app whose whole nav is role-gated.
452
+ // Mutation: emit the ungrouped section unconditionally, and an empty list reappears.
453
+ const { container } = render(
454
+ <AppShell title="Terp" nav={[]} renderLink={(item, children) => <a href={item.to}>{children}</a>}>
455
+ <p>page content</p>
456
+ </AppShell>,
457
+ );
458
+
459
+ expect(screen.getByRole("navigation", { name: "Primary" })).toBeInTheDocument();
460
+ expect(container.querySelectorAll('[data-terp="appshell-nav-group"]')).toHaveLength(0);
461
+ expect(container.querySelectorAll('[data-terp="appshell-nav-list"]')).toHaveLength(0);
462
+ expect(screen.queryAllByRole("list")).toHaveLength(0);
463
+ });
464
+
465
+ it("renders no heading element anywhere in the navigation", () => {
466
+ // The decision the prose argues and nothing enforced. `Heading` refuses level 1 to reserve
467
+ // the outline for the routed view's title, and the sidebar renders BEFORE `<main>` — so a
468
+ // heading per group would sit above every page's h1 on every page in the product. axe cannot
469
+ // catch it: `heading-order` is best-practice, outside the lane's tags, and h2 -> h1 is a
470
+ // decrease that passes anyway. Mutation: render the label as an <h2>.
471
+ render(
472
+ <AppShell
473
+ title="Terp"
474
+ nav={grouped}
475
+ navGroups={[{ id: "work", label: "Werkruimte" }]}
476
+ renderLink={(item, children) => <a href={item.to}>{children}</a>}
477
+ >
478
+ <p>page content</p>
479
+ </AppShell>,
480
+ );
481
+
482
+ const navigation = screen.getByRole("navigation", { name: "Primary" });
483
+ expect(navigation.querySelector("h1, h2, h3, h4, h5, h6")).toBeNull();
484
+ // The label is still there and still announced — this is not "no label", it is "no heading".
485
+ expect(screen.getByText("Werkruimte")).toBeInTheDocument();
486
+ });
173
487
  });