@tensor_1/react 2.47.0 → 2.49.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 (204) hide show
  1. package/README.md +540 -2
  2. package/dist/{SchedulingCalendar-B6TakO2h.d.ts → SchedulingCalendar-WWyFL3Eo.d.ts} +51 -2
  3. package/dist/announce.d.ts +10 -0
  4. package/dist/announce.js +30 -0
  5. package/dist/chart-analytic.cjs +33 -25
  6. package/dist/chart-preset.cjs +23 -15
  7. package/dist/chart.cjs +23 -15
  8. package/dist/combobox-model.d.ts +10 -1
  9. package/dist/combobox-model.js +15 -0
  10. package/dist/command-palette-model.d.ts +44 -0
  11. package/dist/command-palette-model.js +36 -0
  12. package/dist/components/AccountMenu.d.ts +44 -0
  13. package/dist/components/AccountMenu.js +43 -0
  14. package/dist/components/AppShell.d.ts +45 -2
  15. package/dist/components/AppShell.js +116 -5
  16. package/dist/components/AuditLog.d.ts +128 -0
  17. package/dist/components/AuditLog.js +196 -0
  18. package/dist/components/BrandProfiles.js +4 -3
  19. package/dist/components/Breadcrumbs.d.ts +9 -2
  20. package/dist/components/Breadcrumbs.js +3 -2
  21. package/dist/components/Button.d.ts +12 -5
  22. package/dist/components/Button.js +7 -3
  23. package/dist/components/Card.d.ts +18 -1
  24. package/dist/components/Card.js +5 -1
  25. package/dist/components/ChangeHighlight.d.ts +30 -0
  26. package/dist/components/ChangeHighlight.js +21 -0
  27. package/dist/components/ChartCore.js +2 -1
  28. package/dist/components/Checkbox.d.ts +27 -1
  29. package/dist/components/Checkbox.js +31 -2
  30. package/dist/components/Chip.d.ts +7 -3
  31. package/dist/components/Chip.js +2 -2
  32. package/dist/components/CodeDisplay.d.ts +65 -0
  33. package/dist/components/CodeDisplay.js +124 -0
  34. package/dist/components/Combobox.d.ts +27 -6
  35. package/dist/components/Combobox.js +195 -58
  36. package/dist/components/CommandPalette.d.ts +36 -1
  37. package/dist/components/CommandPalette.js +254 -2
  38. package/dist/components/ContactCard.d.ts +66 -0
  39. package/dist/components/ContactCard.js +74 -0
  40. package/dist/components/Count.d.ts +24 -0
  41. package/dist/components/Count.js +32 -0
  42. package/dist/components/DashboardCanvas.js +14 -0
  43. package/dist/components/DataWorkspace.d.ts +167 -0
  44. package/dist/components/DataWorkspace.js +380 -0
  45. package/dist/components/DateFieldParts.d.ts +2 -1
  46. package/dist/components/DateFieldParts.js +7 -0
  47. package/dist/components/DatePicker.d.ts +9 -2
  48. package/dist/components/DatePicker.js +67 -28
  49. package/dist/components/DateRangePicker.d.ts +12 -2
  50. package/dist/components/DateRangePicker.js +13 -12
  51. package/dist/components/DetailPage.d.ts +7 -1
  52. package/dist/components/DetailPage.js +2 -1
  53. package/dist/components/Dialogs.d.ts +71 -10
  54. package/dist/components/Dialogs.js +130 -30
  55. package/dist/components/DiffViewer.d.ts +23 -3
  56. package/dist/components/DiffViewer.js +26 -12
  57. package/dist/components/DiscardPrompt.js +16 -0
  58. package/dist/components/DisplayPreferences.d.ts +83 -0
  59. package/dist/components/DisplayPreferences.js +185 -0
  60. package/dist/components/EditorialHome.d.ts +23 -3
  61. package/dist/components/EditorialHome.js +16 -11
  62. package/dist/components/EntityPicker.d.ts +20 -3
  63. package/dist/components/EntityPicker.js +32 -13
  64. package/dist/components/FilterEditor.d.ts +18 -4
  65. package/dist/components/FilterEditor.js +141 -19
  66. package/dist/components/FocusOnMount.d.ts +282 -0
  67. package/dist/components/FocusOnMount.js +12 -0
  68. package/dist/components/InfoTip.js +11 -12
  69. package/dist/components/InlineEdit.d.ts +87 -20
  70. package/dist/components/InlineEdit.js +197 -54
  71. package/dist/components/Input.d.ts +12 -2
  72. package/dist/components/Input.js +5 -3
  73. package/dist/components/Kanban.d.ts +14 -3
  74. package/dist/components/Kanban.js +9 -4
  75. package/dist/components/Kbd.d.ts +6 -1
  76. package/dist/components/Kbd.js +6 -6
  77. package/dist/components/Lightbox.d.ts +41 -5
  78. package/dist/components/Lightbox.js +95 -9
  79. package/dist/components/MatchReview.d.ts +40 -1
  80. package/dist/components/MatchReview.js +130 -4
  81. package/dist/components/MetricInput.js +3 -2
  82. package/dist/components/Modal.d.ts +2 -1
  83. package/dist/components/Modal.js +13 -4
  84. package/dist/components/MotionList.d.ts +303 -0
  85. package/dist/components/MotionList.js +175 -0
  86. package/dist/components/NavFavourites.d.ts +5 -0
  87. package/dist/components/NavFavourites.js +7 -7
  88. package/dist/components/OtpInput.d.ts +33 -0
  89. package/dist/components/OtpInput.js +123 -0
  90. package/dist/components/PageHeader.d.ts +7 -1
  91. package/dist/components/PageHeader.js +2 -2
  92. package/dist/components/Pagination.js +6 -5
  93. package/dist/components/PasswordInput.d.ts +34 -0
  94. package/dist/components/PasswordInput.js +101 -0
  95. package/dist/components/Peek.js +3 -3
  96. package/dist/components/Popover.js +11 -0
  97. package/dist/components/PrintLayout.d.ts +63 -0
  98. package/dist/components/PrintLayout.js +114 -0
  99. package/dist/components/QrCode.d.ts +47 -0
  100. package/dist/components/QrCode.js +55 -0
  101. package/dist/components/QrScanner.d.ts +95 -0
  102. package/dist/components/QrScanner.js +266 -0
  103. package/dist/components/RelationshipGraph.d.ts +32 -1
  104. package/dist/components/RelationshipGraph.js +22 -6
  105. package/dist/components/RelationshipGraphLayered.d.ts +1 -1
  106. package/dist/components/RelationshipGraphLayered.js +268 -118
  107. package/dist/components/RelationshipGraphList.js +33 -6
  108. package/dist/components/RuleBuilder.js +39 -7
  109. package/dist/components/SchedulingAgenda.d.ts +3 -1
  110. package/dist/components/SchedulingCalendar.d.ts +3 -1
  111. package/dist/components/SchedulingCalendar.js +43 -4
  112. package/dist/components/SchedulingResourceDay.d.ts +57 -0
  113. package/dist/components/SchedulingResourceDay.js +287 -0
  114. package/dist/components/SchedulingTimeGrid.d.ts +3 -1
  115. package/dist/components/SchedulingTimeline.d.ts +3 -1
  116. package/dist/components/SearchSuggest.d.ts +23 -3
  117. package/dist/components/SearchSuggest.js +56 -18
  118. package/dist/components/Segmented.d.ts +39 -4
  119. package/dist/components/Segmented.js +39 -5
  120. package/dist/components/Select.d.ts +3 -2
  121. package/dist/components/Select.js +7 -1
  122. package/dist/components/ShortcutHint.d.ts +11 -10
  123. package/dist/components/ShortcutHint.js +44 -38
  124. package/dist/components/Sidebar.d.ts +36 -4
  125. package/dist/components/Sidebar.js +37 -12
  126. package/dist/components/SortableList.d.ts +45 -0
  127. package/dist/components/SortableList.js +147 -0
  128. package/dist/components/Stat.d.ts +28 -3
  129. package/dist/components/Stat.js +13 -3
  130. package/dist/components/States.d.ts +33 -1
  131. package/dist/components/States.js +43 -1
  132. package/dist/components/StatusPill.d.ts +14 -3
  133. package/dist/components/StatusPill.js +11 -3
  134. package/dist/components/Steps.js +2 -1
  135. package/dist/components/Table.d.ts +9 -2
  136. package/dist/components/Table.js +4 -2
  137. package/dist/components/TableControls.js +18 -58
  138. package/dist/components/TableOverflowProbe.d.ts +12 -0
  139. package/dist/components/TableOverflowProbe.js +27 -0
  140. package/dist/components/Tabs.d.ts +2 -1
  141. package/dist/components/Tabs.js +10 -2
  142. package/dist/components/TagInput.d.ts +67 -0
  143. package/dist/components/TagInput.js +342 -0
  144. package/dist/components/Textarea.d.ts +5 -0
  145. package/dist/components/Textarea.js +2 -2
  146. package/dist/components/TimePicker.d.ts +80 -0
  147. package/dist/components/TimePicker.js +347 -0
  148. package/dist/components/Toast.d.ts +1 -1
  149. package/dist/components/Toast.js +109 -4
  150. package/dist/components/Tooltip.d.ts +8 -4
  151. package/dist/components/Tooltip.js +86 -28
  152. package/dist/components/Tree.d.ts +8 -0
  153. package/dist/components/Tree.js +11 -5
  154. package/dist/display-preferences-model.d.ts +53 -0
  155. package/dist/display-preferences-model.js +64 -0
  156. package/dist/emoji.cjs +143 -49
  157. package/dist/filter-model.d.ts +59 -3
  158. package/dist/filter-model.js +67 -2
  159. package/dist/hooks/useAnchoredPosition.js +14 -2
  160. package/dist/hooks/useChangeHighlight.d.ts +33 -0
  161. package/dist/hooks/useChangeHighlight.js +44 -0
  162. package/dist/hooks/useDeferredReorder.d.ts +32 -0
  163. package/dist/hooks/useDeferredReorder.js +47 -0
  164. package/dist/hooks/useDialog.d.ts +5 -0
  165. package/dist/hooks/useDialog.js +17 -3
  166. package/dist/hooks/useKeyboardGrab.d.ts +58 -0
  167. package/dist/hooks/useKeyboardGrab.js +118 -0
  168. package/dist/hooks/useOptimisticValue.d.ts +51 -0
  169. package/dist/hooks/useOptimisticValue.js +61 -0
  170. package/dist/hooks/useReducedMotion.d.ts +4 -1
  171. package/dist/hooks/useReducedMotion.js +7 -1
  172. package/dist/hooks/useResolvedFilterLabels.d.ts +15 -0
  173. package/dist/hooks/useResolvedFilterLabels.js +34 -0
  174. package/dist/hooks/useSlidingIndicator.d.ts +18 -0
  175. package/dist/hooks/useSlidingIndicator.js +80 -0
  176. package/dist/hooks/useVirtualRows.d.ts +21 -2
  177. package/dist/hooks/useVirtualRows.js +30 -3
  178. package/dist/icons/Icon.d.ts +1 -1
  179. package/dist/index.cjs +19687 -13423
  180. package/dist/index.d.cts +3419 -651
  181. package/dist/index.d.ts +48 -18
  182. package/dist/index.js +153 -9
  183. package/dist/labels-context.d.ts +93 -3
  184. package/dist/motion.d.ts +11 -0
  185. package/dist/motion.js +35 -0
  186. package/dist/overlay-stack.d.ts +6 -0
  187. package/dist/overlay-stack.js +13 -0
  188. package/dist/qr-model.d.ts +65 -0
  189. package/dist/qr-model.js +323 -0
  190. package/dist/relationship-graph-model.d.ts +92 -3
  191. package/dist/relationship-graph-model.js +134 -15
  192. package/dist/reorder.d.ts +30 -0
  193. package/dist/reorder.js +74 -0
  194. package/dist/rich-text.cjs +46 -4
  195. package/dist/scheduling-resource-model.d.ts +76 -0
  196. package/dist/scheduling-resource-model.js +74 -0
  197. package/dist/search-suggest-model.js +3 -1
  198. package/dist/shell-context.d.ts +11 -0
  199. package/dist/shell-context.js +7 -0
  200. package/dist/time-field-model.d.ts +46 -0
  201. package/dist/time-field-model.js +152 -0
  202. package/dist/toast-store.d.ts +22 -3
  203. package/dist/toast-store.js +26 -9
  204. package/package.json +2 -1
