@liberation-data/desk 0.1.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 (172) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +4 -0
  3. package/README.md +436 -0
  4. package/dist/core/commands.d.ts +40 -0
  5. package/dist/core/commands.d.ts.map +1 -0
  6. package/dist/core/commands.js +92 -0
  7. package/dist/core/commands.js.map +1 -0
  8. package/dist/core/desk.d.ts +46 -0
  9. package/dist/core/desk.d.ts.map +1 -0
  10. package/dist/core/desk.js +178 -0
  11. package/dist/core/desk.js.map +1 -0
  12. package/dist/core/events.d.ts +42 -0
  13. package/dist/core/events.d.ts.map +1 -0
  14. package/dist/core/events.js +54 -0
  15. package/dist/core/events.js.map +1 -0
  16. package/dist/core/index.d.ts +13 -0
  17. package/dist/core/index.d.ts.map +1 -0
  18. package/dist/core/index.js +7 -0
  19. package/dist/core/index.js.map +1 -0
  20. package/dist/core/location.d.ts +30 -0
  21. package/dist/core/location.d.ts.map +1 -0
  22. package/dist/core/location.js +99 -0
  23. package/dist/core/location.js.map +1 -0
  24. package/dist/core/shortcuts.d.ts +36 -0
  25. package/dist/core/shortcuts.d.ts.map +1 -0
  26. package/dist/core/shortcuts.js +73 -0
  27. package/dist/core/shortcuts.js.map +1 -0
  28. package/dist/core/titles.d.ts +3 -0
  29. package/dist/core/titles.d.ts.map +1 -0
  30. package/dist/core/titles.js +26 -0
  31. package/dist/core/titles.js.map +1 -0
  32. package/dist/core/types.d.ts +51 -0
  33. package/dist/core/types.d.ts.map +1 -0
  34. package/dist/core/types.js +2 -0
  35. package/dist/core/types.js.map +1 -0
  36. package/dist/desk.css +2550 -0
  37. package/dist/react/Desktop.d.ts +24 -0
  38. package/dist/react/Desktop.d.ts.map +1 -0
  39. package/dist/react/Desktop.js +212 -0
  40. package/dist/react/Desktop.js.map +1 -0
  41. package/dist/react/Dock.d.ts +42 -0
  42. package/dist/react/Dock.d.ts.map +1 -0
  43. package/dist/react/Dock.js +103 -0
  44. package/dist/react/Dock.js.map +1 -0
  45. package/dist/react/MenuBar.d.ts +64 -0
  46. package/dist/react/MenuBar.d.ts.map +1 -0
  47. package/dist/react/MenuBar.js +245 -0
  48. package/dist/react/MenuBar.js.map +1 -0
  49. package/dist/react/appFrame.d.ts +37 -0
  50. package/dist/react/appFrame.d.ts.map +1 -0
  51. package/dist/react/appFrame.js +119 -0
  52. package/dist/react/appFrame.js.map +1 -0
  53. package/dist/react/arrange.d.ts +24 -0
  54. package/dist/react/arrange.d.ts.map +1 -0
  55. package/dist/react/arrange.js +61 -0
  56. package/dist/react/arrange.js.map +1 -0
  57. package/dist/react/commands.d.ts +23 -0
  58. package/dist/react/commands.d.ts.map +1 -0
  59. package/dist/react/commands.js +41 -0
  60. package/dist/react/commands.js.map +1 -0
  61. package/dist/react/context.d.ts +25 -0
  62. package/dist/react/context.d.ts.map +1 -0
  63. package/dist/react/context.js +28 -0
  64. package/dist/react/context.js.map +1 -0
  65. package/dist/react/controls.d.ts +111 -0
  66. package/dist/react/controls.d.ts.map +1 -0
  67. package/dist/react/controls.js +83 -0
  68. package/dist/react/controls.js.map +1 -0
  69. package/dist/react/conversation.d.ts +41 -0
  70. package/dist/react/conversation.d.ts.map +1 -0
  71. package/dist/react/conversation.js +73 -0
  72. package/dist/react/conversation.js.map +1 -0
  73. package/dist/react/dnd.d.ts +51 -0
  74. package/dist/react/dnd.d.ts.map +1 -0
  75. package/dist/react/dnd.js +138 -0
  76. package/dist/react/dnd.js.map +1 -0
  77. package/dist/react/dragContext.d.ts +32 -0
  78. package/dist/react/dragContext.d.ts.map +1 -0
  79. package/dist/react/dragContext.js +14 -0
  80. package/dist/react/dragContext.js.map +1 -0
  81. package/dist/react/events.d.ts +41 -0
  82. package/dist/react/events.d.ts.map +1 -0
  83. package/dist/react/events.js +91 -0
  84. package/dist/react/events.js.map +1 -0
  85. package/dist/react/index.d.ts +43 -0
  86. package/dist/react/index.d.ts.map +1 -0
  87. package/dist/react/index.js +22 -0
  88. package/dist/react/index.js.map +1 -0
  89. package/dist/react/layers.d.ts +5 -0
  90. package/dist/react/layers.d.ts.map +1 -0
  91. package/dist/react/layers.js +65 -0
  92. package/dist/react/layers.js.map +1 -0
  93. package/dist/react/overlays.d.ts +67 -0
  94. package/dist/react/overlays.d.ts.map +1 -0
  95. package/dist/react/overlays.js +98 -0
  96. package/dist/react/overlays.js.map +1 -0
  97. package/dist/react/popup.d.ts +21 -0
  98. package/dist/react/popup.d.ts.map +1 -0
  99. package/dist/react/popup.js +94 -0
  100. package/dist/react/popup.js.map +1 -0
  101. package/dist/react/search.d.ts +33 -0
  102. package/dist/react/search.d.ts.map +1 -0
  103. package/dist/react/search.js +82 -0
  104. package/dist/react/search.js.map +1 -0
  105. package/dist/react/setupProgress.d.ts +35 -0
  106. package/dist/react/setupProgress.d.ts.map +1 -0
  107. package/dist/react/setupProgress.js +68 -0
  108. package/dist/react/setupProgress.js.map +1 -0
  109. package/dist/react/shell.d.ts +17 -0
  110. package/dist/react/shell.d.ts.map +1 -0
  111. package/dist/react/shell.js +13 -0
  112. package/dist/react/shell.js.map +1 -0
  113. package/dist/react/sidebar.d.ts +23 -0
  114. package/dist/react/sidebar.d.ts.map +1 -0
  115. package/dist/react/sidebar.js +26 -0
  116. package/dist/react/sidebar.js.map +1 -0
  117. package/dist/react/table.d.ts +39 -0
  118. package/dist/react/table.d.ts.map +1 -0
  119. package/dist/react/table.js +86 -0
  120. package/dist/react/table.js.map +1 -0
  121. package/dist/react/tasks.d.ts +41 -0
  122. package/dist/react/tasks.d.ts.map +1 -0
  123. package/dist/react/tasks.js +79 -0
  124. package/dist/react/tasks.js.map +1 -0
  125. package/dist/react/tour.d.ts +42 -0
  126. package/dist/react/tour.d.ts.map +1 -0
  127. package/dist/react/tour.js +68 -0
  128. package/dist/react/tour.js.map +1 -0
  129. package/dist/react/windowBoundary.d.ts +22 -0
  130. package/dist/react/windowBoundary.d.ts.map +1 -0
  131. package/dist/react/windowBoundary.js +50 -0
  132. package/dist/react/windowBoundary.js.map +1 -0
  133. package/dist/react/wizard.d.ts +41 -0
  134. package/dist/react/wizard.d.ts.map +1 -0
  135. package/dist/react/wizard.js +60 -0
  136. package/dist/react/wizard.js.map +1 -0
  137. package/llms.txt +410 -0
  138. package/package.json +64 -0
  139. package/src/core/commands.ts +142 -0
  140. package/src/core/desk.ts +229 -0
  141. package/src/core/events.ts +99 -0
  142. package/src/core/index.ts +43 -0
  143. package/src/core/location.ts +127 -0
  144. package/src/core/shortcuts.ts +119 -0
  145. package/src/core/titles.ts +28 -0
  146. package/src/core/types.ts +49 -0
  147. package/src/desk.css +2550 -0
  148. package/src/react/Desktop.tsx +349 -0
  149. package/src/react/Dock.tsx +258 -0
  150. package/src/react/MenuBar.tsx +350 -0
  151. package/src/react/appFrame.tsx +183 -0
  152. package/src/react/arrange.ts +82 -0
  153. package/src/react/commands.ts +60 -0
  154. package/src/react/context.tsx +55 -0
  155. package/src/react/controls.tsx +364 -0
  156. package/src/react/conversation.tsx +208 -0
  157. package/src/react/dnd.tsx +189 -0
  158. package/src/react/dragContext.tsx +60 -0
  159. package/src/react/events.tsx +149 -0
  160. package/src/react/index.ts +64 -0
  161. package/src/react/layers.ts +66 -0
  162. package/src/react/overlays.tsx +254 -0
  163. package/src/react/popup.tsx +176 -0
  164. package/src/react/search.tsx +177 -0
  165. package/src/react/setupProgress.ts +113 -0
  166. package/src/react/shell.tsx +27 -0
  167. package/src/react/sidebar.tsx +84 -0
  168. package/src/react/table.tsx +176 -0
  169. package/src/react/tasks.tsx +176 -0
  170. package/src/react/tour.tsx +183 -0
  171. package/src/react/windowBoundary.tsx +110 -0
  172. package/src/react/wizard.tsx +150 -0
