@objectifthunes/whiteboard 0.2.4 → 0.2.6

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.
package/README.md ADDED
@@ -0,0 +1,1274 @@
1
+ # @objectifthunes/whiteboard
2
+
3
+ A pan / zoom **whiteboard canvas for React** with draggable floating panels, a minimap, snap-to-grid, and a complete set of UI primitives — all themed via CSS custom properties.
4
+
5
+ <p align="center">
6
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/overview.png" width="49%" alt="All panels, light mode" />
7
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/dark.png" width="49%" alt="All panels, dark mode" />
8
+ </p>
9
+ <p align="center">
10
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/detail.png" width="98%" alt="Panels zoomed in" />
11
+ </p>
12
+
13
+ > One package, zero design system lock-in: drop `<WhiteboardShell>` into any React app and start placing panels.
14
+
15
+ ---
16
+
17
+ ## Highlights
18
+
19
+ - **Pan & zoom canvas** — drag to pan, scroll/pinch to zoom, pointer-capture safe.
20
+ - **Floating panels** — drag to reposition, double-click to focus, snap to grid.
21
+ - **Minimap** — live overview with click / drag navigation and per-panel double-click focus.
22
+ - **ZoomBar** — zoom in/out, fit to content, reset positions, snap toggle, extra slots.
23
+ - **Zustand store** — full programmatic control (`fitToContent`, `focusPanel`, `resetWidgets`, …).
24
+ - **45+ UI components** — buttons, forms, alerts, typography, skeletons, dialogs, cards, navigation.
25
+ - **Light / dark theme** — CSS custom properties + the built-in `<ThemeToggle />`.
26
+ - **Tiny** — 42 kB raw / 10 kB gzipped JS, single CSS file.
27
+ - **Tree-shakeable ESM** + full TypeScript declarations.
28
+
29
+ ---
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ npm install @objectifthunes/whiteboard
35
+ # peer deps
36
+ npm install react react-dom zustand
37
+ ```
38
+
39
+ Peer-dependency ranges: `react >= 18`, `react-dom >= 18`, `zustand >= 4`.
40
+
41
+ ---
42
+
43
+ ## Quick Start
44
+
45
+ ```tsx
46
+ import '@objectifthunes/whiteboard/style.css'
47
+ import {
48
+ WhiteboardShell,
49
+ FloatingPanel,
50
+ ThemeToggle,
51
+ } from '@objectifthunes/whiteboard'
52
+
53
+ export function App() {
54
+ return (
55
+ <div style={{ position: 'relative', height: '100vh' }}>
56
+ <WhiteboardShell extraActions={<ThemeToggle />}>
57
+ <FloatingPanel
58
+ title="My Panel"
59
+ defaultPosition={{ x: 100, y: 100 }}
60
+ focusable
61
+ >
62
+ Drag me · Scroll to zoom · Double-click to focus
63
+ </FloatingPanel>
64
+ </WhiteboardShell>
65
+ </div>
66
+ )
67
+ }
68
+ ```
69
+
70
+ The shell needs a positioned, sized parent — usually `position: relative; height: 100vh` (or any explicit height). Auto-fit kicks in on first paint, so panels appear pre-framed.
71
+
72
+ ---
73
+
74
+ ## Component Index
75
+
76
+ **Whiteboard:** [WhiteboardShell](#whiteboardshell) · [FloatingPanel](#floatingpanel) · [Minimap](#minimap) · [ZoomBar](#zoombar) · [ConfirmDialog](#confirmdialog) · [PanelErrorBoundary](#panelerrorboundary)
77
+
78
+ **Store & hooks:** [useWhiteboardStore](#usewhiteboardstore) · [useWhiteboardLayout](#usewhiteboardlayout) · [Geometry helpers](#geometry-helpers) · [Panel-rect helpers](#panel-rect-helpers)
79
+
80
+ **UI — Buttons:** [Button](#button) · [ButtonRow](#buttonrow) · [PanelCloseButton](#panelclosebutton)
81
+
82
+ **UI — Forms:** [Field](#field) · [Label](#label) · [Input](#input) · [Textarea](#textarea) · [Select](#select) · [CoordGrid · CoordInput](#coordgrid--coordinput)
83
+
84
+ **UI — Status & Feedback:** [Alert](#alert) · [Pill](#pill) · [Chip](#chip) · [TagRow](#tagrow) · [LoadingState](#loadingstate)
85
+
86
+ **UI — Layout:** [Stack](#stack) · [Inline](#inline) · [TitleRow](#titlerow) · [SplitLayout](#splitlayout) · [IconText](#icontext) · [PageShell · PageCard](#pageshell--pagecard)
87
+
88
+ **UI — Typography:** [PageTitle](#pagetitle) · [StoryTitle](#storytitle) · [AssetTitle](#assettitle) · [SectionTitle · SectionDescription](#sectiontitle--sectiondescription) · [MutedText](#mutedtext)
89
+
90
+ **UI — Cards & Lists:** [ItemCard](#itemcard) · [ItemList](#itemlist) · [List](#list) · [PickerCard](#pickercard) · [PickerGrid](#pickergrid) · [ChoiceCard](#choicecard) · [ChoiceGroup](#choicegroup)
91
+
92
+ **UI — Overlays:** [GeneratingOverlay](#generatingoverlay) · [EmptyState](#emptystate) · [PanelErrorBoundary](#panelerrorboundary)
93
+
94
+ **UI — Navigation & Canvas:** [VerticalToolbar](#verticaltoolbar) · [AvatarBadge](#avatarbadge) · [CanvasStage](#canvasstage) · [OverlayIconButton](#overlayiconbutton) · [ImageThumb](#imagethumb)
95
+
96
+ **UI — Skeletons:** [Skeleton](#skeleton-base) · [Primitive skeletons](#primitive-skeletons) · [Widget skeletons](#widget-skeletons) · [ChoiceGroupSkeleton](#choicegroupskeleton)
97
+
98
+ **UI — Panel sections:** [PanelSection](#panelsection) · [PanelTitle](#paneltitle)
99
+
100
+ **Theming:** [ThemeToggle](#themetoggle) · [CSS custom properties](#css--theming)
101
+
102
+ ---
103
+
104
+ ## Whiteboard Components
105
+
106
+ ### `WhiteboardShell`
107
+
108
+ The root container. Handles pan, zoom, viewport tracking, auto-fit on first mount, session reset on unmount, and renders the `ZoomBar` + `Minimap`.
109
+
110
+ ```tsx
111
+ <WhiteboardShell
112
+ showMinimap={true} // default: true
113
+ minimapLoading={false} // show a spinner inside the minimap
114
+ extraActions={<ThemeToggle />} // any node rendered inside the ZoomBar
115
+ >
116
+ {/* FloatingPanels go here */}
117
+ </WhiteboardShell>
118
+ ```
119
+
120
+ | Prop | Type | Default | Description |
121
+ | ---------------- | ----------- | ------- | -------------------------------------------- |
122
+ | `children` | `ReactNode` | — | Panels (any node) rendered on the canvas. |
123
+ | `showMinimap` | `boolean` | `true` | Render the minimap in the bottom corner. |
124
+ | `minimapLoading` | `boolean` | `false` | Show a spinner instead of the minimap rects. |
125
+ | `extraActions` | `ReactNode` | — | Extra controls rendered at the bottom of the ZoomBar (theme toggle, custom action). |
126
+
127
+ **Interactions out of the box**
128
+
129
+ - Drag canvas background → pan
130
+ - Scroll wheel → zoom toward cursor (clamped `0.2`–`3`)
131
+ - Right-click is consumed (no native context menu over the canvas)
132
+ - ResizeObserver keeps the viewport size in sync
133
+ - `resetSession` runs on unmount so the next mount starts clean
134
+
135
+ ---
136
+
137
+ ### `FloatingPanel`
138
+
139
+ A draggable card placed on the canvas.
140
+
141
+ ```tsx
142
+ <FloatingPanel
143
+ title="Settings"
144
+ defaultPosition={{ x: 100, y: 80 }}
145
+ width={320} // default: 300
146
+ focusable // adds the focus button in the header
147
+ focusPadding={40} // padding when focusing (default: 40)
148
+ focusMaxScale={1.5} // max zoom when focusing (default: 1.5)
149
+ headerActions={<Button>…</Button>}
150
+ trackRect={rectRef} // MutableRefObject<PanelRect>, kept in sync
151
+ >
152
+ {/* any content */}
153
+ </FloatingPanel>
154
+ ```
155
+
156
+ | Prop | Type | Default | Description |
157
+ | ----------------- | ----------------------------------- | ------- | ----------- |
158
+ | `title` | `ReactNode` | — | Header label. |
159
+ | `defaultPosition` | `{ x: number, y: number }` | — | Initial world-space position. |
160
+ | `width` | `number` | `300` | Pixel width. Height is content-driven. |
161
+ | `focusable` | `boolean` | `false` | Renders the corner focus button. |
162
+ | `focusPadding` | `number` | `40` | Padding used by the focus camera. |
163
+ | `focusMaxScale` | `number` | `1.5` | Camera scale ceiling when focusing. |
164
+ | `headerActions` | `ReactNode` | — | Buttons rendered next to the focus button. |
165
+ | `trackRect` | `MutableRefObject<PanelRect>` | — | Ref kept in sync with current `{x, y, width, height, focusPadding, focusMaxScale}`. |
166
+ | `className` | `string` | — | Extra class merged onto the panel root. |
167
+
168
+ Double-click the panel or press the focus button to zoom the camera to that panel. Snap-to-grid (toggled in the `ZoomBar`) also realigns the panel on activation.
169
+
170
+ ---
171
+
172
+ ### `Minimap`
173
+
174
+ Lives in the bottom-right corner, scaled to panel content bounds. Click to pan, drag to scrub, scroll to zoom, double-click a panel rect to focus it.
175
+
176
+ ```tsx
177
+ <WhiteboardShell showMinimap minimapLoading={false} />
178
+ // Renders <Minimap loading={false} /> internally
179
+ ```
180
+
181
+ You normally don't render `<Minimap>` directly — `WhiteboardShell` does it for you.
182
+
183
+ | Prop | Type | Default | Description |
184
+ | --------- | --------- | ------- | -------------------------------------- |
185
+ | `loading` | `boolean` | `false` | Show a spinner instead of panel rects. |
186
+
187
+ ---
188
+
189
+ ### `ZoomBar`
190
+
191
+ Vertical bar in the right edge with: zoom out, current %, zoom in, **Fit to content**, **Reset positions**, **Snap to grid** toggle, and any `extraActions` you pass in.
192
+
193
+ ```tsx
194
+ <ZoomBar extraActions={<ThemeToggle />} />
195
+ ```
196
+
197
+ Like `<Minimap>`, this is rendered by `WhiteboardShell` — only render it directly if you're building a custom shell. The component reads & writes directly to `useWhiteboardStore`.
198
+
199
+ ---
200
+
201
+ ### `ConfirmDialog`
202
+
203
+ A portaled, accessible confirmation dialog. Closes on `Escape`, click outside, or the Cancel button.
204
+
205
+ ```tsx
206
+ <ConfirmDialog
207
+ open={open}
208
+ title="Delete scene?"
209
+ message="This cannot be undone."
210
+ confirmLabel="Delete"
211
+ loading={deleting}
212
+ error={errorMessage}
213
+ onConfirm={handleDelete}
214
+ onCancel={() => setOpen(false)}
215
+ />
216
+ ```
217
+
218
+ | Prop | Type | Default | Description |
219
+ | -------------- | ------------------- | ----------- | -------------------------------------------- |
220
+ | `open` | `boolean` | — | Controls visibility. |
221
+ | `title` | `string` | — | Dialog title (also `aria-label`). |
222
+ | `message` | `string` | — | Body text. |
223
+ | `confirmLabel` | `string` | `'Confirm'` | Confirm button label. |
224
+ | `loading` | `boolean` | `false` | Disables confirm and shows `Deleting…`. |
225
+ | `error` | `string \| null` | — | Inline error shown above the actions. |
226
+ | `onConfirm` | `() => void` | — | Confirm handler. |
227
+ | `onCancel` | `() => void` | — | Cancel handler (also fires on Escape / backdrop). |
228
+
229
+ ---
230
+
231
+ ### `PanelErrorBoundary`
232
+
233
+ A class-based error boundary that renders a friendly fallback + Retry button when a panel's subtree throws.
234
+
235
+ ```tsx
236
+ <PanelErrorBoundary fallbackMessage="This panel crashed.">
237
+ <PotentiallyBrokenWidget />
238
+ </PanelErrorBoundary>
239
+ ```
240
+
241
+ | Prop | Type | Default |
242
+ | ----------------- | -------- | ------------------------ |
243
+ | `fallbackMessage` | `string` | `'This panel crashed.'` |
244
+
245
+ ---
246
+
247
+ ## Store & Hooks
248
+
249
+ ### `useWhiteboardStore`
250
+
251
+ A Zustand store that exposes camera, viewport, panels registry, and actions.
252
+
253
+ ```ts
254
+ import { useWhiteboardStore } from '@objectifthunes/whiteboard'
255
+
256
+ const offset = useWhiteboardStore(s => s.offset) // { x, y }
257
+ const scale = useWhiteboardStore(s => s.scale) // 0.2 — 3
258
+ const snapToGrid = useWhiteboardStore(s => s.snapToGrid)
259
+ const setSnapToGrid = useWhiteboardStore(s => s.setSnapToGrid)
260
+ const fitToContent = useWhiteboardStore(s => s.fitToContent)
261
+ const focusPanel = useWhiteboardStore(s => s.focusPanel)
262
+ const resetWidgets = useWhiteboardStore(s => s.resetWidgets)
263
+ ```
264
+
265
+ **Selectors (state)**
266
+
267
+ | Key | Type |
268
+ | ------------------ | --------------------------------------- |
269
+ | `offset` | `{ x: number, y: number }` |
270
+ | `scale` | `number` (clamped 0.2–3) |
271
+ | `viewportSize` | `{ width: number, height: number }` |
272
+ | `snapToGrid` | `boolean` |
273
+ | `snapGridSize` | `number` (defaults to `20`) |
274
+ | `panels` | `Map<string, PanelRect>` |
275
+ | `resetFns` | `Map<string, () => void>` |
276
+ | `registryVersion` | `number` (bumps on registry change) |
277
+
278
+ **Actions**
279
+
280
+ | Method | Description |
281
+ | --------------------- | ---------------------------------------------------------- |
282
+ | `setOffset(v)` | Set camera offset (value or updater). |
283
+ | `setScale(v)` | Set camera scale. |
284
+ | `setViewportSize(v)` | Called automatically by the shell. |
285
+ | `setSnapToGrid(v)` | Toggle snap mode. Dispatches `whiteboard-snap-now` so existing panels realign. |
286
+ | `register(id, rect)` | Add/replace a panel rect. Called by `FloatingPanel`. |
287
+ | `unregister(id)` | Remove a panel rect. |
288
+ | `registerReset(id, fn)` / `unregisterReset(id)` | Register a reset handler for `resetWidgets`. |
289
+ | `fitToContent()` | Reframe the camera so all registered panels fit (with padding). |
290
+ | `focusPanel(rect, { padding?, maxScale? })` | Reframe the camera onto a single rect. |
291
+ | `resetWidgets()` | Call every panel's reset handler then re-fit. |
292
+ | `resetSession()` | Discard all state. Called on shell unmount. |
293
+
294
+ ---
295
+
296
+ ### `useWhiteboardLayout`
297
+
298
+ Compute snap-aligned default positions from a map of panel widths. Returns world-space coords that you feed into each panel's `defaultPosition`.
299
+
300
+ ```tsx
301
+ const { positions, panelWidth, layout } = useWhiteboardLayout({
302
+ widths: { settings: 320, preview: 480, layers: 280 },
303
+ startX: 40,
304
+ y: 60,
305
+ gap: 20,
306
+ })
307
+
308
+ // positions.settings → { x: 40, y: 60 }
309
+ // positions.preview → { x: 380, y: 60 }
310
+ // positions.layers → { x: 880, y: 60 }
311
+ ```
312
+
313
+ All inputs are normalized to `WHITEBOARD_GRID` (20 px) so panels stay snapped even with snap mode off.
314
+
315
+ ---
316
+
317
+ ### Geometry helpers
318
+
319
+ ```ts
320
+ import {
321
+ computeWhiteboardFit, // ({ panels, viewportSize, padding? }) → { scale, offset } | null
322
+ computeWhiteboardRectFocus, // ({ rect, viewportSize, padding?, maxScale? }) → { scale, offset }
323
+ snapToWhiteboardGrid, // (n: number) → nearest multiple of 20
324
+ WHITEBOARD_GRID, // constant: 20
325
+ } from '@objectifthunes/whiteboard'
326
+ ```
327
+
328
+ These are the same primitives that power `fitToContent` and `focusPanel`. Use them when you need to drive the camera from a custom source (URL state, animation, etc).
329
+
330
+ ---
331
+
332
+ ### Panel-rect helpers
333
+
334
+ ```ts
335
+ import { usePanelRect, belowPanel } from '@objectifthunes/whiteboard'
336
+
337
+ const settingsRect = usePanelRect({ x: 100, y: 100 })
338
+
339
+ return (
340
+ <>
341
+ <FloatingPanel title="Settings" defaultPosition={{ x: 100, y: 100 }} trackRect={settingsRect}>…</FloatingPanel>
342
+ <FloatingPanel title="Preview" defaultPosition={belowPanel(settingsRect.current)}>…</FloatingPanel>
343
+ </>
344
+ )
345
+ ```
346
+
347
+ - `usePanelRect(initial)` → `MutableRefObject<PanelRect>` with `width`/`height` filled after first measurement.
348
+ - `belowPanel(rect, gap = WHITEBOARD_GRID)` → `{ x, y }` for the next panel directly below.
349
+
350
+ ---
351
+
352
+ ### `cn(...)`
353
+
354
+ Tiny class-name joiner, exported for users who don't already pull in `clsx` / `classnames`.
355
+
356
+ ```ts
357
+ import { cn } from '@objectifthunes/whiteboard'
358
+ cn('btn', isPrimary && 'btn--primary', className)
359
+ // → "btn btn--primary <className>"
360
+ ```
361
+
362
+ ---
363
+
364
+ ## UI — Buttons
365
+
366
+ <p align="center">
367
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-button.png" width="380" alt="Button, ButtonRow, PanelCloseButton" />
368
+ </p>
369
+
370
+ ### `Button`
371
+
372
+ ```tsx
373
+ <Button>Primary</Button>
374
+ <Button variant="secondary">Secondary</Button>
375
+ <Button variant="danger">Delete</Button>
376
+ <Button loading loadingText="Saving…">Save</Button>
377
+ <Button disabled>Disabled</Button>
378
+ <Button iconOnly aria-label="Delete"><TrashIcon /></Button>
379
+ <Button fullWidth>Stretch</Button>
380
+ ```
381
+
382
+ | Prop | Type | Default |
383
+ | ------------- | ------------------------------------- | ----------- |
384
+ | `variant` | `'primary' \| 'secondary' \| 'danger'`| `'primary'` |
385
+ | `fullWidth` | `boolean` | `false` |
386
+ | `iconOnly` | `boolean` | `false` |
387
+ | `loading` | `boolean` | `false` |
388
+ | `loadingText` | `string` | — |
389
+ | All native `<button>` props | … | — |
390
+
391
+ `loading` automatically disables the button and prefixes a spinner.
392
+
393
+ ### `ButtonRow`
394
+
395
+ A horizontal row that gives equal sizing + a consistent gap to all children.
396
+
397
+ ```tsx
398
+ <ButtonRow>
399
+ <Button variant="secondary">Cancel</Button>
400
+ <Button>Confirm</Button>
401
+ </ButtonRow>
402
+ ```
403
+
404
+ | Prop | Type | Default |
405
+ | ----- | ------------- | ------- |
406
+ | `as` | `ElementType` | `'div'` |
407
+
408
+ ### `PanelCloseButton`
409
+
410
+ A pre-built secondary button with an `X` icon. Use it inside a panel's `headerActions`.
411
+
412
+ ```tsx
413
+ <PanelCloseButton onClick={onClose} label="Close" />
414
+ ```
415
+
416
+ | Prop | Type | Default |
417
+ | --------- | ------------ | ---------- |
418
+ | `onClick` | `() => void` | — |
419
+ | `label` | `string` | `'Close'` |
420
+
421
+ ---
422
+
423
+ ## UI — Forms
424
+
425
+ <p align="center">
426
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-forms.png" width="380" alt="Field, Input, Textarea, Select, CoordGrid" />
427
+ </p>
428
+
429
+ ### `Field`
430
+
431
+ Wraps a control with a label, optional hint and inline error.
432
+
433
+ ```tsx
434
+ <Field label="Name" htmlFor="name" hint="Display name" error={errors.name}>
435
+ <Input id="name" placeholder="John Doe" />
436
+ </Field>
437
+
438
+ <Field as="fieldset" label="Notifications" layout="control">
439
+ {/* checkbox stack */}
440
+ </Field>
441
+ ```
442
+
443
+ | Prop | Type | Default |
444
+ | --------- | -------------------------- | --------- |
445
+ | `label` | `ReactNode` | — |
446
+ | `htmlFor` | `string` | — |
447
+ | `hint` | `ReactNode` | — |
448
+ | `error` | `ReactNode` | — |
449
+ | `layout` | `'stack' \| 'control'` | `'stack'` |
450
+ | `as` | `ElementType` | `'div'` |
451
+
452
+ ### `Label`
453
+
454
+ Bare styled `<label>`. Most of the time you'll get one for free from `<Field>`.
455
+
456
+ ```tsx
457
+ <Label htmlFor="email">Email</Label>
458
+ ```
459
+
460
+ ### `Input`
461
+
462
+ Styled `<input>`. Accepts all native input props; supports refs.
463
+
464
+ ```tsx
465
+ <Input type="email" placeholder="you@example.com" />
466
+ ```
467
+
468
+ ### `Textarea`
469
+
470
+ Styled `<textarea>`. Supports refs.
471
+
472
+ ```tsx
473
+ <Textarea rows={4} placeholder="About you…" />
474
+ ```
475
+
476
+ ### `Select`
477
+
478
+ Styled `<select>`. Supports refs.
479
+
480
+ ```tsx
481
+ <Select defaultValue="light">
482
+ <option value="light">Light</option>
483
+ <option value="dark">Dark</option>
484
+ </Select>
485
+ ```
486
+
487
+ ### `CoordGrid` / `CoordInput`
488
+
489
+ Two-column grid for `x / y / z / scale`-style numeric inputs.
490
+
491
+ ```tsx
492
+ <CoordGrid>
493
+ <CoordInput axis="X" value={x} onChange={e => setX(+e.target.value)} />
494
+ <CoordInput axis="Y" value={y} onChange={e => setY(+e.target.value)} />
495
+ <CoordInput axis="Z" value={z} onChange={e => setZ(+e.target.value)} />
496
+ <CoordInput axis="Scale" value={s} onChange={e => setS(+e.target.value)} />
497
+ </CoordGrid>
498
+ ```
499
+
500
+ `CoordInput` forces `type="number"` and `step="0.01"`. Pass any other native input prop through.
501
+
502
+ ---
503
+
504
+ ## UI — Status & Feedback
505
+
506
+ <p align="center">
507
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-status.png" width="380" alt="Alert, Pill, Chip, TagRow, LoadingState" />
508
+ </p>
509
+
510
+ ### `Alert`
511
+
512
+ A one-line status message with four tones.
513
+
514
+ ```tsx
515
+ <Alert tone="info">Everything looks good.</Alert>
516
+ <Alert tone="success">Saved.</Alert>
517
+ <Alert tone="error">Something went wrong.</Alert>
518
+ <Alert tone="muted">No results.</Alert>
519
+ ```
520
+
521
+ | Prop | Type | Default |
522
+ | ------ | --------------------------------------------- | -------- |
523
+ | `tone` | `'info' \| 'success' \| 'error' \| 'muted'` | `'info'` |
524
+
525
+ ### `Pill`
526
+
527
+ A compact rounded label.
528
+
529
+ ```tsx
530
+ <Pill>Draft</Pill>
531
+ <Pill tone="success">Published</Pill>
532
+ <Pill tone="warning">Review</Pill>
533
+ <Pill tone="danger">Rejected</Pill>
534
+ ```
535
+
536
+ | Prop | Type | Default |
537
+ | ------ | ------------------------------------------------- | ----------- |
538
+ | `tone` | `'default' \| 'success' \| 'warning' \| 'danger'` | `'default'` |
539
+
540
+ ### `Chip`
541
+
542
+ A subtle tag-style label, perfect for tech tags or filters.
543
+
544
+ ```tsx
545
+ <Chip>React</Chip>
546
+ <Chip>TypeScript</Chip>
547
+ ```
548
+
549
+ ### `TagRow`
550
+
551
+ A horizontal wrapping row for chips, pills, or any small inline items.
552
+
553
+ ```tsx
554
+ <TagRow>
555
+ <Chip>react</Chip>
556
+ <Chip>typescript</Chip>
557
+ <Chip>vite</Chip>
558
+ </TagRow>
559
+ ```
560
+
561
+ ### `LoadingState`
562
+
563
+ An inline spinner + label.
564
+
565
+ ```tsx
566
+ <LoadingState label="Fetching…" />
567
+ <LoadingState /> {/* defaults to "Loading..." */}
568
+ ```
569
+
570
+ | Prop | Type | Default |
571
+ | ------- | -------- | -------------- |
572
+ | `label` | `string` | `'Loading...'` |
573
+
574
+ ---
575
+
576
+ ## UI — Layout
577
+
578
+ <p align="center">
579
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-layout.png" width="380" alt="Stack, Inline, TitleRow, SplitLayout, IconText" />
580
+ </p>
581
+
582
+ ### `Stack`
583
+
584
+ Vertical flex container with a consistent gap.
585
+
586
+ ```tsx
587
+ <Stack size="md">{/* default */}
588
+ <SectionTitle>Settings</SectionTitle>
589
+ <p>…</p>
590
+ </Stack>
591
+
592
+ <Stack size="sm" as="section">…</Stack>
593
+ ```
594
+
595
+ | Prop | Type | Default |
596
+ | ------ | --------------- | ------- |
597
+ | `size` | `'sm' \| 'md'` | `'md'` |
598
+ | `as` | `ElementType` | `'div'` |
599
+
600
+ ### `Inline`
601
+
602
+ Horizontal row with controllable justification.
603
+
604
+ ```tsx
605
+ <Inline justify="start">…</Inline>
606
+ <Inline justify="between">…</Inline>
607
+ <Inline justify="end">…</Inline>
608
+ ```
609
+
610
+ | Prop | Type | Default |
611
+ | --------- | --------------------------------- | --------- |
612
+ | `justify` | `'start' \| 'between' \| 'end'` | `'start'` |
613
+ | `as` | `ElementType` | `'div'` |
614
+
615
+ ### `TitleRow`
616
+
617
+ A `space-between` row tailored for `title + action` pairs.
618
+
619
+ ```tsx
620
+ <TitleRow>
621
+ <SectionTitle>Layers</SectionTitle>
622
+ <Button variant="secondary">Add</Button>
623
+ </TitleRow>
624
+ ```
625
+
626
+ ### `SplitLayout`
627
+
628
+ Two-column responsive grid (image + content, avatar + meta, etc.).
629
+
630
+ ```tsx
631
+ <SplitLayout variant="element">
632
+ <ImageThumb src={thumb} alt="Cover" size="md" />
633
+ <Stack size="sm">
634
+ <AssetTitle>Hero</AssetTitle>
635
+ <MutedText>Updated 5 min ago</MutedText>
636
+ </Stack>
637
+ </SplitLayout>
638
+ ```
639
+
640
+ | Prop | Type | Default |
641
+ | --------- | ------------------------------------------ | ------- |
642
+ | `variant` | `'element' \| 'character' \| 'user'` | — |
643
+
644
+ ### `IconText`
645
+
646
+ Inline icon + text helper.
647
+
648
+ ```tsx
649
+ <IconText icon={<CheckIcon size={14} />}>Connected</IconText>
650
+ <IconText as="span" icon={<CalendarIcon size={14} />}>Due Friday</IconText>
651
+ ```
652
+
653
+ | Prop | Type | Default |
654
+ | ------ | -------------- | -------- |
655
+ | `icon` | `ReactNode` | — |
656
+ | `as` | `ElementType` | `'span'` |
657
+
658
+ ### `PageShell` / `PageCard`
659
+
660
+ Page-level containers — `PageShell` is a `<main>` with consistent padding; `PageCard` is a bordered card. Use these outside the whiteboard for full-page screens.
661
+
662
+ ```tsx
663
+ <PageShell>
664
+ <PageCard>
665
+ <h1>Account</h1>
666
+
667
+ </PageCard>
668
+ </PageShell>
669
+ ```
670
+
671
+ ---
672
+
673
+ ## UI — Typography
674
+
675
+ <p align="center">
676
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-typography.png" width="380" alt="PageTitle, StoryTitle, AssetTitle, SectionTitle, SectionDescription, MutedText" />
677
+ </p>
678
+
679
+ All typography primitives accept `as` for the rendered tag and pass through any HTML attributes.
680
+
681
+ ### `PageTitle`
682
+
683
+ Top-level page heading.
684
+
685
+ ```tsx
686
+ <PageTitle>Account settings</PageTitle>
687
+ <PageTitle as="h2">Embedded title</PageTitle>
688
+ ```
689
+
690
+ ### `StoryTitle`
691
+
692
+ Slightly smaller hero-style title for story / item cards.
693
+
694
+ ```tsx
695
+ <StoryTitle>The night the lights went out</StoryTitle>
696
+ ```
697
+
698
+ ### `AssetTitle`
699
+
700
+ Compact title used in lists. Set `clamp` to truncate at one line.
701
+
702
+ ```tsx
703
+ <AssetTitle clamp>Very, very, very long asset name</AssetTitle>
704
+ ```
705
+
706
+ | Prop | Type | Default |
707
+ | ------- | --------- | ------- |
708
+ | `clamp` | `boolean` | `false` |
709
+
710
+ ### `SectionTitle` / `SectionDescription`
711
+
712
+ The label + supporting copy you place above a panel section.
713
+
714
+ ```tsx
715
+ <SectionTitle>Assets</SectionTitle>
716
+ <SectionDescription>Drag an asset onto the canvas.</SectionDescription>
717
+ ```
718
+
719
+ ### `MutedText`
720
+
721
+ De-emphasized paragraph for hints and metadata.
722
+
723
+ ```tsx
724
+ <MutedText size="xs">Last edited 5 min ago</MutedText>
725
+ <MutedText>Helper copy.</MutedText>
726
+ <MutedText size="md">Wider helper copy.</MutedText>
727
+ ```
728
+
729
+ | Prop | Type | Default |
730
+ | ------ | ----------------------- | ------- |
731
+ | `size` | `'xs' \| 'sm' \| 'md'` | `'sm'` |
732
+
733
+ ---
734
+
735
+ ## UI — Cards & Lists
736
+
737
+ <p align="center">
738
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-cards.png" width="380" alt="ItemCard, ItemList, PickerGrid, ChoiceGroup" />
739
+ </p>
740
+
741
+ ### `ItemCard`
742
+
743
+ Bordered card used inside lists — elements, facts, secrets, users, characters, assets.
744
+
745
+ ```tsx
746
+ <ItemCard as="li">
747
+ <Inline justify="between">
748
+ <span>Character sprite</span>
749
+ <Pill tone="success">Active</Pill>
750
+ </Inline>
751
+ </ItemCard>
752
+ ```
753
+
754
+ ### `ItemList`
755
+
756
+ Vertical list container with a consistent gap. Pairs with `ItemCard`.
757
+
758
+ ```tsx
759
+ <ItemList as="ul">
760
+ <ItemCard as="li">First</ItemCard>
761
+ <ItemCard as="li">Second</ItemCard>
762
+ </ItemList>
763
+ ```
764
+
765
+ ### `List`
766
+
767
+ A `list-style: none` reset, useful when you need an unstyled `<ul>` / `<ol>`.
768
+
769
+ ```tsx
770
+ <List as="ul">
771
+ <li>Alpha</li>
772
+ <li>Beta</li>
773
+ </List>
774
+
775
+ <List reset={false}>…still has bullets</List>
776
+ ```
777
+
778
+ | Prop | Type | Default |
779
+ | ------- | ------------- | ------- |
780
+ | `as` | `ElementType` | `'ul'` |
781
+ | `reset` | `boolean` | `true` |
782
+
783
+ ### `PickerCard`
784
+
785
+ A clickable card used inside `PickerGrid`. Defaults to `<button>` (set `as="div"` for skeletons).
786
+
787
+ ```tsx
788
+ <PickerCard onClick={() => select('a')}>Option A</PickerCard>
789
+ <PickerCard as="div" className="picker-card--skeleton">…</PickerCard>
790
+ ```
791
+
792
+ ### `PickerGrid`
793
+
794
+ Responsive grid of `PickerCard`s.
795
+
796
+ ```tsx
797
+ <PickerGrid variant="elements">
798
+ <PickerCard onClick={…}>A</PickerCard>
799
+ <PickerCard onClick={…}>B</PickerCard>
800
+ </PickerGrid>
801
+ ```
802
+
803
+ | Prop | Type | Default |
804
+ | --------- | ------------------------------------------ | ------- |
805
+ | `variant` | `'elements' \| 'characters' \| 'library'` | — |
806
+
807
+ ### `ChoiceCard`
808
+
809
+ Single selectable card. Manage `active` from a controlled state.
810
+
811
+ ```tsx
812
+ <ChoiceCard active={value === 'a'} onClick={() => setValue('a')}>
813
+ Option A
814
+ </ChoiceCard>
815
+ ```
816
+
817
+ | Prop | Type | Default |
818
+ | -------- | --------- | ------- |
819
+ | `active` | `boolean` | `false` |
820
+
821
+ ### `ChoiceGroup`
822
+
823
+ Radio-style list of `ChoiceCard`s built from a typed options array.
824
+
825
+ ```tsx
826
+ type Direction = 'left' | 'right'
827
+
828
+ <ChoiceGroup<Direction>
829
+ options={[
830
+ { value: 'left', label: 'Left to right' },
831
+ { value: 'right', label: 'Right to left', description: 'Arabic, Hebrew' },
832
+ ]}
833
+ value={direction}
834
+ onChange={setDirection}
835
+ />
836
+ ```
837
+
838
+ Each option:
839
+
840
+ ```ts
841
+ type ChoiceOption<T> = {
842
+ value: T
843
+ label: ReactNode
844
+ description?: ReactNode
845
+ disabled?: boolean
846
+ }
847
+ ```
848
+
849
+ ---
850
+
851
+ ## UI — Overlays
852
+
853
+ <p align="center">
854
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-overlays.png" width="380" alt="ConfirmDialog, GeneratingOverlay, EmptyState, PanelErrorBoundary" />
855
+ </p>
856
+
857
+ > `ConfirmDialog` and `PanelErrorBoundary` are documented under [Whiteboard Components](#confirmdialog) — they're not purely UI primitives.
858
+
859
+ ### `GeneratingOverlay`
860
+
861
+ Cover content with a semi-transparent layer + spinner while work is in progress.
862
+
863
+ ```tsx
864
+ <GeneratingOverlay isGenerating={loading} message="Building…">
865
+ <YourContent />
866
+ </GeneratingOverlay>
867
+ ```
868
+
869
+ | Prop | Type | Default |
870
+ | -------------- | ----------- | ----------------------------- |
871
+ | `isGenerating` | `boolean` | — |
872
+ | `message` | `string` | `'Generating, please wait…'` |
873
+
874
+ ### `EmptyState`
875
+
876
+ Friendly placeholder for empty lists / first-run states.
877
+
878
+ ```tsx
879
+ <EmptyState
880
+ title="No items yet"
881
+ description="Create your first item to get started."
882
+ action={<Button>Create item</Button>}
883
+ />
884
+ ```
885
+
886
+ | Prop | Type | Default |
887
+ | ------------- | ----------- | ------- |
888
+ | `title` | `ReactNode` | — |
889
+ | `description` | `ReactNode` | — |
890
+ | `action` | `ReactNode` | — |
891
+
892
+ ---
893
+
894
+ ## UI — Navigation & Canvas
895
+
896
+ <p align="center">
897
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-navigation.png" width="380" alt="VerticalToolbar, AvatarBadge, CanvasStage, OverlayIconButton" />
898
+ </p>
899
+
900
+ ### `VerticalToolbar`
901
+
902
+ A fixed vertical strip of icon buttons (think app-shell sidebar).
903
+
904
+ ```tsx
905
+ <VerticalToolbar
906
+ position="left" // 'left' | 'right' | 'static'
907
+ bottom={<button className="vertical-toolbar__icon-btn">⎋</button>}
908
+ >
909
+ <a className="vertical-toolbar__icon-btn is-active" href="/"><DashboardIcon /></a>
910
+ <a className="vertical-toolbar__icon-btn" href="/stories"><BookIcon /></a>
911
+ </VerticalToolbar>
912
+ ```
913
+
914
+ | Prop | Type | Default |
915
+ | ---------- | --------------------------------- | ------- |
916
+ | `position` | `'left' \| 'right' \| 'static'` | `'left'`|
917
+ | `bottom` | `ReactNode` | — |
918
+
919
+ ### `AvatarBadge`
920
+
921
+ A circular initials/avatar badge.
922
+
923
+ ```tsx
924
+ <AvatarBadge>MG</AvatarBadge>
925
+ <AvatarBadge title="Maxence">M</AvatarBadge>
926
+ ```
927
+
928
+ ### `CanvasStage`
929
+
930
+ A 16:9 bordered media container, ideal for previews and editor canvases.
931
+
932
+ ```tsx
933
+ <CanvasStage hint="Tap to interact">
934
+ <YourMedia />
935
+ </CanvasStage>
936
+ ```
937
+
938
+ | Prop | Type | Default |
939
+ | ------- | -------- | ------- |
940
+ | `hint` | `string` | — |
941
+
942
+ ### `OverlayIconButton`
943
+
944
+ A secondary icon-only button positioned over media (zoom, prev/next, etc.). Stops pointer & wheel propagation so it doesn't pan the underlying canvas.
945
+
946
+ ```tsx
947
+ <CanvasStage>
948
+ <OverlayIconButton placement="top-right" aria-label="Zoom">
949
+ <ZoomInIcon />
950
+ </OverlayIconButton>
951
+ <OverlayIconButton placement="bottom-left" aria-label="Previous">
952
+ <ChevronLeftIcon />
953
+ </OverlayIconButton>
954
+ <OverlayIconButton placement="bottom-right" aria-label="Next">
955
+ <ChevronRightIcon />
956
+ </OverlayIconButton>
957
+ </CanvasStage>
958
+ ```
959
+
960
+ | Prop | Type | Default |
961
+ | ----------- | ------------------------------------------------- | ------- |
962
+ | `placement` | `'top-right' \| 'bottom-left' \| 'bottom-right'` | — |
963
+ | (all other `Button` props except `variant` / `iconOnly`) | — | — |
964
+
965
+ ### `ImageThumb`
966
+
967
+ Image thumbnail with placeholder + error fallback.
968
+
969
+ ```tsx
970
+ <ImageThumb src={url} alt="Cover" size="md" fit="cover" />
971
+ <ImageThumb src={null} alt="Empty" placeholder={<MutedText>No image</MutedText>} />
972
+ ```
973
+
974
+ | Prop | Type | Default |
975
+ | -------------- | ----------------------------- | ------------- |
976
+ | `src` | `string \| null` | — |
977
+ | `alt` | `string` | — |
978
+ | `size` | `'sm' \| 'md' \| 'fluid'` | `'md'` |
979
+ | `fit` | `'contain' \| 'cover'` | `'contain'` |
980
+ | `placeholder` | `ReactNode` | `'No image'` |
981
+ | `onImageError` | `() => void` | — |
982
+
983
+ ---
984
+
985
+ ## UI — Skeletons
986
+
987
+ <p align="center">
988
+ <img src="https://raw.githubusercontent.com/MaxouJS/whiteboard/main/docs/images/panel-skeletons.png" width="820" alt="Skeleton primitives and widget skeletons" />
989
+ </p>
990
+
991
+ ### `Skeleton` (base)
992
+
993
+ Animated placeholder. Use the primitives below for common shapes, or compose your own.
994
+
995
+ ```tsx
996
+ <Skeleton style={{ width: 120, height: 16 }} />
997
+ <Skeleton radius="md" style={{ width: 80, height: 80 }} />
998
+ <Skeleton radius="pill" style={{ width: 64, height: 24 }} />
999
+ ```
1000
+
1001
+ | Prop | Type | Default |
1002
+ | -------- | -------------------------- | ------- |
1003
+ | `radius` | `'sm' \| 'md' \| 'pill'` | `'sm'` |
1004
+ | `as` | `ElementType` | `'div'` |
1005
+
1006
+ ### Primitive skeletons
1007
+
1008
+ Pre-sized shapes that line up with the corresponding component:
1009
+
1010
+ ```tsx
1011
+ import {
1012
+ TitleSkeleton, // matches an asset / section title line
1013
+ LineSkeleton, // <LineSkeleton short /> for a half-width line
1014
+ InputSkeleton, // matches <Input>
1015
+ SelectSkeleton, // matches <Select>
1016
+ TextareaSkeleton, // matches <Textarea>
1017
+ ButtonSkeleton, // matches <Button>
1018
+ IconButtonSkeleton, // matches <Button iconOnly>
1019
+ ChipSkeleton, // matches <Chip> / <Pill>
1020
+ ThumbSkeleton, // matches <ImageThumb>
1021
+ AvatarSkeleton, // matches <AvatarBadge>
1022
+ CanvasSkeleton, // matches <CanvasStage>
1023
+ } from '@objectifthunes/whiteboard'
1024
+ ```
1025
+
1026
+ All accept native `div` props (`className`, `style`, …).
1027
+
1028
+ ### Widget skeletons
1029
+
1030
+ Higher-level placeholders that mirror common card layouts.
1031
+
1032
+ ```tsx
1033
+ import {
1034
+ PanelFormSkeleton, // <PanelFormSkeleton inputs={3} showButton />
1035
+ StoryCardSkeleton, // matches a hero story card
1036
+ UserCardSkeleton, // matches a user row
1037
+ UserListSkeleton, // <UserListSkeleton count={5} />
1038
+ AssetCardSkeleton, // matches an asset card with thumb + chips + actions
1039
+ PickerGridSkeleton, // <PickerGridSkeleton count={8} gridClass="picker-grid--elements" />
1040
+ } from '@objectifthunes/whiteboard'
1041
+ ```
1042
+
1043
+ `PanelFormSkeleton`
1044
+
1045
+ | Prop | Type | Default |
1046
+ | ------------ | --------- | ------- |
1047
+ | `inputs` | `number` | `1` |
1048
+ | `showButton` | `boolean` | `true` |
1049
+
1050
+ `UserListSkeleton`
1051
+
1052
+ | Prop | Type | Default |
1053
+ | ------- | -------- | ------- |
1054
+ | `count` | `number` | `3` |
1055
+
1056
+ `PickerGridSkeleton`
1057
+
1058
+ | Prop | Type | Default |
1059
+ | ----------- | -------- | ------- |
1060
+ | `count` | `number` | `8` |
1061
+ | `gridClass` | `string` | — |
1062
+
1063
+ ### `ChoiceGroupSkeleton`
1064
+
1065
+ A skeleton form for `ChoiceGroup`.
1066
+
1067
+ ```tsx
1068
+ <ChoiceGroupSkeleton count={4} withDescription />
1069
+ ```
1070
+
1071
+ | Prop | Type | Default |
1072
+ | ----------------- | --------- | ------- |
1073
+ | `count` | `number` | `4` |
1074
+ | `withDescription` | `boolean` | `false` |
1075
+
1076
+ ---
1077
+
1078
+ ## UI — Panel sections
1079
+
1080
+ ### `PanelSection`
1081
+
1082
+ A semantic `<section>` with optional heading, description, and a right-aligned actions slot.
1083
+
1084
+ ```tsx
1085
+ <PanelSection
1086
+ heading="Assets"
1087
+ description="Drag an asset onto the canvas."
1088
+ actions={<Button variant="secondary">Upload</Button>}
1089
+ >
1090
+ <ItemList>…</ItemList>
1091
+ </PanelSection>
1092
+ ```
1093
+
1094
+ | Prop | Type |
1095
+ | ------------- | ----------- |
1096
+ | `heading` | `ReactNode` |
1097
+ | `description` | `ReactNode` |
1098
+ | `actions` | `ReactNode` |
1099
+
1100
+ ### `PanelTitle`
1101
+
1102
+ Title with a leading icon. Pass a **component type** (not an element):
1103
+
1104
+ ```tsx
1105
+ import { LayersIcon } from 'lucide-react'
1106
+
1107
+ <FloatingPanel title={<PanelTitle icon={LayersIcon} label="Layers" />} … />
1108
+ ```
1109
+
1110
+ | Prop | Type |
1111
+ | ------- | ---------------------------------------------------------- |
1112
+ | `icon` | `ComponentType<{ size?: number; className?: string }>` |
1113
+ | `label` | `string` |
1114
+
1115
+ ---
1116
+
1117
+ ## Theming
1118
+
1119
+ ### `ThemeToggle`
1120
+
1121
+ Controlled toggle that renders a moon / sun icon. Wire it to your own theme state.
1122
+
1123
+ ```tsx
1124
+ const [theme, setTheme] = useState<'light' | 'dark'>('light')
1125
+
1126
+ useEffect(() => {
1127
+ document.documentElement.dataset.theme = theme
1128
+ }, [theme])
1129
+
1130
+ <WhiteboardShell
1131
+ extraActions={
1132
+ <ThemeToggle
1133
+ theme={theme}
1134
+ onToggle={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
1135
+ />
1136
+ }
1137
+ />
1138
+ ```
1139
+
1140
+ | Prop | Type | Default |
1141
+ | ----------- | --------------------- | --------- |
1142
+ | `theme` | `'light' \| 'dark'` | `'light'` |
1143
+ | `onToggle` | `() => void` | — |
1144
+ | `lightIcon` | `ReactNode` | sun |
1145
+ | `darkIcon` | `ReactNode` | moon |
1146
+ | `className` | `string` | — |
1147
+
1148
+ ### CSS & theming
1149
+
1150
+ The package ships a single stylesheet. Import it once at the root:
1151
+
1152
+ ```tsx
1153
+ import '@objectifthunes/whiteboard/style.css'
1154
+ ```
1155
+
1156
+ Theming uses CSS custom properties on `[data-theme]`. The built-in styles expose tokens for surfaces, borders, text, and accents — you can override any of them on `:root` or scoped to `[data-theme="dark"]`:
1157
+
1158
+ ```css
1159
+ :root {
1160
+ --wb-bg: #ffffff;
1161
+ --wb-fg: #0b0b0c;
1162
+ --wb-muted: #6c6c70;
1163
+ --wb-border: #e5e5e7;
1164
+ --wb-accent: #2563eb;
1165
+ /* …and more — inspect dist/whiteboard.css for the full set */
1166
+ }
1167
+
1168
+ [data-theme='dark'] {
1169
+ --wb-bg: #0b0b0c;
1170
+ --wb-fg: #f5f5f7;
1171
+ --wb-muted: #9a9a9f;
1172
+ --wb-border: #2a2a2c;
1173
+ }
1174
+ ```
1175
+
1176
+ `<ThemeToggle />` is intentionally **uncontrolled** of the DOM — you decide where the `data-theme` attribute lives (usually `<html>`).
1177
+
1178
+ ---
1179
+
1180
+ ## TypeScript
1181
+
1182
+ All exports ship with `.d.ts` declarations. Notable re-exported types:
1183
+
1184
+ ```ts
1185
+ import type { PanelRect, WhiteboardStore, ChoiceOption } from '@objectifthunes/whiteboard'
1186
+ ```
1187
+
1188
+ `PanelRect`:
1189
+
1190
+ ```ts
1191
+ type PanelRect = {
1192
+ x: number
1193
+ y: number
1194
+ width: number
1195
+ height: number
1196
+ focusPadding?: number
1197
+ focusMaxScale?: number
1198
+ }
1199
+ ```
1200
+
1201
+ `ChoiceOption<T extends string>`:
1202
+
1203
+ ```ts
1204
+ type ChoiceOption<T extends string> = {
1205
+ value: T
1206
+ label: ReactNode
1207
+ description?: ReactNode
1208
+ disabled?: boolean
1209
+ }
1210
+ ```
1211
+
1212
+ ---
1213
+
1214
+ ## Recipes
1215
+
1216
+ ### Track a panel's rect, then place another below it
1217
+
1218
+ ```tsx
1219
+ const settingsRect = usePanelRect({ x: 40, y: 60 })
1220
+
1221
+ <FloatingPanel title="Settings" defaultPosition={{ x: 40, y: 60 }} trackRect={settingsRect}>
1222
+
1223
+ </FloatingPanel>
1224
+
1225
+ <FloatingPanel title="Layers" defaultPosition={belowPanel(settingsRect.current)}>
1226
+
1227
+ </FloatingPanel>
1228
+ ```
1229
+
1230
+ ### Programmatic camera control
1231
+
1232
+ ```tsx
1233
+ const fitToContent = useWhiteboardStore(s => s.fitToContent)
1234
+ const focusPanel = useWhiteboardStore(s => s.focusPanel)
1235
+
1236
+ <Button onClick={fitToContent}>Fit all</Button>
1237
+ <Button onClick={() => focusPanel({ x: 0, y: 0, width: 600, height: 400 }, { maxScale: 2 })}>
1238
+ Zoom hero
1239
+ </Button>
1240
+ ```
1241
+
1242
+ ### Loading → real content swap
1243
+
1244
+ ```tsx
1245
+ <FloatingPanel title="Assets" defaultPosition={pos}>
1246
+ {loading ? (
1247
+ <PanelFormSkeleton inputs={2} />
1248
+ ) : (
1249
+ <PanelSection heading="Assets">
1250
+ <ItemList>…</ItemList>
1251
+ </PanelSection>
1252
+ )}
1253
+ </FloatingPanel>
1254
+ ```
1255
+
1256
+ ### Custom shell without minimap
1257
+
1258
+ ```tsx
1259
+ <WhiteboardShell showMinimap={false}>
1260
+ <FloatingPanel title="Solo" defaultPosition={{ x: 80, y: 80 }}>…</FloatingPanel>
1261
+ </WhiteboardShell>
1262
+ ```
1263
+
1264
+ ---
1265
+
1266
+ ## Browser support
1267
+
1268
+ Modern evergreen browsers. Uses `PointerEvent` and `ResizeObserver`; both are available everywhere except very old IE/Edge legacy.
1269
+
1270
+ ---
1271
+
1272
+ ## License
1273
+
1274
+ MIT © ObjectifThunes