package/README.md CHANGED
@@ -192,7 +192,7 @@ Rules the component cannot enforce for you:
192
192
  - **Dimming is not a signal.** `dimmedNodeIds` must be accompanied by text that says what is dimmed and why.
193
193
  - **Localise everything.** Every visible and announced string comes from `labels`; the English defaults live in `DEFAULT_RELATIONSHIP_GRAPH_LABELS`.
194
194
 
195
- **Map controls (`controls`, `initialView`).** The default `controls="full"` is the labelled toolbar above the map. `controls="compact"` is the map convention: an icon cluster in the map's bottom-right corner (zoom in, zoom out, fit, reset, full screen, each with a tooltip) and the zoom level beside it; drag pans, Ctrl/⌘ + wheel or a pinch zooms around the pointer, and a plain wheel keeps scrolling the page and briefly says how to zoom. The map itself takes keyboard focus: arrow keys pan (on an item they still move between items), `+` and `−` zoom, `0` resets and Escape leaves full screen. Full screen covers the page (the page behind does not scroll) and there a plain wheel zooms. `controls="none"` draws nothing; drag, pinch and Ctrl/⌘ + wheel still work. `initialView="fit"` opens with every item in view (down to the 40 % minimum) instead of the 75 % readability floor, and Reset returns to it.
195
+ **Map controls (`controls`, `initialView`).** The default `controls="full"` is the labelled toolbar above the map. `controls="compact"` is the map convention: an icon cluster in the map's bottom-right corner (zoom in, zoom out, fit, reset, full screen, each with a tooltip) and the zoom level beside it; drag pans, Ctrl/⌘ + wheel or a pinch zooms around the pointer, and a plain wheel keeps scrolling the page and briefly says how to zoom. The map itself takes keyboard focus: arrow keys pan (on an item they still move between items), `+` and `−` zoom, `0` resets and Escape leaves full screen. Full screen covers the page (the page behind does not scroll) and there a plain wheel zooms. `controls="none"` draws nothing; drag, pinch and Ctrl/⌘ + wheel still work. `initialView="fit"` opens with every item in view (down to the 40 % minimum) instead of the 75 % readability floor, and Reset returns to it. With `controls="compact"` the hint, the zoom level and the cluster sit in a band along the map's bottom edge; the map ends at the band, and fit, the first view and Reset leave its height free.
196
196
 
