efficient-shell 5.6.1 → 6.4.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 (112) hide show
  1. package/README.md +184 -44
  2. package/THIRD_PARTY_NOTICES.md +212 -0
  3. package/dist/Browser-USPZWDYH.js +43 -0
  4. package/dist/Calculator-FP5HUWCW.js +29 -0
  5. package/dist/ConfirmDialog-3TIHKJRT.js +1 -0
  6. package/dist/{CurrencyConverter-2IGOOR3I.js → CurrencyConverter-XD5LYOCD.js} +8 -8
  7. package/dist/Documents-ADFEHEUW.js +52 -0
  8. package/dist/Files-BVUVRNC5.js +87 -0
  9. package/dist/Notepad-WJI5ODGS.js +25 -0
  10. package/dist/PdfViewer-226I3LSD.js +1 -0
  11. package/dist/PomodoroTimer-GEMQHIPJ.js +91 -0
  12. package/dist/Preview-HNSEYNZL.js +182 -0
  13. package/dist/Sidebar-PVHZPNIY.js +44 -0
  14. package/dist/{Spreadsheet-SRAUU3OA.js → Spreadsheet-6Z5ACGTT.js} +14 -14
  15. package/dist/{Stock-7EKOAQ7C.js → Stock-X3LIU4ZX.js} +4 -4
  16. package/dist/{Weather-RWI6NKB2.js → Weather-BLO354GM.js} +14 -14
  17. package/dist/{WorldClock-CEHWDFEO.js → WorldClock-BAH7QFRG.js} +7 -7
  18. package/dist/apps/index.js +1 -1
  19. package/dist/bin/ui-check.js +1 -1
  20. package/dist/brand.css +1 -1
  21. package/dist/chunk-2SSRFEOZ.js +91 -0
  22. package/dist/{chunk-TFETN2GI.js → chunk-4JZZLUGG.js} +1 -1
  23. package/dist/{chunk-GG7AE6OC.js → chunk-5K76RQML.js} +1 -1
  24. package/dist/chunk-5LUN632W.js +5 -0
  25. package/dist/chunk-5MDQNFOG.js +9 -0
  26. package/dist/chunk-7IQ3B77H.js +1 -0
  27. package/dist/{chunk-SKB6L53D.js → chunk-7PGHRG6J.js} +9 -9
  28. package/dist/chunk-CXJQ42XT.js +21 -0
  29. package/dist/chunk-DBXZS7OA.js +29 -0
  30. package/dist/{chunk-X4FJ54PP.js → chunk-FIEA5CGB.js} +1 -1
  31. package/dist/chunk-GJ37JCC2.js +1 -0
  32. package/dist/{chunk-KXAUDC3A.js → chunk-GWDVD3VQ.js} +27 -27
  33. package/dist/chunk-HQLAISFX.js +11 -0
  34. package/dist/chunk-I2H2CKID.js +3 -0
  35. package/dist/{chunk-YIH6JLMV.js → chunk-I534M65N.js} +2 -2
  36. package/dist/{chunk-WWF2EQXF.js → chunk-IHZL3GJU.js} +1 -1
  37. package/dist/chunk-K6DKX55N.js +1 -0
  38. package/dist/chunk-KNUR2UEG.js +1 -0
  39. package/dist/{chunk-JXAAZGLJ.js → chunk-MORDXCV6.js} +1 -1
  40. package/dist/chunk-PFKMIPZH.js +1028 -0
  41. package/dist/chunk-PJPK2ADX.js +1 -0
  42. package/dist/chunk-Q5O5UMJY.js +6 -0
  43. package/dist/chunk-QJHWLDEN.js +108 -0
  44. package/dist/chunk-R5VRLW5Z.js +1 -0
  45. package/dist/chunk-S3LGKIZ3.js +12 -0
  46. package/dist/chunk-SBF5A6XV.js +1 -0
  47. package/dist/chunk-SK5DL6JX.js +24 -0
  48. package/dist/{chunk-CLPJDJH4.js → chunk-UUYPUKP7.js} +1 -1
  49. package/dist/{chunk-AFYQTVZO.js → chunk-UVBK6YSR.js} +1 -1
  50. package/dist/chunk-VR6TR575.js +13 -0
  51. package/dist/{chunk-7WC3EOFS.js → chunk-XO2AMGVU.js} +1 -1
  52. package/dist/{chunk-N3A3GWJI.js → chunk-YX2IMURA.js} +12 -12
  53. package/dist/chunk-ZEIKSMFR.js +10 -0
  54. package/dist/client-DLm3Fvbc.d.ts +65 -0
  55. package/dist/file-intake/index.js +1 -1
  56. package/dist/i18n/index.d.ts +1 -1
  57. package/dist/icons/efficient-looks.d.ts +1 -1
  58. package/dist/icons/efficient.d.ts +1 -1
  59. package/dist/icons/index.d.ts +12 -12
  60. package/dist/icons/index.js +1 -1
  61. package/dist/{index-ZI6vl2Wq.d.ts → index-D5bms_uA.d.ts} +186 -76
  62. package/dist/index.d.ts +6 -4
  63. package/dist/index.js +639 -639
  64. package/dist/locales/zh-Hans.d.ts +1 -1
  65. package/dist/locales/zh-Hans.js +1 -1
  66. package/dist/map/index.js +22 -22
  67. package/dist/markdown/index.js +6 -6
  68. package/dist/markup/index.d.ts +460 -36
  69. package/dist/markup/index.js +1 -1
  70. package/dist/page-tabs/index.js +5 -5
  71. package/dist/pdf/index.js +1 -1
  72. package/dist/{registry-J-TU7zKx.d.ts → registry-Dw6QO2Ch.d.ts} +12 -9
  73. package/dist/{strings-BVB64Nck.d.ts → strings-fYzxZPn4.d.ts} +5 -0
  74. package/dist/telemetry/index.d.ts +39 -0
  75. package/dist/telemetry/index.js +1 -0
  76. package/dist/ui/index.d.ts +4 -3
  77. package/dist/ui/index.js +1 -1
  78. package/dist/ui.css +62 -21
  79. package/package.json +7 -21
  80. package/dist/Browser-RXGEZ77D.js +0 -43
  81. package/dist/Calculator-MVPKTZZF.js +0 -29
  82. package/dist/ConfirmDialog-XOYCYFA7.js +0 -1
  83. package/dist/Documents-YBLR7WVX.js +0 -52
  84. package/dist/Files-7XNR5IL2.js +0 -87
  85. package/dist/Notepad-ZQYQXV75.js +0 -25
  86. package/dist/PdfViewer-SQVMMA7B.js +0 -1
  87. package/dist/PomodoroTimer-XIUN4M73.js +0 -91
  88. package/dist/Preview-YCOA5JPP.js +0 -182
  89. package/dist/Sidebar-VI7E3EKI.js +0 -44
  90. package/dist/chunk-3HJTOINK.js +0 -1
  91. package/dist/chunk-4SWPNARR.js +0 -5
  92. package/dist/chunk-5ABLFFC5.js +0 -24
  93. package/dist/chunk-6LTDJKIL.js +0 -138
  94. package/dist/chunk-F6B3J3GW.js +0 -6
  95. package/dist/chunk-HZZNJHRH.js +0 -1
  96. package/dist/chunk-IMMHJIV5.js +0 -29
  97. package/dist/chunk-OGXU7QFW.js +0 -12
  98. package/dist/chunk-P4CIOJHP.js +0 -3
  99. package/dist/chunk-RBDGN4YU.js +0 -9
  100. package/dist/chunk-RC2EG6P4.js +0 -1026
  101. package/dist/chunk-RL626E7Q.js +0 -13
  102. package/dist/chunk-RMJZOKHY.js +0 -10
  103. package/dist/chunk-SFCDHHQP.js +0 -1
  104. package/dist/chunk-TNBCUZVE.js +0 -1
  105. package/dist/chunk-VWBQ2P5G.js +0 -108
  106. package/dist/chunk-XWSWYMGM.js +0 -2
  107. package/dist/chunk-YGC7AX3X.js +0 -11
  108. package/dist/chunk-Z7D6Y5WA.js +0 -21
  109. package/dist/icons/heroicons.d.ts +0 -6
  110. package/dist/icons/heroicons.js +0 -1
  111. package/dist/icons/lucide.d.ts +0 -6
  112. package/dist/icons/lucide.js +0 -1
