@pasquelin/panels 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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/dist/components/Band.d.ts +13 -0
  4. package/dist/components/Center.d.ts +18 -0
  5. package/dist/components/IconButton.d.ts +20 -0
  6. package/dist/components/Panel.d.ts +31 -0
  7. package/dist/components/PanelFrame.d.ts +23 -0
  8. package/dist/components/PanelHeader.d.ts +15 -0
  9. package/dist/components/Panels.d.ts +30 -0
  10. package/dist/components/Rail.d.ts +23 -0
  11. package/dist/components/ResizeHandle.d.ts +37 -0
  12. package/dist/components/Separator.d.ts +6 -0
  13. package/dist/components/Surface.d.ts +9 -0
  14. package/dist/components/ZoneEdge.d.ts +17 -0
  15. package/dist/components/content.d.ts +9 -0
  16. package/dist/components/labels.d.ts +12 -0
  17. package/dist/core/clamps.d.ts +55 -0
  18. package/dist/core/context.d.ts +39 -0
  19. package/dist/core/cx.d.ts +5 -0
  20. package/dist/core/hooks/useArrangement.d.ts +36 -0
  21. package/dist/core/hooks/useContainerFit.d.ts +9 -0
  22. package/dist/core/hooks/usePanels.d.ts +26 -0
  23. package/dist/core/hooks/usePointerDrag.d.ts +21 -0
  24. package/dist/core/hooks/useZone.d.ts +28 -0
  25. package/dist/core/layoutEffect.d.ts +10 -0
  26. package/dist/core/persistence.d.ts +30 -0
  27. package/dist/core/store.d.ts +86 -0
  28. package/dist/core/types.d.ts +88 -0
  29. package/dist/cx-CcykAxZN.js +6 -0
  30. package/dist/cx-YyuC5RtB.cjs +1 -0
  31. package/dist/dockview/DockviewCenter.d.ts +37 -0
  32. package/dist/dockview/index.d.ts +3 -0
  33. package/dist/dockview.cjs +1 -0
  34. package/dist/dockview.js +39 -0
  35. package/dist/index.cjs +1 -0
  36. package/dist/index.d.ts +26 -0
  37. package/dist/index.js +935 -0
  38. package/dist/styles.css +1 -0
  39. package/llms-full.txt +1242 -0
  40. package/llms.txt +150 -0
  41. package/package.json +99 -0