197
197
  ```tsx
198
198
  <RelationshipGraph layout="layered" controls="compact" initialView="fit" height="clamp(24rem, 62vh, 44rem)" … />
@@ -256,10 +256,102 @@ const showSpinner = useDelayedPending(upload.isPending);
256
256
  {showSpinner && <Spinner size="sm" label={t('uploading')} />}
257
257
  ```
258
258
 
259
+ ### What changed: highlight, count, optimistic values (motion standard §2.5, §2.6, §2.8)
260
+
261
+ - **`ChangeHighlight value={…}`** (or `useChangeHighlight(value)` on your own element, or
262
+ `.uix-changed` on plain markup) tints the element when `value` changes after mount and fades
263
+ the tint; never on the first render; a second change restarts it. `as="tr"` tints the row's
264
+ cells; `variant="ring"` rings something with a colour of its own (a status pill, a star).
265
+ Under reduced motion or forced colours it is a steady outline for the same time. Give it
266
+ words: `announce="Status changed to Resolved by A. Novak"` speaks through a polite region
267
+ (inside the open dialog when there is one). `flashChanged(el)` and `announcePolite(text)`
268
+ are the same pieces for changes that do not flow through a React value.
269
+ - **`Count value={n}`** for a badge or stat value: one 4 px tick in the change's direction with
270
+ a fade, tabular figures, `format` for "99+". Never a loop, never a count-up.
271
+ - **`useOptimisticValue(serverValue, commit, { onRollback })`** only for reversible, personal
272
+ actions (star, pin, reaction, mark read, display preference, own saved-view order). It shows
273
+ the change at once; if `commit` rejects, the last confirmed value comes back, `rollbacks`
274
+ counts up (flash it with `ChangeHighlight value={rollbacks}`) and `onRollback` lets you say
275
+ what was restored. State transitions, assignment, approvals and priority stay pessimistic.
276
+
277
+ ```tsx
278
+ <ChangeHighlight as="tr" value={row.updatedAt} announce={t('rowUpdated', { id: row.id, actor })}>…</ChangeHighlight>
279
+ <Td><ChangeHighlight value={row.status} variant="ring"><StatusPill tone={tone}>{label}</StatusPill></ChangeHighlight></Td>
280
+ <span className="uix-navitem__badge"><Count value={unread} format={(n) => (n > 99 ? '99+' : String(n))} /></span>
281
+
282
+ const star = useOptimisticValue(view.starred, (next) => api.star(view.id, next), { onRollback: () => toast.error(t('starNotSaved')) });
283
+ <ChangeHighlight value={star.rollbacks} variant="ring" announce={star.rollback ? t('starRestored') : undefined}>
284
+ <StarButton pinned={star.value} label={view.name} onToggle={(next) => star.set(next)} />
285
+ </ChangeHighlight>
286
+ ```
287
+
288
+ ### Lists that move, the success outcome, sliding indicators (motion standard §2.5, §2.8)
289
+
290
+ - **`MotionList`** (`items`, `getKey`, `renderItem`, `as`): a new item rises 8 px and fades in;
291
+ a removed item stays in place (hidden from assistive tech, `inert`), fades out, and only then
292
+ leaves, so the items below glide up; a re-sort glides (FLIP, transform only). Nothing moves on
293
+ the first render. Focus stays on a moved item and goes to the next item when the focused one
294
+ leaves; focus outside the list is never pulled in. `renderItem` must pass unknown props to its
295
+ element (`<li>`, or a `Tr` with `as="tbody"`).
296
+ - **`useDeferredReorder(items, getKey)`**: never re-sort under the pointer. While a mouse is over
297
+ the list or keyboard focus is inside it, a new order waits (content still updates, new rows go
298
+ to the end); `held` says so; the order applies when they leave. Spread `listProps` on the list.
299
+ - **`SuccessState`** in `States`: the outcome of a rare journey (a request submitted): a mark
300
+ that draws once, the title (focused on mount for `page` and `full-page`), `reference`,
301
+ `next` (what happens next), `action`. Reduced motion shows the mark at once.
302
+ - **Tabs, Segmented**: the line underline, the pill and the lifted segment slide to the chosen
303
+ option on a click (160 ms); a keyboard change and reduced motion jump. Server HTML, forced
304
+ colours and Segmented's link mode keep each option's own mark. `useSlidingIndicator` is the
305
+ hook behind it.
306
+ - **CollapsibleSection** opening fades the body in and settles it 4 px (on `::details-content`,
307
+ where supported); an already open section, a keyboard open and closing are instant.
308
+
309
+ ```tsx
310
+ const queue = useDeferredReorder(rows, (row) => row.id);
311
+ <MotionList {...queue.listProps} as="tbody" items={queue.items} getKey={(row) => row.id}
312
+ renderItem={(row) => <Tr>…</Tr>} />
313
+
314
+ <SuccessState title={t('requestSubmitted')} reference={<>{req.number} <CopyButton … /></>}
315
+ next={[t('confirmationSent'), t('agentPicksUp')]} action={<ButtonLink href={`/requests/${req.id}`}>{t('viewRequest')}</ButtonLink>} />
316
+ ```
317
+
318
+ ### Drag affordances (motion standard §2.8)
319
+
320
+ One set of drag states for every draggable thing (`drag.css`), as attributes so any element can
321
+ carry them: `[data-uix-drag="lifted"]` (picked up by the pointer: a little larger, a deeper
322
+ shadow), `[data-uix-drag="grabbed"]` (picked up from the keyboard: a dashed ring, no motion),
323
+ `[data-uix-drop-target]` (the zone it would land in), `.uix-drop-line` (the gap it would drop
324
+ into) and `[data-uix-settle]` (the landing). Reduced motion keeps the states and drops the scale
325
+ and the landing.
326
+
327
+ - **SortableList** and **DashboardCanvas** lift and settle on their own.
328
+ - **Kanban**: `KanbanCard dragging`, `KanbanColumn dropTarget`, `<KanbanDropLine />` at the gap;
329
+ call `settleDrop(cardElement)` once the card is in its new place.
330
+ - **`useKeyboardGrab({ id, label, onMove, onDrop, onCancel, labels })`** for a draggable thing you
331
+ build: Space or Enter picks the focused item up, the arrows call `onMove(direction)` (return
332
+ what to say, e.g. "Position 3 of 8"), Space or Enter drops, Escape or leaving puts it back;
333
+ every step is said politely. Spread `itemProps` (it includes a `ref`) on the focusable item; the
334
+ picked-up state follows `id`, so an item moved into another list stays picked up and focused.
335
+ Words: `labels` or `UixLabelsProvider` `keyboardGrab`. The kit's lists have their own paths.
336
+
337
+ ```tsx
338
+ <KanbanColumn title={t('doing')} dropTarget={drag?.column === 'doing'}>
339
+ {cards.map((card, i) => <Fragment key={card.id}>
340
+ {drag?.column === 'doing' && drag.index === i && <KanbanDropLine />}
341
+ <KanbanCard title={card.title} dragging={drag?.id === card.id} onMove={…} />
342
+ </Fragment>)}
343
+ {drag?.column === 'doing' && drag.index >= cards.length && <KanbanDropLine />}
344
+ </KanbanColumn>
345
+
346
+ // your own draggable list: the picked-up state follows the id, across lists too
347
+ const grab = useKeyboardGrab({ id: item.id, label: item.title, onMove: (dir) => move(item.id, dir), onCancel: restore });
348
+ <li tabIndex={0} {...grab.itemProps}>{item.title}</li>
349
+ ```
350
+
259
351
  ### Scrolling tabs, toned stat tiles and copying