package/llms.txt ADDED
@@ -0,0 +1,410 @@
1
+ # @liberation-data/desk
2
+
3
+ A desktop for the web, in React: windows that fill the desk, layered, and arrange when asked, with a dock, a menu bar,
4
+ commands routed to whoever is responsible, and the controls to fill the windows. Apache-2.0.
5
+
6
+ This file is the whole public surface in one place, for a coding agent. Prose for people:
7
+ `docs/guide.md`. How it should look and behave: `HIG.md`.
8
+
9
+ ## Install and entry points
10
+
11
+ ```
12
+ npm install @liberation-data/desk # peer: react ^19
13
+ ```
14
+
15
+ ```ts
16
+ import { createDesk } from '@liberation-data/desk' // no DOM, no React
17
+ import { Desktop, DeskProvider } from '@liberation-data/desk/react' // components and hooks
18
+ import '@liberation-data/desk/desk.css' // tokens and styles
19
+ ```
20
+
21
+ ## The shape of an app
22
+
23
+ ```tsx
24
+ const desk = createDesk()
25
+
26
+ <DeskShell desk={desk}> {/* desk + bus + toasts; or DeskProvider on its own */}
27
+ <MenuBar menus={…} status={…} />
28
+ <div className="screen"> {/* position: relative; the stage fills it */}
29
+ <Desktop title={id => TITLES[id]} renderWindow={id => SURFACES[id]} empty={…} />
30
+ <InputBar onSubmit={…} />
31
+ <Dock entries={…} />
32
+ <SearchPalette open={…} onOpenChange={…} search={…} />
33
+ </div>
34
+ </DeskShell>
35
+ ```
36
+
37
+ Rules that are easy to get wrong:
38
+ - `.desk-stage` fills its parent: give the parent a height, and `position: relative` if the dock or input
39
+ bar overlays it. Set `--desk-inset-bottom` to the height of anything overlaying the bottom.
40
+ - A window is an id; `renderWindow(id)` says what it looks like. Every open window renders, including ones
41
+ offstage in the touch layout — that is what preserves their state.
42
+ - Opening a window that is already open focuses it. For a second window onto the same thing use
43
+ `openInstance`, and render by `windowType(id)`.
44
+
45
+ ## Core: `@liberation-data/desk`
46
+
47
+ ```ts
48
+ createDesk(options?: DeskOptions): Desk
49
+ interface DeskOptions { cascade?: Partial<CascadeOptions>; stage?: () => Size; initial?: DeskState }
50
+
51
+ interface Desk {
52
+ getState(): DeskState
53
+ subscribe(listener: (state: DeskState) => void): () => void
54
+ open(id: string, options?: { mode?: 'filled' | 'floating'; frame?: Frame }): void // default fills; a frame floats
55
+ openInstance(type: string, options?): string // 'query', then 'query#2' — returns the id it opened
56
+ close(id: string): void
57
+ closeAll(): void
58
+ focus(id: string): void
59
+ float(id: string, frame?: Frame): void // no frame: last place it sat, else the next cascade step
60
+ fill(id: string): void // fill the desk; remembers the frame for float()
61
+ toggleMode(id: string): void // zoom: fill ⇄ free
62
+ placeAll(frames: Record<string, Frame>): void // free windows at these frames; Arrange uses it
63
+ restore(state: DeskState): void // e.g. from a URL; normalised, not trusted
64
+ setStage(stage: () => Size): void
65
+ }
66
+
67
+ interface DeskState {
68
+ windows: readonly DeskWindow[] // the order they were opened = the order Arrange lays out
69
+ stack: readonly string[] // back to front; the last one is key
70
+ }
71
+ type DeskWindow = { id: string; mode: 'filled' } | { id: string; mode: 'floating'; frame: Frame }
72
+ interface Frame { x: number; y: number; width: number; height: number }
73
+
74
+ focusedId(state): string | null
75
+ windowType(id): string // 'query#2' → 'query'; what to render
76
+ instancesOf(state, type): DeskWindow[] // every window of a kind
77
+ isOpen(state, id): boolean
78
+ normalise(state): DeskState
79
+ cascadeFrame(slot, stage, options?): Frame
80
+ cascadeSlot(frame, stage, options?): number
81
+ nextCascadeFrame(windows, stage, options?): Frame
82
+ EMPTY: DeskState
83
+ ```
84
+
85
+ Every window opens filled, layered over the others; a filled window follows the stage's size. Arrange
86
+ lays windows out once as free windows (`placeAll`); moving or resizing one changes only that one. Dragging a
87
+ filled window frees it where it sits. Edge drops take a half; double-click or the green control zooms. State is immutable; every mutator returns void and notifies
88
+ subscribers only when something actually changed.
89
+
90
+ ### URL and history
91
+
92
+ ```ts
93
+ syncWithLocation(desk, stage: () => Size, options?: LocationOptions, env?: LocationEnv): () => void
94
+ serialize(state, options?): URLSearchParams // #w=rides,map~&f=map (`~` = free; the rest fill)
95
+ parse(params, stage, options?): DeskState
96
+ interface LocationOptions { key?: 'w'; focusKey?: 'f'; isKnown?: (id: string) => boolean }
97
+ ```
98
+
99
+ Opening or closing pushes history (Back closes); focus and mode changes replace it. Frames are never in the
100
+ URL. Other hash parameters are left alone. A URL naming no windows adopts whatever the app opened.
101
+
102
+ ### Commands (the responder chain)
103
+
104
+ ```ts
105
+ perform(desk, command: string, args?, options?: ChainOptions): boolean // true if something handled it
106
+ canPerform(desk, command: string, options?: ChainOptions): boolean // what a menu asks
107
+ addCommandHandler(element: EventTarget, command, handler, { enabled? }): () => void
108
+ addDeskCommands(stage: EventTarget, desk): () => void
109
+ chainStart(desk, options?): EventTarget
110
+ windowElement(doc, id): Element | null
111
+ DeskCommands = { closeWindow, zoomWindow, arrange, nextWindow, previousWindow }
112
+ // arrange is answered by a mounted Desktop: 1 fills, 2 split, 3 one tall + two stacked, 4+ a grid of usable cells;
113
+ // the focused window takes the first place. Put it in the Window menu as "Arrange", shortcut 'mod+alt+a'.
114
+ WINDOW_ATTRIBUTE = 'data-desk-window'; STAGE_ATTRIBUTE = 'data-desk-stage'
115
+ ```
116
+
117
+ The chain is the DOM: focused element → its window → stage → document. The first responder that implements
118
+ a command decides; if it is disabled the command stops there (AppKit's rule). Events are `desk:perform` and
119
+ `desk:can-perform`, bubbling and composed.
120
+
121
+ ### Shortcuts
122
+
123
+ ```ts
124
+ bindShortcuts(desk, keymap: Record<string, string>, { target?, apple? }): () => void
125
+ parseShortcut('mod+shift+z'): Shortcut
126
+ matchesShortcut(event, shortcut, apple?): boolean
127
+ formatShortcut('mod+comma', apple?): string // '⌘,' or 'Ctrl+,'
128
+ isApplePlatform(): boolean
129
+ isInstalledApp(): boolean // gate ⌘W / ⌘` on this; never take ⌘T or ⌘N
130
+ titleCase('ride card'): string // 'Ride Card' — app and window names; a document keeps its own name
131
+ isTitleCase(name): boolean // for a test over the names an app defines
132
+ ```
133
+
134
+ `mod` is ⌘ on Apple, Ctrl elsewhere. Matching also falls back to the physical key, so ⌥] works on a Mac. A
135
+ shortcut nothing handles leaves the browser's default alone. Bare keys are ignored while typing in a field.
136
+
137
+ ### Dragging between windows
138
+
139
+ ```tsx
140
+ useDragSource<T>({ type, disabled? }): { dragProps(payload, preview?), dragging } // lists
141
+ useDraggable<T>({ type, payload, preview?, disabled? }): { dragProps, dragging } // one thing
142
+ useDropTarget<T>({ accepts, onDrop, disabled? }): { dropProps, over, ready }
143
+ useDragging(): Drag | null
144
+ interface Drag<T> { type: string; payload: T; from: string | null }
145
+ type Accepts = string | string[] | ((type: string) => boolean)
146
+ ```
147
+
148
+ Pointer Events, so touch and pen work; Escape calls a drag off; the drop brings the receiving window
149
+ forward. `dropProps` carries `data-ready` and `data-over`, which the stylesheet already draws. A
150
+ `DeskProvider` supplies the drag state, as it does the bus.
151
+
152
+ ### Events between windows
153
+
154
+ ```ts
155
+ createBus(): Bus
156
+ interface Bus {
157
+ publish<T>(topic: string, payload: T, options?: { from?: string | null }): DeskEvent<T>
158
+ subscribe<T>(topic: string, handler: (e: DeskEvent<T>) => void, options?: { replay?: boolean }): () => void
159
+ last<T>(topic: string): DeskEvent<T> | undefined
160
+ }
161
+ interface DeskEvent<T> { topic: string; payload: T; from: string | null; at: number }
162
+ topicMatches(pattern, topic): boolean // 'ride.*' and '*' are the only wildcards
163
+ InputCommands = { submit: 'desk.input.submit', describe: 'desk.input.describe' }
164
+ ```
165
+
166
+ ## React: `@liberation-data/desk/react`
167
+
168
+ ### Shell
169
+
170
+ ```tsx
171
+ <DeskShell desk?={Desk} options?={DeskOptions} bus?={Bus} toasts?={true}>
172
+ <DeskProvider desk={desk}> // or options={…} to make one
173
+ useDesk(): Desk
174
+ useDeskState(): DeskState
175
+ useWindowId(): string | null // inside a window's content
176
+
177
+ <Desktop
178
+ renderWindow={(id) => ReactNode} // memoised: chrome re-renders, content does not
179
+ title={(id) => ReactNode}
180
+ actions?={(id) => ReactNode} // window-wide controls in the title bar
181
+ loading?={(id) => ReactNode} // inside a window while its code loads
182
+ failed?={(id, error, reload) => ReactNode} // inside a window that could not open
183
+ empty?={ReactNode}
184
+ layout?={'auto' | 'desktop' | 'fullscreen'} // auto: touch screens get one window at a time
185
+ className?={string}
186
+ />
187
+ ```
188
+
189
+ ### Loading and failing, per window
190
+
191
+ ```tsx
192
+ const RoutePlanner = lazyWindow(() => import('./apps/RoutePlanner')) // use in renderWindow
193
+ ```
194
+
195
+ Every window has its own Suspense and error boundary: one window loading or throwing never blanks another,
196
+ the menu bar or the dock. A failure is logged with `console.error` and shows Reload, which remounts the
197
+ window; `lazyWindow` retries its import on reload, unlike `React.lazy`, which caches a failed import.
198
+
199
+ ### Dock
200
+
201
+ ```tsx
202
+ <Dock entries={DockEntry[]} label?="Dock" placement?={'overlay' | 'inline'} />
203
+ dockItem({ id, label, icon, window?, description?, badge?, onSelect?, disabled? })
204
+ dockStack({ id, label, items: DockItem[], icon? })
205
+ dockSeparator(id)
206
+ ```
207
+
208
+ An item opens `window ?? id` and shows a dot while that window is open, accent when it is key. A stack fans
209
+ out above the dock. One tab stop; arrow keys along it; Escape folds a stack away.
210
+
211
+ ### Menu bar
212
+
213
+ ```tsx
214
+ <MenuBar menus={Menu[]} status?={Menu[]} leading?={ReactNode} trailing?={ReactNode} shortcuts?={true} />
215
+ interface Menu { id: string; label: string; title?: ReactNode; emphasis?: boolean; items: MenuItem[] | (() => MenuItem[]) }
216
+ // emphasis: the app's own menu, its name in bold, conventionally first
217
+ menuCommand(label, command, { shortcut?, checked?, detail?, args? })
218
+ menuAction(label, onSelect, { disabled?, shortcut?, checked?, detail? })
219
+ menuSeparator(); menuHeader(label)
220
+ windowMenuItems(state, focus, title): MenuItem[]
221
+ ```
222
+
223
+ Command items are enabled via `canPerform` as the menu opens, and menus bind their own shortcuts. Opening a
224
+ menu does not take focus from the key window, so a chosen command reaches the same responder a shortcut
225
+ would. `items` as a function is re-read on every open.
226
+
227
+ ### Commands and input
228
+
229
+ ```tsx
230
+ useCommand<Args>(command, handler, { enabled?: boolean | (() => boolean), at?: 'window' | 'app' | RefObject })
231
+ usePerform(): (command, args?) => boolean
232
+ useCanPerform(): (command) => boolean
233
+ useShortcuts(keymap, { target?, apple? })
234
+
235
+ useWindowInput(handler: (text: string) => void, { placeholder?, target?, disabled? })
236
+ <InputBar onSubmit={(text) => void} fallbackPlaceholder? fallbackTarget? label?
237
+ mode?={'summoned' | 'inline'} shortcut?={'mod+j' | null} /> // summoned: centred, Escape closes
238
+ InputCommands.open = 'desk.input.open' // a menu item can summon it
239
+ ```
240
+
241
+ `useCommand` defaults to the window it is rendered in, and to the app outside one.
242
+
243
+ ### Dragging between windows
244
+
245
+ ```tsx
246
+ useDragSource<T>({ type, disabled? }): { dragProps(payload, preview?), dragging } // lists
247
+ useDraggable<T>({ type, payload, preview?, disabled? }): { dragProps, dragging } // one thing
248
+ useDropTarget<T>({ accepts, onDrop, disabled? }): { dropProps, over, ready }
249
+ useDragging(): Drag | null
250
+ interface Drag<T> { type: string; payload: T; from: string | null }
251
+ type Accepts = string | string[] | ((type: string) => boolean)
252
+ ```
253
+
254
+ Pointer Events, so touch and pen work; Escape calls a drag off; the drop brings the receiving window
255
+ forward. `dropProps` carries `data-ready` and `data-over`, which the stylesheet already draws. A
256
+ `DeskProvider` supplies the drag state, as it does the bus.
257
+
258
+ ### Events
259
+
260
+ ```tsx
261
+ <BusProvider bus?={Bus}> // optional: a desk already carries a bus
262
+ useBus(): Bus
263
+ usePublish(): <T>(topic, payload) => DeskEvent<T> // stamped with the window it came from
264
+ useDeskEvent<T>(topic, handler, { replay? }) // replay: deliver the last event immediately
265
+ ```
266
+
267
+ ### Generated apps
268
+
269
+ ```tsx
270
+ <AppFrame title src?|srcDoc? listens?={string[]} says?={string[]} accepts?={Accepts} opens?={string[]}
271
+ sandbox?="allow-scripts allow-forms" />
272
+ withAppBridge(html): string // puts the app-side script in the page's <head>
273
+ APP_BRIDGE_SCRIPT // the same script, to include some other way
274
+ ```
275
+
276
+ Inside the page: `desk.on(topic, message => …)`, `desk.onDrop(message => …)`, `desk.publish(topic, payload)`,
277
+ `desk.open(window)`, and `desk.window` once connected. Messages carry `desk: 1`. The host ignores anything not
278
+ from its own frame, drops publishes outside `says` and opens outside `opens`, never echoes an app its own
279
+ events, and sets the frame's `pointer-events: none` while a drag is in the air so drops reach it.
280
+
281
+ ### Search
282
+
283
+ ```tsx
284
+ <SearchPalette open onOpenChange search={(q) => SearchResult[] | Promise<SearchResult[]>}
285
+ placeholder? label? hint? empty?={(q) => ReactNode} shortcut?={'mod+k' | null} />
286
+ interface SearchResult { id; title; subtitle?; group?; icon?; kind?; onSelect(): void }
287
+ windowResults(desk, title, group?): SearchResult[]
288
+ SearchCommand = 'desk.search' // a menu item can open it
289
+ ```
290
+
291
+ ### Controls
292
+
293
+ ```tsx
294
+ <Button intent?={'normal'|'default'|'quiet'|'destructive'} size?={'regular'|'small'} icon? … />
295
+ <SegmentedControl options={{value,label,icon?,disabled?}[]} value onChange label size? disabled? />
296
+ <Toggle checked onChange label? description? disabled? aria-label? /> // applies at once
297
+ <Checkbox checked indeterminate? onChange label? description? disabled? /> // waits for Save
298
+ <Slider value onChange label min? max? step? disabled? format?={(v) => string} />
299
+ <TextField label labelHidden? help? error? … /> // error marks it invalid
300
+ <RadioGroup options={{value,label,description?,disabled?}[]} value onChange label labelHidden? />
301
+ <ChoiceGroup options={{value,label,description?,icon?,tag?,disabled?}[]} value onChange label labelHidden? />
302
+ // cards for a setup step; native radios, arrow keys move the choice
303
+ <PopUpButton value={T|null} onChange options={{value,label,description?,icon?,disabled?}[]}
304
+ label labelHidden? placeholder? disabled? size? />
305
+ ```
306
+
307
+ ### Lists
308
+
309
+ ```tsx
310
+ <Table rows columns rowId={(row) => string} label rowProps?={(row) => object}
311
+ selected?={string | string[] | null} selection?={'single' | 'multiple'}
312
+ onSelect? onSelectionChange? onActivate? sort? onSortChange? empty? />
313
+ interface Column<Row> { key; header; render?; align?; numeric?; sortable?; width? }
314
+ interface Sort { key: string; direction: 'ascending' | 'descending' }
315
+
316
+ <Sidebar sections={{ id, label?, items: { id, label, icon?, badge?, disabled? }[] }[]}
317
+ value onChange label footer? />
318
+ ```
319
+
320
+ Table: one tab stop, arrow keys move the selection, Enter or double click activates, `aria-sort` on
321
+ sortable headings. It renders what it is given — no paging, no fetching.
322
+
323
+ ### Overlays
324
+
325
+ ```tsx
326
+ <Popover open onOpenChange label trigger={(props) => <Button {...props} />} placement? align? />
327
+ <Sheet open onDismiss title description? actions? /> // renders inside its own window
328
+ <Alert open title message? confirmLabel onConfirm onCancel cancelLabel? destructive? />
329
+ <ToastProvider>; useToast(): { show(toast): string; dismiss(id): void }
330
+ toast.show({ message, tone?: 'neutral'|'ok'|'bad', action?: { label, onSelect }, duration?: 6000 })
331
+ ```
332
+
333
+ All share one layer stack: Escape dismisses the topmost. Popover, Sheet and Alert trap focus and return it.
334
+ A toast pauses while the pointer or focus is on it.
335
+
336
+ ### Conversation
337
+
338
+ ```tsx
339
+ <Thread messages={Message[]} me?="me" typing? onRetry? empty? label? />
340
+ interface Message { id; from; authorName?; body: ReactNode; at?: Date | string; state?: 'sending'|'sent'|'failed' }
341
+ <Composer value onChange onSubmit placeholder? label? disabled? busy? onStop? accessory? maxRows? />
342
+ ```
343
+
344
+ No transport of its own. The thread follows new messages only while the reader is at the end. The composer
345
+ sends on Enter, keeps Shift+Enter for a new line, and never sends mid-composition in an input method.
346
+
347
+ ### Teaching and first run
348
+
349
+ ```tsx
350
+ <TourBar tour={{ id, name, description?, steps }} onFinish? onStop? offer? />
351
+ interface TourStep { window?: string; point?: string; caption: ReactNode; yourTurn?: boolean;
352
+ until?: string | { topic: string; when: (event: DeskEvent) => boolean } }
353
+
354
+ const tasks = useTasks(Task[]): { items: TaskProgress[]; running; done; failed; start(); restart() }
355
+ interface Task { id; name: ReactNode; detail?: ReactNode; run: (report: (detail: ReactNode) => void) => unknown }
356
+ <Checklist items={TaskProgress[]} onRetry? progress?={true} label? /> // a failed row shows its error and Try again
357
+
358
+ const setup = useSetupProgress({ key, initial: Answers, store?: SetupStore })
359
+ // { loaded, index, setIndex, answers, answer(patch), finished, finish(), reset() }
360
+ interface SetupStore<A> { load(): SetupRecord<A> | undefined | Promise<…>; save(record): void | Promise<void>; clear() }
361
+ localSetupStore(key) // the default: localStorage under `desk.setup.<key>`; never put secrets in answers
362
+
363
+ <Wizard steps={WizardStep[]} index onIndexChange onFinish label? />
364
+ interface WizardStep { id; name; title; description?; body?; glyph?; complete?; continueLabel?;
365
+ skip?: { label, onSkip }; working?; onEnter?;
366
+ onContinue?: () => unknown | Promise<unknown>; busyLabel? }
367
+ ```
368
+
369
+ A tour step opens its window and points at `point` (a `data-tour` name or a selector). A step with `until` moves on
370
+ when that event is published (no Done button). `offer` shows the tour's name and description with Show me / Not now
371
+ (Not now calls onStop) and opens nothing until accepted — use it right after setup. A wizard step offers
372
+ Continue only when `complete`, and `working` steps offer no Back. `onContinue` runs before moving on: Continue
373
+ shows `busyLabel` (default "Working…") and is disabled; a thrown Error's message is shown in the step and the
374
+ step stays; returning `false` stays silently. Pressing again while it runs does nothing. Tasks run in order and
375
+ stop at the first that throws; `start()` runs whatever is not done (the retry), `restart()` runs them all again.
376
+ For a waiting step: `working: !tasks.failed, complete: tasks.done, onEnter: tasks.restart`.
377
+
378
+ ## Styling
379
+
380
+ Every value reads a `--desk-*` token; redefine tokens, never override selectors. Colour roles: `--desk-ground`,
381
+ `--desk-surface`, `--desk-titlebar`, `--desk-ink`, `--desk-muted`, `--desk-rule`, `--desk-accent`,
382
+ `--desk-ok|warn|bad`, `--desk-on-accent`. Spacing `--desk-space-1..7` = 4, 8, 12, 16, 24, 32, 48 px. Also
383
+ `--desk-radius`, `--desk-control-height`, `--desk-control-radius`, `--desk-font`, `--desk-font-mono`,
384
+ `--desk-ease`, `--desk-dock-size`, `--desk-dock-glass`, `--desk-inset-bottom`.
385
+
386
+ Light and dark follow `prefers-color-scheme`; `data-theme="light" | "dark"` on the root wins.
387
+
388
+ ## Testing
389
+
390
+ ```ts
391
+ const desk = createDesk({ stage: () => ({ width: 1000, height: 700 }) }) // jsdom measures nothing
392
+ render(<DeskProvider desk={desk}>…</DeskProvider>)
393
+ ```
394
+
395
+ Query a window by `[data-desk-window="id"]`: on a touch layout an offstage window is `inert`, so it is not
396
+ in the accessibility tree.
397
+
398
+ ## Facts an agent should not guess
399
+
400
+ - React 19 is a peer dependency; the core entry point imports no React and touches no DOM except through
401
+ the command helpers.
402
+ - Focus follows the key window when it changes, but never on first render.
403
+ - First run (HIG §8): check on Continue with `onContinue`, not on the next step; show waiting work with
404
+ `useTasks` + `Checklist`; open the chosen window last so it is in front; offer the tour (`offer`) once on
405
+ arrival; remember progress with `useSetupProgress`, and never put passwords or keys in its answers.
406
+ - A `DeskProvider` already carries an event bus; `BusProvider` is only for supplying or sharing one.
407
+ - `renderWindow` must be stable (module scope or `useCallback`) for the memoisation to hold.
408
+ - `parse`/`syncWithLocation` warn once when no `isKnown` is given: unknown ids from a URL are then trusted.
409
+ - `npm run size` enforces a gzipped budget: core 10 KiB, react 46 KiB, css 10 KiB.
410
+ - The package is ESM only, with `"sideEffects": ["**/*.css"]`.
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@liberation-data/desk",
3
+ "version": "0.1.0",
4
+ "description": "A desktop for the web, for React: windows, a dock, menus and commands, controls, tours and setup assistants.",
5
+ "license": "Apache-2.0",
6
+ "author": "Jasper Blues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/liberation-data/desk.git"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": [
13
+ "**/*.css"
14
+ ],
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "llms.txt",
19
+ "NOTICE"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/core/index.d.ts",
24
+ "import": "./dist/core/index.js"
25
+ },
26
+ "./react": {
27
+ "types": "./dist/react/index.d.ts",
28
+ "import": "./dist/react/index.js"
29
+ },
30
+ "./desk.css": "./dist/desk.css"
31
+ },
32
+ "scripts": {
33
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/desk.css dist/desk.css",
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "vitest run",
36
+ "example": "vite examples/garage",
37
+ "prepublishOnly": "npm run typecheck && npm test && npm run build",
38
+ "size": "node scripts/size.mjs",
39
+ "check": "npm run typecheck && npm test && npm run build && npm run size",
40
+ "check:browser": "node scripts/browser-check.mjs"
41
+ },
42
+ "peerDependencies": {
43
+ "react": "^19.0.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "react": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "@testing-library/react": "^16.3.3",
52
+ "@testing-library/user-event": "^14.6.7",
53
+ "@types/react": "^19.3.0",
54
+ "@types/react-dom": "^19.3.0",
55
+ "@vitejs/plugin-react": "^6.1.1",
56
+ "jsdom": "^30.0.1",
57
+ "puppeteer-core": "^25.11.0",
58
+ "react": "^19.3.0",
59
+ "react-dom": "^19.3.0",
60
+ "typescript": "^7.0.2",
61
+ "vite": "^8.3.0",
62
+ "vitest": "^5.0.0"
63
+ }
64
+ }
@@ -0,0 +1,142 @@
1
+ import { focusedId } from './desk.js'
2
+ import type { Desk } from './desk.js'
3
+
4
+ /*
5
+ * The responder chain, built from the DOM rather than beside it.
6
+ *
7
+ * A command that names no target — Copy, Close, text typed into the desk's input
8
+ * bar — is dispatched as an event from the start of the chain and bubbles up
9
+ * through ancestors: focused element → its window → the stage → the document.
10
+ * The first element with a handler for that command decides. Menus ask the same
11
+ * chain whether a command can be performed before they draw an item enabled.
12
+ *
13
+ * Deciding is AppKit's rule: the first responder that implements a command is
14
+ * its target, and its answer stands. A disabled handler stops the command rather
15
+ * than letting it fall through to something further up the chain.
16
+ */
17
+
18
+ export type CommandId = string
19
+
20
+ /** Marks a window's element, so the chain can start at the key window. */
21
+ export const WINDOW_ATTRIBUTE = 'data-desk-window'
22
+ /** Marks the stage, the desk-level responder. */
23
+ export const STAGE_ATTRIBUTE = 'data-desk-stage'
24
+
25
+ const PERFORM = 'desk:perform'
26
+ const QUERY = 'desk:can-perform'
27
+
28
+ interface PerformDetail {
29
+ readonly command: CommandId
30
+ readonly args: unknown
31
+ handled: boolean
32
+ }
33
+
34
+ interface QueryDetail {
35
+ readonly command: CommandId
36
+ enabled: boolean | undefined
37
+ }
38
+
39
+ export type CommandHandler<Args = unknown> = (args: Args) => void
40
+
41
+ export interface CommandOptions {
42
+ /** Whether the handler can act right now. Asked before performing and when a menu opens. Default: always. */
43
+ readonly enabled?: () => boolean
44
+ }
45
+
46
+ /**
47
+ * Makes `element` a responder for `command`. Returns a function that removes it.
48
+ * Attach to `document` for an app-level responder.
49
+ */
50
+ export function addCommandHandler<Args = unknown>(
51
+ element: EventTarget,
52
+ command: CommandId,
53
+ handler: CommandHandler<Args>,
54
+ options: CommandOptions = {},
55
+ ): () => void {
56
+ const enabled = options.enabled ?? (() => true)
57
+
58
+ const onPerform = (event: Event) => {
59
+ const detail = (event as CustomEvent<PerformDetail>).detail
60
+ if (detail.command !== command) return
61
+ event.stopPropagation()
62
+ if (!enabled()) return
63
+ detail.handled = true
64
+ handler(detail.args as Args)
65
+ }
66
+
67
+ const onQuery = (event: Event) => {
68
+ const detail = (event as CustomEvent<QueryDetail>).detail
69
+ if (detail.command !== command) return
70
+ event.stopPropagation()
71
+ detail.enabled = enabled()
72
+ }
73
+
74
+ element.addEventListener(PERFORM, onPerform)
75
+ element.addEventListener(QUERY, onQuery)
76
+ return () => {
77
+ element.removeEventListener(PERFORM, onPerform)
78
+ element.removeEventListener(QUERY, onQuery)
79
+ }
80
+ }
81
+
82
+ export interface ChainOptions {
83
+ /** Start here instead of at the focused element. */
84
+ readonly from?: Element | null
85
+ readonly document?: Document
86
+ }
87
+
88
+ /** Where a command starts: focus inside the key window, else the key window, else the stage, else the document. */
89
+ export function chainStart(desk: Desk | null, options: ChainOptions = {}): EventTarget {
90
+ if (options.from) return options.from
91
+ const doc = options.document ?? globalThis.document
92
+ const key = desk ? focusedId(desk.getState()) : null
93
+ const window = key ? windowElement(doc, key) : null
94
+ const active = doc.activeElement
95
+ if (window && active && window.contains(active)) return active
96
+ return window ?? doc.querySelector(`[${STAGE_ATTRIBUTE}]`) ?? doc
97
+ }
98
+
99
+ export const windowElement = (doc: ParentNode, id: string): Element | null =>
100
+ [...doc.querySelectorAll(`[${WINDOW_ATTRIBUTE}]`)].find(el => el.getAttribute(WINDOW_ATTRIBUTE) === id) ?? null
101
+
102
+ /** Sends a command along the chain. Returns whether anything handled it. */
103
+ export function perform<Args = unknown>(desk: Desk | null, command: CommandId, args?: Args, options: ChainOptions = {}): boolean {
104
+ const detail: PerformDetail = { command, args, handled: false }
105
+ chainStart(desk, options).dispatchEvent(new CustomEvent(PERFORM, { detail, bubbles: true, composed: true }))
106
+ return detail.handled
107
+ }
108
+
109
+ /** Asks the chain whether a command would be handled right now. */
110
+ export function canPerform(desk: Desk | null, command: CommandId, options: ChainOptions = {}): boolean {
111
+ const detail: QueryDetail = { command, enabled: undefined }
112
+ chainStart(desk, options).dispatchEvent(new CustomEvent(QUERY, { detail, bubbles: true, composed: true }))
113
+ return detail.enabled ?? false
114
+ }
115
+
116
+ /** The commands the desk itself answers, at the stage. */
117
+ export const DeskCommands = {
118
+ closeWindow: 'desk.window.close',
119
+ /** Fills the key window, or frees it: the green control. */
120
+ zoomWindow: 'desk.window.zoom',
121
+ /** Answered by a mounted Desktop, which knows the space windows are laid out in. */
122
+ arrange: 'desk.window.arrange',
123
+ nextWindow: 'desk.window.next',
124
+ previousWindow: 'desk.window.previous',
125
+ } as const
126
+
127
+ export function addDeskCommands(stage: EventTarget, desk: Desk): () => void {
128
+ const key = () => focusedId(desk.getState())
129
+ const cycle = (step: 1 | -1) => {
130
+ const { windows } = desk.getState()
131
+ const current = windows.findIndex(w => w.id === key())
132
+ const next = windows[(current + step + windows.length) % windows.length]
133
+ if (next) desk.focus(next.id)
134
+ }
135
+ const removers = [
136
+ addCommandHandler(stage, DeskCommands.closeWindow, () => { const id = key(); if (id) desk.close(id) }, { enabled: () => key() !== null }),
137
+ addCommandHandler(stage, DeskCommands.zoomWindow, () => { const id = key(); if (id) desk.toggleMode(id) }, { enabled: () => key() !== null }),
138
+ addCommandHandler(stage, DeskCommands.nextWindow, () => cycle(1), { enabled: () => desk.getState().windows.length > 1 }),
139
+ addCommandHandler(stage, DeskCommands.previousWindow, () => cycle(-1), { enabled: () => desk.getState().windows.length > 1 }),
140
+ ]
141
+ return () => removers.forEach(remove => remove())
142
+ }