package/README.md CHANGED
@@ -54,9 +54,12 @@ Peer deps you should already have in a typical React + Tailwind v4 app:
54
54
 
55
55
  ```bash
56
56
  npm i react react-dom react-router-dom @tanstack/react-query react-hook-form \
57
- tailwindcss @headlessui/react @heroicons/react
57
+ tailwindcss @headlessui/react
58
58
  ```
59
59
 
60
+ No icon package: the shell draws every icon from its own set
61
+ (`efficient-shell/icons/efficient`).
62
+
60
63
  All of those except `react` / `react-dom` are declared **optional** peers. The
61
64
  shell's components need them — a portal that drops one gets a module-not-found at
62
65
  build — but a consumer taking only `efficient-shell/markup` needs none of them, and
@@ -922,6 +925,23 @@ End go to `min`/`max`, and Enter or a double-click resets to `defaultWidth`. A
922
925
  drag starts after 3px, so a click never nudges it. `storageKey` restores the
923
926
  width after mount and keeps it once a drag settles. Its hint is a `Tooltip`.
924
927
 
928
+ For the seam between two stacked panes, such as a message box under a thread,
929
+ pass `orientation="horizontal"` and `side="top"` or `side="bottom"` (where the
930
+ pane sits). The word and its values are `Divider`'s and `IconRail`'s, and they
931
+ name the line: a grip between stacked panes is a horizontal line. It drags up
932
+ and down, its cursor is `row-resize`, it is a separator with
933
+ `aria-orientation="horizontal"`, and ArrowUp/ArrowDown move it. Home, End,
934
+ Enter, the double-click and `storageKey` work as above. `width`, `min`, `max`
935
+ and `defaultWidth` are then the pane's height. The default is
936
+ `orientation="vertical"`, and its markup is unchanged.
937
+
938
+ ```tsx
939
+ <section className="relative" style={{ height }}>
940
+ <ResizeHandle orientation="horizontal" side="bottom" width={height} onResize={setHeight} min={108} max={520} defaultWidth={220} storageKey="chat.composer" />
941
+ …
942
+ </section>
943
+ ```
944
+
925
945
  **`WidthsBar`** sets how a row's columns share its width: a bar split into
926
946
  the columns, a handle between each pair, and a typed % box for each column.
927
947
  The last column fills itself. It came from the campaign designer's "Column
@@ -1017,6 +1037,25 @@ Round 4:
1017
1037
  option checked), `disabled` options (greyed, skipped by the arrows, inert to
1018
1038
  a press), and `clearable={false}` to drop the "×". Groups, flattening and the
1019
1039
  check are shared with `Select`.