260
352
 
261
353
  - **`Tabs overflow="scroll"`** keeps a long tab row on one line that scrolls sideways. The selected tab is always scrolled into view, and edge buttons appear only on the side that has hidden tabs. The buttons are pointer-only: keyboard users move with Arrow, Home and End. Combine it with `TabPanel keepMounted` on a record page with many sections.
262
- - **`Stat tone`** (`warning`, `danger`) draws a toned outline and value; the label stays muted, so the tone never replaces words. `size="compact"` fits dense fact bands.
354
+ - **`Stat tone`** (`warning`, `danger`) colours the value and leads it with a dot of that tone; the border stays neutral and the label muted, so the tone never replaces words (a toned outline before 2.48). `size="compact"` fits dense fact bands.
263
355
  - **`Stat onActivate`** makes the whole tile a button that opens an editor (`aria-haspopup="dialog"`). Pass `activateLabel` (e.g. "change") so the name reads "Priority: P1, change", and `expanded` while the editor is open. The ref points at the button, for anchoring a popover.
264
356
  - **`Stat pending`** is for a tile whose action is still running (a save, a state change). The button gets `aria-busy="true"` and `aria-disabled="true"`, is dimmed with a progress cursor, and calls neither `onActivate` nor your `onClick` / `onKeyDown` until `pending` is false again. It is never natively `disabled`, so it keeps focus and a screen reader can still read it. Only a tile with `onActivate` has the state; a read-only tile and a link tile ignore the prop.
265
357
  - **Tiles in one row line up.** A grid or flex row stretches its tiles to the tallest one. Read-only, button and link tiles all start their label and value at the same offset from the top edge, whatever the height (before 2.38.0 a button tile centred its rows).