package/llms-full.txt ADDED
@@ -0,0 +1,1242 @@
1
+ # @pasquelin/panels
2
+
3
+ > **React panel chassis** (React 19, no runtime dependencies), published to npm (ESM + CJS +
4
+ > types). Icon rails on the edges, five resizable zones around a free centre, a layout that
5
+ > survives a reload. **Headless underneath, repaintable on top** — the components are built on
6
+ > hooks that render nothing, and every visual value is a CSS custom property.
7
+ > MIT. Live: https://pasquelin.github.io/panel/
8
+
9
+ ## Mental model (read this first)
10
+
11
+ - **It is a LAYOUT, not a framework.** It draws the frame and remembers the sizes. What goes in
12
+ the panels is the consuming project's business. No state management, no data layer, no icon
13
+ set, no i18n, no opinion about the centre.
14
+ - **`<Panel>` and `<Center>` are DESCRIPTORS**, in the shape of `<Route>`: they return `null` and
15
+ are never rendered where they are written. `<Panels>` reads them out of its children with
16
+ `Children.toArray` and hands their content to the zone they named. They must therefore be
17
+ children of `<Panels>`, not of a component in between.
18
+ - **Not portals, and deliberately so**: a portal puts panel content in a different React tree, so
19
+ context, error boundaries and suspense would stop at the boundary and a panel could not read a
20
+ provider its own file sits under.
21
+ - **One store per chassis**, created not imported (`createPanelsStore`). No module singleton —
22
+ two chassis can live in one application, and a test cannot leak into the next.
23
+ - **What is stored is not what is shown.** Stored: which panel each half holds, and the sizes.
24
+ Resolved at render: what a zone draws (a `solo` panel silences the other half), the size it
25
+ takes (bounded against the opposite zone), whether it takes room at all (the band's two halves
26
+ share one height).
27
+ - **There are no "surfaces" or sections.** An earlier design carried them; they were removed. In
28
+ React the router changes the children — there is nothing to solve.
29
+ - **A panel that should not be offered is a panel you do not declare.** That replaces what a
30
+ capability system would be for.
31
+
32
+ ## Install and shape
33
+
34
+ ```bash
35
+ pnpm add @pasquelin/panels
36
+ ```
37
+
38
+ ```tsx
39
+ import { Panels, Panel } from '@pasquelin/panels'
40
+ import '@pasquelin/panels/styles.css'
41
+
42
+ type PanelId = 'files' | 'notes'
43
+
44
+ <Panels<PanelId>>
45
+ <Panel<PanelId> id="files" zone="left" title="Files" icon={<Icon />}>
46
+ <FileTree />
47
+ </Panel>
48
+ <Panel<PanelId> id="notes" zone="right" title="Notes" opens={380}>
49
+ <Notes />
50
+ </Panel>
51
+ <Panels.Center>
52
+ <YourApp />
53
+ </Panels.Center>
54
+ </Panels>
55
+ ```
56
+
57
+ `<Panels>` fills its parent: that parent needs a height. On a flex child, `minHeight: 0` is the
58
+ usual missing piece.
59
+
60
+ ## Geometry
61
+
62
+ - Zones: `left` `right` `top` `bottomLeft` `bottomRight`. **An empty zone takes no room at all.**
63
+ - Each zone has two halves: `primary` (nearest the window edge) and `secondary`.
64
+ - Panels sharing a zone AND a slot **take turns** — the rail switches between them. Different
65
+ slots show together, stacked.
66
+ - `bottomLeft` + `bottomRight` share **one height**. Whichever is alone runs under the opposite
67
+ column; a side column runs to the foot unless the band's half on its side is drawing.
68
+ - The centre stays at one place in the React tree through every arrangement — moving it would
69
+ unmount whatever engine or canvas it holds.
70
+
71
+ ## API
72
+
73
+ ### Components
74
+ `Panels` `Panel` `Center` · `Rail` `RailZone` `ZoneEdge` `Band` `PanelFrame` · `Surface`
75
+ `PanelHeader` `IconButton` `Separator` `ResizeHandle`
76
+
77
+ ### `<Panels>` props
78
+ `store` `storageKey` `storage` `defaultOpen` `header` `footer` `railHeader` `labels` `theme`
79
+ `className`
80
+
81
+ ### `<Panel>` props
82
+ `id` `zone` `slot?` `title` `icon?` `actions?` `opens?` `solo?`
83
+
84
+ ### Hooks
85
+ - `usePanels<Id>()` → `{ panels, reveal, close, toggle, isShown, focusedZone, reset }`
86
+ - `useZone<Id>(zone)` → `{ primary, secondary, draws, size, split, focused }`
87
+ - `useArrangement` `useShownIn` `useZoneDraws` `useZoneTakesRoom` `useBandHalves`
88
+ - `useContainerFit(ref)` `usePointerDrag()` `usePanelsState(selector)` `usePanelsActions()`
89
+
90
+ ### Core
91
+ `createPanelsStore` `shownIn` `specOf` `zoneDraws` `zoneTakesRoom` `undraggedSizeOf`
92
+ `fitZoneSize` `fitSplit` `sharedSizes` `sizeKeyOf` `MIN_SIZE` `MIN_CENTER` `MIN_SPLIT`
93
+ `DEFAULT_SIZES` · `browserStorage` `memoryStorage` `readLayout` `writeLayout` `LAYOUT_VERSION`
94
+ · `ZONES` `SLOTS` `BOTTOM_ZONES` `ZONES_BY_SIDE` `isBottom` `isHorizontal` `isLeading` `cx`
95
+
96
+ ### Optional entry point
97
+ `@pasquelin/panels/dockview` → `DockviewCenter`, for document tabs. Separate so its weight only
98
+ lands on projects that want it. Requires `dockview-react` as a peer.
99
+
100
+ ## Driving it from outside React
101
+
102
+ ```tsx
103
+ const store = createPanelsStore<PanelId>()
104
+ socket.on('alert', () => store.getState().show('alerts'))
105
+ window.electron?.onMenu(id => store.getState().show(id))
106
+
107
+ <Panels<PanelId> store={store}>…</Panels>
108
+ ```
109
+
110
+ ## Theming
111
+
112
+ Every value is a custom property on `.pnl-root`. Set them anywhere that reaches the chassis.
113
+
114
+ Surfaces: `--pnl-chassis` `--pnl-panel` `--pnl-elevated` `--pnl-border` `--pnl-text` `--pnl-muted`
115
+ Accent: `--pnl-accent` `--pnl-accent-text`
116
+ Gauges: `--pnl-rail` `--pnl-rail-button` `--pnl-rail-inset` `--pnl-gutter` `--pnl-header`
117
+ `--pnl-radius` `--pnl-radius-sm` `--pnl-font-size`
118
+
119
+ - **Panels are DARKER than the chassis.** That inversion is the one visual idea the library is
120
+ built on. Invert it back by repainting two tokens.
121
+ - Defaults sit inside `:where()`, at zero specificity, so a consumer's palette always wins
122
+ whatever order the stylesheets load in. A guard reads the sheet to keep it that way.
123
+ - `<Panels theme="dark" | "light">` forces the palette; left out it follows `prefers-color-scheme`.
124
+
125
+ ## Traps worth knowing
126
+
127
+ - `usePanels` **must be called inside `<Panels>`**. A component that renders the chassis cannot
128
+ also read it — put the consumer in a child, or in a panel's `actions`.
129
+ - Subscribe with **scalar** selectors. `lengths` and `available` are replaced wholesale on every
130
+ write, so subscribing to either as an object re-renders on every frame of a drag.
131
+ - `close(id)` closes THAT panel or nothing. `close(zone, slot)` on the store empties the half
132
+ whatever stands in it — two panels share a half.
133
+ - `useZoneTakesRoom` ≠ `useZoneDraws`: the band's halves share a height, so either drawing means
134
+ the strip takes it.
135
+ - Two chassis in one app need two `storageKey`s, or they overwrite each other.
136
+ - The chassis measures **its container**, never the window — that is what lets it sit inside an
137
+ existing page.
138
+ - Client-side only: it reads `localStorage` and measures the DOM. Under Next.js, `'use client'`.
139
+
140
+ ## Not in v1
141
+
142
+ Floating panels, and dragging a panel from one zone to another. The state model already allows
143
+ an override of the declared zone; the gesture is not built.
144
+
145
+ ## Documentation
146
+
147
+ - English: docs/en/README.md · PANELS · LAYOUT · HOOKS · THEMING · COMPONENTS · DOCKVIEW · RECIPES
148
+ - Français: docs/fr/README.md (same eight chapters)
149
+ - Architecture: docs/ARCHITECTURE.md
150
+ - Full text of all of it: llms-full.txt
151
+
152
+ ==============================================================================
153
+ README
154
+ ==============================================================================
155
+
156
+ # @pasquelin/panels
157
+
158
+ **[Live demo ↗](https://pasquelin.github.io/panel/)** · **[Documentation 🇫🇷](docs/fr/README.md)** · **[Documentation 🇬🇧](docs/en/README.md)** · MIT
159
+
160
+ A React panel chassis: icon rails on the edges, resizable zones around a free centre, and a
161
+ layout that survives a reload.
162
+
163
+ It is a **layout, not a framework**. What goes in the panels is your project's business — the
164
+ library draws the frame, remembers the sizes, and gets out of the way.
165
+
166
+ ```bash
167
+ pnpm add @pasquelin/panels
168
+ ```
169
+
170
+ ```tsx
171
+ import { Panels, Panel } from '@pasquelin/panels'
172
+ import '@pasquelin/panels/styles.css'
173
+
174
+ type PanelId = 'files' | 'chat'
175
+
176
+ export function App() {
177
+ return (
178
+ <Panels<PanelId>>
179
+ <Panel<PanelId> id="files" zone="left" title="Files" icon={<FilesIcon />}>
180
+ <FileTree />
181
+ </Panel>
182
+
183
+ <Panel<PanelId> id="chat" zone="right" title="Assistant" icon={<ChatIcon />}>
184
+ <Conversation />
185
+ </Panel>
186
+
187
+ <Panels.Center>
188
+ <Outlet />
189
+ </Panels.Center>
190
+ </Panels>
191
+ )
192
+ }
193
+ ```
194
+
195
+ That is the whole of it. The rails, the resize handles, the persistence and the keyboard are
196
+ already there.
197
+
198
+ ## What you get
199
+
200
+ - **Five zones** — `left`, `right`, `top`, `bottomLeft`, `bottomRight`. A zone nobody fills takes
201
+ no room at all.
202
+ - **Two halves per zone** — `primary` and `secondary`, parted by a handle. Panels sharing a half
203
+ take turns; the rail switches between them.
204
+ - **Icon rails** that stay put when a zone closes, so a closed panel is always one click away.
205
+ - **Resize** by pointer or keyboard, clamped so the centre never disappears.
206
+ - **Persistence** to `localStorage` out of the box, or wherever you say.
207
+ - **Typed ids** — `reveal('chatt')` does not compile.
208
+ - **No dependencies** beyond React. No icon set, no CSS framework, no i18n.
209
+
210
+ ## Panel props
211
+
212
+ | Prop | Meaning |
213
+ | --- | --- |
214
+ | `id` | Unique, and the type parameter of `<Panels>` |
215
+ | `zone` | Which edge it hangs from |
216
+ | `slot` | `primary` (nearest the edge) or `secondary`. Defaults to `primary` |
217
+ | `title` | Accessible name and header title, **already translated** |
218
+ | `icon` | Anything React renders — the library imposes no icon set |
219
+ | `actions` | Rendered on the panel's own title row |
220
+ | `opens` | The width this panel wants when it leads its zone |
221
+ | `solo` | Takes the zone whole; the other half is put away, not closed, and comes back |
222
+
223
+ ## Driving the panels from anywhere
224
+
225
+ `usePanels()` is all a header, a shortcut or a menu needs:
226
+
227
+ ```tsx
228
+ const { panels, reveal, close, toggle, isShown } = usePanels<PanelId>()
229
+ ```
230
+
231
+ From **outside React** — a socket message, an Electron menu — build the store yourself and keep
232
+ the reference:
233
+
234
+ ```tsx
235
+ const store = createPanelsStore<PanelId>()
236
+
237
+ socket.on('alert', () => store.getState().show('alerts'))
238
+
239
+ <Panels<PanelId> store={store}>…</Panels>
240
+ ```
241
+
242
+ ## Making it yours
243
+
244
+ Three levels, and you can stop at any of them.
245
+
246
+ **1. Repaint it.** Every value is a custom property on `.pnl-root`:
247
+
248
+ ```css
249
+ .pnl-root {
250
+ --pnl-panel: #101418;
251
+ --pnl-chassis: #1b1f24;
252
+ --pnl-accent: #47965c; /* the rails follow your brand */
253
+ --pnl-radius: 10px;
254
+ --pnl-rail: 56px;
255
+ }
256
+ ```
257
+
258
+ **2. Replace a piece.** `Rail`, `PanelHeader`, `ResizeHandle`, `Surface`, `IconButton` and the
259
+ rest are exported. Build your own frame out of the ones you keep.
260
+
261
+ **3. Take the logic only.** Every component is built on hooks that render nothing:
262
+
263
+ ```tsx
264
+ const zone = useZone('left') // what it draws, its size, its split
265
+ const { reveal, isShown } = usePanels()
266
+ useContainerFit(ref) // re-clamps when the container resizes
267
+ ```
268
+
269
+ Draw whatever you like on top. The chassis has no opinion about it.
270
+
271
+ ## It fits inside your page
272
+
273
+ The chassis measures **its own container**, never the window. Put it in a route, under your
274
+ navigation, beside your sidebar — the clamps follow the box it is actually in.
275
+
276
+ ## Header and footer are yours
277
+
278
+ `<Panels>` draws no chrome. `header` and `footer` are slots; pass your own, or nothing. Where a
279
+ project's panels come from — a router, a state machine, a config — is that project's business.
280
+
281
+ ## Examples
282
+
283
+ All four run at **[pasquelin.github.io/panel](https://pasquelin.github.io/panel/)**, or locally:
284
+
285
+ ```bash
286
+ pnpm dev
287
+ ```
288
+
289
+ - `examples/minimal` — the smallest working chassis
290
+ - `examples/router` — the centre as a React Router outlet
291
+ - `examples/dockview` — document tabs, on the optional Dockview entry point
292
+ - `examples/theme` — the same chassis under four palettes
293
+
294
+ ## Documentation
295
+
296
+ | | 🇫🇷 Français | 🇬🇧 English |
297
+ | --- | --- | --- |
298
+ | **Guide + index** | [docs/fr/](docs/fr/README.md) | [docs/en/](docs/en/README.md) |
299
+ | Panels | [PANELS.md](docs/fr/PANELS.md) | [PANELS.md](docs/en/PANELS.md) |
300
+ | Layout | [LAYOUT.md](docs/fr/LAYOUT.md) | [LAYOUT.md](docs/en/LAYOUT.md) |
301
+ | Hooks | [HOOKS.md](docs/fr/HOOKS.md) | [HOOKS.md](docs/en/HOOKS.md) |
302
+ | Theming | [THEMING.md](docs/fr/THEMING.md) | [THEMING.md](docs/en/THEMING.md) |
303
+ | Components | [COMPONENTS.md](docs/fr/COMPONENTS.md) | [COMPONENTS.md](docs/en/COMPONENTS.md) |
304
+ | Document tabs | [DOCKVIEW.md](docs/fr/DOCKVIEW.md) | [DOCKVIEW.md](docs/en/DOCKVIEW.md) |
305
+ | Recipes | [RECIPES.md](docs/fr/RECIPES.md) | [RECIPES.md](docs/en/RECIPES.md) |
306
+ | Architecture | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | — |
307
+
308
+ For coding agents: [`llms.txt`](llms.txt) is the mental model, the API and the traps in one
309
+ page; [`llms-full.txt`](llms-full.txt) is every English chapter concatenated. Both ship with the
310
+ package, and `pnpm llms` regenerates the second from the first plus `docs/`.
311
+
312
+ ## Licence
313
+
314
+ **MIT** — see [LICENSE](LICENSE).
315
+
316
+ Every dependency is MIT too, so there is no copyleft obligation anywhere in the tree.
317
+ `zustand` is bundled into the published package and its notice travels with it; everything else
318
+ is a peer dependency the consuming project installs itself. The full accounting is in
319
+ [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md), and `pnpm licences:check` fails the build if it
320
+ stops being true.
321
+
322
+ ==============================================================================
323
+ Panels
324
+ ==============================================================================
325
+
326
+ # Panels
327
+
328
+ A panel is declared with `<Panel>`, which is a **descriptor**: it is never rendered where you
329
+ write it. `<Panels>` reads its props to build the rail and hands its children to the zone it
330
+ named.
331
+
332
+ ```tsx
333
+ <Panel<PanelId>
334
+ id="files"
335
+ zone="left"
336
+ slot="primary"
337
+ title="Files"
338
+ icon={<FilesIcon />}
339
+ actions={<RefreshButton />}
340
+ opens={320}
341
+ >
342
+ <FileTree />
343
+ </Panel>
344
+ ```
345
+
346
+ ## Props
347
+
348
+ | Prop | Required | Meaning |
349
+ | --- | --- | --- |
350
+ | `id` | yes | Unique, and the type parameter of `<Panels>` |
351
+ | `zone` | yes | `left`, `right`, `top`, `bottomLeft`, `bottomRight` |
352
+ | `slot` | no | `primary` (nearest the edge) or `secondary`. Defaults to `primary` |
353
+ | `title` | yes | Accessible name and header title, **already translated** |
354
+ | `icon` | no | Anything React renders. No icon set is imposed |
355
+ | `actions` | no | Rendered on the panel's own title row, beside its name |
356
+ | `opens` | no | The size this panel wants when it leads its zone |
357
+ | `solo` | no | Takes the zone whole. `primary` only |
358
+
359
+ ## Sharing a half
360
+
361
+ Two panels with the same `zone` and the same `slot` **take turns**: the rail switches between
362
+ them, and only one is on screen at a time.
363
+
364
+ ```tsx
365
+ <Panel id="files" zone="left" title="Files">…</Panel>
366
+ <Panel id="search" zone="left" title="Search">…</Panel>
367
+ ```
368
+
369
+ To show them **together**, stacked, put the second in the other half:
370
+
371
+ ```tsx
372
+ <Panel id="files" zone="left" title="Files">…</Panel>
373
+ <Panel id="outline" zone="left" slot="secondary" title="Outline">…</Panel>
374
+ ```
375
+
376
+ The rail draws the same cut as a separator: icons above it open in the first half, icons below in
377
+ the second. The rail is the legend of the column.
378
+
379
+ ## Which panel opens first
380
+
381
+ A half with nothing chosen opens on the **first panel declared for it**. Declaration order is
382
+ therefore meaningful: put the panel a reader should land on first.
383
+
384
+ To open somewhere else, name the halves yourself:
385
+
386
+ ```tsx
387
+ <Panels<PanelId> defaultOpen={{ left: { primary: 'search' } }}>
388
+ ```
389
+
390
+ `defaultOpen` is read once, and never against a layout that was restored — an arrangement the
391
+ reader made outranks a default.
392
+
393
+ ## `opens`
394
+
395
+ A zone has a width of its own (320 for `left`, 260 for `right`, 180 for `top`, 240 for the band).
396
+ A panel that needs more says so:
397
+
398
+ ```tsx
399
+ <Panel id="chat" zone="right" title="Assistant" opens={460}>
400
+ ```
401
+
402
+ A size the reader dragged always wins over it: a length somebody chose is an answer about the
403
+ column, not about the panel that happened to be in it.
404
+
405
+ ## `solo`
406
+
407
+ A `solo` panel takes its zone **whole**; the other half draws nothing while it is up.
408
+
409
+ ```tsx
410
+ <Panel id="chat" zone="right" title="Assistant" solo opens={460}>
411
+ ```
412
+
413
+ The half it silences is **put away, not closed**. Close the solo panel and what was there comes
414
+ back untouched. That stash is never persisted: a column reopening by itself days later, on an
415
+ arrangement nobody remembers making, is not a restoration.
416
+
417
+ `solo` only applies to `primary`.
418
+
419
+ ## Adding and removing panels
420
+
421
+ The registry follows your JSX. A panel that stops being declared is unregistered, and the half it
422
+ occupied is emptied — a stored id naming a panel that no longer exists would otherwise leave a
423
+ frame with nothing to draw in it.
424
+
425
+ Conditional panels work as you would expect:
426
+
427
+ ```tsx
428
+ {project && (
429
+ <Panel id="git" zone="left" slot="secondary" title="History">…</Panel>
430
+ )}
431
+ ```
432
+
433
+ This is how you express what a capability system would otherwise be for: a panel that should not
434
+ be offered is a panel you do not declare.
435
+
436
+ ==============================================================================
437
+ Layout
438
+ ==============================================================================
439
+
440
+ # Layout
441
+
442
+ ```
443
+ ┌──────────────────────────────────────────────────────────┐
444
+ │ header (yours, or nothing) │
445
+ ├──┬────────────────────────────────────────────────────┬──┤
446
+ │ │ top │ │
447
+ │R │────────────────────────────────────────────────────│R │
448
+ │A │ left │ │ right │A │
449
+ │I │ primary │ │ primary │I │
450
+ │L │ ┄┄┄┄┄┄┄ │ CENTRE │ ┄┄┄┄┄┄┄ │L │
451
+ │ │ secondary │ │ secondary │ │
452
+ │ │────────────┴────────────────────────┴──────────────│ │
453
+ │ │ bottomLeft ┊ bottomRight │ │
454
+ ├──┴────────────────────────────────────────────────────┴──┤
455
+ │ footer (yours, or nothing) │
456
+ └──────────────────────────────────────────────────────────┘
457
+ ┄┄┄ handle between the two halves of a zone
458
+ │ handle between a zone and the centre
459
+ ┊ handle between the band's two halves
460
+ ```
461
+
462
+ ## Five zones, two halves each
463
+
464
+ `left`, `right`, `top`, `bottomLeft`, `bottomRight`. **A zone nobody fills takes no room at all** —
465
+ no width, no handle, nothing.
466
+
467
+ Each zone is cut in two: `primary` is the half nearest the window edge the zone hangs from — the
468
+ top of a side column, the left of the bottom strip.
469
+
470
+ ## The band is one strip
471
+
472
+ `bottomLeft` and `bottomRight` share **one height**. Whichever of them is alone runs under the
473
+ opposite column; together they split the width, parted by a handle that starts at the middle.
474
+
475
+ That is also why a side column runs to the foot of the frame **unless** the band's half on its
476
+ side is drawing.
477
+
478
+ ## The centre
479
+
480
+ Whatever you put in `<Panels.Center>`. It stays at the same place in the React tree through every
481
+ arrangement of the zones around it — moved, React would unmount it and take down whatever engine,
482
+ canvas or editor it holds.
483
+
484
+ ## Resizing
485
+
486
+ Three handles, three different questions:
487
+
488
+ | Handle | Moves |
489
+ | --- | --- |
490
+ | Between a zone and the centre | the zone's own length |
491
+ | Between two halves of a zone | the divider inside it |
492
+ | Between the band's two zones | how they share the width |
493
+
494
+ Every one is a `role="separator"`, reachable with <kbd>Tab</kbd> and driven by the arrow keys — a
495
+ separator that only answers a pointer is a control a keyboard user cannot operate at all.
496
+
497
+ ## The floors
498
+
499
+ | | |
500
+ | --- | --- |
501
+ | `MIN_SIZE` | 140 — smallest a zone may be dragged to |
502
+ | `MIN_CENTER` | 240 — room the centre always keeps |
503
+ | `MIN_SPLIT` | 100 — room a half keeps inside its zone |
504
+
505
+ A zone is bounded against **what the opposite zone already takes**. Capping each side at half the
506
+ container independently would let left and right add up to the full width, leaving the centre at
507
+ zero — and overflowing once the container shrinks.
508
+
509
+ That bound applies whether or not anything was ever dragged. Two untouched columns asking for 320
510
+ and 380 on a 900 px container do not leave the centre 104 px: they give ground in proportion to
511
+ what they asked for, so neither collapses while the other keeps its full width.
512
+
513
+ ## It measures its container, not the window
514
+
515
+ The chassis observes **its own box**. Put it in a route, under your navigation, beside your
516
+ sidebar — the floors follow the box it is actually in.
517
+
518
+ This is what lets it be adopted a piece at a time in an application that already exists.
519
+
520
+ ## Persistence
521
+
522
+ What is stored: which half holds which panel, and the sizes. That is all.
523
+
524
+ ```tsx
525
+ <Panels
526
+ storageKey="my-app:layout" // two chassis in one app need two keys
527
+ storage={myStorage} // or null to store nothing
528
+ >
529
+ ```
530
+
531
+ Focus and the solo stash are session state and are deliberately not written down.
532
+
533
+ A custom store is two functions:
534
+
535
+ ```ts
536
+ const storage = {
537
+ read: (key: string) => string | null,
538
+ write: (key: string, value: string) => void,
539
+ }
540
+ ```
541
+
542
+ A layout written by another version, or corrupted, is **dropped rather than half-read**: your
543
+ defaults are a deliberate arrangement and a broken one is not. Unknown zones, unknown slots and
544
+ values of the wrong type are discarded on the way in.
545
+
546
+ ==============================================================================
547
+ Hooks
548
+ ==============================================================================
549
+
550
+ # Hooks
551
+
552
+ The components are built on these. Take them and draw your own frame, or use them from a header
553
+ that knows nothing about zones.
554
+
555
+ ## `usePanels`
556
+
557
+ Everything a header, a shortcut or a menu needs.
558
+
559
+ ```tsx
560
+ const { panels, reveal, close, toggle, isShown, focusedZone, reset } = usePanels<PanelId>()
561
+ ```
562
+
563
+ | | |
564
+ | --- | --- |
565
+ | `panels` | Every declared panel, in declaration order |
566
+ | `reveal(id)` | Brings it up in the half it declared, and focuses its zone |
567
+ | `close(id)` | Closes **that** panel, or nothing |
568
+ | `toggle(id)` | What a rail icon does |
569
+ | `isShown(id)` | Whether it is on screen right now |
570
+ | `focusedZone` | The last zone clicked, or `null` |
571
+ | `reset()` | Back to the opening arrangement |
572
+
573
+ `close(id)` is asked about the panel, not the half: two panels share a half, and closing "the
574
+ half" would close whichever happened to be standing there and report success.
575
+
576
+ A header built on this follows the chassis whatever moves it — a rail click, a keyboard
577
+ shortcut, a socket message:
578
+
579
+ ```tsx
580
+ function Header() {
581
+ const { panels, isShown, toggle } = usePanels<PanelId>()
582
+
583
+ return (
584
+ <nav>
585
+ {panels.map(panel => (
586
+ <button key={panel.id} aria-pressed={isShown(panel.id)} onClick={() => toggle(panel.id)}>
587
+ {panel.title}
588
+ </button>
589
+ ))}
590
+ </nav>
591
+ )
592
+ }
593
+ ```
594
+
595
+ ⚠️ It must be called **inside** `<Panels>`. A component that renders the chassis cannot also read
596
+ it — put the button in a child, or in a panel's `actions`.
597
+
598
+ ## `useZone`
599
+
600
+ What one zone needs to draw itself.
601
+
602
+ ```tsx
603
+ const { primary, secondary, draws, size, split, focused } = useZone<PanelId>('left')
604
+ ```
605
+
606
+ `primary` and `secondary` are the panels the zone actually **draws**, which is not always what it
607
+ holds — a `solo` panel silences the other half. `size` is already bounded against the opposite
608
+ zone and the measured room.
609
+
610
+ ## `useShownIn`, `useZoneDraws`, `useZoneTakesRoom`, `useBandHalves`
611
+
612
+ Smaller questions, for a frame of your own.
613
+
614
+ `useZoneTakesRoom` is not `useZoneDraws`: the band's two halves share one height, so either of
615
+ them drawing means the strip is taking that height. Ask the wrong one and a zone can be dragged
616
+ over room the band is already using.
617
+
618
+ ## `useContainerFit`
619
+
620
+ Re-clamps the zones when your container changes size.
621
+
622
+ ```tsx
623
+ const box = useRef<HTMLDivElement>(null)
624
+ useContainerFit(box)
625
+ ```
626
+
627
+ Only needed if you build your own frame — `<Panels>` already does it, on the box the zones and
628
+ the centre share rather than on the whole chassis.
629
+
630
+ ## Outside React
631
+
632
+ Build the store yourself and keep the reference:
633
+
634
+ ```tsx
635
+ const store = createPanelsStore<PanelId>()
636
+
637
+ socket.on('alert', () => store.getState().show('alerts'))
638
+ window.electron?.onMenu(id => store.getState().show(id))
639
+
640
+ <Panels<PanelId> store={store}>…</Panels>
641
+ ```
642
+
643
+ `store.getState()` gives the same actions the hooks call: `show`, `close`, `toggle`, `focus`,
644
+ `resize`, `resplit`, `resplitBand`, `fit`, `reset`.
645
+
646
+ ## `usePanelsState`
647
+
648
+ Any slice, subscribed:
649
+
650
+ ```tsx
651
+ const open = usePanelsState<PanelId, OpenByZone<PanelId>>(state => state.open)
652
+ ```
653
+
654
+ Prefer scalar selectors. `lengths` and `available` are replaced wholesale on every write, so
655
+ subscribing to either as an object wakes your component on every frame of a drag.
656
+
657
+ ==============================================================================
658
+ Theming
659
+ ==============================================================================
660
+
661
+ # Theming
662
+
663
+ Every value is a custom property on the chassis root. There is no theming API beyond this,
664
+ because it does not need one.
665
+
666
+ ```css
667
+ .pnl-root {
668
+ --pnl-panel: #101418;
669
+ --pnl-accent: #47965c;
670
+ --pnl-radius: 10px;
671
+ }
672
+ ```
673
+
674
+ Scope it however you like — a class on `<Panels>`, an ancestor, `:root`. The chassis reads the
675
+ computed value, so anything that reaches it works.
676
+
677
+ ```tsx
678
+ <Panels className="my-theme">
679
+ ```
680
+
681
+ ## The tokens
682
+
683
+ ### Surfaces
684
+
685
+ | Token | Default (dark) | |
686
+ | --- | --- | --- |
687
+ | `--pnl-chassis` | `#2b2d30` | The frame |
688
+ | `--pnl-panel` | `#191a1c` | The surfaces laid on it |
689
+ | `--pnl-elevated` | `#3c3f44` | Hover, and an active rail icon |
690
+ | `--pnl-border` | `#34363a` | Separators |
691
+ | `--pnl-text` | `#dfe1e5` | |
692
+ | `--pnl-muted` | `#91959b` | Secondary text, idle icons |
693
+
694
+ **Panels are darker than the chassis.** That inversion is the one visual idea the library is
695
+ built on — it is what reads as "panels on a frame" rather than as a web page. Invert it back by
696
+ repainting two tokens; nothing else assumes a direction.
697
+
698
+ ### Accent
699
+
700
+ | Token | Default | |
701
+ | --- | --- | --- |
702
+ | `--pnl-accent` | `#346ef2` | What is **actioned**: an active icon in the focused zone |
703
+ | `--pnl-accent-text` | `#ffffff` | What is written on it |
704
+
705
+ Set `--pnl-accent` on any ancestor and the chassis takes your brand instead of imposing one.
706
+
707
+ The default is a placeholder, and the contrast of **your** accent is yours to check: white on it
708
+ needs 4.5:1 for a word, 3:1 for a glyph that informs.
709
+
710
+ ### Gauges
711
+
712
+ | Token | Default | |
713
+ | --- | --- | --- |
714
+ | `--pnl-rail` | `48px` | Rail width |
715
+ | `--pnl-rail-button` | `36px` | Rail icon button |
716
+ | `--pnl-rail-inset` | `14px` | |
717
+ | `--pnl-gutter` | `6px` | The space between surfaces, **which is the resize area** |
718
+ | `--pnl-header` | `40px` | Panel title row |
719
+ | `--pnl-radius` | `6px` | |
720
+ | `--pnl-radius-sm` | `4px` | |
721
+ | `--pnl-font-size` | `13px` | |
722
+
723
+ The gutter is not decoration: it **is** where the pointer resizes. Widening it widens the target.
724
+
725
+ ## Light, dark, and following the reader
726
+
727
+ Left alone, the chassis follows `prefers-color-scheme`. Force it:
728
+
729
+ ```tsx
730
+ <Panels theme="dark"> // or "light"
731
+ ```
732
+
733
+ The light palette is the **mirror** of the dark one, not its inversion: surfaces stand apart from
734
+ the chassis by being lighter, with the chassis as a grey gutter around white panels.
735
+
736
+ ## Why your palette always wins
737
+
738
+ Every default token is declared inside `:where()`, which carries **no specificity**. Anything you
739
+ write — a class, an id, an element selector — outranks it, whatever order the stylesheets load in.
740
+
741
+ This was a bug once: the defaults sat on `.pnl-root:not([data-pnl-theme='light'])`, which
742
+ outscores any single class, so a project's palette applied in the dark and vanished the moment
743
+ the reader's system asked for the light one. A guard now reads the stylesheet to keep it at zero.
744
+
745
+ ## Beyond colour
746
+
747
+ If tokens are not enough, every piece is exported and replaceable on its own — see
748
+ [Components](COMPONENTS.md). And if you want none of the rendering, the
749
+ [hooks](HOOKS.md) draw nothing at all.
750
+
751
+ ==============================================================================
752
+ Components
753
+ ==============================================================================
754
+
755
+ # Components
756
+
757
+ Three levels of adoption, and you can stop at any of them: take the chassis whole, replace a
758
+ piece, or take only the [hooks](HOOKS.md) and draw everything yourself.
759
+
760
+ ## What is exported
761
+
762
+ | | |
763
+ | --- | --- |
764
+ | `Panels` | The chassis. Collects the descriptors and assembles the frame |
765
+ | `Panel` · `Center` | Descriptors. Never rendered where they are written |
766
+ | `Rail` · `RailZone` | The icon rail, and one zone's group of icons |
767
+ | `ZoneEdge` | A zone: its two halves and its handle |
768
+ | `Band` | The bottom strip, and its two zones |
769
+ | `PanelFrame` | One panel on screen: surface, title row, body |
770
+ | `Surface` | The rounded box |
771
+ | `PanelHeader` | The title row |
772
+ | `IconButton` | The rail's button, and the header's |
773
+ | `Separator` · `ResizeHandle` | |
774
+ | `DEFAULT_LABELS` | Every word the chassis says |
775
+
776
+ ## Replacing a piece
777
+
778
+ Build your own frame out of the ones you keep:
779
+
780
+ ```tsx
781
+ import { PanelsProvider, Rail, ZoneEdge, useBandHalves } from '@pasquelin/panels'
782
+
783
+ function MyChassis({ children }) {
784
+ const band = useBandHalves()
785
+
786
+ return (
787
+ <PanelsProvider>
788
+ <div className="my-frame">
789
+ <MyOwnRail side="left" />
790
+ <ZoneEdge zone="left" labels={MY_LABELS} />
791
+ <main>{children}</main>
792
+ <Rail side="right" />
793
+ </div>
794
+ </PanelsProvider>
795
+ )
796
+ }
797
+ ```
798
+
799
+ `<Panels>` is itself only a provider plus an arrangement of these pieces — there is nothing in it
800
+ you cannot write yourself.
801
+
802
+ ## Words
803
+
804
+ The chassis says four things. They are English by default; pass your own, already translated:
805
+
806
+ ```tsx
807
+ <Panels
808
+ labels={{
809
+ closePanel: 'Fermer le panneau',
810
+ resizeZone: 'Redimensionner la zone',
811
+ resizeSplit: 'Redimensionner les deux panneaux',
812
+ resizeBand: 'Redimensionner la bande',
813
+ }}
814
+ >
815
+ ```
816
+
817
+ The library carries **no i18n**. A key would impose a namespace on every project that installs
818
+ it, and translation is something your application already knows how to do.
819
+
820
+ ## Class names
821
+
822
+ Every class is prefixed `pnl-`. They are part of the public surface — style them, override them,
823
+ target them in tests:
824
+
825
+ ```
826
+ pnl-root · pnl-middle · pnl-columns · pnl-row · pnl-stack · pnl-centre
827
+ pnl-rail · pnl-rail__group · pnl-rail__button
828
+ pnl-zone · pnl-band · pnl-surface · pnl-body
829
+ pnl-header · pnl-header__title · pnl-header__actions · pnl-header__trailing
830
+ pnl-icon-button · pnl-separator · pnl-handle
831
+ ```
832
+
833
+ ## Accessibility
834
+
835
+ What the chassis does for you:
836
+
837
+ - the rail is a `role="toolbar"`, its buttons carry `aria-pressed`
838
+ - every panel is a `<section>` named by its title
839
+ - every handle is a `role="separator"` with `aria-orientation`, `aria-valuenow` and its bounds,
840
+ focusable and driven by the arrow keys
841
+ - the focus ring is visible and `prefers-reduced-motion` is respected
842
+
843
+ What is yours: the contrast of your palette, and the accessible names of whatever you put inside
844
+ the panels.
845
+
846
+ ==============================================================================
847
+ Document tabs
848
+ ==============================================================================
849
+
850
+ # Document tabs
851
+
852
+ The centre is a free slot: a router outlet, a canvas, a map. If you want **document tabs** —
853
+ draggable, splittable, stackable — the library ships an adapter over
854
+ [Dockview](https://dockview.dev).
855
+
856
+ It is a **separate entry point**, so its weight only lands on the projects that want it.
857
+
858
+ ```bash
859
+ pnpm add dockview-react
860
+ ```
861
+
862
+ ```tsx
863
+ import { DockviewCenter } from '@pasquelin/panels/dockview'
864
+
865
+ function Editor({ params }) {
866
+ return <FileView name={params.name as string} />
867
+ }
868
+
869
+ const DOCUMENTS = { editor: Editor }
870
+
871
+ <Panels.Center>
872
+ <DockviewCenter
873
+ documents={DOCUMENTS}
874
+ empty={NothingOpen}
875
+ layout={stored}
876
+ onLayout={setStored}
877
+ onReady={setApi}
878
+ />
879
+ </Panels.Center>
880
+ ```
881
+
882
+ | Prop | |
883
+ | --- | --- |
884
+ | `documents` | What each kind renders, keyed by the component name a panel asks for |
885
+ | `empty` | Drawn while nothing is open. Without one, Dockview shows a bare watermark |
886
+ | `layout` | A layout to restore, as `api.toJSON()` gave it |
887
+ | `onLayout` | Called whenever the arrangement changes |
888
+ | `onReady` | The api, for opening documents from outside |
889
+
890
+ ## Opening a document
891
+
892
+ ```tsx
893
+ const open = (name: string) => {
894
+ const held = api.getPanel(name)
895
+ if (held) return held.api.setActive() // already open: bring it forward
896
+
897
+ api.addPanel({ id: name, component: 'editor', title: name, params: { name } })
898
+ }
899
+ ```
900
+
901
+ ## Panels never enter the centre
902
+
903
+ The rule the whole chassis is built on. **A document has a name; a panel has an icon.**
904
+
905
+ Documents are things a person opens and closes, and they wear a tab that carries their name.
906
+ Panels are switched from the rail and wear an icon. Mixing them gives you a window where nothing
907
+ tells you what is a file and what is a tool.
908
+
909
+ ## Theming
910
+
911
+ The adapter dresses Dockview in the chassis' tokens: repaint `--pnl-panel` and the tab strip
912
+ follows. Without it you would get Dockview's default navy in the middle of your palette.
913
+
914
+ ## Restoring a layout
915
+
916
+ Dockview throws on a layout naming a component it cannot find. A refused one is **dropped**, and
917
+ `onLayout(undefined)` is called so you can forget it — kept, it would fail again at every launch.
918
+
919
+ ==============================================================================
920
+ Recipes
921
+ ==============================================================================
922
+
923
+ # Recipes
924
+
925
+ ## Inside an application that already exists
926
+
927
+ The chassis measures **its own container**, so it does not have to own the window.
928
+
929
+ ```tsx
930
+ <div className="my-app">
931
+ <MyNavigation />
932
+ <section style={{ flex: 1, minHeight: 0 }}>
933
+ <Panels<PanelId>>…</Panels>
934
+ </section>
935
+ </div>
936
+ ```
937
+
938
+ The only requirement is that the box has a height. `minHeight: 0` on a flex child is the usual
939
+ missing piece — without it the child refuses to shrink and the chassis overflows.
940
+
941
+ ## A header that is entirely yours
942
+
943
+ `<Panels>` draws no chrome. Pass your own, or nothing:
944
+
945
+ ```tsx
946
+ <Panels header={<MyHeader />} footer={<MyStatusBar />}>
947
+ ```
948
+
949
+ Anything inside them can drive the chassis with [`usePanels`](HOOKS.md) — it does not need to
950
+ know what a zone is.
951
+
952
+ ## Electron
953
+
954
+ Nothing is Electron-specific, but two things are worth wiring.
955
+
956
+ **The native menu**, which lives in the main process and cannot call a hook:
957
+
958
+ ```tsx
959
+ const store = createPanelsStore<PanelId>()
960
+
961
+ window.electron.onMenuItem(id => store.getState().show(id))
962
+
963
+ <Panels<PanelId> store={store}>…</Panels>
964
+ ```
965
+
966
+ **Persistence to disk** rather than `localStorage`:
967
+
968
+ ```tsx
969
+ <Panels
970
+ storage={{
971
+ read: key => window.electron.readLayout(key),
972
+ write: (key, value) => window.electron.writeLayout(key, value),
973
+ }}
974
+ >
975
+ ```
976
+
977
+ A frameless window's drag region is your header's business: put `WebkitAppRegion: 'drag'` on it,
978
+ and `'no-drag'` on the controls inside.
979
+
980
+ ## Keyboard shortcuts
981
+
982
+ ```tsx
983
+ function Shortcuts() {
984
+ const { toggle } = usePanels<PanelId>()
985
+
986
+ useEffect(() => {
987
+ const onKey = (event: KeyboardEvent) => {
988
+ if (event.metaKey && event.key === 'b') toggle('files')
989
+ }
990
+ window.addEventListener('keydown', onKey)
991
+ return () => window.removeEventListener('keydown', onKey)
992
+ }, [toggle])
993
+
994
+ return null
995
+ }
996
+ ```
997
+
998
+ Render it inside `<Panels>`, like any other consumer of the hook.
999
+
1000
+ ## Two chassis in one application
1001
+
1002
+ Give them different storage keys, or they overwrite each other's layout:
1003
+
1004
+ ```tsx
1005
+ <Panels storageKey="app:main">…</Panels>
1006
+ <Panels storageKey="app:report">…</Panels>
1007
+ ```
1008
+
1009
+ Each `<Panels>` makes its own store; nothing is shared between them.
1010
+
1011
+ ## Testing
1012
+
1013
+ The chassis is ordinary React. Give it a storage that forgets, so one test cannot leak into the
1014
+ next:
1015
+
1016
+ ```tsx
1017
+ import { memoryStorage } from '@pasquelin/panels'
1018
+
1019
+ render(
1020
+ <Panels storage={memoryStorage()}>
1021
+ <Panel id="files" zone="left" title="Files">
1022
+ <p>file list</p>
1023
+ </Panel>
1024
+ </Panels>,
1025
+ )
1026
+
1027
+ await user.click(screen.getByRole('button', { name: 'Files' }))
1028
+ ```
1029
+
1030
+ Everything is reachable by role: the rail is a `toolbar`, panels are `region`s named by their
1031
+ title, handles are `separator`s.
1032
+
1033
+ Note that jsdom lays nothing out, so `useContainerFit` measures zero and the zones keep the sizes
1034
+ they ask for. That is enough to test what is open and what is not; it is not enough to test the
1035
+ floors — those are unit-tested against `sharedSizes` instead.
1036
+
1037
+ ## Server rendering
1038
+
1039
+ The chassis reads `localStorage` and measures the DOM, so it belongs on the client. Under
1040
+ Next.js, mark the file `'use client'`; there is nothing useful to render on the server anyway,
1041
+ since the layout is the reader's own.
1042
+
1043
+ ==============================================================================
1044
+ Architecture
1045
+ ==============================================================================
1046
+
1047
+ # Architecture
1048
+
1049
+ How the chassis works inside. For using it, start at
1050
+ [docs/en](en/README.md) · [docs/fr](fr/README.md).
1051
+
1052
+ ## Two layers, and the boundary between them
1053
+
1054
+ ```
1055
+ core/ the logic. Renders nothing, touches no DOM but to measure
1056
+ types zones, slots, what a panel spec is
1057
+ clamps the floors and the sharing — pure functions, tested alone
1058
+ store one zustand store per chassis, created not imported
1059
+ persistence localStorage by default, any adapter
1060
+ context the provider, and the subscribed readers
1061
+ hooks/ usePanels · useZone · useArrangement · useContainerFit · usePointerDrag
1062
+
1063
+ components/ built on those hooks, replaceable one at a time
1064
+ Panels collects the descriptors, assembles the frame
1065
+ Panel·Center descriptors — never rendered where they are written
1066
+ Rail · ZoneEdge · Band · PanelFrame
1067
+ Surface · PanelHeader · IconButton · Separator · ResizeHandle
1068
+
1069
+ dockview/ a separate entry point. Nothing else imports it
1070
+ ```
1071
+
1072
+ The rule: **`core/` never renders, `components/` never decides.** A hook that returns JSX belongs
1073
+ in `components/`; a component that computes a size belongs in `core/`.
1074
+
1075
+ ## The store is created, not imported
1076
+
1077
+ `createPanelsStore()` makes one per chassis. There is no module-level singleton, which is what
1078
+ lets two chassis live in one application, and what lets a test render without leaking into the
1079
+ next.
1080
+
1081
+ The provider makes one if you do not pass one. Passing your own is how you drive the chassis from
1082
+ outside React — see [Recipes](en/RECIPES.md).
1083
+
1084
+ ## Descriptors, not portals
1085
+
1086
+ `<Panel>` and `<Center>` return `null`. `<Panels>` reads them out of its children with
1087
+ `Children.toArray`, which flattens fragments and drops the falsy, so `{ready && <Panel/>}` and a
1088
+ mapped list both work.
1089
+
1090
+ The alternative was a portal per panel. It was rejected because a portal puts the panel's content
1091
+ in a **different React tree** from the one it was declared in: context, error boundaries and
1092
+ suspense would all stop at the boundary, and a panel could not read a provider its own file sits
1093
+ under.
1094
+
1095
+ The cost is that panels must be children of `<Panels>`, not of some component in between. That is
1096
+ the same constraint `<Route>` has, and it is understood.
1097
+
1098
+ ## What is stored, and what is resolved
1099
+
1100
+ Stored: which panel each half holds, and the sizes. Nothing else.
1101
+
1102
+ Everything else is **resolved at render**:
1103
+
1104
+ - what a zone *draws* is not what it *holds* — a `solo` panel silences the other half
1105
+ - the size a zone *takes* is bounded against the opposite zone and the measured room
1106
+ - whether a zone *takes room* is not whether it *draws* — the band's two halves share one height
1107
+
1108
+ Each of those was a bug before it was a function. They are `shownIn`, `sharedSizes` and
1109
+ `zoneTakesRoom`, and each has a test that starts from the arrangement that broke it.
1110
+
1111
+ ## Why the selectors are scalar
1112
+
1113
+ `resize`, `resplit` and `fit` replace `lengths` and `available` wholesale. A component subscribed
1114
+ to either as an object therefore re-renders on **every** `pointermove` of a drag — with five zones
1115
+ mounted, five re-renders a frame where two are owed.
1116
+
1117
+ `useZone` subscribes to individual numbers instead, and `PanelFrame` and `ZoneEdge` are memoised
1118
+ to stop the cascade below them.
1119
+
1120
+ ## Measuring the columns, not the chassis
1121
+
1122
+ `useContainerFit` observes the box the zones and the centre share — not the root, which also holds
1123
+ the rails, the header and the footer. Counting those would let a column overrun the centre by
1124
+ exactly their width.
1125
+
1126
+ Observing the *container* rather than the window is what lets the chassis live inside a page that
1127
+ already exists.
1128
+
1129
+ ## The stylesheet owes its specificity to its consumers
1130
+
1131
+ Every default token sits inside `:where()`, at zero specificity, so anything a project writes
1132
+ wins whatever order the stylesheets load in. The layout rules keep normal specificity: they are
1133
+ not something a repaint should reach.
1134
+
1135
+ `src/styles/tokens.test.ts` reads the sheet and fails if a token block escapes `:where()`.
1136
+
1137
+ ## What was deliberately left out
1138
+
1139
+ - **surfaces / sections** — the chassis has no notion of "which screen am I on". In React the
1140
+ router changes the children; there is no problem to solve.
1141
+ - **capabilities** — a panel that should not be offered is a panel you do not declare.
1142
+ - **i18n** — four strings, passed in already translated.
1143
+ - **an icon set** — `icon` takes any node.
1144
+ - **floating and dragging panels between zones** — planned, and the state model already allows an
1145
+ override of the declared zone. Not in v1.
1146
+
1147
+ ==============================================================================
1148
+ Licences
1149
+ ==============================================================================
1150
+
1151
+ # Third-party notices
1152
+
1153
+ `@pasquelin/panels` is distributed under the MIT licence — see [LICENSE](LICENSE).
1154
+
1155
+ This file lists the third-party code it carries or requires, and reproduces the notices those
1156
+ licences ask to be preserved. **Every dependency below is MIT**, so there is no copyleft
1157
+ obligation and no conflict with this project's own licence.
1158
+
1159
+ ---
1160
+
1161
+ ## Bundled into the published package
1162
+
1163
+ Code that ships **inside** `dist/`, and whose notice therefore travels with this library.
1164
+
1165
+ ### zustand
1166
+
1167
+ - Version: 5.0.15
1168
+ - Licence: MIT
1169
+ - Homepage: https://github.com/pmndrs/zustand
1170
+
1171
+ The chassis keeps its state in a zustand store, bundled so that consumers never have to install
1172
+ or know about it. Its notice, reproduced in full as MIT requires:
1173
+
1174
+ ```
1175
+ MIT License
1176
+
1177
+ Copyright (c) 2019 Paul Henschel
1178
+
1179
+ Permission is hereby granted, free of charge, to any person obtaining a copy
1180
+ of this software and associated documentation files (the "Software"), to deal
1181
+ in the Software without restriction, including without limitation the rights
1182
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1183
+ copies of the Software, and to permit persons to whom the Software is
1184
+ furnished to do so, subject to the following conditions:
1185
+
1186
+ The above copyright notice and this permission notice shall be included in all
1187
+ copies or substantial portions of the Software.
1188
+
1189
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1190
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1191
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1192
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1193
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1194
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1195
+ SOFTWARE.
1196
+ ```
1197
+
1198
+ ---
1199
+
1200
+ ## Required, not bundled
1201
+
1202
+ Peer dependencies. They are installed by the consuming project, under their own licences, and no
1203
+ part of them is copied here.
1204
+
1205
+ | Package | Licence | Required |
1206
+ | --- | --- | --- |
1207
+ | [react](https://github.com/facebook/react) | MIT | yes |
1208
+ | [react-dom](https://github.com/facebook/react) | MIT | yes |
1209
+ | [dockview-react](https://github.com/mathuo/dockview) | MIT | only for `@pasquelin/panels/dockview` |
1210
+
1211
+ ---
1212
+
1213
+ ## Used by the examples and the showcase only
1214
+
1215
+ None of this is published to npm — it builds the site under `examples/`.
1216
+
1217
+ | Package | Licence | Used for |
1218
+ | --- | --- | --- |
1219
+ | [react-router-dom](https://github.com/remix-run/react-router) | MIT | the `router` example |
1220
+ | [dockview-react](https://github.com/mathuo/dockview) | MIT | the `dockview` example |
1221
+ | [vite](https://github.com/vitejs/vite) · [vitest](https://github.com/vitest-dev/vitest) | MIT | build and tests |
1222
+ | [typescript](https://github.com/microsoft/TypeScript) | Apache-2.0 | build only, nothing shipped |
1223
+ | [eslint](https://github.com/eslint/eslint) · [prettier](https://github.com/prettier/prettier) | MIT | development only |
1224
+
1225
+ ### Fonts
1226
+
1227
+ The showcase loads three families from Google Fonts, each under the
1228
+ [SIL Open Font License 1.1](https://openfontlicense.org/):
1229
+
1230
+ - **Space Grotesk** — © Florian Karsten
1231
+ - **IBM Plex Sans** — © IBM Corp.
1232
+ - **JetBrains Mono** — © JetBrains s.r.o.
1233
+
1234
+ They are linked, not redistributed. The library itself embeds no font and sets no
1235
+ `font-family`: it inherits whatever the host application uses.
1236
+
1237
+ ---
1238
+
1239
+ ## Keeping this file honest
1240
+
1241
+ Run `pnpm licences:check` after adding or upgrading a dependency. It reads the installed tree and
1242
+ fails if anything is not MIT, or if a package is bundled without a notice here.