1040
+ - **`SearchableSelect` as a list filter, and with several values** (admin
1041
+ list pages). `filter` marks a field that filters a list ("All Suppliers").
1042
+ While it holds a value it is "on": the accent border and a light accent fill,
1043
+ as a set `FilterBar` chip, in light and dark. Empty or disabled, it is a
1044
+ plain field. With `filter` or `multiple`:
1045
+ - the open list may be **wider than the field** — at least the field's
1046
+ width, growing to fit its options up to the kit's popup width, inside the
1047
+ owning window (UI-11) — so long names are not cut;
1048
+ - **open, the field still shows what is chosen** (in the field's ink) until
1049
+ the person types.
1050
+
1051
+ With `multiple`, a **"Clear all"** row tops the open list while anything is
1052
+ chosen (not with `clearable={false}`). Closed, the field shows **the labels
1053
+ that fit, then "+N"** for the rest ("AA, AC, MF +3"), never a bare "…"; a
1054
+ field too narrow for one label and a count says "6 selected". Hovering a
1055
+ shortened field shows every chosen label in a `Tooltip`. A single-pick field
1056
+ without `filter` is unchanged. New strings: `select.clearAll`,
1057
+ `select.selectedCount(count)`. `Tooltip` shows no bubble while its `content`
1058
+ is empty.
1020
1059
  - **`IconButton`**: `pressed` wears the pressed look without `aria-pressed`,
1021
1060
  for a menu button that is open (`aria-expanded`), a radio, or a window's
1022
1061
  button.
@@ -1082,21 +1121,21 @@ All exports are named — `import { Modal, ... } from 'efficient-shell'`.
1082
1121
  | `ProductionTimeline` | The scrubbable production bar, drawn on `TimelineTrack` inside the shared timeline card: a sentence-case heading with the PO as its subject, the window and the lead time as meta, a Play **pill** on the right whose glyph follows its state, and a footer carrying the status line and legend chips drawn with the track's own glyphs — a shipment is a diamond with a truck in it and an inspection a disc with a flask, on the rail and in the chip alike, because every kind is painted in the one accent and the glyph is the only thing left to tell them apart. The thumb only ever rests on a date a report was filed on — drag it and it snaps report to report, arrow it and it steps one report at a time — so the bar never shows an estimate dressed as a fact. Press Play and it **travels** between them: a fresh press rewinds to the first report, the thumb reads each snapshot for 700 ms and then glides to the next with the date chip counting the days it crosses, and the status line under the bar changes on arrival rather than while it is on its way. Pausing freezes it mid-glide and pressing Play again carries on from exactly there; touching the thumb ends the playback and hands it back. The walk keeps time by the clock rather than by the frame rate, so an idle or throttled pane does not stretch it. `onScrubProgress` forwards the track's in-flight fraction — `{ fromKey, toKey, t, ms }` keyed by report id, `null` at a stop — which is how a consumer's items table moves WITH the thumb instead of jumping a whole report's worth of quantities when it lands; it is optional and undefined by default, so a caller that only reads the snapshot needs no change. `onPickReport` selects a report; the optional `onOpenReport` / `onOpenMarker` make the label above the thumb a real button that opens the document behind it. `reportLabel` names a report wherever the bar states its identity — the caption over the thumb, the popover header, each dot's accessible name, the slider's value text and the status line — and defaults to its own `progress_number`, while `resetLabel` replaces the whole of the `Back to …` button: together they are what lets a portal that must not print an internal document number run the same card. A report carries an optional `preview` for its hover/focus popover — the stage row it filed, the note attached to it — and `renderReportPreview` builds one for the reports that carry none, the convenience for a caller whose list arrives straight off an API; the report's own preview wins where both exist, and the popover prints the report number and the date itself. `useProductionTimeline(opts)` owns the slider state and the derived snapshot so the caller's items table and the bar read the same state; `STAGES` / `calcOverall` / `calcReportOverall` are the stage maths it shares with that table. **`variant="rail"`** is the bar alone, for a window whose own header already says what the order is: no card, no heading, no lead-time line, no label under the active report — every report, shipment, inspection and **invoice** (kind `invoice`, a disc with a receipt) is a dot with a popover — the thumb with its date chip, `Start` and `Est. done` flanking the rail, the estimated completion as a hollow mark on the right edge while it is still ahead, and under the rail one row: Play, the return button when the thumb has been moved, and the `<report> · date · N% overall · N pc in stock` line. The rail spreads the reports (the track's `spread` axis) rather than folding two a week apart into a `×2`. `onPlayStart` fires before the thumb moves, on a fresh run and on a resume, so a window that keeps the bar in view while its table shows something else can switch the table first. |
1083
1122
  | `MilestoneTimeline` | The static sibling, on the same `TimelineTrack` and in the same card: one dot per milestone on a date axis, no scrubber. `heading` and `subject` split the title from the record it is about (`Mould development` · **001F/1813**), and the meta line states when it started, how long it has run and how many milestones are dated. The axis **cuts** rather than squeezes: a stretch holding more than 30% of the window's time becomes a fixed notch with a break glyph saying how many days it hides, and every stretch that still keeps time gets at least 48 px. Labels are packed into **two lanes** by their **measured** width, and a run of same-kind revisions folds into one `DFM ×4` pill whose members keep their dots on the rail: hovering it magnifies that stretch until each member can carry its own label, a click pins the popover listing them, Escape closes it. A milestone carries an optional `glyph` (a `default` one that names `doc` is drawn as the signed drawing it is), a `preview` for the popover, and an `onOpen` that the popover's "Open" button fires — `onClick` selects the dot on the bar, `onOpen` opens the drawing behind it, and a card that declares only the first keeps its Open button because the footer falls back to it. Label and date are printed by the popover above the preview, so a consumer supplies neither. A milestone with **no date gets no position**, because the only position available would be a date the record does not have: it is listed beside the bar under "Not yet reached" (two rows, then a real `+N more` button) and takes no part in the range, the fill or a phase bracket. `phase` groups of 2+ get a bracket marking parallel work, the fill never runs past today, and today gets its dashed rule and tag even when it IS the right edge — the commonest case, because a programme with no finish date ends at today. **`variant="rail"`** is the bar alone, for a window whose stepper already names the stages: no card, no heading, no label under any dot — every milestone is a dot with a popover, a revision carries a two-character `caption` over its dot (`v3`; captions that would overprint collapse to `v1–v4`), `edgeCaptions` flank the rail on its own row (`{ start: 'Start', end: 'Ready' }`), nothing is cut (the `spread` axis: every stretch at least 48 px, the idle ones drawn short rather than notched), and what has not happened is one dashed **cap** at the end of a dashed tail — a real button announced as the last undated milestone, naming the rest in its popover. The stepper and the rail then point at each other: `highlightKeys` lights one soft band from the leftmost named milestone to the rightmost and every dot inside it (a single key rings one dot; the cap answers to the pending milestone's key), and `onHoverChange` reports the milestone under the pointer or the focus, `null` on leave. A `provisional` milestone — a sample ORDERED, drawn where the shipment will be — is hollow, never "where we are", and outside the fill. |
1084
1123
  | `SettingRow` | One setting in a settings card: the name with its explanation stacked under it on the left, the control or value on the right (it never shrinks), and an optional body (`children`) that opens beneath behind its own line. Each row draws its own top line (`divider`) and padding, so rows stack in a card. One prop per slot: `label` (the name) and `value` (a control or a read-only fact). `size="compact"` is the editor-panel row. Handles a value that is a control (`controlId` labels it), a read-only fact, a value nobody knows (an em dash in the faint ink, never an empty cell), and a `quiet` row nothing can write yet. A name over a field (children, no value) draws nothing on the right. `DescriptionList` is the record-detail neighbour and stays that. |
1085
- | `Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`, `FormField`, `Label` | Form controls — controlled (`value`/`onChange`); `Input`/`Textarea` forward native props for react-hook-form. `Button` and `Switch` take `disabledReason`: persistent text beside the control wired with `aria-describedby`, never a `title` — a tooltip needs a hover a disabled control does not reliably get. `Switch` also takes `disabledReasonId`, so a panel whose controls are all dead for one reason states it once and points every control at it. A `Select` option carries an optional `description`, rendered as a second muted line **in the open list only** — the closed trigger stays one line, so one explained option cannot change the height of every field in the row. `NativeSelect` (and so `Select` on touch) ignores it: an `<option>` holds text and nothing else. An option also takes `lang` (a BCP 47 tag) when its label is written in another language than the page's — a language picker naming each language in its own words: it goes on the option in the open list, on the native `<option>` and on the closed trigger's label while chosen, so a screen reader and the font fallback treat each name as its own language (WCAG 3.1.2). On the desktop listbox, `title` joins `aria-label` / `aria-labelledby` / `aria-describedby` on the VISIBLE trigger button — it used to ride the native spread onto the `sr-only` `<select>`, where a tooltip has no pointer that can reach it and a fallback name sits on an `aria-hidden` element. `name`, `required` and the forwarded ref stay on the native control, which is what it is for. A `Checkbox` or `Radio` with both a `label` and a `description` is NAMED by the label and DESCRIBED by the description (`aria-labelledby` / `aria-describedby`); the whole row is still its `<label>`, so its words still tick it. `FormField` points its one child at the hint or error on screen with `aria-describedby`, and marks it `aria-invalid` while `error` is set. A child that already has its own `aria-describedby` (a react-hook-form binding often does) keeps it: the ids are merged, the child's own first, and each id is said once — a repeated id is a message a screen reader reads twice. |
1124
+ | `Button`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`, `FormField`, `Label` | Form controls — controlled (`value`/`onChange`); `Button`'s `variant` is `primary` (the default), `secondary` (outlined), `secondary-danger` (outlined in red: a destructive action in a footer of outlined buttons, e.g. Delete beside Archive — the same size and shape as `secondary`, red border and text, a light red wash on hover), `ghost`, `ghost-danger` (red text, no box: a destructive action among quiet `ghost` buttons), `danger` (solid red: the one action a confirm dialog asks for) or `link`; `Input`/`Textarea` forward native props for react-hook-form. `Button` and `Switch` take `disabledReason`: persistent text beside the control wired with `aria-describedby`, never a `title` — a tooltip needs a hover a disabled control does not reliably get. `Switch` also takes `disabledReasonId`, so a panel whose controls are all dead for one reason states it once and points every control at it. A `Select` option carries an optional `description`, rendered as a second muted line **in the open list only** — the closed trigger stays one line, so one explained option cannot change the height of every field in the row. `NativeSelect` (and so `Select` on touch) ignores it: an `<option>` holds text and nothing else. An option also takes `lang` (a BCP 47 tag) when its label is written in another language than the page's — a language picker naming each language in its own words: it goes on the option in the open list, on the native `<option>` and on the closed trigger's label while chosen, so a screen reader and the font fallback treat each name as its own language (WCAG 3.1.2). On the desktop listbox, `title` joins `aria-label` / `aria-labelledby` / `aria-describedby` on the VISIBLE trigger button — it used to ride the native spread onto the `sr-only` `<select>`, where a tooltip has no pointer that can reach it and a fallback name sits on an `aria-hidden` element. `name`, `required` and the forwarded ref stay on the native control, which is what it is for. A `Checkbox` or `Radio` with both a `label` and a `description` is NAMED by the label and DESCRIBED by the description (`aria-labelledby` / `aria-describedby`); the whole row is still its `<label>`, so its words still tick it. `FormField` points its one child at the hint or error on screen with `aria-describedby`, and marks it `aria-invalid` while `error` is set. A child that already has its own `aria-describedby` (a react-hook-form binding often does) keeps it: the ids are merged, the child's own first, and each id is said once — a repeated id is a message a screen reader reads twice. |
1086
1125
  | `buttonClasses`, `iconButtonClasses` | `Button`'s and `IconButton`'s look as a class string, for something that must navigate and so cannot be a `<button>`: `<Link href="/organisations/new" className={buttonClasses({ variant: 'secondary' })}>Add a business</Link>`. Same options and defaults as the component (`variant`, `size`, `block`, `className`), and the components render through these functions, so the two cannot drift. What the string cannot give an `<a>`: a disabled state (`disabled:` never matches an anchor — drop the `href`, add `role="link"` and `aria-disabled="true"`, or render a disabled `Button`), and the cursor (an `<a href>` gets the browser's pointer, a `<button>` the host's preflight; set it once for both). Keep its `inline-flex` — ask for `block` rather than passing a display utility. An icon-only link still needs the `aria-label` `IconButton` would have required. Client references, like every kit function: call them in a `'use client'` file. |
1087
1126
  | `labelClasses` | `Label`'s look as a class string, for a `<label>` a host lays out itself — beside a check box, in a row with its control, `sr-only`: `<label htmlFor="x" className={labelClasses({ inline: true, className: 'flex gap-2' })}>`. `inline: true` drops `Label`'s block layout (`mb-1 block`) and keeps its size, weight and ink; without it the string is exactly what `Label` renders. A client reference: call it in a `'use client'` file. |
1088
1127
  | `Segmented` | A row of mutually exclusive options, all visible: a button group, or with `name` a real radio group that submits. Named by `label`, or by an element already on the page (`aria-labelledby`, a list row's own heading) and described by another (`aria-describedby`); an option's `title` says on hover what choosing it means. |
1089
1128
  | `DisplayChips` | Which parts of a section are shown, one chip per part ("Breadcrumb", "Stats", "Intro text"): blue with an eye while shown, grey with a crossed eye while hidden, and a press flips it. The chips wrap, so a long list takes a few short lines instead of one switch row per part. Controlled: `items` (`{ id, label, shown, disabled?, disabledReason? }`) and `onChange(id, shown)`. The group is named by `label` or `aria-labelledby`. Each chip is a real toggle button (`aria-pressed`), named by its part and described by its state in words (`displayChips.shown` / `hidden`), with a hover label that says what a press does; Space and Enter are the button's own. A chip with `disabledReason` stays focusable, its press does nothing and the reason is said, as on `Button`. Not `Segmented` (one choice of several) and not `TagInput` (typing values in). |
1090
1129
  | `RadioGroup` | One choice from a short visible list, with the group chrome each panel used to rebuild: a `role="radiogroup"` named by its own label (`aria-labelledby`, because `<label for>` cannot name a div), the `hint`/`error`/`required` wiring borrowed from `FormField`, and `orientation` for a row that wraps rather than compresses. The options are real native radios sharing one `name`, which is what gives the group its arrow keys, its disabled-skipping and its single tab stop — all from the user agent, none of it hand-rolled. `value: null` is "nothing chosen yet", not a silent first option. |
1091
1130
  | `Snippet` | A value the user copies rather than reads — an endpoint, a tenant id, a shell command. Monospace, truncated with the whole value on its `title` (`multiline` wraps instead), and a copy button that flips to a check for two seconds and says "Copied" through an `aria-live` region. `value` is what reaches the clipboard and `children` is what is shown, so a masked key displays masked and copies whole. `symbol` prints a leading glyph that is never copied and has **no default** — a URL has no `$` in front of it. A refused clipboard toasts rather than looking like it worked. Not a read-only `Input`: a value that can only be copied is not a control and should not take a tab stop or invite an edit. `id` and `aria-describedby` land on the COPY BUTTON — the only focusable thing in the row, and a labelable one — so a wrapping `FormField` really does label it and its hint is really announced; with `hideCopyButton` they fall back to the box, because an id that resolves to nothing is not wiring. |
1092
- | `Kbd` | The badge the shortcut constants (`CMD_ENTER`, `ALT_SHIFT_E`, …) are shown in — a real `<kbd>`, two rungs, `className` layered on so a portal can tint it. It renders the string it is handed; which symbol a platform uses is what the constants already decide. |
1131
+ | `Kbd` | The badge the shortcut constants (`CMD_ENTER`, `ALT_SHIFT_E`, …) are shown in — a real `<kbd>`, two rungs, `className` layered on so a portal can tint it. `tone` says what it sits on: `neutral` (default, grey), `accent` (inside a primary or submit button: the `kbd-submit` chip, with no colour utility of its own) or `warning` (an amber button). It renders the string it is handed; which symbol a platform uses is what the constants already decide. |
1093
1132
  | `FormErrorSummary` | The error list at the top of a failed form (WCAG 3.3.1, the GOV.UK pattern): takes focus when errors appear, each message is a link that focuses the offending control by its `FormField` id. `fieldId` is optional: an error with no control behind it — a server's `_general` / `non_field_errors`, or a field the form does not draw — is listed as plain text, not a link that goes nowhere. Renders nothing while `errors` is empty. |
1094
1133
  | `TagInput` | Multi-value field — chosen values as removable chips, typing filters the option list in the same dropdown SearchableSelect uses. `allowFreeText` admits unlisted entries; Backspace in the empty input removes the last chip. The value array stays duplicate-free by construction. |
1095
1134
  | `DatePicker`, `TimePicker`, `DateTimePicker`, `DateRangePicker` | Date/time fields, all careful to speak LOCAL dates and wall-clock times (never `toISOString`). `TimePicker` and `DateTimePicker` wrap the platform's own inputs in the kit's field styling; `TimePicker` hands back an `HH:MM` string — a time of day names no calendar day, so it never invents a Date. **`DatePicker`** opens the kit's `Calendar` in a popover (`native` swaps in the platform's date input). Its value posts from a hidden input that takes `name` and `form`; `required` adds "Required" to the trigger's description (`datePicker.required`; a button may not carry `aria-required`) and drops Clear from the footer (`clearable` overrides either way); `invalid` paints the field, and the reader hears the error message the host links through `aria-describedby` (`FormField` does) — the trigger carries no `aria-invalid`, which a button may not either; Today is offered only when `min`/`max` allow today; `aria-*`, `data-*`, `title`, `tabIndex`, `autoFocus`, `onFocus` and `onBlur` reach the trigger, while what only a text box can use (`readOnly`, `pattern`, `autoComplete`…) applies with `native` alone. Named by a `<label htmlFor={id}>` or `aria-labelledby`, the trigger is described by the date spelled out. A browser never validates a hidden input, so `required` cannot block a submit in the popover. **`DateRangePicker`** is a from/to calendar with presets in one popover: side by side on a desktop; where that does not fit the room the box allows (a phone, a narrow shell window — measured, not a media query), the panel takes the full width between the margins and stacks, the presets a wrapping row of chips above the calendar and From/To one line each. `fullWidth` fills a filter-grid cell instead of shrink-wrapping its label. `id` (on the trigger button, for `<label htmlFor>`), `aria-labelledby` and `aria-label` name the trigger, and the chosen range is then its description ("Period, button, 1 August 2026 to 3 October 2026"). `name={{ from, to }}` posts the applied range as two hidden `YYYY-MM-DD` inputs (`''` when unset), so a GET form or a server action reads it as it read two date inputs. `required` adds "Required" to the trigger's description (`dateRange.required`) and, unless `clearable` is given, removes Clear. `min` / `max` (`YYYY-MM-DD`) bound it as they bound `DatePicker`: days outside are disabled, a preset whose range reaches outside is not offered (none at all on a range that starts today — time off, a booking — and then no Custom either), and with nothing chosen the panel opens on the first month that has a day to pick. `disabled` makes the whole control inert: a disabled trigger with the kit's disabled look, no panel, no Clear ×, and the hidden inputs disabled so the range is not posted (an open panel closes). `allowOpenEnd` lets Apply commit ONE end on its own — "from 1 August", "up to 30 June" — and turns the panel's From and To boxes into buttons that choose which end the next day sets (the start first, as before); the open end comes back as `''`. Without it Apply needs both ends, as it always has. Both pickers take `weekStartsOn` (0 Sunday, 1 Monday); without it the week starts where the provider's `locale` says. |
1096
- | `Card`, `StatCard` | Surface panel (optional header/footer; `description` puts one sentence under the title, outside the heading) + dashboard metric tile. |
1135
+ | `Card`, `StatCard` | Surface panel (optional header/footer; `description` puts one sentence under the title, outside the heading; `headingLevel` makes the title a heading and the card a region named by it, and an `aria-label` names the region instead when the title is not the name, such as a count; `tone="warning"` draws the border amber for a card that needs attention, and the card's words still say why; `tone="current"` marks the current card in a list with a light-blue header band, a blue title and `aria-current`) + dashboard metric tile. |
1097
1136
  | `Avatar`, `AvatarGroup` | User avatar with initials fallback + status dot; overlapping stack with +N overflow. |
1098
1137
  | `Banner` | Static in-flow alert (`tone`: info / success / warning / danger / neutral). `neutral` is a note that is none of the other four ("Prices exclude GST"): grey in the kit's neutral, the same wash and ink as a neutral `StatusBadge` (`--status-neutral-soft` / `--status-neutral-soft-ink`, `--status-neutral-solid` when `emphasis="solid"`), with the info glyph. `action` renders a control at the right edge — after the text, so a screen reader reaches the problem before the remedy, and before the dismiss ×, so "Update card" is never past "close this". Vertically centred against the words and `shrink-0`, so a message that wraps does not drag the button down or squeeze it — and on a narrow banner (a phone, a narrow column: the words keep at least 12rem) it drops UNDER the words rather than leave them a column one word wide. A banner that names a condition and leaves the fix three menus away is the usual shape of a notice nobody acts on. |
1099
- | `Tabs`, `Accordion`, `Tooltip` | Controlled tab strip, collapsible sections, frosted hover/focus tooltip. A tooltip's `side` is a preference: near an edge the bubble slides along that side to stay 8 px clear of it, and flips to the opposite side when its own has no room, keeping inside the screen and inside the shell window that owns its trigger (UI-11). It never leaves that box: a bubble wider than the room wraps, and one taller than the room is cut off at it. The bubble is drawn on `<body>`, in a `fixed` layer laid over its trigger, so a scrolling pane or an `overflow-hidden` box around the trigger cannot cut it off; it follows the trigger when that moves and closes once the trigger has scrolled out of sight. A small arrow on the edge facing the trigger points at the trigger's centre wherever the bubble ended up, kept clear of the bubble's corners; it is the bubble's own surface (`.rosh-tooltip-arrow` in `ui.css`), solid and seamless under `rosh-reduce-transparency`, and hidden from assistive technology. |
1138
+ | `Tabs`, `Accordion`, `Tooltip` | Controlled tab strip, collapsible sections, solid hover/focus tooltip. A tooltip's `side` is a preference: near an edge the bubble slides along that side to stay 8 px clear of it, and flips to the opposite side when its own has no room, keeping inside the screen and inside the shell window that owns its trigger (UI-11). It never leaves that box: a bubble wider than the room wraps, and one taller than the room is cut off at it. The bubble is drawn on `<body>`, in a `fixed` layer laid over its trigger, so a scrolling pane or an `overflow-hidden` box around the trigger cannot cut it off; it follows the trigger when that moves and closes once the trigger has scrolled out of sight. A small arrow on the edge facing the trigger points at the trigger's centre wherever the bubble ended up, kept clear of the bubble's corners; it is the bubble's own surface (`.rosh-tooltip-arrow` in `ui.css`), seamless, and hidden from assistive technology. The bubble is SOLID, not glass, in every setting: the kit's solid popup surface (`--rosh-glass-surface`, `--rosh-glass-solid-border`, `--rosh-glass-solid-shadow`), so words behind a small bubble never mix with its own. |
1100
1139
  | `Stepper` | A linear wizard's progress strip — numbered circles, connectors, `aria-current="step"`. Controlled like Tabs; completed steps are clickable to go back (when `onChange` is wired), upcoming steps never are — moving forward belongs to the wizard's own Continue button, behind its validation. |
1101
1140
  | `DataTable` | Read-only table for server-driven lists: it renders and reports, and never sorts or pages its own `data`. Controlled sort and pager, row selection, column groups, pinned columns, windowed rendering. `onRow` gives a row `onClick`, a `className`, any `data-*` attributes for its `<tr>` — a test's or a stylesheet's hook (`data-testid`, `data-state`) — the pointer and the keyboard coming in and out (`onMouseEnter` … `onBlur`) and `current`. `rowHref` makes each row one real link (`onRowPress` a button) with its controls still pressable — see [DataTable rows that are links](#datatable-rows-that-are-links). `stackOnNarrow` turns each row into a labelled card on a narrow container — see [DataTable on a narrow screen](#datatable-on-a-narrow-screen). |
1102
1141
  | `Pagination` | Numbered page control (pairs with tables; complements `ListFooter`). |
@@ -1274,8 +1313,8 @@ import 'efficient-shell/ui.css';
1274
1313
  ```
1275
1314
 
1276
1315
  **It reaches nothing but `react` and `react-dom`.** None of the optional peers —
1277
- no `react-router-dom`, `@tanstack/react-query`, `axios`, `@headlessui/react` or
1278
- `@heroicons/react` — which matters because they are declared *optional*, so a
1316
+ no `react-router-dom`, `@tanstack/react-query`, `axios` or `@headlessui/react`
1317
+ — which matters because they are declared *optional*, so a
1279
1318
  consumer who has not installed them gets an unresolvable import rather than a
1280
1319
  degraded style. CI asserts it against the built artifact
1281
1320
  (`scripts/verify-dist.mjs`), not just the source, because leakage can arrive
@@ -1552,7 +1591,14 @@ maps `--color-blue-*` sets these as well:
1552
1591
 
1553
1592
  Give the chip `kbd-submit` and no colour utility of its own. The dark and
1554
1593
  accent themes remap palette utilities as `!important`, so one written beside
1555
- the class (`Kbd`'s gray ones included) wins over it there:
1594
+ the class (`Kbd`'s gray ones included) wins over it there. `<Kbd tone="accent">`
1595
+ is that chip, so write it in React; a bare `<kbd>` is the same chip in HTML:
1596
+
1597
+ ```tsx
1598
+ <button className="btn-submit rounded-lg px-4 py-2 text-sm font-medium">
1599
+ Save <Kbd keys={CMD_ENTER} tone="accent" />
1600
+ </button>
1601
+ ```
1556
1602
 
1557
1603
  ```html
1558
1604
  <button class="btn-submit rounded-lg px-4 py-2 text-sm font-medium">
@@ -1584,7 +1630,7 @@ Tailwind colour the kit always drew:
1584
1630
  | `--rosh-focus-ring-deep` | blue-500 | charts (plot, legend), a chip's remove, the date-range field |
1585
1631
  | `--rosh-focus-ring-accent` | blue-500, follows the accent | HelpCenter's search, the prompt dialog's field |
1586
1632
  | `--rosh-focus-ring-zone` | `rgba(59,130,246,.45)` | upload zones and thumbnails |
1587
- | `--rosh-focus-ring-invalid` | red-400 at 30% | an invalid field, `ghost-danger` |
1633
+ | `--rosh-focus-ring-invalid` | red-400 at 30% | an invalid field, `ghost-danger`, `secondary-danger` |
1588
1634
  | `--rosh-focus-ring-invalid-strong` | red-400 at 40% | the `danger` Button |
1589
1635
  | `--rosh-focus-ring-invalid-solid` | red-400 | `FormErrorSummary` |
1590
1636
 
@@ -1696,8 +1742,8 @@ rows take their height from `--control-height-sm` / `-md` / `-lg` (24 / 32 /
1696
1742
  `efficient-shell/ui`: `SPACING_SCALE`, `SPACING_ROLES`, `CONTROL_HEIGHTS`, and
1697
1743
  `isSpacingStep('2.5')` for a Tailwind step.
1698
1744
 
1699
- **Glass.** Menus, popups, listboxes and tooltips are frosted glass
1700
- (`glassStyle()`). `--menu-opacity` (0–1, default `0.95`) sets how much shows
1745
+ **Glass.** Menus, popups and listboxes are frosted glass
1746
+ (`glassStyle()`); a `Tooltip` bubble is always solid (`solidSurfaceStyle()`). `--menu-opacity` (0–1, default `0.95`) sets how much shows
1701
1747
  through. `rosh-reduce-transparency` on `<html>` makes every popup one solid
1702
1748
  `--rosh-glass-surface` (default: `--surface`) with no blur — and strips
1703
1749
  `backdrop-filter` from everything else on the page too, since it is the user's
@@ -3295,27 +3341,57 @@ that checks them, live in the EFFICIENT harness (`52-brand-surface.md`).
3295
3341
 
3296
3342
  ### Editorial markup — `efficient-shell/markup`
3297
3343
 
3298
- One grammar for copy a human types into a plain text box, so a toolbar button and
3299
- a parser can never disagree about what a delimiter means: `**bold**`, `_italic_`,
3300
- `~~strike~~`, `==highlight==` (the brand accent, applied to a selection).
3344
+ The ONE grammar for copy a human types into a plain text box — in both editors,
3345
+ on the published page and in the campaign email — so a toolbar button and a
3346
+ parser can never disagree about what a delimiter means. It moved here from
3347
+ efficient-easel (`src/markup`, 6d31d5d) on 2026-10-12 (Lewis, D1) and replaced
3348
+ the smaller grammar this subpath held before.
3301
3349
 
3302
3350
  **Its own subpath because it imports nothing** — no React, no JSX, no DOM, none
3303
3351
  of this package's peers. A public site can take the rule without inheriting a 3D
3304
3352
  viewer and a PDF renderer; `dist/markup/index.js` is a standalone file with zero
3305
- import statements.
3353
+ import statements, and the `./markup` export carries a `default` condition so a
3354
+ build script running under `tsx` resolves it too.
3306
3355
 
3307
3356
  What is shared is the RULE, not the rendering. A web renderer paints with
3308
3357
  theme-token classes and an email renderer must inline every style, so each host
3309
- keeps its own renderer and walks the same token list.
3358
+ keeps its own renderer and walks the same tree or token list.
3359
+
3360
+ | Mark | Means | Written by a button |
3361
+ |---|---|---|
3362
+ | `**x**` | **Strong** (bold) | yes, ⌘B |
3363
+ | `_x_` | italic, only at a word edge | yes, ⌘I |
3364
+ | `~~x~~` | strikethrough | yes |
3365
+ | `==x==` | **Emphasis** (the brand accent) | `applyMark` writes it; a copy field no longer offers it (`RETIRED_MARKS`) |
3366
+ | `^^x^^` | **Quiet** | `applyMark` writes it; a copy field no longer offers it (`RETIRED_MARKS`) |
3367
+ | `[words](href)` | a link — `http`, `https`, `mailto`, `tel` or no scheme only | yes, ⌘K |
3368
+ | `- ` at the start of a line | a bullet line, one level | yes |
3369
+ | `1. ` at the start of a line | a numbered line, one level: 1-3 digits, `. `, words; the number is kept as typed | yes |
3370
+ | `[x]{underline}`, `[x]{sup}`, `[x]{sub}` | underline, superscript, subscript (tree only) | `applyMark` / `toggleMark` |
3371
+ | `[x]{color=name}` / `[x]{color=#rrggbb}` | a theme colour by name (`isColorName`), or any colour as exactly six hex digits (`isColorValue`; saved lowercase) (tree only) | `applyColor` / `colorOver` |
3372
+ | `@@x@@`, `++x++` | Emphasis, Strong (the storefront's old word styles). `++` opens only at a word start, so `A++ rated tyres, A++ service` is plain text | no — read only |
3373
+ | `*x*`, `#x#`, `[x]` | legacy accent, bold, italic (per product set) | no — read only |
3374
+
3375
+ The three names a merchant sees are in `MARK_NAMES`. A run never crosses a line
3376
+ break, and runs, spans and links nest up to four levels
3377
+ (`[**_[x]{sup}_**]{color=accent}`); a fifth stays as typed. Any block after `]`
3378
+ that is not exactly one of the four (`[x]{foo}`, `[x]{color=#f00}`,
3379
+ `[x]{{first_name}}`) is plain text. The spans and the numbered line are read by
3380
+ `parseMarkup` only: `tokenizeInline` keeps them as text, so a renderer that has
3381
+ not learned them prints the delimiters.
3310
3382
 
3311
3383
  | Export | Notes |
3312
3384
  |---|---|
3313
- | `applyMark(text, start, end, style)` | Pure writer behind a toolbar button — returns the new text plus where the selection should sit. Pressing the same button again unwraps. |
3314
- | `MARKUP_TOOLS`, `COPY_FIELD_TOOLS`, `markupTools(styles)` | Button descriptors, and the four a copy field offers (bold / italic / strike / highlight). A host renders its own buttons; only the rule is shared. |
3315
- | `tokenizeInline(value, rules)` | Parses to `InlineToken[]`. Tokens ALTERNATE, starting and ending with a `text` token that may be empty — a renderer that wraps every segment depends on the empty ones to keep its markup stable. |
3316
- | `stripInline(value, rules)` | The plain words — the only correct source for an `alt`, an `aria-label` or a structured-data field. Deleting delimiter characters by hand breaks the moment the grammar grows a marker. |
3317
- | `STANDARD_MARKUP` | The four marks above. |
3318
- | `STOREFRONT_MARKUP`, `CAMPAIGN_MARKUP` | Standard plus a host's own LEGACY runs, so already-published copy keeps rendering as it does today. Designed to be deleted once stored content has been converted. |
3385
+ | `parseMarkup(value, rules)` → `MarkupLine[]` | The whole grammar as a tree. Each run keeps the delimiters that were saved. |
3386
+ | `serializeMarkup(lines)` | The inverse: `serializeMarkup(parseMarkup(s, rules)) === s`, for every string. Pinned by `tests/markupRoundTrip.test.ts`; `tests/markupSameAnswers.test.ts` holds 1,609 strings from the repos' own code and tests whose answers were recorded once from easel's copy. |
3387
+ | `isSafeLinkHref(href)` | The link rule. Call it before writing any `href`. |
3388
+ | `tokenizeInline(value, rules)`, `stripInline(value, rules)` | The flat reading older renderers use: runs only, no links, no nesting. Tokens ALTERNATE, starting and ending with a `text` token that may be empty. `stripInline` is the only correct source for an `alt`, an `aria-label` or a structured-data field. |
3389
+ | `applyMark(text, start, end, style, rules?, placeholders?)`, `markedAt`, `MARKUP_TOOLS`, `markupTools`, `COPY_FIELD_TOOLS`, `MARK_PLACEHOLDERS` | The writer behind a toolbar button (pressing the same button again unwraps), whether a button is pressed, and the button descriptors. A copy field offers bold, italic and strike. |
3390
+ | `applyColor(text, start, end, name)`, `isColorName`, `isColorValue`, `spanFor` | A colour carries a value, so it has its own writer: it writes, changes and (`null`) takes off `[x]{color=name}`, and changes nothing for a value `isColorValue` refuses (`#f00`, `rgb()`, a typo). |
3391
+ | `findLink`, `writeLink`, `removeLink` | The link window's reading and writing, with the parser's own link rule. |
3392
+ | `markedLines`, `writeMarkedLines`, `sameMarks` | The saved string as characters, each carrying its runs, and back byte for byte — what an editor that hides the delimiters edits. |
3393
+ | `toggleMark`, `marksIn`, `linkOver`, `unlinkAt`, `colorOver`, `colorIn`, `setList`, `listIn`, `clearMarks`, `clearStyle` | Writers that act on the WORDS whichever runs they sit in, splitting a run rather than crossing a delimiter, and the readers a toolbar draws its pressed state from. |
3394
+ | `STANDARD_MARKUP`, `STOREFRONT_MARKUP`, `CAMPAIGN_MARKUP`, `RETIRED_MARKS` | The rule sets. The last two add each product's LEGACY runs, designed to be deleted once stored content has been converted. |
3319
3395
 
3320
3396
  Two delimiter choices worth knowing. Italic is `_phrase_`, not `*phrase*`,
3321
3397
  because a single asterisk already means the accent color in the products that
@@ -3414,6 +3490,58 @@ so an app importing from both has one hook.
3414
3490
  | `FileIntakeAlert` | The rejection list as `role="alert"`, styled with Tailwind utilities (`text-xs text-red-600`). A page whose Tailwind does not scan this package renders `rejections` itself, as above — keep the `role="alert"`. |
3415
3491
  | `FileIntakeOptions`, `FileIntakeLimits`, `FileRejection`, `FileRejectionReason` | Types. |
3416
3492
 
3493
+ ### Web vitals and errors — `startClientTelemetry` (`efficient-shell/telemetry`)
3494
+
3495
+ Field performance and client errors from a portal, sent to the backend's
3496
+ first-party intake (`POST /api/client-telemetry/web-batches/`, efficient-backend
3497
+ docs/adr/0057). Call it once, after sign-in is known; the host decides whether
3498
+ it runs, what the app is called and what a route is.
3499
+
3500
+ ```ts
3501
+ import { startClientTelemetry } from 'efficient-shell'; // or 'efficient-shell/telemetry'
3502
+
3503
+ const telemetry = startClientTelemetry({
3504
+ app: 'admin-portal', // the intake's web app code
3505
+ release: APP_VERSION, // this build's version
3506
+ getAccessToken: () => getAccessToken(),// in-memory bearer token; null = send nothing
3507
+ routeKey: () => currentRouteTemplate(),// '/sales-orders/:id' or a window key, never the URL
3508
+ enabled: true, // the host's switch
3509
+ vitalsSampleRate: 1, // share of page loads whose vitals are collected
3510
+ });
3511
+ // telemetry.flush(), telemetry.reportError(error), telemetry.stop()
3512
+ ```
3513
+
3514
+ What it collects:
3515
+
3516
+ - **Web vitals** — LCP, INP, CLS, FCP and TTFB through `web-vitals` 6.2.3,
3517
+ bundled (the host installs nothing). LCP, FCP and TTFB are filed under the
3518
+ route the page loaded on; INP and CLS under the route when they are reported.
3519
+ The device class (`desktop`, `tablet`, `mobile`) comes from the viewport and
3520
+ the pointer.
3521
+ - **Errors** — window `error`, `unhandledrejection`, and the render errors both
3522
+ `ErrorBoundary` and `WindowErrorBoundary` catch (a consumer's own `onError`
3523
+ still runs beside it). Each error is the class, a redacted message (no
3524
+ emails, tokens, URLs, UUIDs or record numbers; 160 characters) and the top
3525
+ five frames as `path:line:column`, unsymbolicated. The same error is sent once
3526
+ a page load with a count, and at most 20 distinct errors a page load.
3527
+
3528
+ How it sends: queued, then posted every minute and when the page is hidden or
3529
+ left, in batches of at most 50 events and 30 KB, with
3530
+ `fetch(…, { keepalive: true })` and the bearer token. Not `sendBeacon`, which
3531
+ cannot carry an `Authorization` header. A failed send is dropped and never
3532
+ retried. No user, URL or query string ever leaves the page; a raw path the host
3533
+ passes as a route is cleaned (query gone, ids made `:id`).
3534
+
3535
+ **The subpath reaches no package at all**, so dealer and POS, which take only
3536
+ `efficient-shell/ui`, can report too. `web-vitals` is a pinned devDependency
3537
+ bundled into it (`tests/telemetryEntryBundlesWebVitals.test.ts`); its licence
3538
+ is in `THIRD_PARTY_NOTICES.md`.
3539
+
3540
+ | Export | Notes |
3541
+ |---|---|
3542
+ | `startClientTelemetry(config)` | Starts collecting; a second call replaces the first. Returns `{ flush, reportError, stop }`. `enabled: false`, a missing `window` or an `app`/`release` that is not version-like starts nothing. |
3543
+ | `ClientTelemetryConfig`, `ClientTelemetry` | Types. The subpath also exports the wire types (`TelemetryBatch`, `TelemetryEvent`, `WebVitalEvent`, `ClientErrorEvent`, `ErrorSource`, `DeviceClass`). |
3544
+
3417
3545
  ### Open pages as tabs — `efficient-shell/page-tabs`
3418
3546
 
3419
3547
  For a routed app — a sidebar, a header, one page at a time — that wants what
@@ -3558,7 +3686,7 @@ read a stored focus on a server.
3558
3686
  | `severityOf(value, warn?, crit?)` | The `SeverityTone` (`success` \| `warning` \| `danger`) a reading earns against **inclusive** bounds; `null` when there's no reading or no usable bounds — the shell hardcodes no threshold. Backs `MetricBar`; use it to roll a `SidebarNavItem severity` up. |
3559
3687
  | `isSeverityTone(value)` | Type guard for the three tones. Validate a backend rollup with it at the fetch boundary, where a bad token can still be reported against its payload, rather than letting it surface as a wrong pixel. |
3560
3688
  | `toast.success / .error / .info` | Toast notifications — auto-mounts container. Each plays the user's chime; `toast.configure({ sound: false })` keeps them quiet for a host whose screen makes sounds of its own (an alarm, a till's beep), and `{ sound: true }` on one toast still asks for it. |
3561
- | `Z_LAYERS` | The shell's z-index scale: `window` 50 (+ `windowStep` 10 per window above), `menu` 400, `pinnedWindow` 999, `overlay` 9999 (the palette, `Dialog`, `Drawer`, the shortcut sheet), `popup` 10000 (portalled listboxes, calendars, the server-status card). Popups sit above overlays so one opened inside a dialog is not drawn behind it. Apply as inline `zIndex` — an interpolated `z-[…]` class is invisible to Tailwind's scan. |
3689
+ | `Z_LAYERS` | The shell's z-index scale, in hundreds (owner ruling, 2026-10-12), bottom to top: `raised` / `sticky` / `inBox` 10 / 20 / 30 (inside one box only), `snapPreview` 900, `window` 1000 (+ `windowStep` 2 per window above), `peek` 1999, `pinnedWindow` 2000, `desktopWidget` 2100, `taskbar` 2200, `sidebar` 2300, `taskbarPreview` 2350, `startMenu` 2400, `restoreTab` 2500, `expose` 2600, `overlay` 2700 (the palette, `Dialog`, `Drawer`, the shortcut sheet, startup and logout), `toast` 2800 (toasts, the notification card, the update banner), `popup` = `menu` 2900 (context menus, portalled listboxes, calendars, the server-status card) and `hint` 3000 (tooltips). Menus and popups sit above overlays and toasts, so one opened inside a dialog is never drawn behind it; a pinned window stays under the taskbar and every menu. Apply as inline `zIndex`, or in markup as `z-(--z-<layer>)` from the matching `--z-*` custom property in `ui.css` — an interpolated `z-[…]` class is invisible to Tailwind's scan. Never write a stack order above 30 as a number: name the layer, and add a new one between two hundreds. |
3562
3690
  | `dismissPopups()`, `OVERLAY_OPEN_EVENT` | An overlay calls `dismissPopups()` as it opens; every open kit popup (`Select`, `SearchableSelect`, `TagInput`, `DatePicker`, `DateRangePicker`, `PopupMenu` via its `onClose`, `ServerStatusIndicator`) closes rather than floating over the overlay. The kit's own overlays already call it. A consumer's own popup listens for `OVERLAY_OPEN_EVENT` on `window` while open. |
3563
3691
  | `Kbd` constants — `MOD`, `ALT`, `SHIFT`, `ENTER`, `ALT_SHIFT_E`, `CMD_K`, … | Symbol constants for rendering keyboard shortcuts. |
3564
3692
 
@@ -3590,7 +3718,7 @@ root entry. They import React and nothing else.
3590
3718
  icon in every repository (2026-10-10). `<Icon name>` is typed to it, so a
3591
3719
  misspelt meaning does not compile.
3592
3720
 
3593
- **One set.** `efficient` is the company's one icon set (Lewis 2026-10-12). Lewis
3721
+ **The default set.** `efficient` is the company's default icon set (Lewis 2026-10-12). Lewis
3594
3722
  picked the drawing of every meaning himself: the heroicons outline drawing where
3595
3723
  heroicons has the meaning, and our own drawing in the heroicons style (24 grid,
3596
3724
  1.5 line, round ends) where it has not. One meaning, one drawing: a name that
@@ -3610,15 +3738,28 @@ for draws its default.
3610
3738
  |---|---|---|---|
3611
3739
  | `efficient-shell/icons/efficient` | `efficient` | every meaning, default look | nothing |
3612
3740
  | `efficient-shell/icons/efficient-looks` | `efficient` (its other looks) | `solid`, `mini` and the kept looks | nothing |
3613
- | `efficient-shell/icons/heroicons` | `heroicons` | **deprecated**: the 197 meanings it drew | `@heroicons/react` |
3614
- | `efficient-shell/icons/lucide` | `lucide` | **deprecated**: the 197 meanings it drew | `lucide-react` (0.577 or later) |
3615
3741
 
3616
- The heroicons and lucide sets go in the next major release; register `efficient`.
3617
- Heroicons drawings are copied under the MIT licence: see `THIRD_PARTY_NOTICES.md`.
3742
+ No icon package is behind it: the shell imports none, and no host needs
3743
+ `@heroicons/react` or `lucide-react` for it. Heroicons
3744
+ drawings are copied under the MIT licence: see `THIRD_PARTY_NOTICES.md`.
3745
+
3746
+ **More sets: added beside the default.** `efficient` is the default set (Lewis
3747
+ 2026-10-12: the set he picked is the default, and we add other sets later). A
3748
+ new set is one module that exports an `IconSet` (its `name`, `label` and the
3749
+ meanings it draws, with `looks` if it has them), registered after the default:
3750
+
3751
+ ```tsx
3752
+ registerIconSet(efficient); // the default: every meaning
3753
+ registerIconSet(sketch); // { name: 'sketch', label: 'Sketch', icons: { trash: SketchTrash, … } }
3754
+ ```
3618
3755
 
3619
- The first set registered is the default (`setDefaultIconSet(name)` changes
3620
- that). A meaning the chosen set does not draw falls back to the default set, so
3621
- a partial set never leaves a hole.
3756
+ A set need not draw every meaning. Whatever the chosen set does not draw falls
3757
+ back to the default set's drawing, so a new set can start with a handful of
3758
+ icons and never leaves a blank. The first set registered is the default
3759
+ (`setDefaultIconSet(name)` changes that); after the chosen set and the default,
3760
+ every other registered set is asked, which is how a product's own glyphs are
3761
+ found. With a `look`, each set is asked for that look and then for its own
3762
+ default drawing, so a chosen set keeps its style.
3622
3763
 
3623
3764
  **Part of the theme.** The `icon_set` preference sits beside the colour theme
3624
3765
  and the corners: `useTheme` and `applyThemePrefs({ icon_set })` choose the set
@@ -3633,16 +3774,16 @@ drawn in the set that is active. It appears only when a host has registered
3633
3774
  more than one set, since one set is nothing to choose between.
3634
3775
 
3635
3776
  **The shell's own icons follow the set too.** A window's close button, the
3636
- sidebar's chevrons, a banner's tone, a number box's steppers: 199 of the icons
3637
- the shell draws itself are `<Icon name>`, so the chosen set draws them. Each
3638
- hands `<Icon>` the drawing it had before as its `fallback`
3639
- (`src/icons/fallbacks.tsx`, not a public entry), which is what a host that
3640
- registered no set still sees: nothing changes there. Size, colour and stroke
3641
- width stay with the place, so an icon takes the same box whichever set draws
3642
- it. What is left inline is not an icon by meaning: charts, spinners,
3777
+ sidebar's chevrons, a banner's tone, a number box's steppers: every icon the
3778
+ shell draws itself is `<Icon name>` (with a `look` where it is filled), so the
3779
+ chosen set draws them. A host that registered no set still sees them: `<Icon>`
3780
+ falls back to `src/icons/kit.tsx` (not a public entry), which the generator
3781
+ writes from the default set's source with exactly the meanings and looks the
3782
+ shell's parts name, so the kit does not bundle the whole set. Size, colour and stroke width stay with the
3783
+ place. What is left inline is not an icon by meaning: charts, spinners,
3643
3784
  illustrations, an app's tool palette, a path a host supplies.
3644
3785
  `tests/shellIconFallbacks.test.tsx` holds each file to its count; a new icon in
3645
- the shell is `<Icon name="…" fallback={…} />`.
3786
+ the shell is `<Icon name="…" />`, and `npm run icons` adds it to the kit.
3646
3787
 
3647
3788
  **A product's own glyphs.** An editor's block types and settings rows are not
3648
3789
  in the catalogue. Register them and draw them by `glyph`, with no icon package
@@ -3661,15 +3802,14 @@ whatever the theme.
3661
3802
  **Adding or changing a drawing.** `src/icons/efficient.json` is the source: per
3662
3803
  name its meaning, where the drawing came from, the default SVG (24 by 24
3663
3804
  viewBox, 1.5 stroke on `currentColor`, round ends, transparent background) and
3664
- its looks, or the name it is an alias of. Add the meaning to
3665
- `src/icons/catalogue.json` too, run `npm run icons`, and commit the lot. Look
3805
+ its looks, or the name it is an alias of. Run `npm run icons` and commit the
3806
+ lot. Look
3666
3807
  before you keep a drawing: `node scripts/icons/glyph-sheet.mjs <new>
3667
3808
  <neighbours…>` draws it at 16px beside the glyphs it could be mistaken for, and
3668
3809
  `tests/efficientSet.test.tsx` fails it if it looks like another meaning.
3669
3810
 
3670
- With no set registered, `<Icon>` draws nothing and warns once per meaning. The
3671
- kit's own components (a window's close button, a filter chip) keep drawing
3672
- through a built-in fallback, so a host that registers nothing sees no change.
3811
+ With no set registered, `<Icon>` draws nothing for a meaning the shell's own
3812
+ parts do not use, and warns once per meaning. Register `efficient` at startup.
3673
3813
 
3674
3814
  ### The UI kit check — `efficient-shell-ui-check`
3675
3815