@@ -1012,6 +1104,9 @@ const v = useVirtualRows(rows, { rowHeight: 44, estimatedViewportHeight: 600, en
1012
1104
  - The ref is the body (the button, link or span that carries the chip's action), also on a
1013
1105
  removable chip; `bodyProps` puts attributes there.
1014
1106
  - `current` on a link chip: `aria-current="page"` and the filled look.
1107
+ - `onClick` gets the click event, so a chip can be a `Menu` trigger:
1108
+ `<Menu trigger={<Chip variant="add" icon={<PlusIcon />}>Filter</Chip>}>…</Menu>`. A handler that takes no
1109
+ argument still works.
1015
1110
 
1016
1111
  #### FileUpload and Attachment
1017
1112
 
@@ -1166,3 +1261,446 @@ mask; an avatar with a dot no longer clips it.
1166
1261
  `entityPicker`. `SearchSuggest` has the two slots these use: `idle` and `note`.
1167
1262
  - `CommandPalette` announces its result count through `UixLabelsProvider` `commandPalette`
1168
1263
  (`resultsOne`, `resultsMany`, `{count}`) or a `resultsLabel(count)` prop; English is unchanged.
1264
+
1265
+ ### Codes, contacts and paper (batch 5)
1266
+
1267
+ #### QrCode
1268
+
1269
+ `QrCode` draws `value` as a scannable SVG QR code with the standard four-module quiet zone.
1270
+ Name it for where it leads (`label`), pick `errorCorrection` (`M` by default; `Q` or `H` for a
1271
+ poster that may be creased or partly covered) and a `size`: CSS pixels, or a length such as
1272
+ `'32mm'` on paper. Keep at least 2 px per module on a screen and 0.4 mm per module on paper.
1273
+ The symbol is ink on paper in light, dark, print and forced colours, because scanners read dark
1274
+ on light. The encoder is the kit's own (`encodeQr`, `qrPath`), with no runtime dependency.
1275
+
1276
+ ```tsx
1277
+ <QrCode value={campaignUrl} label={t('qrOpensCampaign')} errorCorrection="Q" size="40mm" />
1278
+ ```
1279
+
1280
+ #### CodeDisplay
1281
+
1282
+ `CodeDisplay` shows a one-time code large enough to read across a counter: monospace groups
1283
+ (`groupSize`, or the code's own spaces and hyphens), a visible `label`, and a screen-reader
1284
+ spelling character by character (`spell` for a spelling alphabet or to tell O from 0).
1285
+ `expiresAt` adds a countdown that is never a live region: a screen reader hears "{time} left"
1286
+ once at `warnBefore` seconds (60) and "expired" once, and `onExpire` runs once. An expired code
1287
+ is struck through and says so in words; `copyable` adds a `CopyButton` while the code is live.
1288
+ The product makes the code and enforces its expiry.
1289
+
1290
+ ```tsx
1291
+ <CodeDisplay code={pairing.code} label={t('pairingCode')} expiresAt={pairing.expiresAt} copyable onExpire={refresh} />
1292
+ ```
1293
+
1294
+ #### ContactCard
1295
+
1296
+ `ContactCard` renders `.uix-contact` in a card (`bare` for a peek, a popover or a card body that
1297
+ already has padding): avatar, `name` (a quiet link with `href` / `renderLink`), `role`, the
1298
+ `email` and `phone` as selectable text, and Email / Call as `mailto:` / `tel:` `ButtonLink`s
1299
+ named for the person ("Email Amra Hadžić"), then your own `actions` and `stats`.
1300
+ `state="purged"` (the person's data was erased) shows a neutral placeholder and nothing that
1301
+ reaches anyone, even when stale props still carry an address; `state="unavailable"` keeps the
1302
+ name and says the details are unavailable. Words: `labels` or `UixLabelsProvider` `contactCard`.
1303
+
1304
+ ```tsx
1305
+ <ContactCard name={p.name} role={p.role} avatar={<Avatar size="lg" src={p.photo} alt="" />} email={p.email} phone={p.phone}
1306
+ state={p.erasedAt ? 'purged' : 'active'} href={`/people/${p.id}`} renderLink={(props) => <Link {...props} />} />
1307
+ ```
1308
+
1309
+ #### EventRow slots
1310
+
1311
+ `EventRow` takes `weekday`, `year` and `time` in its date tile (the tile widens to its longest
1312
+ line), `dateTime` to make the tile a `<time>` with a machine-readable date, and `media` for a
1313
+ thumbnail at the row's end: an `<img>` drawn 4:3 and cropped to fill, moved under the text on a
1314
+ phone. Without them the markup is unchanged.
1315
+
1316
+ ```tsx
1317
+ <EventRow weekday="Wed" month="Oct" day="22" year="2026" time="18:00" dateTime="2026-10-22T18:00+02:00"
1318
+ title={<Link href="/program/42">Book launch</Link>} meta="National library" media={<img src={cover} alt="" />} />
1319
+ ```
1320
+
1321
+ #### PrintLayout
1322
+
1323
+ `PrintLayout` is a print document frame for the browser's own print (`window.print()`): `size`
1324
+ `A4` (default) or `A5`, `orientation`, a `header` and a `footer` that repeat on every printed page
1325
+ (it renders a layout `<table>`: Chromium repeats a real `<thead>` / `<tfoot>`, not a CSS table of
1326
+ divs), "page / pages" in the bottom-right margin (`pageNumbers`), and ink-on-paper tokens for
1327
+ everything inside, also as a paper preview on screen in the dark theme. While it is on the page,
1328
+ printing shows only the documents (`isolate`, default): the app shell, toolbars and toasts are
1329
+ left out and scroll boxes cannot cut the document. `singlePage` keeps a poster or a one-page
1330
+ readout in one piece; when its content does not fit, the preview is outlined and
1331
+ `onOverflowChange(true)` is called. `PrintSignature` is a signature line with place and date and
1332
+ an optional stamp box (never split across pages); `PrintPageBreak` starts a new page. Utilities:
1333
+ `.uix-print-keep`, `.uix-print-hide`, `.uix-print-only`.
1334
+
1335
+ ```tsx
1336
+ <PrintLayout header={<PracticeHeader />} footer={<DoctorFooter />}>
1337
+ <h1>{t('findings')}</h1>
1338
+ <DescriptionList items={patient} />
1339
+ <PrintSignature placeAndDate={placeAndDate} name={doctor} stamp />
1340
+ </PrintLayout>
1341
+ ```
1342
+
1343
+ #### QrScanner
1344
+
1345
+ `QrScanner` is a camera viewfinder for a decoder you supply: `decode(frame)` gets
1346
+ `{ imageData, video }` (frames at most `maxFrameSize` px on the long side, every `interval` ms)
1347
+ and returns the text it read; the kit ships no decoder and makes no network request.
1348
+ `onResult(value)` is called once per new text (the same text again within `repeatAfter` ms is
1349
+ not repeated), and a status message says it once. Before the browser's prompt the scanner shows
1350
+ your `explainer` and a start button (with `autoStart`, a camera the browser already allows starts
1351
+ at once); after a refusal it says how to allow the camera again; it says so when the browser has
1352
+ no camera API, no camera is found, or the camera sends no picture. `manualEntry` is rendered in
1353
+ every state. A hint appears after `hintAfter` ms of frames without a code; a torch toggle appears
1354
+ when the camera has one. The camera stops on Stop, on unmount and while the page is hidden.
1355
+
1356
+ ```tsx
1357
+ <QrScanner decode={({ imageData }) => wasmDecoder.read(imageData)} onResult={redeem}
1358
+ explainer={<p>{t('cameraWhy')}</p>} manualEntry={<VoucherCodeField />} />
1359
+ ```
1360
+
1361
+ A scan trail (what was read, what the product did with it, what to do next) is the product's:
1362
+ keep `onResult` and `onStatusChange` in state and show the last few as a `Timeline` under the
1363
+ scanner, newest first, with the outcome in words ("Not changed: already lent").
1364
+
1365
+ #### SchedulingCalendar: the day by resource
1366
+
1367
+ `view="day"` with `resources` draws one column per resource (a practitioner, a room) on a slot
1368
+ axis in `timeZone`: `resourceSpans` are the free, booked and off stretches the server composed;
1369
+ a free span is cut into bookable slots of `slotMinutes` (15), a booking and time off stay whole,
1370
+ and a time no span covers is hatched "no schedule" (never free; nothing is inferred). `dayStart`
1371
+ / `dayEnd` set the axis (07:00–19:00). `onSelectSlot` gets `{ resourceId, start, end, kind, span }`
1372
+ for a click, Enter or Space on a slot or a booking; `selectedSlot` marks one. The view is one tab
1373
+ stop: arrows move between slots and resources, Home / End to a column's ends. The gutter and the
1374
+ heads stay in view while the grid scrolls. `loading` shows skeletons on the first load and dims
1375
+ what is on screen on a refetch. Below `resourceListBelow` (480 px of its own width) the day is a
1376
+ list, one resource after another (`resourceLayout` fixes either).
1377
+
1378
+ ```tsx
1379
+ <SchedulingCalendar view="day" anchorDate={day} timeZone={tenantZone} entries={[]}
1380
+ resources={practitioners} resourceSpans={spans} slotMinutes={15} dayStart="07:30" dayEnd="17:00"
1381
+ onSelectSlot={openSlotPopover} selectedSlot={open && { resourceId: open.resourceId, start: open.start }} loading={isFetching} />
1382
+ ```
1383
+
1384
+ #### RelationshipGraph: gaps on the map
1385
+
1386
+ For a map of what is expected and what is there (TENSOR's K3 gaps): an edge takes
1387
+ `variant: 'dashed'` (an expected relation that is missing), a `tone` and a `description` that
1388
+ both nodes say to a screen reader ("covers, from Control K3.2 (expected, missing)"); a node takes
1389
+ a `tone`; a legend item takes the same `tone`, a `shape` (`edge` or `node`) and a `variant`, so
1390
+ it matches what it explains. Say a tone's meaning in words too (`description`, the node's text).
1391
+ `revealId` scrolls a node into view without moving focus (a table row beside the map), and with
1392
+ reduced motion it jumps. Without `rootId` no node gets the root border (`markRoot` forces it
1393
+ either way). `controls="none"` hides the toolbar in the radial layout as well. Edge labels that
1394
+ would overlap in a dense column are moved apart, or left out when they would drift too far (the
1395
+ relationship stays in the nodes' descriptions and the list). An edge across more than one column
1396
+ is a solid "skip" edge in a channel under the columns it crosses, no longer dashed or flagged as a
1397
+ cycle; `LayeredEdgeKind` gains `skip` and `back`.
1398
+
1399
+ `RelationshipGraph layout="layered"` was measured at CMDB topology sizes (HAR-1387; 300 and 600
1400
+ items): no items overlap; a column that takes many forward edges says their common relation once
1401
+ in its header and only edges that differ keep a label; a busy node fans out along one bus; a
1402
+ node's description names five relationships and points to the list, whose rows list at most eight
1403
+ neighbours until "Show all N" (`connectedHeading`, `showAllRelations`, `showFewerRelations`,
1404
+ `moreRelations`); a pan or a zoom only moves the stage, so dragging stays at the display's frame
1405
+ rate. A force layout was not needed.
1406
+
1407
+ ### Form fields (HAR-1812, HAR-1799, …)
1408
+
1409
+ #### CheckboxGroup
1410
+
1411
+ ```tsx
1412
+ <CheckboxGroup label={t('tags')} hint={t('tagsHint')} error={errors.tags} columns={3} required>
1413
+ {tags.map((tag) => (
1414
+ <Checkbox key={tag.id} name="tags" value={tag.id} label={tag.name}
1415
+ checked={chosen.includes(tag.id)} onChange={() => toggle(tag.id)} />
1416
+ ))}
1417
+ </CheckboxGroup>
1418
+ ```
1419
+
1420
+ The checkbox counterpart of `<RadioGroup label>`: a `<fieldset>` whose `<legend>` names the group,
1421
+ so it is announced with each box, and the field's message slot below (`hint`, or `error` with
1422
+ `role="alert"`, tied to the group with `aria-describedby`). `columns` is a ceiling: no column is
1423
+ narrower than 10rem, so a phone gets one. `disabled` disables every box; `required` draws the
1424
+ marker (which boxes must be ticked is your rule).
1425
+
1426
+ #### PasswordInput and OtpInput
1427
+
1428
+ ```tsx
1429
+ <Field label={t('password')} error={errors.password}>
1430
+ <PasswordInput name="password" autoComplete="current-password" />
1431
+ </Field>
1432
+ <Field label={t('code')} hint={t('codeHint')} error={errors.code}>
1433
+ <OtpInput name="code" length={6} groupSize={3} onComplete={(code) => verify(code)} />
1434
+ </Field>
1435
+ ```
1436
+
1437
+ `PasswordInput` is an `Input type="password"` with a reveal toggle inside its end: a button with
1438
+ `aria-pressed` whose name stays "Show password" (`labels.show`, or `UixLabelsProvider`
1439
+ `passwordInput`). A pointer press keeps the caret in the field; Tab reaches the toggle. When the
1440
+ form submits, a revealed password is hidden first, so the browser still offers to save it.
1441
+ `revealed` / `defaultRevealed` / `onRevealedChange`, `size`, and every `Input` prop pass through.
1442
+
1443
+ `OtpInput` is ONE input (`inputMode="numeric"`, `autoComplete="one-time-code"`, `pattern`;
1444
+ no `maxLength`, so an autofilled "ABCD-EFGH" is cleaned before it is cut) drawn as `length` cells, so the phone's code autofill, password managers and screen
1445
+ readers see one field. Typing or pasting "123 456" keeps the characters; `onComplete(code)` runs
1446
+ once each time the code fills. `mode="alphanumeric"` takes letters (upper-cased); `groupSize`
1447
+ draws a gap; `aria-invalid` (from `Field error`) turns the cells red.
1448
+
1449
+ `InputGroup` gains `trailingAction`: a real control inside the field's end (class
1450
+ `uix-input-group__action`, square, the field's height less 4 px a side), which both use.
1451
+
1452
+ #### TimePicker
1453
+
1454
+ ```tsx
1455
+ <Field label={t('opensAt')} hint={t('businessHours')}>
1456
+ <TimePicker name="opens" value={opens} onValueChange={setOpens} locale={locale} minuteStep={30} min="08:00" max="18:00" />
1457
+ </Field>
1458
+ ```
1459
+
1460
+ A time of day beside `DatePicker`; the value is `HH:mm` (what `<input type="time">` holds) or
1461
+ `null`. The field shows the locale's clock (14:30 in `de`, 2:30 PM in `en-US`; `hour12` overrides)
1462
+ and reads a typed time on Enter or blur: `1430`, `14.30`, `9`, `2:30 pm`. ArrowUp / ArrowDown step
1463
+ the part under the caret (the hour, or the minutes to the next `minuteStep`), PageUp / PageDown the
1464
+ hour, Alt+ArrowDown opens the list. The clock button lists the times at `minuteStep` between `min`
1465
+ and `max`; the list takes focus on the chosen time, Enter picks, Escape closes and focus goes back
1466
+ to the field. Text that is not a time, or one before `min` / after `max`, marks the field invalid and
1467
+ says why. `name` submits `HH:mm` through a hidden input. Words: `labels` or `UixLabelsProvider`
1468
+ `timePicker`. `DateTimePicker` now uses it for its time (no native `type="time"` is left);
1469
+ `minuteStep` defaults to 15 there and only steps the keys and the list, any typed minute is kept.
1470
+
1471
+ #### Date fields without call-site workarounds (HAR-2432)
1472
+
1473
+ ```tsx
1474
+ <UixLabelsProvider labels={{
1475
+ dateFormat: { locale: 'de', formatDate: toDDMMYYYY, parseDate: fromDDMMYYYY, placeholder: 'TT.MM.JJJJ' },
1476
+ dateRangePicker: { label: t('chooseRange'), invalidMessage: t('rangeInvalid') },
1477
+ }}>
1478
+ ```
1479
+
1480
+ - `dateFormat` sets `locale`, `formatDate`, `parseDate` and `placeholder` for every `DatePicker`,
1481
+ `DateTimePicker`, `DateRangePicker` (and the `locale` of `TimePicker`) below the provider; a prop
1482
+ on one field still wins.
1483
+ - `FilterPopover type="date"` renders the kit `DatePicker`; the value stays `YYYY-MM-DD` (`''` for
1484
+ none).
1485
+ - `DateRangePickerLabels` has `label` and `invalidMessage`, so no English default is left outside
1486
+ the labels group; the props still win.
1487
+ - `DateTimePicker` names its time from the label's accessible text (an `aria-hidden` "*" is left
1488
+ out) and follows the label when its text changes after mount.
1489
+
1490
+ #### Combobox at parity with Select (HAR-1588)
1491
+
1492
+ ```tsx
1493
+ <span id="cat-label" className="uix-field__label">{t('category')}</span>
1494
+ <Combobox aria-labelledby="cat-label" label={t('category')} emptyLabel={t('noMatch')} value={category} onValueChange={setCategory}
1495
+ options={[{ label: t('hardware'), options: hardware }, { label: t('software'), options: software }, other]} />
1496
+ ```
1497
+
1498
+ The list is a top-layer popover placed with `useAnchoredPosition` (like `Select`), so a Drawer, a
1499
+ Popover or an `overflow: hidden` cell never clips it. ↑ ↓ PageUp PageDown move (Home End too while
1500
+ an option is active), disabled options are skipped and the list does not wrap; Enter chooses;
1501
+ Escape closes it and puts the chosen option's text back, as leaving the field does. `options` take
1502
+ groups (`{ label, options, disabled }`, rendered as `role="group"`); the no-match row is outside the
1503
+ listbox; the chosen option has a check mark. New: `aria-labelledby` (replaces `label` as the name),
1504
+ `aria-describedby`, `aria-invalid` and `inputId` land on the input; `placement`;
1505
+ `filterComboboxItems`.
1506
+
1507
+ #### TagInput
1508
+
1509
+ ```tsx
1510
+ <Field label={t('labels')} hint={t('labelsHint')}>
1511
+ <TagInput name="labels" value={labels} onValueChange={setLabels} suggestions={known} maxTags={10}
1512
+ validate={(tag) => (tag.length > 40 ? t('tooLong') : null)} />
1513
+ </Field>
1514
+ ```
1515
+
1516
+ Type and press Enter or a comma to add a chip; a paste of "a, b, c" adds three. A duplicate (ignoring
1517
+ case and accents), a `validate` refusal or one past `maxTags` keeps the text and says why under the
1518
+ field. Suggestions (those not added yet) open in a top-layer list once the person types or presses
1519
+ ArrowDown; Enter adds the active one. Backspace in the empty field reaches the last chip; ArrowLeft /
1520
+ ArrowRight move between chips and back; Backspace or Delete removes the focused chip and focus stays
1521
+ with the tags; the field is the only tab stop. Each change is announced politely. Inside `Field` the
1522
+ text input gets the label, hint and error; `name` submits one value per tag. Words: `labels` or
1523
+ `UixLabelsProvider` `tagInput`.
1524
+
1525
+ #### SearchSuggest freeText (HAR-2465)
1526
+
1527
+ ```tsx
1528
+ <label className="uix-field__label" htmlFor="maker">{t('manufacturer')}</label>
1529
+ <SearchSuggest freeText inputId="maker" name="manufacturer" label={t('manufacturer')}
1530
+ value={maker} onValueChange={setMaker} options={spellings} loading={loading} />
1531
+ ```
1532
+
1533
+ A text field with suggestions: the value is the text. Enter keeps what was typed and never submits
1534
+ the surrounding form (also while `loading`); no row is picked unless the person moves to it
1535
+ (ArrowDown / ArrowUp, then Enter) or clicks it, and then its title replaces the text through
1536
+ `onValueChange`, again without submitting. Blur, Tab and Escape keep the text (a second Escape does
1537
+ not clear it). No magnifier is drawn. `onSelect` is optional. Without `freeText` the search mode is
1538
+ unchanged. Decision: `Docs/adr/0004-search-suggest-free-text.md`.
1539
+
1540
+ #### EntityPicker and SearchSuggest gaps (HAR-1834)
1541
+
1542
+ - A failure Retry cannot fix: reject `onSearch` with `{ retryable: false, message? }`, or pass
1543
+ `isRetryable(error)`. The list shows the message (or `labels.forbidden`) and no Retry row.
1544
+ - `SearchSuggest` takes `disabled` and `invalid`; `EntityPicker` forwards them, so an empty disabled
1545
+ picker cannot be typed in and an empty required one with an error is announced invalid.
1546
+ - Clear starts a new search: the old query and rows are gone and the field has focus.
1547
+ - A visible label names the field: with a `<label for>` (a `Field`) or `aria-labelledby`
1548
+ (`EntityPicker`) / `inputLabelledBy` (`SearchSuggest`), no `aria-label` overrides it.
1549
+ - While the next query loads the last rows stay in view (`aria-busy` at once), and the spinner waits
1550
+ 300 ms (`useDelayedPending`), so a fast answer shows none; Enter during a search picks nothing and
1551
+ submits nothing.
1552
+ ### Calm status colour, the app shell and display preferences (2.48)
1553
+
1554
+ #### StatusPill treatments, Stat sentiment, quiet Alert (HAR-1563)
1555
+
1556
+ ```tsx
1557
+ <StatusPill tone="danger" treatment="dot">Breached</StatusPill> // a table cell
1558
+ <StatusPill tone="progress">In progress</StatusPill> // the record's header (soft, the default)
1559
+ <StatusPill tone="warning" treatment="outline">Due in 2h</StatusPill> // a flag among other chrome
1560
+ <Stat label="Breached SLAs" value={7} trend={{ direction: 'up', label: '2', sentiment: 'negative' }} />
1561
+ ```
1562
+
1563
+ - `StatusPill treatment`: `'soft'` (default, the tinted wash; `'filled'` is its old name and still works), `'dot'`
1564
+ (no fill, a neutral label, the tone only in the dot: for tables) and `'outline'` (a neutral hairline, a hushed
1565
+ label and the dot; only `critical` keeps a toned border). `dot` and `outline` always draw the dot. **Changed:**
1566
+ `outline` used to colour the label and a 40 % border in the tone's hue.
1567
+ - `StatusPill tone="progress"`: in flight, the same blue as `info`.
1568
+ - `Stat trend.sentiment`: `'positive'` (green), `'negative'` (red) or `'neutral'` (grey, the default). **Changed:** the
1569
+ chip used to be green for `up` and red for `down`, so a rise in breached SLAs showed green. Pass the sentiment the
1570
+ change has for your metric.
1571
+ - `Stat tone`: the border stays neutral and a dot leads the toned value (**changed:** the border was toned).
1572
+ - `Alert`: the borders are a 24 % hairline of the tone (**changed:** 30 to 40 %); the warning icon is the text
1573
+ role, at least 3:1 on its tint in both themes.
1574
+
1575
+ #### A Stat link tile looks like a link at rest (HAR-1803)
1576
+
1577
+ A tablet has no hover, so a tile that leads somewhere has to show it before it is touched. A link tile
1578
+ (`href`) draws a trailing chevron (`ChevronRightIcon`) in its label row; a tile that opens a dialog or menu
1579
+ keeps its down chevron; an in-place action tile (`onActivate` with `haspopup={false}`, class
1580
+ `.uix-stat--action`) underlines its value with a dotted line. All three are shapes, so they read without
1581
+ colour and in forced-colours mode, and none depends on hover. A plain figure tile is unchanged. The
1582
+ hover tint of interactive tiles now applies only where the device can hover (`@media (hover: hover)`).
1583
+
1584
+ ```tsx
1585
+ <Stat label={t('overdue')} value={4} href="/loans?overdue" renderLink={toRoute} /> // › at rest
1586
+ ```
1587
+
1588
+ #### Inset: a well inside a card body (HAR-1818)
1589
+
1590
+ ```tsx
1591
+ <Card title={t('current')}>
1592
+ <Inset role="group" aria-label={t('headerPreview')}><BrandBar brand={brand} /></Inset>
1593
+ <Inset padding="sm"><code>{css}</code></Inset>
1594
+ </Card>
1595
+ ```
1596
+
1597
+ `Inset` (`.uix-inset`) frames a preview or a specimen inside a card body: a hairline border, the md radius,
1598
+ the subtle background and `--uix-space-4` padding (`padding="sm"`: `--uix-space-3`). Use it instead of a `Card`
1599
+ in a `Card`, which brings a second shadow, card padding and card semantics. `tone="surface"` is for a well on
1600
+ a panel that is already subtle. It lays out nothing; give it a `role="group"` and a name when the preview needs
1601
+ one.
1602
+
1603
+ #### Icons, not text glyphs (HAR-1576)
1604
+
1605
+ Toast, Pagination, MetricInput, SavedViewMenu and Steps draw the icon set (`XIcon`, the chevrons, `PlusIcon`
1606
+ / `MinusIcon`, `StarIcon`) where they drew `× ‹ › « + − ★ !`. The table sort arrows and the `Select` trigger's
1607
+ chevron are the icon set's glyphs through a CSS mask in `currentColor`, so they follow the theme; plain CSS gets
1608
+ the same through `.uix-glyph`:
1609
+
1610
+ ```html
1611
+ <span class="uix-glyph uix-glyph--chevron-down" aria-hidden="true"></span>
1612
+ ```
1613
+
1614
+ `node packages/react/scripts/glyph-icons.mjs` is the guard (it runs in `npm test`): a glyph as the whole content
1615
+ of a button, a link or an icon slot, or in a CSS `content:`, fails with its file and line. Text that only looks
1616
+ like an icon (a Kbd legend, a diff sign) goes in `glyph-icons.allow.json` with a reason.
1617
+
1618
+ #### The staff shell: full viewport and the type presets (HAR-1815)
1619
+
1620
+ ```tsx
1621
+ <AppShell layout="viewport" sidebar={<StaffNav />}>
1622
+ <PageHeader size="display" eyebrow={t('today')} title={t('overview')} subtitle={t('overviewLead')} actions={actions} />
1623
+ </AppShell>
1624
+ <DetailPage variant="staff" back={{ href: '/loans', label: t('allLoans') }} title={loan.code} metrics={metrics}>
1625
+ <Card><SectionHead size="compact" as="h3" title={t('copies')} /><Table>…<CellSub tabular>{copy.inventory}</CellSub>…</Table></Card>
1626
+ </DetailPage>
1627
+ ```
1628
+
1629
+ - `AppShell layout="viewport"`: no frame, `100dvh`, no top-bar row (a `topbar` sits at the top of the main
1630
+ column), staff padding (`space-7 space-6 space-6`, `space-8` at the sides from 80rem), and below 40rem one
1631
+ column with the sidebar hidden. `layout="frame"` is the default and the old look.
1632
+ - `PageHeader size="display"`: 40 px heading-font title, bold hushed eyebrow, body-size hushed subtitle (42rem),
1633
+ actions on the bottom line.
1634
+ - `DetailPage variant="staff"`: h2 / 1.15 title, un-underlined hushed back link, eyebrow metric labels over
1635
+ regular tabular h3 values.
1636
+ - `SectionHead size="compact"` (15 px / 1.3 / 600) and `as="h3"`; `CellSub tabular`.
1637
+
1638
+ #### Sidebar actions and search, AccountMenu, the rail hotkey and the phone nav tier (HAR-1371)
1639
+
1640
+ ```tsx
1641
+ const [nav, setNav] = useState<ShellNav>('full');
1642
+ <AppShell layout="viewport" navHotkey="[" navTierStorageKey="staff-nav" onNavChange={setNav} phoneNav="drawer"
1643
+ sidebar={(
1644
+ <Sidebar brand={brand} nav={nav === 'rail' ? 'rail' : 'full'}
1645
+ actions={<><ModeToggle /><Menu trigger={<Button icon size="sm" variant="ghost" aria-label={t('tools')}><EllipsisIcon /></Button>}>…</Menu></>}
1646
+ search={{ label: t('search'), shortcut: ['Mod', 'K'], onActivate: openPalette }}
1647
+ footer={<AccountMenu collapsed={nav === 'rail'} user={{ name, sub: email, status: 'online' }} items={<>
1648
+ <MenuItem href="/profile">{t('profile')}</MenuItem>
1649
+ <MenuItem onSelect={signOut} tone="danger">{t('signOut')}</MenuItem>
1650
+ </>} />}>
1651
+ {navItems}
1652
+ </Sidebar>
1653
+ )}>
1654
+ {page}
1655
+ </AppShell>
1656
+ ```
1657
+
1658
+ - `Sidebar actions` sit beside the brand (hidden in the rail); `search` is a row that opens your search and shows
1659
+ its keys (it does not bind them: your command palette listens); `footer` is pinned to the bottom.
1660
+ - Key caps are for a keyboard: on a touch phone or tablet (`(hover: none) and (pointer: coarse)`) the kit draws
1661
+ none, in the search row, Menu and CommandPalette rows, Tooltips, SearchSuggest and a Button's hint. What a
1662
+ screen reader hears is unchanged (`aria-keyshortcuts`, the spoken keys and descriptions stay).
1663
+ - `AccountMenu`: avatar (photo, your node, or initials), name and a second line as a `Menu` trigger;
1664
+ `collapsed` in the rail.
1665
+ - `navHotkey` switches full ↔ rail instantly, never while typing; `navTierStorageKey` remembers the tier before
1666
+ the first paint and reports it through `onNavChange`.
1667
+ - `phoneNav="drawer"`: at 40rem and under, a menu button opens the sidebar in a start-side `Drawer`.
1668
+
1669
+ #### Display preferences: theme, contrast, text size, motion, density (HAR-1372)
1670
+
1671
+ ```tsx
1672
+ // app/layout.tsx: before the stylesheets, so nothing flashes. The script sets attributes on <html>
1673
+ // before React hydrates, so <html> needs suppressHydrationWarning; under a Content-Security-Policy
1674
+ // give the inline script your per-request nonce (or allow its hash).
1675
+ <html lang="bs" suppressHydrationWarning>
1676
+ <head>
1677
+ <script nonce={nonce} dangerouslySetInnerHTML={{ __html: displayPreferencesScript('staff-display') }} />
1678
+ </head>
1679
+ <body>{children}</body>
1680
+ </html>
1681
+
1682
+ // a settings page, or a dialog from AccountMenu
1683
+ <DisplayPreferences storageKey="staff-display" />
1684
+ // the header
1685
+ <ThemeSwitcher storageKey="staff-display" display="icons" />
1686
+ // your own control
1687
+ const { preferences, setPreference, resolvedTheme } = useDisplayPreferences({ storageKey: 'staff-display' });
1688
+ ```
1689
+
1690
+ The tokens read five attributes on `<html>`: `data-theme` (light / dark), `data-contrast="more"`,
1691
+ `data-text-scale="sm|lg|xl"`, `data-motion="reduce"` and `data-density="compact|comfortable"`. A choice applies
1692
+ at once (transitions are suspended for that frame), is stored, and `system` follows the OS. The head script and the
1693
+ hook read the same key and the same defaults; pass both the same. `useReducedMotion` honours `data-motion`.
1694
+
1695
+ #### StatRow: a row of tiles that reflows
1696
+
1697
+ ```tsx
1698
+ <StatRow>{facts.map((f) => <Stat key={f.id} size="compact" {...f} />)}</StatRow>
1699
+ <StatRow className="uix-stat-row--hero">{kpis}</StatRow> // 12rem tiles for 34 px values
1700
+ ```
1701
+
1702
+ `StatRow` (`.uix-stat-row`) puts as many tiles side by side as fit at 8.5rem each (12rem with
1703
+ `.uix-stat-row--hero`, or as soon as a hero tile is in the row; `minTileWidth` / `--stat-row-min` to
1704
+ choose), all the same height, down to one per row on a phone. A fixed column count squeezed six tiles
1705
+ into 320 px until their labels broke letter by letter. Labels now wrap at a space and break inside a
1706
+ word only when that word alone is wider than the tile.