efficient-shell 0.0.0-stage → 4.126.2

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 (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +753 -2
  3. package/dist/Browser-DBRSIQ4J.js +478 -0
  4. package/dist/Browser-DBRSIQ4J.js.map +1 -0
  5. package/dist/Calculator-HCKFTSJR.js +188 -0
  6. package/dist/Calculator-HCKFTSJR.js.map +1 -0
  7. package/dist/ConfirmDialog-LJXMB45A.js +3 -0
  8. package/dist/ConfirmDialog-LJXMB45A.js.map +1 -0
  9. package/dist/CurrencyConverter-BKKORN6K.js +232 -0
  10. package/dist/CurrencyConverter-BKKORN6K.js.map +1 -0
  11. package/dist/Documents-5MD5SKPB.js +437 -0
  12. package/dist/Documents-5MD5SKPB.js.map +1 -0
  13. package/dist/Files-KY35DPWB.js +801 -0
  14. package/dist/Files-KY35DPWB.js.map +1 -0
  15. package/dist/Notepad-2BO5VEUG.js +406 -0
  16. package/dist/Notepad-2BO5VEUG.js.map +1 -0
  17. package/dist/PdfViewer-LSP3YNFC.js +4 -0
  18. package/dist/PdfViewer-LSP3YNFC.js.map +1 -0
  19. package/dist/PomodoroTimer-SVJ62RXM.js +629 -0
  20. package/dist/PomodoroTimer-SVJ62RXM.js.map +1 -0
  21. package/dist/Preview-N4UYYXG6.js +2955 -0
  22. package/dist/Preview-N4UYYXG6.js.map +1 -0
  23. package/dist/Sidebar-J76PV4QF.js +290 -0
  24. package/dist/Sidebar-J76PV4QF.js.map +1 -0
  25. package/dist/Spreadsheet-5EWZGMXN.js +515 -0
  26. package/dist/Spreadsheet-5EWZGMXN.js.map +1 -0
  27. package/dist/Stock-CDUS4VCZ.js +154 -0
  28. package/dist/Stock-CDUS4VCZ.js.map +1 -0
  29. package/dist/Weather-EEDBWBPQ.js +427 -0
  30. package/dist/Weather-EEDBWBPQ.js.map +1 -0
  31. package/dist/WorldClock-FFFULVTJ.js +198 -0
  32. package/dist/WorldClock-FFFULVTJ.js.map +1 -0
  33. package/dist/apps/index.d.ts +267 -0
  34. package/dist/apps/index.js +147 -0
  35. package/dist/apps/index.js.map +1 -0
  36. package/dist/brand.css +2 -0
  37. package/dist/chunk-2VM2FRZJ.js +215 -0
  38. package/dist/chunk-2VM2FRZJ.js.map +1 -0
  39. package/dist/chunk-32W6VY2J.js +549 -0
  40. package/dist/chunk-32W6VY2J.js.map +1 -0
  41. package/dist/chunk-3CUSBZWG.js +59 -0
  42. package/dist/chunk-3CUSBZWG.js.map +1 -0
  43. package/dist/chunk-3L463T5H.js +26 -0
  44. package/dist/chunk-3L463T5H.js.map +1 -0
  45. package/dist/chunk-4TVV2BIU.js +13334 -0
  46. package/dist/chunk-4TVV2BIU.js.map +1 -0
  47. package/dist/chunk-6XQHH7D2.js +105 -0
  48. package/dist/chunk-6XQHH7D2.js.map +1 -0
  49. package/dist/chunk-7CCHEEYC.js +94 -0
  50. package/dist/chunk-7CCHEEYC.js.map +1 -0
  51. package/dist/chunk-7KIOEKHS.js +617 -0
  52. package/dist/chunk-7KIOEKHS.js.map +1 -0
  53. package/dist/chunk-AXWC3332.js +1103 -0
  54. package/dist/chunk-AXWC3332.js.map +1 -0
  55. package/dist/chunk-BFBWLCO6.js +16 -0
  56. package/dist/chunk-BFBWLCO6.js.map +1 -0
  57. package/dist/chunk-C3NELUTX.js +79 -0
  58. package/dist/chunk-C3NELUTX.js.map +1 -0
  59. package/dist/chunk-CT3HVZRZ.js +26 -0
  60. package/dist/chunk-CT3HVZRZ.js.map +1 -0
  61. package/dist/chunk-D7PYW2QS.js +265 -0
  62. package/dist/chunk-D7PYW2QS.js.map +1 -0
  63. package/dist/chunk-DMCFOWOJ.js +153 -0
  64. package/dist/chunk-DMCFOWOJ.js.map +1 -0
  65. package/dist/chunk-FQB24B42.js +416 -0
  66. package/dist/chunk-FQB24B42.js.map +1 -0
  67. package/dist/chunk-GA35NEND.js +56 -0
  68. package/dist/chunk-GA35NEND.js.map +1 -0
  69. package/dist/chunk-HNH7FOYB.js +69 -0
  70. package/dist/chunk-HNH7FOYB.js.map +1 -0
  71. package/dist/chunk-HYZ5QMVO.js +462 -0
  72. package/dist/chunk-HYZ5QMVO.js.map +1 -0
  73. package/dist/chunk-I464GYJJ.js +17 -0
  74. package/dist/chunk-I464GYJJ.js.map +1 -0
  75. package/dist/chunk-JFQGPY7W.js +819 -0
  76. package/dist/chunk-JFQGPY7W.js.map +1 -0
  77. package/dist/chunk-MGGY74RY.js +14 -0
  78. package/dist/chunk-MGGY74RY.js.map +1 -0
  79. package/dist/chunk-N3CG5HG5.js +144 -0
  80. package/dist/chunk-N3CG5HG5.js.map +1 -0
  81. package/dist/chunk-W6XTG23K.js +3708 -0
  82. package/dist/chunk-W6XTG23K.js.map +1 -0
  83. package/dist/chunk-YG45DAUT.js +96 -0
  84. package/dist/chunk-YG45DAUT.js.map +1 -0
  85. package/dist/chunk-YMGXZABY.js +7 -0
  86. package/dist/chunk-YMGXZABY.js.map +1 -0
  87. package/dist/chunk-YNGPTOL6.js +46 -0
  88. package/dist/chunk-YNGPTOL6.js.map +1 -0
  89. package/dist/chunk-YNHLQ24V.js +400 -0
  90. package/dist/chunk-YNHLQ24V.js.map +1 -0
  91. package/dist/chunk-YNKK7NE3.js +171 -0
  92. package/dist/chunk-YNKK7NE3.js.map +1 -0
  93. package/dist/chunk-YPO6RDMX.js +20 -0
  94. package/dist/chunk-YPO6RDMX.js.map +1 -0
  95. package/dist/file-intake/index.d.ts +87 -0
  96. package/dist/file-intake/index.js +3 -0
  97. package/dist/file-intake/index.js.map +1 -0
  98. package/dist/index-dQia6NpB.d.ts +5904 -0
  99. package/dist/index.d.ts +2495 -0
  100. package/dist/index.js +7460 -0
  101. package/dist/index.js.map +1 -0
  102. package/dist/markdown/index.d.ts +118 -0
  103. package/dist/markdown/index.js +184 -0
  104. package/dist/markdown/index.js.map +1 -0
  105. package/dist/markup/index.d.ts +235 -0
  106. package/dist/markup/index.js +135 -0
  107. package/dist/markup/index.js.map +1 -0
  108. package/dist/page-tabs/index.d.ts +154 -0
  109. package/dist/page-tabs/index.js +706 -0
  110. package/dist/page-tabs/index.js.map +1 -0
  111. package/dist/shell.css +105 -0
  112. package/dist/sounds-NT4DEZGD.js +3 -0
  113. package/dist/sounds-NT4DEZGD.js.map +1 -0
  114. package/dist/styles.css +22 -0
  115. package/dist/themes.css +249 -0
  116. package/dist/types-BOinJXUf.d.ts +118 -0
  117. package/dist/ui/index.d.ts +4 -0
  118. package/dist/ui/index.js +15 -0
  119. package/dist/ui/index.js.map +1 -0
  120. package/dist/ui.css +1518 -0
  121. package/package.json +178 -4
package/README.md CHANGED
@@ -1,3 +1,754 @@
1
- # Temporary Holding Version
1
+ # react-os-shell
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A desktop-style React UI shell — windows, taskbar, start menu, sticky notes, frosted-glass theming — plus bundled apps.
4
+
5
+ > **Status:** v0.8.0 — the bundled Email + Calendar apps and their Node IMAP/SMTP/CalDAV bridge have been removed. Mail is now the consuming app's responsibility.
6
+
7
+ ### → [Live demo](https://efficient-pty-ltd.github.io/efficient-shell/)
8
+
9
+ A backend-less playground hosted on GitHub Pages. Wallpapers, themes, sticky notes, the spreadsheet, all wired to `localStorage` so the page survives a refresh. Source is in [`examples/demo/`](examples/demo/).
10
+
11
+ [![react-os-shell demo](docs/hero.png)](https://efficient-pty-ltd.github.io/efficient-shell/)
12
+
13
+ <sub>The screenshot is auto-captured against the deployed demo by [`.github/workflows/screenshot.yml`](.github/workflows/screenshot.yml). Run it manually: `gh workflow run "Capture hero screenshot"` (or use the Actions tab).</sub>
14
+
15
+ ## What's in the box
16
+
17
+ **Shell:** `<Layout>`, `<StartMenu>`, `<Desktop>` (with sticky notes + folders), `<WindowManager>`, `<Modal>` (standard / compact / widget styles), `<PopupMenu>`, `<ConfirmDialog>`, `<GlobalSearch>` (Cmd-K), `<ShortcutHelp>`, `<NotificationBell>`, `<StatusBadge>`, `<SearchableSelect>`, frosted-glass theming.
18
+
19
+ **UI primitives:** `<Button>`, `<Input>`, `<Textarea>`, `<Select>`, `<Checkbox>`, `<Radio>` / `<RadioGroup>`, `<FormField>`, `<Label>`, `<Snippet>`, `<Kbd>`, `<Card>` / `<StatCard>`, `<Avatar>` / `<AvatarGroup>`, `<Banner>`, `<Tabs>`, `<Accordion>`, `<Tooltip>`, `<Pagination>`, `<MetricBar>`, `<BudgetBar>`, `<SettingRow>`, and dependency-free `<Sparkline>` / `<LineChart>` / `<BarChart>` / `<DonutChart>` charts.
20
+
21
+ **Page templates:** ready-made screens composed from the primitives — `<DashboardTemplate>`, `<DataTablePage>`, `<FormLayoutPage>`, `<CheckoutTemplate>`, `<EmailTemplate>`, `<ChatTemplate>`, `<GalleryTemplate>`, `<AuthScreen>`, `<ErrorPage>`. `<ErrorBoundary>` catches a render crash and shows the 500 page in place of a blank screen.
22
+
23
+ **Apps:**
24
+ - **Utilities:** Calculator, Notepad, Spreadsheet, Weather, CurrencyConverter, PomodoroTimer, WorldClock, TodoList
25
+ - **Documents / Web:** Preview, Documents, Files, Browser
26
+
27
+ Most apps ship in the `bundledApps` registry; a few (WorldClock, Notepad) want consumer-supplied prefs wiring to persist content across reloads. The bundled `Customization` settings page is also exported separately for consumers to register at `/settings/customization`.
28
+
29
+ **Hooks:** `useWindowManager`, `useWindowDirty`, `useTheme`, full hotkey/nav system.
30
+
31
+ **Themes:** light + dark (frosted-glass tinting; the package ships base styles, additional theme variants like pink/green/grey/blue can layer on top).
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ npm i react-os-shell@npm:efficient-shell
37
+ ```
38
+
39
+ Published on npm as **`efficient-shell`** (formerly `react-os-shell`, which
40
+ stops at 4.126.0). Installed under the alias above, the package keeps the
41
+ `react-os-shell` name in `node_modules`, so every import in this README works
42
+ as written.
43
+
44
+ Peer deps you should already have in a typical React + Tailwind v4 app:
45
+
46
+ ```bash
47
+ npm i react react-dom react-router-dom @tanstack/react-query react-hook-form \
48
+ tailwindcss @headlessui/react @heroicons/react
49
+ ```
50
+
51
+ All of those except `react` / `react-dom` are declared **optional** peers. The
52
+ shell's components need them — a portal that drops one gets a module-not-found at
53
+ build — but a consumer taking only `react-os-shell/markup` needs none of them, and
54
+ `autoInstallPeers` would otherwise install the lot on its behalf.
55
+
56
+ **`pdfjs-dist` is ranged, not wildcarded.** The Preview viewer accepts
57
+ `^5.6.205 || ^6.0.0`. pdf.js removes API across majors — 6.0 dropped the
58
+ bare-string `getDocument(url)` this viewer used to call, and an unbounded range
59
+ let that arrive as a routine upgrade — so a new major goes into the range only
60
+ once the viewer has been checked against it.
61
+
62
+ Preview needs no wiring for pdf.js's WebAssembly decoders. It names
63
+ `pdfjs-dist/wasm/jbig2.wasm` and `pdfjs-dist/wasm/openjpeg.wasm` through
64
+ `new URL(..., import.meta.url)`, so a production bundle emits them from your own
65
+ installed copy and hands them to pdf.js without reaching a CDN. Those are the
66
+ decoders JBIG2 (scanned) and JPEG 2000 images need; without a location pdf.js
67
+ declines to fetch them and drops such images from an otherwise-rendered page.
68
+
69
+ Two caveats worth knowing. The specifier is resolved by your bundler at build
70
+ time, so this is verified for production builds; a dev server that pre-bundles
71
+ dependencies may not resolve it, in which case you get exactly the previous
72
+ behaviour rather than a new failure. And a host that would rather serve the
73
+ whole `pdfjs-dist/wasm/` directory itself sets
74
+ `window.__REACT_OS_SHELL_PDF_WASM__` to its URL before opening a Preview window.
75
+
76
+ ## Quick start (~50 lines)
77
+
78
+ ```tsx
79
+ // App.tsx
80
+ import { BrowserRouter, Routes, Route } from 'react-router-dom';
81
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
82
+ import {
83
+ Layout,
84
+ WindowManagerProvider,
85
+ ConfirmProvider,
86
+ ShellAuthProvider,
87
+ ShellPrefsProvider,
88
+ ShellEntityFetcherProvider,
89
+ StatusBadgeProvider,
90
+ setShellApiClient,
91
+ setShellAuthBridge,
92
+ setShellWindowRegistry,
93
+ createWindowRegistry,
94
+ useLocalStoragePrefs,
95
+ } from 'react-os-shell';
96
+ import { bundledApps } from 'react-os-shell/apps';
97
+ import 'react-os-shell/styles.css';
98
+ import axios from 'axios';
99
+
100
+ const apiClient = axios.create({ baseURL: '/api' });
101
+ setShellApiClient(apiClient);
102
+ setShellWindowRegistry(createWindowRegistry(bundledApps));
103
+ setShellAuthBridge({ user: { first_name: 'Demo' }, logout: () => {} });
104
+
105
+ const navSections = [
106
+ { to: '/', label: 'Home' },
107
+ { label: 'Utilities', items: [
108
+ { to: '/calculator', label: 'Calculator' },
109
+ { to: '/notepad', label: 'Notepad' },
110
+ { to: '/files', label: 'Files' },
111
+ ] },
112
+ ];
113
+
114
+ const queryClient = new QueryClient();
115
+
116
+ export default function App() {
117
+ const prefs = useLocalStoragePrefs('my-app');
118
+ return (
119
+ <QueryClientProvider client={queryClient}>
120
+ <ConfirmProvider>
121
+ <BrowserRouter>
122
+ <ShellAuthProvider value={{ hasAnyPerm: () => true }}>
123
+ <ShellPrefsProvider value={prefs}>
124
+ <ShellEntityFetcherProvider value={(endpoint, id) => apiClient.get(`${endpoint}${id}/`).then(r => r.data)}>
125
+ <StatusBadgeProvider groups={{}}>
126
+ <WindowManagerProvider>
127
+ <Routes>
128
+ <Route path="*" element={<Layout navSections={navSections} navIcons={{}} />} />
129
+ </Routes>
130
+ </WindowManagerProvider>
131
+ </StatusBadgeProvider>
132
+ </ShellEntityFetcherProvider>
133
+ </ShellPrefsProvider>
134
+ </ShellAuthProvider>
135
+ </BrowserRouter>
136
+ </ConfirmProvider>
137
+ </QueryClientProvider>
138
+ );
139
+ }
140
+ ```
141
+
142
+ That gives you the full desktop with all utility, document and web apps reachable through the start menu. Add your own entity windows by extending the registry, and wire the notification / bug-report / sticky-note systems through optional config callbacks when you want them.
143
+
144
+ ## Concepts
145
+
146
+ ### Window registry
147
+
148
+ Every window the shell can open lives in a `WindowRegistry` map. Two entry shapes:
149
+
150
+ - **Page** — `{ component: LazyExoticComponent, label, size?, widget?, compact?, appStyle?, flushBody?, … }`. Opened via `openPage(routeKey)`. `flushBody` keeps the standard title bar + footer but drops the body padding (pair it with `<SidebarLayout>` for two-pane apps).
151
+ - **Entity** — `{ endpoint, render(entity, …), title(entity), footer?, … }`. Opened via `openEntity(typeKey, id)`. The shell GETs `${endpoint}${id}/` (via the consumer-supplied entity fetcher) and hands the result to `render`.
152
+
153
+ Compose multiple partial maps with `createWindowRegistry(...maps)`:
154
+
155
+ ```ts
156
+ import { bundledApps } from 'react-os-shell/apps';
157
+ import { erpEntities } from './shell-config/erpEntities';
158
+
159
+ const windows = createWindowRegistry(bundledApps, erpEntities);
160
+ setShellWindowRegistry(windows);
161
+ ```
162
+
163
+ ### Nav sections
164
+
165
+ `Layout` renders the start menu from a `(NavSection | NavItem)[]` you pass in:
166
+
167
+ ```ts
168
+ const navSections = [
169
+ { to: '/', label: 'Home' },
170
+ { label: 'Clients', items: [
171
+ { to: '/orders', label: 'Sales Orders', perms: ['view_order'] },
172
+ { to: '/reports/ar', label: 'Accounts Receivable Report', menuLabel: 'AR Report' },
173
+ { to: '/clients', label: 'Clients' },
174
+ ]},
175
+ ];
176
+ ```
177
+
178
+ Start-menu rows are always one line. Keep `label` as the full semantic name;
179
+ when it does not fit the narrowest menu size, supply `menuLabel` with an
180
+ established domain abbreviation such as `AR`, `AP`, `PO`, or `GRNI`. The shell
181
+ shows the compact wording, keeps matching search against the full `label`, and
182
+ truncates as a final overflow guard. Do not invent a new acronym merely to make
183
+ a row shorter.
184
+
185
+ An abbreviated row is named `"<menuLabel>, <label>"` to assistive technology —
186
+ that order matters, because an accessible name must *contain* the text on
187
+ screen for voice control to act on it (WCAG 2.5.3). A row showing its own full
188
+ `label` gets no `aria-label` at all; its text content already is the name. The
189
+ native tooltip appears only where the visible text is not the whole name: an
190
+ explicit `menuLabel`, or a label the row had to clip.
191
+
192
+ Items with `perms` are filtered through `<ShellAuthProvider value={{ hasAnyPerm }}>`.
193
+
194
+ `perms` is an **any-of** test: the row shows if the user holds at least one of them. When a row should appear only for someone holding *every* permission in a set, use `allPerms` instead — it is an **all-of** test, and the two can be combined on the same entry:
195
+
196
+ ```ts
197
+ // Reaching price sheets puts the row in the menu; seeing prices is what the
198
+ // page itself needs. Adding both to `perms` would show it to MORE people.
199
+ { to: '/suppliers/price-sheets', label: 'Price Sheets',
200
+ perms: ['view_supplierpricesheet'], allPerms: ['view_supplier_prices'] },
201
+ ```
202
+
203
+ Reach for `allPerms` whenever the permission that lists a destination is broader than the one its page enforces — otherwise the row renders and then refuses on click. Both fields work on sections, on the items inside them, on top-level items passed beside the sections (`{ to: '/', label: 'Home' }` above), and on nested children, in the Start menu, the sidebar and the mobile sheet alike.
204
+
205
+ #### Nesting
206
+
207
+ An item's `children` are items, so a menu nests as deeply as you configure it — there is no maximum:
208
+
209
+ ```ts
210
+ { label: 'HR', items: [
211
+ { to: '/hr/recruitment', label: 'Recruitment', children: [
212
+ { to: '/hr/recruitment/jobs', label: 'Job Postings', children: [
213
+ { to: '/hr/recruitment/jobs/open', label: 'Open' },
214
+ { to: '/hr/recruitment/jobs/closed', label: 'Closed' },
215
+ ]},
216
+ ]},
217
+ ]}
218
+ ```
219
+
220
+ Each level behaves the same way: in the start menu it opens a flyout on hover (flipping to the left of its parent when it runs out of screen), and in the sidebar it expands as an accordion, indented one more step. A group's `to` is a synthetic key by convention — no page sits behind it, and clicking the row opens its submenu rather than navigating. A group whose every branch is permission-hidden is dropped entirely, so a user never gets a row that opens onto nothing.
221
+
222
+ ### useWindowManager
223
+
224
+ The hook every component uses to open / close / minimise windows:
225
+
226
+ ```ts
227
+ const { openPage, openEntity, closeEntity, openWindows } = useWindowManager();
228
+
229
+ openPage('/calculator');
230
+ openEntity('order', 'uuid-123');
231
+ ```
232
+
233
+ ### useWindowDirty
234
+
235
+ Register controlled unsaved state from a component rendered by `PageWindow`:
236
+
237
+ ```tsx
238
+ const [dirty, setDirty] = useState(false);
239
+ useWindowDirty(dirty);
240
+ ```
241
+
242
+ The page window uses the standard `Modal` discard confirmation while any
243
+ mounted registration is dirty. The page itself is held too: while any
244
+ registration is dirty, a reload, Back out of the app or closing the tab gets
245
+ the browser's "Leave site?" prompt, because leaving the page discards every
246
+ window at once and no `Modal` gets to ask. Set the value to `false` after save
247
+ or discard; unmounting also removes the registration. Calls outside a managed
248
+ page window are ignored.
249
+
250
+ ### Right-click menu
251
+
252
+ `Layout` mounts `ShellContextMenu`, one `contextmenu` listener on the document,
253
+ so a right-click anywhere in the shell opens a shell menu instead of the
254
+ browser's. It is drawn with `PopupMenu`, so it matches every other menu in the
255
+ app.
256
+
257
+ What it offers follows what was clicked:
258
+
259
+ | Under the pointer | Items |
260
+ |---|---|
261
+ | Selected text | Copy — with its formatting, so a copied table pastes into a spreadsheet as cells |
262
+ | A link | Open link in new tab, Copy link address |
263
+ | Anything else | — |
264
+
265
+ Every menu also carries Back, Forward, Reload and Copy page address, so the
266
+ menu is never empty and the page actions never move. A window holding unsaved
267
+ changes (`useWindowDirty`) makes Reload and Back ask first.
268
+
269
+ These keep the browser's own menu, on purpose:
270
+
271
+ - **Text inputs, textareas and contenteditable.** Spellcheck suggestions and
272
+ "Add to dictionary" cannot be rebuilt by a web page, and a Paste item of our
273
+ own would need a clipboard permission prompt. Non-text inputs (a checkbox, a
274
+ range, a color swatch) have none of that to lose and get the shell menu.
275
+ - **Images, canvases, video and audio.** Save image as, Copy image and the
276
+ playback controls are the browser's to offer.
277
+ - **A long-press on a touch screen**, which is how text gets selected by touch.
278
+ - **Shift+right-click** anywhere, so "Inspect" is still one gesture away for
279
+ developers.
280
+ - **`data-native-context-menu`** on an element keeps the browser's menu for
281
+ that element and everything inside it — for a surface the shell has no
282
+ business re-skinning, such as an embedded viewer.
283
+
284
+ `<Layout contextMenu={false}>` turns the shell menu off altogether.
285
+
286
+ A surface with its own context menu needs no change and gets none: the listener
287
+ stands down on an event whose `preventDefault()` has already been called, which
288
+ every `onContextMenu` handler in the shell does. Write yours the same way.
289
+ `PopupMenu` does it for right-clicks on itself, so a menu never opens on top of
290
+ another one. `keepsNativeMenu(target)` and
291
+ `describeContextTarget(target, selectionText)` are exported if you want the
292
+ same decisions in your own handler.
293
+
294
+ `EntityList` is one of those surfaces, and every list gets its row menu without
295
+ wiring anything: **Open** (the row right-clicked), **Copy** for text selected
296
+ under the pointer, **Copy `<first column>`** and **Copy rows** — the ticked
297
+ rows as a table of the visible columns in their on-screen order, which pastes
298
+ into a spreadsheet as cells — then **Export selected to CSV** when the list
299
+ passes `exportEndpoint`, the page's own `contextActions`, and **Select all**,
300
+ **Clear selection** and **Refresh** (when `onRetry` is wired). A consumer's own
301
+ menu item that copies something can call `copyToClipboard(label, text, html?)`
302
+ for the same copy path and toast.
303
+
304
+ Note that a cross-origin iframe is outside the reach of any parent listener —
305
+ the page inside it shows whatever menu it draws for itself.
306
+
307
+ ## API reference
308
+
309
+ All exports are named — `import { Modal, ... } from 'react-os-shell'`.
310
+
311
+ ### Components
312
+
313
+ | Export | Purpose |
314
+ |---|---|
315
+ | `Layout` | Top-level shell — desktop + taskbar + start menu. Mount once inside your providers. `branding={{ productName, logo, tagline }}` sets the visual identity in one object — start-menu button, startup splash, logout cover, mobile landing; its fields win over the older loose `productName`/`productIcon` props. The About dialog and What's New changelog stay on `DesktopHostConfig`. |
316
+ | `BrandMark` | Aspect-preserving tenant mark for favicon, compact-icon and wordmark slots, with load-failure fallback, neutral monogram behaviour and optional tone-aware contrast treatment. |
317
+ | `BrandAssetEditor` | Shared staged upload/remove lifecycle with one enforced file contract and standard browser, search-result and shell-slot previews. Persistence is injected through `onSave` and `onRemove`. |
318
+ | `ComposerAttachments`, `AttachmentDropZone`, `AttachmentList`, `AttachButton` | Files attached to a message being written — a chat reply, a mail compose, a feedback thread. The paperclip ("Attach files"), a drop anywhere on the composer ("Drop files to attach"), and a paste into the text area all reach one pending `File[]` through the same checks, and a rejected file is announced. Holds the list and stops; the composer's send puts the files in its request. `ComposerAttachments` is the standard layout (text area, chips, trigger row); the three parts compose a composer with an icon rail or a footer of its own. `useFileIntake` is the hook behind every upload primitive, exported for exactly that — never so a consumer renders its own file input. |
319
+ | `FilePicker` | Documents on a record: one dashed zone that is a button (click, Enter, Space) and a drop target, listing the chosen `File[]`. It never uploads — the form submits them as it submits everything else. `accept`, `maxSizeBytes` and `maxFiles` are enforced on a drop exactly as on a pick, and a rejected file is announced (`role="alert"`) naming the file and the rule. Forwards its ref to the zone for `FormErrorSummary`. |
320
+ | `useFileIntake`, `acceptsFile`, `FileIntakeAlert` | The intake every upload primitive above takes its files through — the file dialog, a drop and a paste reach one set of checks (`accept`, `maxSizeBytes`, `maxFiles`) and one announced rejection list. For a surface with a layout of its own; never so a consumer renders its own file input. Also on the React-only subpath [`react-os-shell/file-intake`](#upload-intake-without-the-shell--react-os-shellfile-intake) for a page that cannot load the shell. |
321
+ | `MediaUploadField` | The single image / video slot — logo, cover, avatar, favicon. Dashed zone empty, preview with Replace / Remove filled, both take a drop. `onFile` hands the consumer the chosen `File` to upload (the field owns the gesture); `onPick` opens the consumer's own library picker instead; with neither it emits an object-URL. `maxSizeBytes` joins `accept` on every gesture. |
322
+ | `MediaUploadGrid` | The gallery sibling: thumbnails, an Add tile, per-thumb remove, drag-and-keyboard reorder, optional cover badge. `onFiles` receives EVERY file of a multi-file drop or dialog pick; `onPick` defers to a library picker. `maxFiles` counts against the items held. |
323
+ | `StartMenu` / `Desktop` / `WindowManagerProvider` | Used internally by `Layout`; rarely instantiated directly. |
324
+ | `Modal`, `ModalActions`, `CopyButton`, `CancelButton` | Window primitive supporting standard / compact / widget styles. |
325
+ | `PopupMenu`, `PopupMenuItem`, `PopupMenuDivider`, `PopupMenuLabel`, `PopupSubmenu` | Right-click / context-menu primitive; `PopupSubmenu` is a row that opens a nested menu. |
326
+ | `ShellContextMenu` | The shell-wide right-click menu, already mounted by `Layout` (`<Layout contextMenu={false}>` turns it off). Mount it yourself only on a screen rendered outside the layout. See [Right-click menu](#right-click-menu). |
327
+ | `DropdownMenu` | Trigger-owned action menu with shared dismissal and keyboard behaviour. Use `side="top"` for a trigger in a bottom action bar; the default `side="bottom"` suits toolbar and row actions. |
328
+ | `ConfirmProvider`, `confirm` | Imperative `confirm({ title, body })` returning a Promise<boolean>. |
329
+ | `GlobalSearch` | Cmd-K command palette. Pass `providers: SearchProvider[]` to add results. Portalled to `<body>` on the overlay layer (`Z_LAYERS.overlay`), so it covers every window, pinned or not; opening it closes any open popup (`dismissPopups`). |
330
+ | `ShortcutHelp` | The keyboard cheatsheet shown on `?`. |
331
+ | `NotificationBell` | Taskbar bell — config via `<Layout notifications={…}>`. |
332
+ | `BugReportDetail` | Used inside an entity-window registry entry; reads from `<BugReportConfigProvider>`. |
333
+ | `StatusBadge` | Colored pill rendering a status string. Map status→semantic group via `<StatusBadgeProvider groups={{...}}>`. `label` overrides the derived text for a status that arrived from elsewhere; the color still comes from `status`. `emphasis` (`subtle` default / `solid`, the same two words `Banner` uses) is the volume: the quiet register is a transparent wash that composites over a raised panel and a hovered row, the loud one is a saturated fill for the detail header where the same fact is the headline. Color comes from the status tokens in `ui.css`, which carry both themes. |
334
+ | `SidebarLayout` | Two-pane layout with a drag-to-resize sidebar (`storageKey` persists the width). Pair with a `flushBody` window so the sidebar runs edge-to-edge. |
335
+ | `SidebarNavItem`, `SidebarGroupLabel` | Filter-sidebar button (optional `count` badge and `severity` marker dot) plus its group heading. Roll the severity up in the app; omitting it renders exactly as before it existed. An unrecognised `severity` renders a visible "unknown" marker and logs — it never silently disappears. `icon` is a 16 px glyph at the leading edge, decorative in the a11y tree and a step quieter than the words it introduces (gray-400 idle, blue-600 active); `trailing` is the right edge after the count, for what a count cannot say — a plan chip, an external-link mark, a sync state. `label` widened to `ReactNode` for a name that needs its own markup; only a string gets the truncation `title`, because a node has no text to read without walking it. The label truncates and the count keeps its edge however many slots are filled. |
336
+ | `MetricBar` | Value + proportional bar with optional `warn` / `crit` threshold ticks — the CPU / memory / disk row. `value={null}` renders "no data" (dashed empty track), never a zero-width bar; with no thresholds the fill stays grey rather than claiming health. `max` must be a positive finite number — given `0`/`NaN` the row prints the value but draws no bar, rather than dividing by zero into a full one. |
337
+ | `BudgetBar` | A run's wall clock, drawn as **budget consumed** — not work done, which nothing here knows. Three states a glance separates: within budget (a solid fill), past the deadline (a hatched band, never the shape of a finished bar; with a `grace` the track spans budget + grace so it is only full when the reaper is due), and **no estimate at all** (no bar — not a zero one, not an empty track). Keeps `MetricBar`'s rule that `elapsed={null}` is no reading and `elapsed={0}` is a measurement. `budgetState(elapsed, budget)` is the same verdict for a list to sort by. |
338
+ | `TimelineTrack` | The time axis both timelines are drawn on, and the reason they look and move alike rather than merely similar. A 6 px rail with a fill, a **date ruler** (month ticks always, week ticks where they clear 14 px, a month label only where it clears 72 px of the last one), dots whose shape and glyph say what kind of event they are — every one of them in the theme accent at its 600 step, the colour a primary `Button` wears, so a shape survives the greyscale print and the colour-blind reader that a hue never did, and one of two label strategies: `labels="lanes"` packs them into two rows and folds runs of the same kind into `×N` pills, `labels="active"` draws the one label a scrubber needs, `labels="none"` draws none — the bare rail, where an item's `caption` is the only text near its mark, the edge captions flank the rail on its own row, and the `pending` things are one dashed cap past a dashed tail rather than a column. `axis="compressed"` CUTS any stretch holding more than 30% of the window's time down to a fixed notch with a break glyph saying how many days it hides, and shares the rest of the track proportionally; `axis="linear"` keeps time proportional throughout; `axis="spread"` cuts nothing and keeps the 48 px floor, the idle stretches paying for it in proportion. `highlightKeys` lights one band from the leftmost named mark to the rightmost and every mark inside it; `onHoverChange` reports the mark under the pointer or the focus. A `provisional` item is hollow, never current, and outside the fill. `endMark` puts a hollow, dashed mark ON a bare rail's right edge for a day the window already runs to — an estimate, a contractual date — with the label and the date in its popover. An optional `thumb` turns it into a scrubber: give it `stops` and it becomes discrete — a drag follows the pointer but snaps to the nearest stop as it goes, arrows step stop to stop, and `valueText` says which one for a screen reader. An optional `playback` then drives that thumb along the rail by itself: the caller owns the button and the `playing` flag, the track owns the travel, because the distance between two stops is a distance in **pixels of its axis** and nobody outside knows how wide the track is. It is paced by that distance — 350 ms per 100 px, clamped to 450–1400 ms, eased in and out — so a compressed `//` notch costs 48 px of travel like any other 48 px instead of swallowing the glide, and the date chip, read back through the axis's inverse mapping, skips exactly the days the notch hides. `onArrive(key, ms)` fires on arrival and never mid-travel; the thumb then rests 700 ms before setting off again. The timing is the **wall clock**, not the frame rate: the rest is waited out on a timer and the travel is measured against the high-resolution timestamp each animation frame carries, so 700 ms is 700 ms on a throttled or occluded surface that hands out one frame a second instead of sixty — and a page that comes back after an idle stretch walks its backlog forward at one report per frame rather than announcing four at once. `onProgress({ fromKey, toKey, t, ms } | null)` hands out the in-flight position on every frame of a glide and `null` the moment there is none (arrival, pause, cancel, end, and a released drag), so a consumer can interpolate a table under the bar on the same eased `t` that places the disc; a drag reports the pointer's linear fraction between its two neighbouring stops. Flipping `playing` off freezes it where it is — keeping what is left of the rest rather than spending it — and flipping it back on carries on from there; taking hold of the thumb — a drag, a click, an arrow key — ends the playback through `onStop`. Under `prefers-reduced-motion` the walk stays, the travel goes, and `onProgress` only ever says `null`. Hovering a `×N` pill **magnifies** its stretch so the members can carry their own labels (or do it from code with `zoomRange`), and an item's `preview` fills the hover/focus popover with the document in miniature, with an "Open" footer that fires its `onOpen` — the popover has already drawn the mark's label and its date above the preview, so a consumer leaves both out of it. Also `markers` — each one free to name its own `glyph` where its kind's is not specific enough — `phases`, `edgeCaptions`, and a `pending` list for the things that have no date. Every mark takes its own pointer, including on a scrubber: the 24 px hit strip that makes a 6 px rail pressable runs UNDER the dots rather than over them, so hover, focus and click reach a report, an inspection or a shipment, while a press on the bare rail between two of them still starts a drag and a drag crossing a mark is not interrupted by it. No two marks are ever drawn on top of each other: a run whose centres are closer than one mark's width — of any kinds, folded or not — becomes ONE mark carrying `×N`, its kind's own shape and glyph where the run shares a kind and a neutral `×N` pill where it does not, named after every member it stands for. A pointer opens it: the axis magnifies around the run and each member is a mark again with its own popover, the same magnification a `×N` label pill asks for. A run that is all one DATE can never be opened — no magnification separates a coordinate from itself — so its popover lists every member with its date and its own preview instead. Folding is a drawing decision only: the thumb's stops, `onActivate` and playback all still see every mark. The marks are an `<ol>` with exactly one `aria-current="step"` and ONE tab stop — arrows traverse, Home/End jump, Enter/Space activates — and every tooltip and pill popover is hoverable, persistent and Escape-dismissible (WCAG 1.4.13). A popover is kept inside the track and inside the shell window that owns it (UI-11), the first day's mark included, and a mark carries no native `title` — its popover is the tooltip, on hover and on focus alike. Under a 320 px track it stops squeezing the axis and switches to a vertical variant. Motion is `rosh-tl-*` classes in `ui.css` behind one `prefers-reduced-motion` rule, replayed on every mount, total budget 600 ms. |
339
+ | `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. |
340
+ | `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. |
341
+ | `SettingRow` | One setting: its name and current value on one baseline, the explanation at full width beneath in a smaller voice. The value never shrinks, so a short badge keeps its line instead of folding into a narrow column. 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. `DescriptionList` is the record-detail neighbour and stays that. |
342
+ | `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. 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. |
343
+ | `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. |
344
+ | `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. |
345
+ | `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. |
346
+ | `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. Renders nothing while `errors` is empty. |
347
+ | `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. |
348
+ | `DatePicker`, `TimePicker`, `DateTimePicker`, `DateRangePicker` | Date/time fields. The first three wrap the platform's own inputs in the kit's field styling; all are careful to speak LOCAL dates and wall-clock times (never `toISOString`). `TimePicker` hands back an `HH:MM` string — a time of day names no calendar day, so it never invents a Date. `DateRangePicker` takes `fullWidth` to fill a filter-grid cell instead of shrink-wrapping its label. |
349
+ | `Card`, `StatCard` | Surface panel (optional header/footer) + dashboard metric tile. |
350
+ | `Avatar`, `AvatarGroup` | User avatar with initials fallback + status dot; overlapping stack with +N overflow. |
351
+ | `Banner` | Static in-flow alert (`tone`: info / success / warning / danger). `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 whole box and `shrink-0`, so a message that wraps does not drag the button down or squeeze it. A banner that names a condition and leaves the fix three menus away is the usual shape of a notice nobody acts on. |
352
+ | `Tabs`, `Accordion`, `Tooltip` | Controlled tab strip, collapsible sections, frosted hover tooltip. |
353
+ | `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. |
354
+ | `Pagination` | Numbered page control (pairs with tables; complements `ListFooter`). |
355
+ | `Sparkline`, `LineChart`, `BarChart`, `DonutChart`, `ScatterChart` | Dependency-free inline-SVG charts (`currentColor`-themed). `LineChart` is the multi-series trend with optional scale, legend, dots and area fill. `ScatterChart` takes `xDomain` / `yDomain` to override the derived axis, and `xScale` / `yScale` of `'log'` for a long tail; a point outside a supplied domain is dropped and counted in the accessible label. |
356
+ | `BulkImportGrid` | Paste-or-upload bulk entry with column mapping, duplicate review and optional sum-merge. Hands resolved rows to `onImport`; owns no persistence. The whole panel takes a dropped CSV; the Upload CSV button opens the same dialog. |
357
+ | `UndoProvider`, `UndoControls` | One undo stack per form window, covering its fields, line items and bulk imports. Register state with `useUndoable` and the shell shows the Undo/Redo pair itself, leftmost in the window footer, as soon as the form has state and the window is editing (a draft, a duplicate, Edit mode) or the form has said `useUndoCanEdit(true)` — a view that merely holds state gets nothing — it joins a footer that is there for other reasons and never creates one, and the mobile chrome (footer hidden) gets nothing. A form mounts `UndoControls` by hand only for a place the footer is not (a plain overlay, a section header); a hand mount claims the stack and the shell then mounts nothing for it, so a form shows one pair wherever it chose to put it. Binds ⌘Z / ⇧⌘Z (and Ctrl+Y) except while the caret is in a field, where the browser's own undo wins. `WindowManager` already mounts one per window, scoped with `windowId` so a keypress reaches only the frontmost window; pass `windowId` yourself for any provider you mount outside a `<Modal>`, or two open windows will step back together. Offered to anyone who may edit the record — gate with `canEdit` and/or `perms`; a reader sees no controls and records no history. The shell-level provider cannot know the record's permissions, so a read-only form nests its own `<UndoProvider canEdit={false}>` to shadow it. A provider nested inside a window puts its own pair in the footer on the same footer terms — it joins a footer that is there for other reasons, never creates one or holds it open, and shows nothing on the mobile chrome — but its `canEdit` takes the place of the editing check: the form that nested it has already said whether the record may be edited, so pass `canEdit={isEditing}` (or the lock) rather than leaving the default. |
358
+ | `Calendar` | Month grid with full keyboard navigation (arrows, Home/End, PageUp/Down) and `role="grid"` semantics. Single or range. The shared grid behind `DatePicker` and `DateRangePicker`. |
359
+ | `DashboardTemplate`, `DataTablePage`, `FormLayoutPage`, `CheckoutTemplate`, `EmailTemplate`, `ChatTemplate`, `GalleryTemplate`, `AuthScreen`, `ErrorPage` | Zero-prop starter page templates composed from the primitives. |
360
+ | `ErrorBoundary` | Catches a render crash and shows `ErrorPage` 500 rather than a blank screen. `showDetails` is off by default, so a visitor is never shown the stack; the fallback is `role="alert"`. Takes `onError` for reporting and `resetKeys` to recover on navigation. |
361
+ | `AppUpdateBanner` | "A new version is available" for a tab that outlived a deploy, without a service worker. Polls the build's `version.json` (a minute apart from a visible tab, never from a hidden one, and once more the moment a hidden tab is shown) and, when the deployed version differs from `currentVersion`, pins a warning banner at the top centre above every window naming both versions with a primary "Refresh now" that reloads. Renders nothing until then. `versionUrl`, `pollMs`, `message`, `onRefresh`. Pair it with `installStaleChunkReload()` for the tab that opens a lazy window before it notices. |
362
+
363
+ ### Providers + setters
364
+
365
+ | Export | Use |
366
+ |---|---|
367
+ | `<ShellAuthProvider value={{ hasAnyPerm }}>` | Permission-filter nav items. |
368
+ | `<ShellPrefsProvider value={{ prefs, save }}>` | Where the shell reads/writes user prefs (theme, taskbar pos, sticky notes, …). Use `useLocalStoragePrefs(key)` for a backend-less default. Since 4.92.0 it is also how `<EntityList>` / `<ResizableTable>` column config and sort persist: with no provider mounted, `useShellPrefs()` reads empty and drops saves, so both silently degrade to localStorage-only and never reach the user's profile. Session restore reads `session_windows` once, at mount: hand the provider prefs that are already loaded by then (an adapter whose prefs arrive later misses that load's replay, though the saved set is kept). |
369
+ | `<ShellStringsProvider value={{ … }}>` | Translates the shell's own strings — window controls, taskbar hints, the logout cover, About/What's New, picker and table defaults, the help viewer. English works with NO provider; the override is a typed partial merged per section, so an incomplete catalog falls back rather than breaking. Prop-level text (`emptyText`, placeholders) always wins over the catalog. |
370
+ | `<ShellEntityFetcherProvider value={(endpoint, id) => …}>` | How the modal stack fetches entity data. |
371
+ | `<BugReportConfigProvider value={{ submit, list?, resolve? }}>` | Wire the bug-report flow to your backend. |
372
+ | `<DesktopHostProvider value={{ stickyResolver?, saveShortcuts?, … }}>` | Sticky-note ref resolver + persistence callbacks. |
373
+ | `<StatusBadgeProvider groups={{ status: 'success' \| ... }}>` | Status string → semantic group. |
374
+ | `<PortalBrandingProvider load={...} fallback={...}>` | Loads public hostname-scoped Company Profile identity before authentication, applies its favicon and optional derived document title, and exposes it through `usePortalBranding()`. |
375
+ | `setShellApiClient(axios)` | Module-level: register your axios instance once. |
376
+ | `setShellAuthBridge({ user, logout })` | Module-level: register user identity / logout handler. |
377
+ | `setShellWindowRegistry(registry)` | Module-level: register your composed `WindowRegistry`. |
378
+
379
+ ### Hooks
380
+
381
+ | Export | Purpose |
382
+ |---|---|
383
+ | `useWindowManager()` | `{ openPage, openEntity, closeEntity, openWindows, … }` |
384
+ | `useWindowDirty(dirty)` | Registers controlled unsaved state with the enclosing `PageWindow`; multiple registrations aggregate with any-dirty semantics, and calls outside a managed page window are ignored. |
385
+ | `useTheme()` | `{ theme, resolved }` — current theme + system-resolved value. |
386
+ | `useAppUpdate({ currentVersion, versionUrl?, pollMs? })` | The deployed version once it differs from the running one, else `null` — what `AppUpdateBanner` draws by, for a host that wants its own notice. Polling stops once an update is known. |
387
+ | `useNewHotkey(handler)` | Cmd/Ctrl+N — for "create new entity" buttons. |
388
+ | `useEditHotkey(handler)` | Alt+Shift+E — for "edit" toggle. |
389
+ | `useModalNav({ onPrev, onNext })` | ←/→ to step through siblings inside a modal. |
390
+ | `useModalSave(handler)` | Cmd-S inside a modal. |
391
+ | `useModalDuplicate(handler)` | Alt-D inside a modal. |
392
+ | `useTableNav({ rows, cols, onCell })` | Arrow-key cell navigation in editable grids. |
393
+ | `useUndoableState(initial, { label, coalesceKey })` | `useState`, with the value in the window's undo stack — a rename, not an extra line. State left as plain `useState` stays out of the history, which is where transient UI, fetched data and validation output belong. |
394
+ | `useUndoable(value, apply, { label, coalesceKey })` | The explicit form, for state whose setter the component does not own. `apply` is the setter you already have. `coalesceKey` folds a run of typing into one step. Register the state itself, never a value derived on the way in (`rows.filter(...)`) — changes are detected by identity, so a fresh array each render reads as a change every render. |
395
+ | `useUndo()` | `{ undo, redo, canUndo, canRedo, undoLabel, clear, baseline, enabled, hasState }` for the enclosing `UndoProvider` — for custom UI, to `clear()` the history after a save, or to `baseline()` it after a load. Call `baseline()` in the same effect that assigns a fetched record, or the load itself becomes the oldest step and the user's first ⌘Z hands back the empty form. `baseline()` discards the history only when a value actually lands behind it, judged by content rather than identity — reached on a refetch edge where the form's guard re-seeds nothing, or where it re-seeds a line grid as a fresh array of the same rows, it leaves the user's edits undoable; pass the record's id (`baseline(id)`) and a switch to another record drops the history even when its values happen to match. Pass it from the first load on — the first named call has nothing to compare with, so it never drops the history by itself — and pass `null`, not `undefined`, for a record with no id yet: `baseline(undefined)` is a bare call, so write `baseline(record?.id ?? null)`. `clear()` always empties it: after a save, "earlier" is on the server. |
396
+ | `useUndoCanEdit(canEdit)` | A form's own word on whether the record may be edited right now — `useUndoCanEdit(!isLocked)`. While false the stack records nothing, ⌘Z is left alone and the footer shows no controls. A nested `<UndoProvider canEdit={false}>` in the form's JSX says the same thing: it shadows the stack for its children AND forwards the read-only claim to the window's stack, which is where the form's own `useUndoableState` calls (made above it in the tree) register. Either form of the claim makes the whole window read-only. The positive claim, `useUndoCanEdit(true)`, is how a window that edits in place outside Edit mode — a detail with inline cells behind a permission — asks the footer for the pair. |
397
+ | `useMultiModal()` | Manages multi-window stacking + activate/blur. |
398
+ | `useShellAuth() / useShellPrefs() / useShellEntityFetcher() / useBugReport() / useDesktopHost()` | Context readers — the shell uses these internally; consumers may also call them. |
399
+
400
+ ### Apps barrel — `react-os-shell/apps`
401
+
402
+ | Export | Type |
403
+ |---|---|
404
+ | `bundledApps` | `WindowRegistry` — 12 ready-to-mount apps. |
405
+ | `utilityApps`, `documentApps`, `webApps` | Subsets of `bundledApps`. |
406
+ | `Calculator`, `Spreadsheet`, `Weather`, `CurrencyConverter`, `PomodoroTimer`, `TodoList`, `Browser` | Lazy components — use directly in custom registry entries. |
407
+ | `PdfViewer` | Lazy component — the PDF reader on its own, for embedding. See below. |
408
+ | `BUILTIN_APP_INFO` | Per-app metadata for the document/web apps (Spreadsheets, Notepad, Documents, Preview, Files, Browser): display name, independent app version and one-line description. Drives each app's "About" dialog (window title menu → About <App>), which also shows the shell version. |
409
+
410
+ #### An embedded PDF — `PdfViewer`
411
+
412
+ `PdfActionButton` and `setPdfPreview` open a PDF in a Preview **window**. (An
413
+ app without windows gives `PdfActionButton` an `openPreview` callback that
414
+ mounts the shell's `Preview` somewhere of its own — a tab, a route, an overlay.)
415
+ When the document belongs *inside* something else — a preview beside the form that
416
+ generates it, a document tab on a record — render `PdfViewer` directly:
417
+
418
+ ```tsx
419
+ import { lazy, Suspense } from 'react';
420
+ const PdfViewer = lazy(() => import('react-os-shell/apps').then(m => ({ default: m.PdfViewer })));
421
+
422
+ <div className="h-[32rem]">
423
+ <Suspense fallback={<p>Loading viewer…</p>}>
424
+ <PdfViewer url={objectUrl} filename="Statement.pdf" fit="page" />
425
+ </Suspense>
426
+ </div>
427
+ ```
428
+
429
+ | Prop | |
430
+ |---|---|
431
+ | `url` | Object URL or remote URL. |
432
+ | `filename` | Display name, and the filename the built-in Download uses. |
433
+ | `fit` | `'width'` (default) fills the container's width and lets a tall page scroll — right for a viewer that owns a window. `'page'` fits the whole page, which is what a pane inside a dialog wants. Either way it is the *initial* mode: the reader can still zoom, and the Fit button re-arms it. |
434
+ | `onDownload` | Replaces the built-in "save `url` as `filename`". |
435
+ | `onEmail` | Adds an Email button to the toolbar. Omit it and there is none. |
436
+
437
+ It fills its parent and scrolls inside, so **give the parent a resolved
438
+ height** — `h-full` inside a flex column, or an explicit height. It brings its
439
+ own toolbar (page nav, zoom, Fit, Print, Download); inside the Preview window
440
+ those same buttons merge into the window's single toolbar row instead.
441
+
442
+ Handing it a new `url` opens that document **on its first page** — the page
443
+ number belongs to the document, not to the viewer, so re-rendering one viewer
444
+ with a shorter document cannot leave it pointing past the end. No `key` needed.
445
+ The reader's zoom is deliberately kept across the change: it is a preference
446
+ for how large they want text, not a fact about the file.
447
+
448
+ Import it **lazily**, as above. It statically imports `pdfjs-dist`, and the
449
+ whole reason the bundled apps are `lazy()` is to keep a PDF parser out of a
450
+ host's startup bundle — `scripts/verify-dist.mjs` fails the build if one gets
451
+ in. `pdfjs-dist` is an optional peer: a consumer that never renders a PDF need
452
+ not install it.
453
+
454
+ The alternative this replaces is `<iframe src={objectUrl}>`, which hands the
455
+ document to the browser's own plugin — its toolbar, its thumbnail rail, its
456
+ idea of the zoom, none of it themeable and none of it testable.
457
+
458
+ ### UI kit without the window manager — `react-os-shell/ui`
459
+
460
+ Not every app that wants this package's components wants a desktop. A
461
+ point-of-sale till is one full-screen screen; a portal may keep its own routed
462
+ pages and sidebar. Both still want the buttons, inputs, cards, badges, tables
463
+ and charts.
464
+
465
+ ```tsx
466
+ import { Button, Card, Banner, FormField, Input } from 'react-os-shell/ui';
467
+ import 'react-os-shell/ui.css';
468
+ ```
469
+
470
+ **It reaches nothing but `react` and `react-dom`.** None of the optional peers —
471
+ no `react-router-dom`, `@tanstack/react-query`, `axios`, `@headlessui/react` or
472
+ `@heroicons/react` — which matters because they are declared *optional*, so a
473
+ consumer who has not installed them gets an unresolvable import rather than a
474
+ degraded style. CI asserts it against the built artifact
475
+ (`scripts/verify-dist.mjs`), not just the source, because leakage can arrive
476
+ through a shared chunk.
477
+
478
+ What you get: every form control, the display and layout primitives
479
+ (`Card`/`StatCard`, `Avatar`, `Banner`, `Tabs`, `Accordion`, `Tooltip`,
480
+ `StatusBadge`, `ColoredBadge`, `EmptyState`, `PageHeader`, `Spinner`,
481
+ `Breadcrumbs`, `TopNav`, `SidebarLayout`, `MetricBar`, `BudgetBar`, `TimelineTrack`,
482
+ `MilestoneTimeline`, `ProductionTimeline`, `SettingRow`, `Snippet`, `Kbd`,
483
+ `Markdown`,
484
+ `HelpCenter`, `EditableGrid`, `SearchableSelect`, `PopupMenu`), the charts, all
485
+ nine page templates, the pageless data primitives (`Pagination`, `Kanban`,
486
+ `ListFooter`, `ListLoadError`), `toast`, and the theming hooks (`useTheme`,
487
+ `resolveTheme`, `applyThemePrefs`) — you need those last ones to reach dark mode
488
+ or an accent at all, since both work by stamping `data-theme`.
489
+
490
+ What you do not get: `Modal` and the window manager, `Layout`, `Desktop`,
491
+ `StartMenu`, `GlobalSearch`, the settings panels, the bundled apps, and the
492
+ components that reach an optional peer for their own reasons (`EntityList`,
493
+ `ResizableTable` and the react-query data hooks, `FilterBar`, `UndoControls`,
494
+ `BulkImportGrid`, `ConfirmProvider`/`confirm`).
495
+
496
+ **Stylesheets: import exactly one.** `ui.css` is the kit; `styles.css` is the
497
+ umbrella over the kit *plus* the window/taskbar/desktop rules. Taking both
498
+ doubles every rule. `ui.css` deliberately does not `@import "tailwindcss"` — you
499
+ supply Tailwind v4 yourself (every consumer already does), which also lets an
500
+ app mid-migration off another component library take the theme and utility
501
+ layers without preflight.
502
+
503
+ `react-os-shell` (the root entry) is unchanged and remains a superset: same
504
+ components, same bindings, plus the shell.
505
+
506
+ ### Brand surfaces — `react-os-shell/brand.css`
507
+
508
+ For output that is not a portal window — a report, a published artifact, a
509
+ proposal, an HTML email, a shop page. Those have no React and no build step,
510
+ so they cannot compose the kit; they link one stylesheet instead:
511
+
512
+ ```html
513
+ <link rel="stylesheet" href="https://unpkg.com/efficient-shell/dist/brand.css">
514
+ ```
515
+
516
+ It is `styles.css` compiled and self-contained, plus the tokens a page without
517
+ utility classes cannot express (accent and `--accent-text`/`--on-accent`,
518
+ status hues and their `-text` variants, radius, type), plus the **report
519
+ primitives** — a small semantic layer in
520
+ `@layer components`, so a utility on the same element still wins:
521
+
522
+ | Group | Classes |
523
+ |---|---|
524
+ | Shell | `ef-report` `ef-shell` `ef-skip-link` `ef-masthead` `ef-identity` `ef-logo-light` `ef-logo-dark` `ef-document-meta` `ef-footer` |
525
+ | Opening, structure | `ef-opening` `ef-opening-claim` `ef-opening-proof` `ef-section` `ef-section-title` `ef-flow` `ef-reading` `ef-peers` |
526
+ | Type roles | `ef-label` `ef-caption` `ef-mono` `ef-numeric` `ef-visually-hidden` `ef-sources` |
527
+ | Figures | `ef-stat-strip` `ef-stat` `ef-stat-label` `ef-stat-value` `ef-stat-unit` `ef-stat-detail` `ef-unavailable` |
528
+ | Evidence | `ef-table-wrap` (caption, header, baseline-aligned cells, `ef-numeric` columns) · `ef-status[data-status]` over the nine groups |
529
+ | Bars, charts | `ef-bar-list` `ef-bar-label` `ef-bar-track` `ef-bar-fill` (`style="--ef-bar: 48%"`) `ef-bar-value` · `ef-chart` `ef-series-1…6` `ef-series-stroke` `ef-series-fill` `ef-chart-axis` `ef-chart-gridline` `ef-chart-label` |
530
+ | Controls | `ef-field` `ef-helper` `ef-error` `ef-button[data-variant]` |
531
+
532
+ A page that stamps no `data-theme` follows the reader's OS preference: the
533
+ stylesheet carries the dark ramp under `prefers-color-scheme`, guarded so any
534
+ explicit stamp wins. Utility color classes remap only under
535
+ `[data-theme="dark"]`, so a page that paints with utilities stamps the
536
+ attribute itself.
537
+
538
+ The list is the API. The stylesheet is compiled, so an `ef-` name outside it
539
+ renders as nothing, the same as `h-[440px]`. The layout primitives exist to
540
+ make the recurring generated-page defects unexpressible rather than merely
541
+ detectable: `ef-table-wrap` owns the full width of its section, `ef-bar-list`
542
+ owns one shared label, plot and value lane, `ef-stat-strip` owns the peer
543
+ grid. The organisation contract that governs these surfaces, and the audit
544
+ that checks them, live in the EFFICIENT harness (`52-brand-surface.md`).
545
+
546
+ ### Editorial markup — `react-os-shell/markup`
547
+
548
+ One grammar for copy a human types into a plain text box, so a toolbar button and
549
+ a parser can never disagree about what a delimiter means: `**bold**`, `_italic_`,
550
+ `~~strike~~`, `==highlight==` (the brand accent, applied to a selection).
551
+
552
+ **Its own subpath because it imports nothing** — no React, no JSX, no DOM, none
553
+ of this package's peers. A public site can take the rule without inheriting a 3D
554
+ viewer and a PDF renderer; `dist/markup/index.js` is a standalone file with zero
555
+ import statements.
556
+
557
+ What is shared is the RULE, not the rendering. A web renderer paints with
558
+ theme-token classes and an email renderer must inline every style, so each host
559
+ keeps its own renderer and walks the same token list.
560
+
561
+ | Export | Notes |
562
+ |---|---|
563
+ | `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. |
564
+ | `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. |
565
+ | `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. |
566
+ | `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. |
567
+ | `STANDARD_MARKUP` | The four marks above. |
568
+ | `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. |
569
+
570
+ Two delimiter choices worth knowing. Italic is `_phrase_`, not `*phrase*`,
571
+ because a single asterisk already means the accent color in the products that
572
+ use this. And `_` never fires inside a word (CommonMark's own rule), which is
573
+ what stops a mail-merge line holding `{{first_name}}` and `{{last_name}}` from
574
+ italicising everything between them — checked with plain character tests, never a
575
+ lookbehind, which is a parse error on Safari below 16.4.
576
+
577
+ ### Markdown — `react-os-shell/markdown`
578
+
579
+ CommonMark + GFM for a body a person or a service WROTE: a note, a chat message,
580
+ a bug report, a help article. React elements out; no HTML string exists at any
581
+ point in the pipeline.
582
+
583
+ **Its own subpath, for the mirror image of `markup`'s reason.** It is the one
584
+ module in this package that needs a third-party runtime, so `react-markdown`,
585
+ `remark-gfm` and `remark-breaks` are declared **optional** peers and only a
586
+ consumer who imports this subpath ever installs them. The package's
587
+ `dependencies` stay empty. Cost when you do take it: ~46 KB gzipped.
588
+
589
+ ```bash
590
+ npm i react-markdown remark-gfm remark-breaks
591
+ ```
592
+
593
+ ```tsx
594
+ import Markdown from 'react-os-shell/markdown';
595
+ import 'react-os-shell/ui.css';
596
+
597
+ <Markdown>{report.description}</Markdown>
598
+ <Markdown variant="article" resolveImageSrc={s => `/media/help/${s}`}>{doc.body}</Markdown>
599
+ ```
600
+
601
+ | Prop | Notes |
602
+ |---|---|
603
+ | `variant` | `note` (default) — typed copy inside a card: headings cap one step above body text, a single newline is a line break. `article` — authored documentation: full heading scale, soft wraps, callout blockquotes, screenshot placeholders, capped measure. |
604
+ | `clamp` | Collapses to ~6 text lines with a mask fade. The fade appears only when the content actually overflows, measured after layout. The "Show more" control is yours. |
605
+ | `resolveImageSrc` | Rewrites a relative image `src` before fetch — article bodies name screenshots by a path only the host can serve. |
606
+ | `components` | Per-element overrides, merged over the variant's set. |
607
+
608
+ **Four constructs are disabled**, each because a body that predates markdown
609
+ would otherwise render surprisingly: a four-space-indented paste (a quoted
610
+ email) does not become a code block; `<John>` renders as the text it is rather
611
+ than being silently DROPPED as a raw-HTML node; and `Title` over `----` stays a
612
+ paragraph and a rule. Fenced blocks and `<https://…>` autolinks are untouched.
613
+
614
+ **No sanitiser, deliberately** — there is nothing to sanitise. Raw HTML is never
615
+ parsed and `javascript:` URLs are neutralised, so the output cannot carry markup
616
+ a writer did not intend. Adding `rehype-raw` would undo exactly that.
617
+
618
+ **No syntax highlighting**, for the same reason as the bundle budget above: a
619
+ highlighter costs more than this package's whole UI kit. Code blocks are
620
+ monospace with their own horizontal scroll, and the fence's info string is
621
+ passed through as `data-language` so a host can light them up itself.
622
+
623
+ #### `MarkdownLite` — when you cannot afford a parser
624
+
625
+ `react-os-shell/ui` exports `MarkdownLite`, a regex renderer that imports
626
+ nothing. It covers headings, emphasis, links, inline code, fenced blocks, flat
627
+ lists, pipe tables, callouts and rules — and it cannot nest, because a regex
628
+ cannot. It is why the till can render prose at all. Reach for it when the bundle
629
+ is the constraint and the body is short; reach for `react-os-shell/markdown`
630
+ when someone else wrote the text.
631
+
632
+ ### Upload intake without the shell — `react-os-shell/file-intake`
633
+
634
+ The hook behind every upload primitive, for a page that must not load the rest
635
+ of this package: a public applicant form, a storefront's return request.
636
+
637
+ ```tsx
638
+ import { useFileIntake } from 'react-os-shell/file-intake';
639
+
640
+ const intake = useFileIntake({
641
+ onAccept: ([file]) => setCv(file),
642
+ multiple: false,
643
+ accept: limits.accept, // from the endpoint's published limits
644
+ maxSizeBytes: limits.maxSizeBytes,
645
+ });
646
+
647
+ <input {...intake.inputProps} /> {/* hidden, never a tab stop */}
648
+ <button type="button" onClick={intake.open} {...intake.zoneProps}>Choose file</button>
649
+ {intake.rejections.length > 0 && (
650
+ <ul role="alert">{intake.rejections.map(r => <li key={r.file.name}>{r.message}</li>)}</ul>
651
+ )}
652
+ ```
653
+
654
+ **It reaches `react` and nothing else** — not `react-dom`, no stylesheet, no
655
+ toast container, no window manager. `scripts/verify-dist.mjs` walks the built
656
+ graph of `dist/file-intake/index.js` on every build and fails it otherwise, and
657
+ pins the export list below. The exports are the same bindings the kit exports,
658
+ so an app importing from both has one hook.
659
+
660
+ | Export | Notes |
661
+ |---|---|
662
+ | `useFileIntake(options)` | `inputProps` for the hidden native input, `zoneProps` for the one focusable control that is also the drop target, `pasteProps` for a composer's text area, `open()`, `dragOver`, `rejections`, `clearRejections()`. `accept`, `maxSizeBytes` and `maxFiles` apply to every gesture. It validates and hands back `File[]`; it never uploads. |
663
+ | `acceptsFile(file, accept)` | The `accept` test the hook applies — extension rules match the name, `type/*` the MIME prefix. |
664
+ | `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"`. |
665
+ | `FileIntakeOptions`, `FileIntakeLimits`, `FileRejection`, `FileRejectionReason` | Types. |
666
+
667
+ ### Open pages as tabs — `react-os-shell/page-tabs`
668
+
669
+ For a routed app — a sidebar, a header, one page at a time — that wants what
670
+ windows gave the desktop: several pages open at once, each keeping its place.
671
+ Every page opened becomes a tab under the header, and the pages behind the
672
+ other tabs stay mounted.
673
+
674
+ ```tsx
675
+ import { PageTabBar, PageTabsOutlet, PageTabsProvider } from 'react-os-shell/page-tabs';
676
+
677
+ <PageTabsProvider
678
+ key={user.id} // a saved strip belongs to one user
679
+ storageKey={`tabs:u${user.id}`}
680
+ resolve={(loc) => (loc.pathname.startsWith('/open/') ? null : { title: titleFor(loc) })}
681
+ pinned={['/dashboard']}
682
+ >
683
+ <Header />
684
+ <PageTabBar />
685
+ <PageTabsOutlet frameClassName="min-h-0 flex-1 overflow-y-auto p-6">
686
+ {(location) => <Routes location={location}>{contentRoutes}</Routes>}
687
+ </PageTabsOutlet>
688
+ </PageTabsProvider>
689
+ ```
690
+
691
+ The address stays the source of truth: `resolve` decides which locations are
692
+ tabs (by default one tab per pathname, so a list's filters move within its
693
+ tab), and clicking a tab navigates to its last address. On React 19.2+ each
694
+ hidden page sits in `<Activity mode="hidden">` — its state kept, its effects
695
+ cleaned up until it is shown again; on React 18 it is only hidden.
696
+
697
+ | Export | Notes |
698
+ |---|---|
699
+ | `PageTabsProvider` | Props: `resolve`, `pinned`, `storageKey`, `maxTabs` (12), `enabled` (off on phones), `confirmDiscard`. Needs `ConfirmProvider` for the default unsaved-changes question. |
700
+ | `PageTabBar` | The strip — it can be the app's whole top bar. Many tabs shrink evenly like a browser's (title fades, then an idle tab's × waits for hover, then icon only; pinned tabs are icon-only) and only scroll at their floor. Click, ×, middle-click, Delete; right-click for Close / Close other tabs / Close tabs to the right / Close all; arrow keys along it. `end` holds controls at its right that do not scroll with the tabs; the active tab takes the page's background (`activeClassName` if yours is not `bg-gray-50`). |
701
+ | `PageTabsOutlet` | Renders `children(location)` once per open tab. The frame is the page's scroll container; scroll positions inside it come back with the tab. |
702
+ | `usePageTabTitle(title)` | Name this page's tab (a record's number once loaded). |
703
+ | `usePageTabDirty(dirty)` | Mark unsaved changes: closing asks first, and so does a reload. |
704
+ | `usePageTab()` | `{ key, close({ to, force }) }` — a form that created its record closes itself and goes to it. |
705
+ | `usePageTabs()` | The strip, from outside any one page. |
706
+
707
+ It reaches `react`, `react-dom` and `react-router-dom` only — never the window
708
+ manager — and `scripts/verify-dist.mjs` checks that against the build.
709
+
710
+ ### Misc
711
+
712
+ | Export | Notes |
713
+ |---|---|
714
+ | `createWindowRegistry(...maps)` | Variadic merge — later partials override earlier on the same key. |
715
+ | `isPageEntry`, `isEntityEntry` | Type guards for `WindowRegistryEntry`. |
716
+ | `glassStyle()` | Returns the theme-aware frosted-glass `style` object. |
717
+ | `reportBug(submit)` | Captures a screenshot via `getDisplayMedia`, opens the dialog, hands the payload to your `submit`. |
718
+ | `formatDate(iso)` | Locale-aware date formatter. |
719
+ | `installStaleChunkReload(options?)` | Call once before the app mounts. A deploy replaces every lazy chunk; a tab opened before it crashes on the first lazy window it opens ("Failed to fetch dynamically imported module", or a `ChunkLoadError`) and no remount can fix it. This reloads the page instead — once per 30 s cooldown, kept in `sessionStorage`, so a chunk still missing after the reload (a broken deploy) crashes visibly instead of looping. It hears Vite's `vite:preloadError` (whose throw it swallows) and, for Next.js (Turbopack) and webpack, a chunk under `chunkPathPrefix` (default `NEXT_CHUNK_PATH_PREFIX`, `/_next/static/`; a path from this origin or a full URL for a CDN `assetPrefix`; `null` for Vite only) that fails as a `<script>`, a stylesheet or a script/style/module preload — or that came back as HTML and threw a `SyntaxError`, which is how a missing chunk surfaces without `X-Content-Type-Options: nosniff`. Prefetches, fonts and images are ignored. Before a chunk reload it asks the server (`HEAD`, `no-store`) whether the file is really gone — a 404/410, or the SPA fallback's HTML — so a dropped connection is left to the loader's own retry and an offline tab is never reloaded onto the browser's error page. Both signals share one cooldown. Returns the uninstaller. |
720
+ | `isStaleChunkError(error)` | True for the three engines' "module the server did not return" messages, Vite's CSS-preload line, and any Error named `ChunkLoadError` (Turbopack, webpack) — what `WindowCrashedFallback` uses to offer "Reload page" instead of "Reload window". |
721
+ | `budgetState(elapsed, budget)` | `no-reading` \| `no-budget` \| `within` \| `over` — the verdict `BudgetBar` draws by, exported so a run list sorts and filters by the same rule rather than re-deriving "late" a second time. |
722
+ | `GROUP_COLORS`, `GROUP_COLORS_SOLID`, `groupColors(group, emphasis?)` | The status palette, both registers, as the class strings the badges emit — for a surface that has to build its own pill (a virtualised cell, a canvas legend) and must not guess at the colors. |
723
+ | `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. |
724
+ | `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. |
725
+ | `toast.success / .error / .info` | Toast notifications — auto-mounts container. |
726
+ | `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. |
727
+ | `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. |
728
+ | `Kbd` constants — `MOD`, `ALT`, `SHIFT`, `ENTER`, `ALT_SHIFT_E`, `CMD_K`, … | Symbol constants for rendering keyboard shortcuts. |
729
+
730
+ ## Why it exists
731
+
732
+ Most "desktop UI" demos on the web are toys with hardcoded windows and no escape hatch. This one was extracted from a working ERP where every entity (sales orders, invoices, vendors, …) opens as its own window with consistent header, footer, hotkeys, depth stacking, and split-view. The shell is **fully decoupled** from any specific backend — every subsystem that needs server data (notifications, bug reports, desktop shortcuts, search, entity fetching) takes its data through callback configs supplied by the consumer. Drop-in localStorage fallbacks ship for prefs and sticky notes so the package works out of the box without a backend.
733
+
734
+ ## Examples
735
+
736
+ - [`examples/demo`](examples/demo/) — small Vite app showcasing the shell + bundled apps with mock data. Live at [efficient-pty-ltd.github.io/efficient-shell](https://efficient-pty-ltd.github.io/efficient-shell/), deployed automatically by [`.github/workflows/pages.yml`](.github/workflows/pages.yml) on every push to `main`.
737
+
738
+ ## Contributing
739
+
740
+ PRs welcome. Open an issue first for non-trivial changes so we can align on shape.
741
+
742
+ ```bash
743
+ npm run typecheck # tsc --noEmit, then tsc -p tsconfig.test.json (src + tests)
744
+ npm test # specs in tests/, run by node's test runner
745
+ npm run build # tsup → dist/
746
+ ```
747
+
748
+ `npm test` bundles `tests/*.test.tsx` with esbuild and runs them under `node --test`, rendering components with `react-dom/server`. No test framework is installed — the runner is `scripts/test.mjs`, ~60 lines. `esbuild` (the runner imports it) and `@types/node` (the specs import `node:test`) are declared devDependencies rather than borrowed from `tsup`'s transitive tree, so a change in its dependency layout cannot break the build job with a module-not-found.
749
+
750
+ Typecheck runs twice on purpose: `tsconfig.json` is the *build* config, so `rootDir: "./src"` fixes the shape of `dist/` and its `include` stops at `src/**`. `tsconfig.test.json` widens the scope to cover `tests/` as well — esbuild strips types without checking them, so without it a type error in a spec is invisible to CI.
751
+
752
+ ## License
753
+
754
+ [MIT](./LICENSE)