phonux 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 (62) hide show
  1. package/DESIGN.md +985 -0
  2. package/LICENSE +21 -0
  3. package/Panel.d.ts +76 -0
  4. package/Panel.js +45 -0
  5. package/PanelFrame.d.ts +154 -0
  6. package/PanelFrame.js +160 -0
  7. package/PanelRow.d.ts +46 -0
  8. package/PanelRow.js +98 -0
  9. package/PanelRowSlot.d.ts +74 -0
  10. package/PanelRowSlot.js +68 -0
  11. package/PhoneDetectPrompt.d.ts +18 -0
  12. package/PhoneDetectPrompt.js +52 -0
  13. package/README.md +86 -0
  14. package/Workspace.d.ts +65 -0
  15. package/Workspace.js +47 -0
  16. package/defaultTheme.d.ts +10 -0
  17. package/defaultTheme.js +26 -0
  18. package/directionalTransition.d.ts +33 -0
  19. package/directionalTransition.js +24 -0
  20. package/dragToClose.d.ts +60 -0
  21. package/dragToClose.js +151 -0
  22. package/fakeHost.d.ts +33 -0
  23. package/fakeHost.js +85 -0
  24. package/hostApi.d.ts +247 -0
  25. package/hostApi.js +54 -0
  26. package/index.d.ts +46 -0
  27. package/index.js +30 -0
  28. package/package.json +30 -0
  29. package/panelCapacity.d.ts +15 -0
  30. package/panelCapacity.js +19 -0
  31. package/panelRowEntry.d.ts +37 -0
  32. package/panelRowEntry.js +11 -0
  33. package/panelRowLayout.d.ts +57 -0
  34. package/panelRowLayout.js +52 -0
  35. package/panelRowOrder.d.ts +46 -0
  36. package/panelRowOrder.js +105 -0
  37. package/panelTiers.d.ts +24 -0
  38. package/panelTiers.js +29 -0
  39. package/panelWindow.d.ts +170 -0
  40. package/panelWindow.js +243 -0
  41. package/panels/usePanelClosing.d.ts +58 -0
  42. package/panels/usePanelClosing.js +140 -0
  43. package/panels/usePanelManager.d.ts +74 -0
  44. package/panels/usePanelManager.js +403 -0
  45. package/panels/useProvidePanels.d.ts +82 -0
  46. package/panels/useProvidePanels.js +142 -0
  47. package/panels/useRowScrollGesture.d.ts +2 -0
  48. package/panels/useRowScrollGesture.js +70 -0
  49. package/panels/useWorkspacePersistence.d.ts +14 -0
  50. package/panels/useWorkspacePersistence.js +74 -0
  51. package/phoneModels.d.ts +26 -0
  52. package/phoneModels.js +43 -0
  53. package/snapshots.d.ts +58 -0
  54. package/snapshots.js +22 -0
  55. package/viewRegistry.d.ts +23 -0
  56. package/viewRegistry.js +30 -0
  57. package/viewState.d.ts +32 -0
  58. package/viewState.js +135 -0
  59. package/windowOverlay.d.ts +87 -0
  60. package/windowOverlay.js +137 -0
  61. package/workspaceState.d.ts +63 -0
  62. package/workspaceState.js +95 -0
package/DESIGN.md ADDED
@@ -0,0 +1,985 @@
1
+ # phonux: design notes
2
+
3
+ This package is the seam between a host app and any view -- Settings, History, a live web panel, or a
4
+ third-party one. Nothing here may depend on the app that hosts it.
5
+
6
+ ## Import boundary
7
+
8
+ `hostApi.tsx`, and the rest of this folder, import only React, MUI, motion and itself -- never
9
+ `localStorage`, the desktop IPC bridge, or any other app-internal module. A view written against these
10
+ contracts must work under ANY host, not just this one.
11
+
12
+ ## The provider is rebuilt every render
13
+
14
+ `HostProvider` (renamed from `PhoneAPIsProvider`)'s `host` value is never memoised, on either side: the host's handlers close over
15
+ render-time state (the panel list, the column count), so a memoised bag would hand a view a stale closure.
16
+
17
+ ## Testing without a host
18
+
19
+ `fakeHost.ts` lives beside the contracts, not in the app's test folder, specifically so a third-party
20
+ view (with no access to the app's own test utilities) can still write a test against these APIs. It records
21
+ every call rather than simulating real behavior, since simulating real behavior would require importing the
22
+ app's own logic -- exactly what this boundary exists to avoid. It has no test-runner dependency and
23
+ imports only the contracts and the data-only phone model list.
24
+
25
+ ## Behavior flags cross this boundary as data only
26
+
27
+ The panel model (`PanelWindow`, `panelWindow.ts`) adds behavior flags (`collapsible`, `listed`, `view`, ...)
28
+ to decide overflow, listing and rendering -- but `PanelSummary` (a view's own read of a panel) exposes only
29
+ what a view needs to render a row: id, url, title, live, locked, formFactor, size. A view never sees or
30
+ reasons about the flags that decided whether a panel is in `panelList` at all.
31
+
32
+ ## The panel-view registry: never a crash, never a silent substitution
33
+
34
+ `registerView` (renamed from `registerPanelView`)/`resolveView` (renamed from `resolvePanelView`) (`viewRegistry.tsx`) let `PanelWindow.view` be an open string
35
+ instead of a closed union, so a host or a third party can spawn any registered component by name.
36
+ `resolveView` never returns nothing: an unregistered key -- a typo, or a plugin that failed to
37
+ register -- renders a visible, inert placeholder naming the title and the key, rather than throwing (which
38
+ would take the whole row down with it) or silently falling back to the built-in web panel (which would
39
+ render a blank webview shell for what is actually broken, more confusing than an honest placeholder). `view`
40
+ is read ONLY at this one render-selection site; every overflow/collapse/close decision elsewhere reads
41
+ `collapsible`/`listed`/`locked` instead, never `view`, so widening it to an open string cannot change any of
42
+ those decisions.
43
+
44
+ ## Position, layer and enter-direction are per-panel metadata, not per-view rules
45
+
46
+ `PanelWindow.anchor`/`layer`/`anchoredEnter` replace three separate hard-coded special cases for History
47
+ (always the leftmost column, a higher zIndex, entering unconditionally via the anchor formula) with one field
48
+ each, read by exactly the site that decides that one behavior: `anchor` by the function that orders the row
49
+ (see "Row order is anchor metadata" below), `layer` by the row's per-slot zIndex, `anchoredEnter` by the row's
50
+ enter-direction choice, which resolves against that panel's own anchor target. `layer` and `anchoredEnter` are
51
+ set once at creation; `anchor` is set at creation and changed by `reanchor` and by a drag. A custom panel can
52
+ opt into any of History's behaviors without becoming History-shaped in any other way -- `listed`,
53
+ `collapsible` and `locked` stay independent of all three.
54
+
55
+ ### `permanent` is the one door to a collapsible:false column
56
+
57
+ `CreatePanelOptions.permanent` (`AppendPanelOptions.permanent` underneath it) is the only way a spawned panel
58
+ can become a permanent column -- there is no raw `collapsible` or public `locked` key. Setting it forces
59
+ `listed:false` regardless of any `listed` the caller also passes: the framework's History-row trash
60
+ (suppressed by `listed:false`, History's own precedent) must never be the close affordance for a column whose
61
+ own component owns its own close control. It adds no fourth field alongside `anchor`/`layer`/`anchoredEnter`
62
+ above -- those three already work on any entry, collapsible or not, unchanged (`create({ permanent: true,
63
+ anchor })` places the column like any panel). A permanent column is exempt from every row gesture, capacity
64
+ park, `reanchor` and from being dragged, for free, by already being `collapsible:false`, the same way History
65
+ already is. Other panels can still be dragged past it -- see `PanelsAPI.move`'s own section below.
66
+
67
+ The "own control only" guarantee also has to hold for the built-in 'web' view, not just a custom one: `view`
68
+ left at its default (or set explicitly to `'web'`) still resolves to the host's own panel component, which otherwise wraps
69
+ EVERY panel in a framework drag-to-close handle unconditionally. That component reads its own `collapsible`
70
+ through the same per-slot context PanelRow.tsx already provides for the reorder exemption
71
+ (`usePanelCloseEligible`, alongside `usePanelReorderEligible`) and renders no DragToClose wrapper -- no grab
72
+ bar at all -- when it reads false, exactly like History's own locked Panel today.
73
+
74
+ ## `size?` is additive: omitted means "read the shared device size, exactly as before this prop existed"
75
+
76
+ `Panel`/`PanelFrame` (`Panel.tsx`/`PanelFrame.tsx`) each gain an optional `size?: PanelSize`
77
+ (`FormFactor`/`PanelSize`, `hostApi.tsx`, beside `PanelSummary`). A caller that omits it -- every
78
+ caller today -- gets exactly what it got before this prop existed: `useHost().device.size`, read live, so it
79
+ still reacts to a phone-size change the same way. This is the whole point of making it optional rather than
80
+ required with a default: a third-party view compiled against an OLDER phonux (one with no `size` prop at
81
+ all) is still a valid caller of the current `Panel`/`PanelFrame` -- passing nothing is not a special case, it
82
+ is the type's own default state, so there is no version where "the new field" could be missing versus
83
+ "explicitly opted out." The omitted default is never a static per-tier constant (e.g. a fixed "phone" pixel
84
+ size) -- it is the live, shared value every panel already read, so a host that resizes its window mid-session
85
+ never leaves a size-less view rendering stale dimensions.
86
+
87
+ `PanelSummary.formFactor`/`size` are themselves optional, not required, even though
88
+ `panelWindow.ts` always sets them on every entry it creates (`createHistoryPanel`, `appendPanel`) -- the same
89
+ choice already made for `anchor`/`layer`/`anchoredEnter` on the internal `PanelWindow` record. A `PanelSummary`
90
+ is a structural type: any object shaped like one satisfies it, including hand-built test fixtures that predate
91
+ this feature and never set these two fields. Required fields would force every such fixture, in this app and
92
+ any third party's, to add them just to keep compiling, for a schema addition that changes no rendered pixel
93
+ by itself (`Panel`/`PanelFrame` never read `PanelSummary.size`; only their own separate `size` prop, passed
94
+ explicitly by the view, does -- see `setFormFactor` below). The app's projection (`summarisePanel()`) follows the same rule in
95
+ the other direction: it forwards `formFactor`/`size` when the record carries them and omits the key when it
96
+ does not, never a key present with the value `undefined` -- so a summary never lists a field its record never
97
+ had, and a view testing `'size' in panel` gets the same answer as `panel.size !== undefined`.
98
+
99
+ ### Where `PanelWindow`'s own default size comes from, without importing desktop code into phonux
100
+
101
+ `panelWindow.ts` is part of this folder: the `PanelWindow` record and every pure transition over a
102
+ `PanelWindow[]` (`appendPanel`, `createHistoryPanel`, `closePanel`, `restorePanel`, ...). It sits inside the
103
+ import boundary above, so it imports only its siblings and never the desktop app. The main barrel exports
104
+ `PanelWindow` and `reorderStepsForOffset` from it; every other name stays `@internal` behind the
105
+ `./internal/panelModel` export key, the entry point for its internal API report, which `dist/` does not ship (see "The API reports in `etc/`"); the package's own tests import `panelWindow.ts` by relative path.
106
+ Its functions are pure, taking no device-state argument, and are tested that way (`test/panel-window.test.ts`
107
+ builds panels with no device store initialized at all). Reading a live device size inside them -- the
108
+ host's device-size store holds one that changes with every resize -- would make them impure, would
109
+ need an import of host code this package may not have, and would need a new parameter threaded in from
110
+ `usePanelManager.ts`/`useProvidePanels.ts`. So the default is `DEFAULT_PANEL_SIZE` (`panelTiers.ts`): the
111
+ phone tier at `DEFAULT_PHONE_SIZE`'s (`hostApi.tsx`) 390x700 -- the SAME value a host's device-size store,
112
+ like `fakeHost.ts`'s, starts at before any resize -- rather than a second hardcoded literal or a live read. A tablet or
113
+ desktop panel takes its tier's size from `TIER_SIZES` (also `panelTiers.ts`). The default is a placeholder, not
114
+ a drawn size: a phone panel never passes its stored `size` on, because its width is always the live phone width (see
115
+ "The width source" below), so the default never reaches a pixel.
116
+
117
+ ## `PanelsAPI.move` is directional (move-by-one), not "move to index"
118
+
119
+ `move(id, 'left' | 'right')` swaps `id` past whichever OTHER shown panel currently sits toward that side,
120
+ rather than taking a raw target index. A raw index is meaningless across the `panelList`/internal-array
121
+ split (`panelList` only ever lists panels, `collapsible: false` columns like History are never addressable
122
+ targets) and would leak the internal array's own indexing into the public surface. The row's own
123
+ drag-to-reorder gesture (the host's panel component) converts a pointer's cumulative offset into a step count and calls
124
+ `move` that many times in the same direction -- the same primitive a future keyboard/single-pointer
125
+ alternative (WCAG 2.5.7) would call once per key press. Panels can differ in width, so the step count is not
126
+ `offset / one slot`: a hop past a neighbour moves the dragged panel by that NEIGHBOUR's span (its width plus
127
+ the gap), so each hop lands half-way across the next neighbour's span (`reorderStepsForOffset`, over the
128
+ neighbour widths the row supplies per slot). With equal widths this is exactly one hop per slot.
129
+
130
+ `collapsible: false` panels are excluded as a drag SOURCE only: they render no grab bar, through a plain React
131
+ context PanelRow.tsx provides per slot (a resolved view only ever receives `PanelSummary`, which carries no
132
+ `collapsible` field to check locally), and `move` is a no-op on their id. As a NEIGHBOUR they are crossable
133
+ like any other panel: a drag passes History or a permanent column, one hop per that column's own span, so a
134
+ drag is one way a panel ends up left of History. After every `move` each panel's anchor is re-read from the
135
+ row as it then stands -- see "The drag contract" in the next section.
136
+
137
+ ## Row order is anchor metadata: `{ panel, position, priority }`
138
+
139
+ Every panel's place in the row comes from its own anchor, never from a fixed rule about History. History is
140
+ the root: it carries no anchor, and every other panel sits on one side of one panel. For a view, the one
141
+ shape is `{ panel?: string; position: 'left' | 'right'; priority?: number }`.
142
+
143
+ - **Where it is set.** `CreatePanelOptions.anchor` when a panel is created, and
144
+ `PanelsAPI.reanchor(id, anchor)` for a panel that already exists, live or parked. `reanchor` is a no-op for
145
+ an unknown id or a permanent column (History included). Both reorder the row at once.
146
+ - **Defaults.** `panel` omitted means History; otherwise it is another panel's `PanelSummary.id`. `priority`
147
+ omitted means one past the highest priority already on that side of that panel: the far end. `create()`
148
+ with no `anchor` at all is `{ position: 'right' }`, so a plain create lands at the row's far right end.
149
+ `{ position: 'left' }` on a row with nothing left of History puts the panel immediately left of History; a
150
+ second one lands farther left.
151
+ - **Priority.** Within one (panel, side), priorities compare numerically and LOWER is NEARER the anchor, on
152
+ both sides; ties keep the current row order. After every placement the host renumbers each side 0, 1, 2 ...
153
+ from the nearest, so `-1` always means "nearest": `{ position: 'right', priority: -1 }` opens a panel
154
+ immediately right of History, whatever is already there.
155
+ - **Anchored to another panel.** A panel anchored to P sits next to P, before P's other neighbours on that
156
+ side: with [History, A, B], opening C with `{ panel: A, position: 'right' }` gives [History, A, C, B].
157
+ When P closes, every panel anchored to P takes over P's own anchor, so they close up in the place P held.
158
+ An anchor naming a panel that is not in the row, or one that closes a cycle, falls back to
159
+ `{ History, 'right' }` with its own priority -- never a crash, never a lost panel.
160
+ - **Placement only, never protection.** `reanchor` never parks or reveals by itself: whether the live panels
161
+ fit depends only on their widths and how many there are, never their order. A panel anchored left of
162
+ History parks by the same rules as any other -- a capacity drop, for one, parks the leftmost of equally
163
+ wide panels first, and that can be it.
164
+ - **The drag contract.** A user drag -- and `PanelsAPI.move`, which the drag calls -- re-derives EVERY panel's
165
+ anchor from the row as it then stands, relative to History: each panel becomes `{ History, the side it is on,
166
+ its distance rank }`. The order is unchanged by that step, but an anchor naming another panel lasts only
167
+ until the next drag. `undoClose` does the same when it puts a panel back at its old index. `create` and
168
+ `reanchor` are the ways to set an anchor on purpose.
169
+ - **Saved with the workspace.** Row order and anchors are workspace state (see "Workspace state persistence:
170
+ shape, ownership and timing" below): saved when a host passes `storage` to `useProvidePanels`, written nowhere
171
+ otherwise. `PanelSummary` never carries `anchor`, so a view reads the resulting order from `panelList`, never
172
+ anyone's anchor.
173
+
174
+ ## `PanelsAPI.setFormFactor`: one panel's own tier, resized in place
175
+
176
+ `setFormFactor(id, next)` gives ONE panel a new tier. Every other panel keeps its own size; panels to its
177
+ right slide over by the width change. It is a size change, never a park: the panel stays live and mounted,
178
+ its content stays the same DOM node, and nothing is captured or evicted for it. If the wider panel no longer
179
+ fits, OTHER panels park by the host's ordinary capacity rule (as few as a narrower window would park), never
180
+ the one just resized, and in the same update, so no frame shows the row overflowing; when it alone cannot
181
+ fit, it stays live anyway (the row always keeps at least one panel).
182
+ Shrinking it gives the room back, and parked panels return as for a wider window. No-op for an unknown id, a
183
+ permanent column such as History (it always stays phone-sized, like `move`'s own exemption), or a panel
184
+ already at `next`. A parked panel may change tier and stays parked. `create({ formFactor })` opens a panel at
185
+ a tier directly (ignored with `permanent`). The host replaces the panel's `size` with a new object each time;
186
+ the default size object is shared by every panel and never changes.
187
+
188
+ Several calls in one handler (resizes, `create`, `activate`) each keep their own panel live the same way.
189
+ When they cannot all fit together, the earlier calls keep their room and a later panel parks, as it would had
190
+ it come second. A row gesture or a close later in the same handler prices the resize at its new width.
191
+
192
+ ### The tier table
193
+
194
+ One table, `TIER_SIZES` (`panelTiers.ts`), holds the tablet and desktop sizes and a phone placeholder (the real
195
+ phone size is the live model's, see "The width source" below); these numbers are tunable, the rest of this
196
+ section is not:
197
+
198
+ | tier | width | height |
199
+ | --- | --- | --- |
200
+ | phone | the live phone model's width | the live phone model's height |
201
+ | tablet | 768 | `'fill'` |
202
+ | desktop | 1200 | `'fill'` |
203
+
204
+ `'fill'` is the row's own height: the same box a phone panel's height sits in. So a bigger tier is wider and
205
+ exactly as tall as the row, never taller than the window. 768 is the common tablet-portrait CSS width, the
206
+ smallest width sites treat as "not a phone". 1200 is a common desktop-layout breakpoint. Both are MAXIMUMS:
207
+ see "The fit cap" below.
208
+
209
+ ### The fit cap
210
+
211
+ A tablet or desktop panel is never wider than the widest ONE live panel can be beside the fixed columns in
212
+ the current window: `min(tier width, max(phone width, row width - 2 x gap - fixed columns - gap))`
213
+ (`computeMaxLiveWidth`, from the same budget arithmetic the row's fit count uses). So a wide panel always
214
+ fits in the row instead of overflowing it, at every window size. With History as the only fixed column and a 390 phone:
215
+
216
+ | window width | cap | desktop panel | tablet panel |
217
+ | --- | --- | --- | --- |
218
+ | 1280 (the default) | 842 | 842 | 768 |
219
+ | 1920 | 1482 | 1200 | 768 |
220
+ | 844 (the window minimum) | 406 | 406 | 406 |
221
+
222
+ The floor is the phone width: when not even a phone fits beside the fixed columns, a narrower "tablet"
223
+ would not fit either, so a bigger tier is then just phone-wide. A phone panel is never capped (the window
224
+ minimum already fits History plus one phone). The cap moves with the window, so a capped panel widens and
225
+ narrows as the window does. Before the first measurement there is no cap.
226
+
227
+ A panel at the cap fills the row beside the fixed columns, so nothing else fits beside it, which is also
228
+ true of the same panel at its raw tier width. The capacity rules therefore park and reveal exactly the same
229
+ panels either way; the cap changes how wide the panel is drawn, never which panels are live.
230
+
231
+ `PanelsAPI.panelList` reports each panel's stored tier `size`. The `panel` prop a resolved view receives
232
+ carries the size it is drawn at, so a view that passes `panel.size` to `Panel`/`PanelFrame` stays inside
233
+ its slot.
234
+
235
+ ### The width source
236
+
237
+ A `'phone'` panel's width is ALWAYS the live phone width -- the model the user picked, `device.size` -- and
238
+ never its stored `size`: that stored value is a placeholder, and reading it would freeze every phone panel at
239
+ 390 whatever the model. Only `'tablet'`/`'desktop'` use their stored `size.width`. One host helper
240
+ (`panelWidthOf`) owns this rule and the fit cap, and every width sum reads through it: the row's capacity,
241
+ `SettingsAPI.fitCount`, the row's centring margin, each slot's position and width, the size box, and the
242
+ neighbour widths a drag crosses. A view follows the same rule: pass `panel.size` to
243
+ `Panel`/`PanelFrame` only for a non-phone tier, and omit it for a phone one so the device size applies.
244
+
245
+ ### How the resize animates
246
+
247
+ The row's slot (`PanelRowSlot`) owns the enter/exit animation, so the resize never lives on the slot
248
+ itself: Motion's docs warn against `layout` and `exit` on one element. It lives on an inner size box
249
+ (`PanelSizeBox`) that the built-in web view renders inside its slot, under its drag box. That box is Motion
250
+ `layout="size"`, a transform-based resize over the same 0.4 s transition the slots slide with; `"size"`
251
+ because the slot's own `x` already animates position. It is the panel's own width and the row's own height,
252
+ so a `'fill'` view's percentage height resolves inside it. A column that is not collapsible (History, a
253
+ permanent column) never changes tier and renders no size box. A third-party view may change tier too: its
254
+ slot, position and the row's capacity follow, but its content snaps to the new size, since only the built-in
255
+ view renders a size box. The row's centring margin glides with the resize even right after a window resize,
256
+ when a window-driven margin change would snap.
257
+
258
+ The size box follows any change of the panel's width, not only a tier change. Choosing a different phone
259
+ model resizes every PHONE panel's size box too, so their width and height glide over the same 0.4 s while
260
+ the slots slide to their new positions; History, which has no size box, takes the new size at once. Every
261
+ panel lands exactly on the new phone size. A window resize that moves the fit cap resizes a capped panel the
262
+ same way.
263
+
264
+ ### The size control, and how a view reads its own tier
265
+
266
+ The built-in web view's header carries one size control beside its other actions. Each press moves that
267
+ panel one tier along phone -> tablet -> desktop -> phone by calling `setFormFactor(panel.id, next)`, and the
268
+ click does nothing else. All three tiers are offered: the fit cap keeps a desktop panel inside the row at
269
+ every window size, so no tier needs holding back. The control is a plain button with a fixed accessible name,
270
+ "Panel size"; the current tier is its description and its glyph (a phone, a tablet or a desktop screen), so
271
+ focus never lands on a control that renamed itself. It renders only on a collapsible panel: History and a
272
+ permanent column never change tier.
273
+
274
+ A view reads its own tier from the `panel` prop it is rendered with: `panel.formFactor ?? 'phone'`. The
275
+ field is optional, so a summary without it is a phone panel. `panel.size` is the size the panel is drawn at,
276
+ already within the fit cap; pass it to `Panel`/`PanelFrame` for a non-phone tier and omit it for a phone
277
+ one (see "The width source"). A third-party view can offer its own control the same way, through
278
+ `useHost().panels.setFormFactor(panel.id, next)`; its slot, its neighbours and the row's capacity follow,
279
+ while its content snaps (see above). Keep such a handler to `setFormFactor` alone: a close in the same
280
+ handler can leave panels the resize parked unable to return to the row.
281
+
282
+ ## `params` is opaque and JSON-serializable by construction
283
+
284
+ `PanelSummary.params`/`PanelsAPI.create({ params })` carry per-component data the registry and the row never
285
+ interpret. `JsonValue` (not `unknown`) is the type on purpose: the saved workspace carries every panel's
286
+ data, so it must already be serializable, and starting from `unknown` would have meant redesigning the field the
287
+ day persistence was added.
288
+
289
+ ## `create()` is two overloads, and its own implementation still guards the positional case
290
+
291
+ `PanelsAPI.create` is `create(): void; create(options: CreatePanelOptions): void;`, never one
292
+ `create(options?: CreatePanelOptions)`. A single optional-argument signature is structurally assignable to
293
+ a DOM `MouseEventHandler`, and `MouseEvent`'s own required `view: Window` field collides with this bag's
294
+ `view?: string` -- a host that writes the ordinary React idiom `onClick={panels.create}` would fail to
295
+ typecheck, or worse, silently spread the click event's `view` into the new panel at runtime. The overload
296
+ form is assignable to a handler type (a function accepting fewer arguments always is); the real
297
+ implementation additionally treats any event-shaped positional argument (duck-typed by a `preventDefault`
298
+ function) as "no options", so a bare `onClick={panels.create}` reference behaves exactly like zero-arg
299
+ `create()` even though nothing here can stop a caller from writing that reference.
300
+
301
+ ## A resolved panel component only ever sees a `PanelSummary`
302
+
303
+ `PanelRow.tsx` takes the row-only `PanelRowEntry` record (not `PanelSummary`) because it alone reads
304
+ `anchor`/`layer`/`anchoredEnter`/`collapsible`, but every component it resolves through the registry --
305
+ built-in or third-party -- is written against `PanelComponent<P>` (renamed to `ViewComponent<P>`) `= ComponentType<{ panel: PanelSummary<P>
306
+ }>`. It narrows through the same `summarisePanel()` projection `PanelsAPI.panelList`/`recentlyClosed` use
307
+ (`panelRowEntry.ts`), so a view can never read a row-only or host-only field (`hostData`, `collapsible`,
308
+ `anchor`, `layer`, `anchoredEnter`) just because it happens to be a plain variable, not an object literal,
309
+ and so escapes TypeScript's excess-property check at the call site.
310
+
311
+ ## Building, packing and publishing
312
+
313
+ The repository root is the package. Its `package.json` is private and its `exports` map points at `.ts` source, so the tests and
314
+ the API reports run against source and nothing is published from the root. `npm run build` compiles to `dist/` and generates the
315
+ publishable `dist/package.json` with its paths rewritten relative to `dist/` (never hand-edited, never committed). `description` and
316
+ `license` come from the root `package.json` (the build refuses a source manifest without them), the generated manifest has no
317
+ `private` key and sets `publishConfig.access` to `public`, and `LICENSE`, `README.md` and `DESIGN.md` ship beside it.
318
+
319
+ `npm run smoke` is the publish gate: it asserts the manifest inside the packed tarball, then proves the tarball installs and
320
+ resolves in a throwaway app outside this checkout, with the real peer set from the registry, in nine checks.
321
+ `npm run smoke -- --from <tarball path or registry spec>` runs the same checks on an already-packed or already-published
322
+ artifact. The bare `--` is required: npm keeps a `--from` placed before it for itself, and the smoke refuses to run rather than
323
+ check the local `dist/` in its place. `npm run release:check` confirms that `name@version` is still free on the registry, runs the build and the smoke, and prints
324
+ the `npm publish` command for the maintainer to run with their own login; it never publishes. `npm publish --dry-run` is not
325
+ evidence, because it accepts a `private: true` manifest.
326
+
327
+ ## `index.ts` splits values from types on purpose
328
+
329
+ TypeScript's `interface` and `type` declarations produce no JavaScript: they are erased before a module
330
+ ever runs, so a runtime membership check (`Object.keys(namespace)`) can only ever see the value half of
331
+ this file's exports. `index.ts` re-exports values with `export { ... }` and types with
332
+ `export type { ... }` as two separate statements per source file specifically so that split is visible
333
+ in the file, not just in the type checker's head -- a reviewer (or a future editor) can tell which half
334
+ of a name governs a runtime test versus a compile-time one just by which statement it is in.
335
+
336
+ ## `directionalTransition` and the test fakes are subpaths, not part of `.`
337
+
338
+ `./internal/directionalTransition` stays out of the main barrel because it is an animation timing helper
339
+ for the package's own slot components, not a contract a view is meant to depend on -- exposing it on `.`
340
+ would invite a consumer to couple to an implementation detail that owes nothing to any view contract.
341
+ `./testing` exists as its own subpath, rather than being re-exported from `.`, so that importing the
342
+ production barrel can never pull test-only fake implementations into a real bundle; a consumer opts into
343
+ fakes by importing the subpath explicitly, and a bundler that only ever sees `.` never sees them at all.
344
+
345
+ `directionalTransition` holds the timing, easing and opacity contract `PanelRowSlot` uses, not its position math. A live panel is
346
+ absolutely positioned (`index * (phoneWidth + GAP)`) and leaves for two reasons: a close slides down, and an auto-collapse
347
+ slides left to a fixed spot just past the row's left edge whatever the panel's own index. One generic `exit` would lose the
348
+ index-independent collapse target, so this is a preset, not a wrapper component. It returns `initial`/`animate` (`distance`
349
+ past rest, faded out), the shared `transition` and a symmetric `exit`; `PanelRowSlot`, its one caller, builds its own two exits
350
+ and takes only `transition`. A host that animates rows of its own builds them over the public `PANEL_TRANSITION`, the same object
351
+ as `SLOT_TRANSITION`.
352
+
353
+ ## `createDefaultTheme` carries the no-motion rule only, never the brand
354
+
355
+ The optional default theme ports phonux-app's own no-animation MUI settings (disabled
356
+ `transitions.create`, and a CssBaseline reset scoped to `transition`/`animation`, never
357
+ `transform`/`opacity`) because a consumer's phonux views run the same slide animations
358
+ that rule protects. It deliberately leaves out phonux-app's palette, typography and
359
+ per-component style overrides: those are this product's own branding, not something a
360
+ third-party consumer should inherit just by taking the default.
361
+
362
+ ## The hierarchy: `WorkspaceGrid` > `Workspace` > `PanelRow` > `Panel` > `View`
363
+
364
+ A `WorkspaceGrid` holds many `Workspace`s, a `Workspace` holds one or more `PanelRow`s, a `PanelRow` holds
365
+ `Panel` slots sized phone, tablet or desktop, and each `Panel` renders one `View` -- the component a
366
+ developer writes and registers. This folder owns `PanelRow`, the `Panel` shell and the `View` registry, plus
367
+ the host contract that connects them; the `Panel` shell and the `View` registry are the two levels a View
368
+ author actually touches.
369
+
370
+ `PanelRow` lives in this folder; `Workspace` is a phonux component too. This section names the split the `Workspace`
371
+ extraction moves code against, so that extraction is a rename-and-relocate pass, not a fresh design.
372
+
373
+ **What phonux owns: `PanelRow`.** `PanelRow` renders N animated slots from a plain entries array --
374
+ `PanelRow.tsx` (the composition) and `PanelRowSlot.tsx` (one slot's motion), over this folder's own animation
375
+ primitives -- with the row's order math (`panelRowOrder.ts`), tier widths (`panelTiers.ts`) and fit math
376
+ (`panelRowLayout.ts`) beside it. It does not consume `PanelSummary`: the row needs fields that decide overflow
377
+ and motion (id, view, collapsible, anchor, layer, anchoredEnter) that a View must never see, so it takes its
378
+ own row-only entry type, `PanelRowEntry`, rather than reusing the View-facing one. The host passes the row's
379
+ root entry as a required `rootId` prop with no default here, so no host's own panel id is baked into this
380
+ folder.
381
+
382
+ **What phonux owns: `Workspace`.** `Workspace` is the shell around the row: the scroll
383
+ container, the centered/animated-margin math `workspaceShellStyle` (in `Workspace.tsx`)
384
+ computes, and the `OverlaySlot` mounts. Like `PanelRow`, it does not consume `PanelSummary`.
385
+
386
+ **What phonux owns: the panel state machine.** The hooks that decide which panels exist and which are live sit in
387
+ `panels/` in this folder: `usePanelManager` (capacity, parking, reveal, reorder), `usePanelClosing` (close, the undo
388
+ window, Undo), `useRowScrollGesture` (the row's wheel and arrow-key gesture) and `useProvidePanels`, which composes
389
+ the other three. A host mounts `useProvidePanels` with its seed entries (`initialEntries`, `rootId`) and the row's
390
+ measured inputs, plus optional hooks: `onPanelsClosed`, `onPanelsRestored`, `resetHostDataOnRestore`,
391
+ `defaultHostData`, `capture` and `storage`. It gets back `ProvidedPanels`: `PanelsAPI` plus the row's animation sets,
392
+ `entries`, `fixedViewCount`, `liveWidths` and `maxLiveWidth`. The provider owns the list from the first render,
393
+ seeded once from `initialEntries`, so a host never holds a `setPanels`; it seeds its always-shown columns through
394
+ `initialEntries` too (the History column is one, and it is still host code). `entries` is the raw list with
395
+ `hostData` in it and stays off `PanelsAPI`, so a host hands its views only `PanelsAPI`'s own members. `hostData`
396
+ stays opaque: phonux never reads inside it, which is why the two places that would need its shape (a created panel,
397
+ a restored one) are host callbacks. Host callbacks must not throw: phonux does not guard them, and a throwing
398
+ callback leaves the operation half-applied. A throwing `onPanelsClosed` leaves the panel out of the list with no
399
+ Undo offered for it; a throwing `resetHostDataOnRestore` part-way through an undo restores the panels before it and
400
+ loses the rest.
401
+
402
+ **What stays host.** The window's own chrome (a draggable title-bar region and the clearance the platform's
403
+ window controls need) and every other window-chrome concern; the IPC calls a panel's own actions make; and
404
+ mounting real web content inside a resolved View. None of that is row layout, and none of it can move here
405
+ without this package depending on the host's runtime.
406
+
407
+ **The second row is one field, not a second shape.** An entry's `row: number` (default 0) groups the flat
408
+ panel array into one `PanelRow` per group, instead of restructuring the array into rows-as-list-of-lists. A
409
+ second row appears once the fit count -- the same pure function of container width and panel widths a single
410
+ row already uses -- says more panels fit than one row should hold; there is no fixed pixel threshold for when
411
+ a second row starts. One field keeps every function that already operates on the flat array (append, close,
412
+ restore, promote) working unchanged, filtered by row, instead of duplicated per row.
413
+
414
+ **Workspace state.** A Workspace's open panels and their order live in the plain `useState` the provider owns.
415
+ When a host passes `storage`, the provider restores that list once at mount and saves it after every change that
416
+ settles; with none, nothing is loaded and nothing is saved. The shape, the ownership split and the save/restore
417
+ timing are in "Workspace state persistence: shape, ownership and timing" below.
418
+
419
+ ## Naming rules R1-R4
420
+
421
+ - **R1. Name by the hierarchy.** A `Panel` slot's own parts are named `Panel*`, what a developer writes is
422
+ named `View*`, and the bag a `View` reaches the host through is named `Host*`.
423
+ - **R2. Phone-only things keep "Phone".** A name keeps "Phone" when the thing it names is phone-specific no
424
+ matter how big its panel gets -- the phone-model catalog, USB phone detection, the phone size -- so the
425
+ prefix still means something once panels stop being phone-shaped.
426
+ - **R3. Size-neutral names stay.** A name that already says nothing about phones is not renamed just because
427
+ its folder is; renaming a name that was never phone-specific would buy nothing.
428
+ - **R4. Every rename keeps the old name as an alias.** The old name is marked `@deprecated`, and wherever the
429
+ export is a value the alias is the same value as the new export, never a separate wrapper, so instanceof
430
+ and identity checks on the old name keep working after the rename.
431
+
432
+ ## The alias policy
433
+
434
+ A renamed export keeps its old name working as a same-value `@deprecated` alias, not a rewritten wrapper,
435
+ until every consumer that imports this package has moved off it. Whoever wires those consumers is the only
436
+ one positioned to know once nothing outside still resolves the old name, so removal is never scheduled here.
437
+
438
+ This is why a rename here is never a breaking change by itself: an existing import keeps compiling and keeps
439
+ returning the same value, only flagged deprecated, for as long as the alias stays in place.
440
+
441
+ ## Workspace state, not "session"
442
+
443
+ A `Workspace`'s own saved state -- which panels are open, their order, sizes and params -- is named
444
+ *workspace state* in this package, never "session". "Session" already names something else: a host's own
445
+ saved sessions, a separate record stored in a file
446
+ that must never change shape. The two are different data owned by different code, so reusing one word for
447
+ both would leave every future name ambiguous about which it meant.
448
+
449
+ ## Workspace state persistence: shape, ownership and timing
450
+
451
+ This section describes phonux's own half of workspace-state persistence: the saved `JsonValue` shape, the pure
452
+ project/hydrate functions, phonux's own storage contract, and the save/restore timing. It defines no
453
+ `WorkspaceGrid` mechanism -- one workspace, one row, a single `{version, panels}` envelope is enough, by design -- and it does not say where or how the data reaches disk; the host's own choices there are
454
+ out of scope (see "Ownership and scope" below).
455
+
456
+ ### The shape
457
+
458
+ Declared as `type`, never `interface`, for the same reason `viewRegistry.tsx`'s documented rule already gives:
459
+ an interface has no implicit index signature, so it silently fails to satisfy `JsonValue` the moment anything
460
+ typed as `JsonValue` touches it. For the same reason `anchor` is an inline object type, not `PanelAnchor`, which
461
+ is an interface.
462
+
463
+ ```ts
464
+ type WorkspaceState = {
465
+ version: 1;
466
+ panels: WorkspacePanelState[];
467
+ };
468
+
469
+ type WorkspacePanelState = {
470
+ id: string;
471
+ view: string;
472
+ url: string;
473
+ title?: string;
474
+ params?: JsonValue;
475
+ listed?: boolean;
476
+ live: boolean;
477
+ anchor: { panel: string; position: 'left' | 'right'; priority: number };
478
+ formFactor?: FormFactor;
479
+ permanent?: boolean;
480
+ layer?: 'base' | 'raised';
481
+ };
482
+ ```
483
+
484
+ `version` is a literal `1`, bumped only on a breaking shape change; an unrecognized value is handled in
485
+ "Versioning and failure" below. `panels` holds one entry per saveable panel, in row order; the host's own root
486
+ column (History today) is never one of them (see "Permanent columns" below). `id` is FILE-SCOPED ONLY: it
487
+ resolves another entry's `anchor.panel` within this same array during hydrate, and is always replaced with a
488
+ fresh runtime id on restore -- a saved id is never reused verbatim, since panel ids restart at 1 each launch.
489
+ `view` is the registry key; an unrecognized key renders the existing placeholder, the same as it does outside
490
+ restore, never dropped. `params` is opaque and view-supplied, carried verbatim and never interpreted, and the
491
+ guard does not look inside it. `listed` defaults to `true` when a panel isn't `permanent`, but `permanent:true`
492
+ forces `listed:false` regardless of any `listed` also present in the same saved entry -- the identical forcing
493
+ rule `appendPanel` already applies at create time, and hydrate applies it again rather than trusting a saved
494
+ `listed` value on a permanent entry. A saved entry with `permanent:true` and `listed:true` together is not a
495
+ shape-guard failure (both fields individually type-check); it is a combination `create()` itself can never
496
+ produce today, most likely from a hand-edited file, and hydrate resolves it silently by letting `permanent`
497
+ win, exactly as `appendPanel` would if both were passed to it. `live` is honored verbatim on restore, except on
498
+ a permanent column (see "Permanent columns" below). `anchor` composes through the existing anchor-fallback logic
499
+ when its `panel` is dangling or cyclic (see "How hydrate builds the list" below). `formFactor` defaults to
500
+ `'phone'`. `permanent` and `layer` are covered in "Permanent columns" and "What's saved, and what's never" below.
501
+
502
+ ### What's saved, and what's never
503
+
504
+ - **Saved.** `id` (file-scoped only, remapped on restore), `view`, `url`, `title`, `params`, `listed`, `live`,
505
+ `anchor`, `formFactor`, `permanent`, `layer`. `listed`, `layer` and `formFactor` are written whenever the panel
506
+ has them, `title` and `params` only when defined, and `permanent` only when true; no key is ever written as
507
+ `undefined`. A panel with no anchor of its own is saved with the one its position in the row implies.
508
+ - **Never saved -- `hostData`.** The host's own opaque per-panel data, which a host may use for its
509
+ runtime handles (a live web view's id, say). No runtime handle is ever saved, and phonux cannot tell a handle from any other
510
+ value inside the blob, so none of it is saved: `WorkspacePanelState` has no field for it. A restored panel
511
+ gets `defaultHostData()` like a created one, so a host that needs a fresh handle per panel builds it there;
512
+ with no `defaultHostData`, or when it returns `undefined`, the panel has no `hostData` key at all.
513
+ - **Never saved -- `size`.** Always recomputed from `formFactor`, never round-tripped.
514
+ - **Never saved -- `locked`.** Always `false` at create time for anything saveable, never independently set
515
+ on a saveable panel today.
516
+ - **Never saved -- `collapsible`.** Not an independent stored fact: it is `appendPanel`'s own `!info.permanent`
517
+ derivation, recomputed from the saved `permanent` field on restore rather than round-tripped.
518
+ - **Never saved -- `anchoredEnter`.** A creation-time enter-animation hint, defaulted on restore, not a fact
519
+ about the panel worth persisting.
520
+ - **Never saved -- History's own entry.** Host-supplied and reseeded fresh every launch (see "Permanent
521
+ columns" below).
522
+
523
+ ### Ownership and scope
524
+
525
+ phonux owns the `WorkspaceState`/`WorkspacePanelState` types, the guard `isWorkspaceState`, the pure
526
+ `projectWorkspaceState`/`hydrateWorkspaceState` function pair, the restore and save timing, and a small
527
+ `WorkspaceStorage` (load/save) contract phonux defines but never puts on the public `Host`/`PanelsAPI` bag. Only
528
+ the three types are public, exported from the package entry point so a host can implement the contract; the
529
+ guard and the pair live in `workspaceState.ts` and stay inside the package, because only `useProvidePanels`
530
+ (through its persistence hook, `panels/useWorkspacePersistence.ts`) and the tests call them. Keeping a capability
531
+ off the public bag has a real precedent already in this package:
532
+ `CaptureRegistryContext` keeps the write side of a picture out of `Host`/`SnapshotsAPI` entirely, as its own
533
+ second context that app code mounts AROUND `HostProvider` (see "Snapshots" below) -- `HostProvider` itself never
534
+ mounts `CaptureRegistryContext`. `HostProvider` does mount two contexts internally, for its own concerns kept off
535
+ the public bag: `OverlayProvider` and `ViewStateProvider` (see "useViewState" below). `WorkspaceStorage` follows
536
+ the same "keep it off the public bag" idea as all three, but not any of their context-based wiring: unlike
537
+ `CaptureRegistryContext` and `ViewStateProvider`, each reached by arbitrary descendant `Panel`/view instances
538
+ independently, `WorkspaceStorage` is consumed at exactly one call site -- inside `useProvidePanels`, by its
539
+ persistence hook (`panels/useWorkspacePersistence.ts`), the only thing that ever calls `load()`/`save()` -- so a
540
+ plain injected value on `useProvidePanels`'s own input, `PanelsInput.storage`, is enough and no context is
541
+ needed. That is the same single-call-site injection an
542
+ existing dependency already uses on that same input (`PanelsInput.capture`). The host owns the storage entirely:
543
+ the file's real name and on-disk location, the IPC channel names, and the write mechanics (including whether the
544
+ write is atomic) are the host's own choice, out of this design's scope. With no `storage`, nothing is loaded and
545
+ nothing is saved: no state is set and no promise or timer is created.
546
+
547
+ ```ts
548
+ declare function isWorkspaceState(value: unknown): value is WorkspaceState;
549
+ declare function projectWorkspaceState(panels: readonly PanelWindow[], rootId: string): WorkspaceState;
550
+ declare function hydrateWorkspaceState(state: WorkspaceState, freshId: () => string, rootId: string): PanelWindow[];
551
+
552
+ type WorkspaceStorage = {
553
+ load(): Promise<WorkspaceState | null>;
554
+ save(state: WorkspaceState): void;
555
+ };
556
+ ```
557
+
558
+ `projectWorkspaceState` and `hydrateWorkspaceState` are pure and phonux-owned, operating on `PanelWindow`, the
559
+ panel model in `panelWindow.ts`; `rootId` is threaded through like every function there, because which entry is
560
+ the root is the app's choice. `WorkspaceStorage` is phonux's own contract, not an implementation: the host
561
+ supplies a real `load`/`save` pair. `save` is fire-and-forget and must not throw: phonux does not guard it, the
562
+ same rule the provider's other host callbacks already state. The provider validates whatever `load()` resolves
563
+ with, so a host may hand it unvalidated JSON.
564
+
565
+ ### How hydrate builds the list
566
+
567
+ Hydrate draws a fresh id for EVERY saved entry before it does anything else, so a saved id that happens to
568
+ equal a fresh one is never reused. It then builds each entry through `appendPanel`, the one creation function,
569
+ so every default and every creation rule (`permanent` forcing `listed:false` and the `'phone'` tier, a tier's
570
+ size from the tier table) has no second copy, and overwrites `anchor` and `live` from the file. It copies only
571
+ the keys it knows, which is what makes a later optional field additive (see "Versioning and failure" below).
572
+
573
+ An anchor's target is mapped as follows: the root stays the root; a saved id maps to the fresh id of that entry;
574
+ anything else (an entry closed before the file was written, or a hand-edited id) maps to ONE id drawn lazily
575
+ after the entries, which no panel holds. A saved id kept as it stood could equal a fresh one -- both count up
576
+ from 1 each launch -- and would attach the panel to a stranger; with the unheld id, `computeRowOrder`'s own
577
+ fallback for a missing target applies (the root's right side, the priority kept), the same one a self-anchor or a
578
+ cycle gets. Hydrate then runs ONE `computeRowOrder` over the whole set: placing the entries one at a time would
579
+ rewrite an anchor that names a later entry.
580
+
581
+ ### Save timing
582
+
583
+ Once the initial restore attempt has settled, phonux computes `projectWorkspaceState` on every committed panels
584
+ change and compares its JSON with the last one saved (or, before any save, with the restore's baseline, see
585
+ below); an equal projection is skipped. Otherwise it schedules `save()` 500 ms later (`WORKSPACE_SAVE_QUIET_MS`)
586
+ and every further change restarts that wait, so a drag is one save and a change that puts the row back to what
587
+ was saved saves nothing. The baseline advances when the timer fires.
588
+
589
+ The gate is React state, not a ref: opening it must re-run the save effect, or a change committed while `load()`
590
+ was pending and then found nothing to restore would never be saved. The hazard the gate exists for is real:
591
+ A host's synchronous seed initializer runs before any async `load()` can possibly resolve, so an
592
+ unconditional save fired before the gate would let that transient boot state clobber a real saved file.
593
+
594
+ There is no flush when the window closes or the provider unmounts: a change made in the last 500 ms before a
595
+ quit is lost. A lost save is the accepted cost; the host's own writing decides whether a file can be torn.
596
+
597
+ ### Restore timing and the storage-hook contract
598
+
599
+ `load()` is necessarily async, so first paint is always the app's existing default (the synchronous seed), and
600
+ the restored list applies via one later state update once `load()` resolves -- never inside a synchronous
601
+ initializer. `WorkspaceStorage.load(): Promise<WorkspaceState | null>` is called once per mount, from a mount
602
+ effect (StrictMode runs that effect twice in development, so `load()` is then called twice and only the call
603
+ that was not cancelled applies). `null`, a rejection, and a `load()` that throws before it returns a promise all
604
+ mean nothing saved, silently. A result that fails `isWorkspaceState`, or names a version this build does not
605
+ recognize, also means nothing saved, and logs one console warning.
606
+
607
+ Fresh ids and `defaultHostData` are drawn in the promise callback, never inside a state updater, because React
608
+ may run an updater twice; the fresh ids come from the same counter `create()` uses, so a restored id never
609
+ equals a created one. The restored panels are merged with `computeRowOrder([...current, ...restored])` and
610
+ skipped when there are none, so a panel created while `load()` was pending survives the restore. A priority tie
611
+ follows `computeRowOrder`'s own rule: the entry later in the array is nearer on the left of the root and the
612
+ earlier one is nearer on the right. Restored panels come after the ones already there, so on the right of History
613
+ the panel that was already in the row is nearer, and on the left the restored one is.
614
+
615
+ The baseline for the save comparison is the JSON of the projection of the list just hydrated, not of the file:
616
+ the file's ids differ from the fresh ones whenever a panel was closed in an earlier session, and project writes
617
+ runtime ids, so comparing against the file would save at every launch. When nothing was restored the baseline is
618
+ the empty projection, so opening the app saves nothing while a panel created earlier than the restore is saved.
619
+ `WorkspaceStorage.save(state): void` is called only from that debounced, settled-gated path, never from any other
620
+ call site. The hook reads `storage`, `rootId`, `freshId` and `defaultHostData` at mount only.
621
+
622
+ ### Restore behavior
623
+
624
+ Restore honors each panel's saved `live` flag verbatim, unconditionally and with no prompt, except that a
625
+ permanent column is always live: a panel that was showing a page reloads it, a parked panel stays parked. Capacity
626
+ still applies after a bulk restore exactly as it does for any width change: `usePanelManager`'s
627
+ capacity-reconcile effect parks live overflow on the current panels array regardless of what produced that
628
+ array, so a restore that brings back more live panels than the current width holds gets parked the same way an
629
+ ordinary resize does, with no separate fit-checking logic needed in hydrate. The parked flags are then saved like
630
+ any other change.
631
+
632
+ ### Versioning and failure
633
+
634
+ - **Missing.** A missing file -- no saved data at all -- is treated the same as first launch: no saved state, nothing restored.
635
+ - **Corrupt.** A corrupt file -- unparsable JSON, or content that fails the shape guard -- likewise yields no saved state, logged once and otherwise silent.
636
+ - **Version.** An unrecognized `version` yields no saved state and no migration path: hydrate never guesses at an old shape, and the next save overwrites that file.
637
+ - **Extension rule.** `version` stays `1` while the format only grows. A later optional field is additive: `isWorkspaceState` ignores keys it does not know and hydrate copies only the keys it knows. Only a breaking change bumps `version`, and a build that then meets a version it does not know sees no saved state.
638
+ - **Not a new failure case: anchors and view keys.** A dangling or cyclic `anchor.panel` and an unregistered `view` key are not new failure cases: hydrate composes through the existing anchor-fallback and view-placeholder mechanisms already used at runtime, the same ones either can hit outside restore.
639
+ - **Not a new failure case: formFactor fit.** A restored panel whose `formFactor` no longer fits the live window width is likewise not a new failure case needing its own fit-checking: `usePanelManager`'s capacity-reconcile effect already fires on any panels-array change, including a bulk restore, and parks the overflow the same way it parks an ordinary width overflow.
640
+
641
+ A panel closed inside the undo window does not survive a restart or quit, by design: `usePanelClosing`'s
642
+ `close()` removes the panel from the live panels array synchronously, and the separate closed/undo state that
643
+ keeps it available for the undo window is never saved.
644
+
645
+ ### Permanent columns
646
+
647
+ A view-created permanent column is saved with `permanent: true` and restored as permanent, and only the host's
648
+ root column (History today) is never saved, because the host reseeds it fresh every launch. A permanent column is
649
+ always live on restore: as for `listed`, `permanent` wins, because an always-shown column is never parked, so one
650
+ saved as parked would never render and could never be revealed. No app code creates a permanent column at mount today, so
651
+ a view-created one would otherwise vanish on restart, against the rule that every saved panel comes back; if
652
+ a host later creates a permanent column at mount itself, it must not also be restored from saved state, a case to
653
+ revisit then.
654
+
655
+ `PanelWindow` has no `permanent` field to read, only `collapsible` (set once at creation from `appendPanel`'s own
656
+ `!info.permanent` derivation and never itself retained as `permanent`), so `projectWorkspaceState` derives the
657
+ saved value as `!collapsible`, excluding the host's own root column, which is never saved at all regardless of
658
+ its `collapsible` value.
659
+
660
+ ### Known limitations
661
+
662
+ - **Panels parked by capacity do not come back by themselves.** A panel parked because the window was too narrow
663
+ is saved parked. After a restart it stays parked when the window is later widened, because the stack that
664
+ reveals the most recently parked panel is not saved.
665
+ - **A panel restored parked has no picture in History** until it has been live once this session. phonux takes a
666
+ picture only when a panel goes from live to parked, and pictures are session memory. `Host.snapshots` is
667
+ optional, so this shows as no picture, never as a failure.
668
+ - **A restored title can repeat.** A restored panel keeps its saved title, so a later default `Panel N` title,
669
+ numbered from this launch's counter, can repeat it.
670
+ - **Restored panels enter with the ordinary animation,** because `anchoredEnter` is not saved.
671
+ - **`params` freezes at creation.** There is no way to update a panel's `params` after it is created, so
672
+ persistence saves a view's resumable content as it was when the panel was created.
673
+
674
+ ## The header-to-frame gap is one constant, and the grab bar fills exactly it
675
+
676
+ `PANEL_HEADER_GAP_PX` (`PanelFrame.tsx`) is the one number both `PanelHeader`'s `bottom` offset and
677
+ `Panel`'s grab-bar element measure themselves against -- the same reason `PANEL_CORNER_RADIUS_UNITS` is a
678
+ named export rather than a literal inlined twice: two independently-tuned numbers drift out of sync the
679
+ moment either changes on its own, and here the drift would show up as a visible seam or overlap between the
680
+ header pill and the grab bar beneath it.
681
+
682
+ `Panel`'s `grabProps` is a separate prop from `headerProps`, not a field folded into it, because the header
683
+ and the grab bar are two different elements: the header keeps only its title/actions and cosmetic `sx`,
684
+ while `grabProps`'s `onPointerDown`/`sx` reach the grab bar instead, so clicking the title text or an action
685
+ icon is never mistaken for a drag start. Omitting `grabProps` renders no grab bar at all, which is the
686
+ correct shape for a locked view (nothing to drag).
687
+
688
+ ## `DragToClose`: drag down to close, optionally sideways to reorder
689
+
690
+ `DragToClose` (`dragToClose.tsx`) knows nothing about panels or the app, only "the user dragged this box down
691
+ far enough, call `onClose`". Its render prop hands out a handle for `Panel`'s `grabProps`:
692
+
693
+ ```tsx
694
+ <DragToClose onClose={() => panels.close(id)}>
695
+ {(handle) => <Panel title="Mine" grabProps={{ ...handle }}>...</Panel>}
696
+ </DragToClose>
697
+ ```
698
+
699
+ The handle is the grab bar and nothing else, so the body keeps its own scrolling and clicks. The box follows
700
+ the pointer vertically and only downward. On release `shouldCloseOnRelease` decides: close (the box leaves
701
+ downward, then the latest `onClose` runs) or snap back on a spring. Motion's drag machinery does the pointer
702
+ work, with its events on `window`, so a drag survives leaving the handle.
703
+
704
+ **Coexistence.** `touch-action` is `pan-x` on the handle: a horizontal swipe still scrolls the row, a vertical
705
+ one is the gesture's. A press on a button, link or field is left alone, and only the primary button grabs.
706
+ While a drag is live every vertically scrolling ancestor is locked (`overflow-y: hidden`, because a
707
+ transformed box would add a scrollbar). The lock ends with the spring back (or a press that stops it) and
708
+ when the box unmounts.
709
+
710
+ **A close is final for the box.** A close never unlocks (the box is parked below the fold), so `onClose` must
711
+ remove the view. Once a release has decided to close, the box takes no more presses: one would stop the exit
712
+ and the close would never run. If the caller brings the box back while `AnimatePresence` still holds it (the
713
+ same key returns, as on an undo), it becomes a fresh box at once: at rest, unlocked, taking presses, its
714
+ pending exit stopped.
715
+
716
+ **Reduced motion.** No exit animation and no spring: a far drag calls `onClose` at once, and a short one (or a
717
+ horizontal reorder release) puts the box back at once.
718
+
719
+ **Reorder on the same handle.** With `reorder` set, the handle also drags horizontally. Both axes stay enabled
720
+ for the whole gesture (`drag={true}`, never toggled: a ref-driven axis prop never re-renders, so Motion would
721
+ keep reading the value from before the gesture began). The first `DIRECTION_LOCK_PX` of movement decides
722
+ which axis wins, and the losing axis's own MotionValue is zeroed on every tick after that. A gesture locked
723
+ horizontal never evaluates `shouldCloseOnRelease`, so residual vertical jitter can never close the view; one
724
+ locked vertical, or one that never locks (a sub-threshold jiggle), runs the close logic unchanged. Without
725
+ `reorder`, for a handle that only closes (History's bar, never a reorder source), `drag` stays `'y'`.
726
+
727
+ **Alternatives to the gesture.** WCAG 2.5.7 asks for a single-pointer alternative: offer a button that calls
728
+ the same `onClose`, as History's trash buttons do. The row's drag-to-reorder needs one too; `PanelsAPI.move`
729
+ exists for it, but no such control is built.
730
+
731
+ ## PanelFrame's inset, scrollbar and header pill
732
+
733
+ Every view renders inside `PanelFrame` so each section reads as one phone silhouette; a view that grew its
734
+ own border/radius/background declarations would drift from the others. The frame is the phone's size unless
735
+ `size` or `expanded` says otherwise: a slot never grows to fill space.
736
+
737
+ **Equal inset with or without a scrollbar.** Content sits `CONTENT_INSET_PX` (12px) from the border on all
738
+ four sides whether or not a scrollbar is painted, with nothing measured at runtime: `scrollbar-gutter:
739
+ stable` reserves the bar's strip permanently and the right padding is pre-shrunk by that strip
740
+ (`SCROLLBAR_PX`, 8px), so the right side is (12 - 8) + 8 = 12px in both states, the bar flush against the
741
+ border.
742
+
743
+ **Why a classic scrollbar.** Chromium's default here is an overlay scrollbar: it floats over content,
744
+ reserves no layout width (`offsetWidth === clientWidth` on a scroller with real overflowing content) and
745
+ draws with its own built-in inset from the scroller's edge, which no padding or box-model change on this
746
+ side can remove. Splitting the frame into an outer box (border, radius, size, `overflow: hidden` clipping, no
747
+ padding) and an inner scroll container (the padding, `flex: 1`, `minHeight: 0`) is therefore not enough on
748
+ its own. The explicit `::-webkit-scrollbar` rules force a classic scrollbar, which reserves real width
749
+ (`offsetWidth - clientWidth` equals the declared `width`) and sits flush against the edge like a web page's
750
+ own scrollbar. Those rules style only the element they are set on, so a nested scroller needs its own copy
751
+ or it falls back to Chromium's default-width bar and breaks the arithmetic above.
752
+
753
+ **Never reserved twice.** A caller that scrolls one level deeper (an embedded web view, a transcript under a pinned
754
+ input bar) spreads `panelFrameBareSx`, which cancels both the padding and the gutter. Keeping either leaves a
755
+ phantom gap outside the inner scrollbar and an uneven inset (28px on the right against 12px on the left).
756
+ Its properties are longhands because MUI resolves `p`/`pr` and `overflow`/`overflowY` independently, so a
757
+ shorthand spread over a longhand does not reliably win.
758
+
759
+ **border-box.** Row-layout width math treats `device.size` as the frame's real rendered size. Under
760
+ content-box the border and padding land on top of it: a 390px phone rendered 416px wide by
761
+ `getBoundingClientRect()`, about 26px of silent under-fit per phone.
762
+
763
+ **The header pill.** `PanelHeader` floats above the frame (it needs a `position: relative` parent) and bakes
764
+ in one look: Paper `variant="outlined"`'s bgcolor and border, shaped as a pill; a radius
765
+ equal to the phone's own corner radius and a height of twice it, so each end is an exact semicircle with the
766
+ phone's curvature, both from the theme rather than a raw 24/48; and a fixed height, so wrapping content never
767
+ changes the shape. Its horizontal padding equals the frame's content inset so header text lines up with the
768
+ frame's own content. Internal layout is left to callers (`sx`), since one centers an ellipsis title and
769
+ another spaces out an icon row. `PanelHeaderRow` is the shared title-left/actions-right layout, so call sites
770
+ stop re-implementing flex/ellipsis markup, and it substitutes placeholders for a falsy `title`/`actions` (a
771
+ dimmed "Untitled", a dimmed "more" glyph) so the pill never blanks or changes shape. Its emptiness check
772
+ mirrors JSX's own falsy-child rules, so `0` or whitespace still counts as content.
773
+
774
+ ## `WindowOverlay`: window-level content with no Portal
775
+
776
+ A view (the app's own or a third party's) shows something over the whole window -- a dialog, a full-screen
777
+ viewer, a toast -- by rendering `WindowOverlay` anywhere in its tree, with no host wiring:
778
+
779
+ ```tsx
780
+ function NotesView() {
781
+ const [open, setOpen] = useState(false);
782
+ return (
783
+ <Panel title="Notes">
784
+ <button onClick={() => setOpen(true)}>About</button>
785
+ <WindowOverlay open={open}><AboutSheet onClose={() => setOpen(false)} /></WindowOverlay>
786
+ </Panel>
787
+ );
788
+ }
789
+ ```
790
+
791
+ `WindowOverlay` registers its children with a store (from a layout effect, never during render) and renders
792
+ null; the host mounts two `<OverlaySlot>`s once, and each renders what was registered for it, as plain
793
+ fragments with no wrapper element. Hoisting matters because a view's own box is the wrong place: History's
794
+ column is a `zIndex: 2` stacking context (capped under the 1400 drag strip), and every live panel sits inside
795
+ a transformed slot, which re-anchors `position: fixed` to itself.
796
+
797
+ The layer adds no wrapper, so it cannot enforce the contract the CONTENT must keep:
798
+
799
+ 1. Its root element is `position: fixed` and sets its own zIndex from `OVERLAY_Z`. A non-fixed root would
800
+ become a flex item of the row and mis-centre it.
801
+ 2. It renders where the SLOT is. Context (theme, `useHost()`) comes from the slot's position; React events
802
+ bubble through the slot's ancestors, not the view's; a provider the view installs itself is not visible
803
+ to it.
804
+ 3. `open={false}` removes the content at once. Content that must animate out (MUI Snackbar) stays registered
805
+ and takes its own `open`.
806
+ 4. Content that stays registered while idle should be a MODULE-LEVEL constant element that reads what it
807
+ needs through `useHost()`: an element built inline is a new node every render and costs one store
808
+ notification per view render.
809
+ 5. Never key an effect inside overlay content on a `useHost()` member: the provider value is rebuilt every
810
+ render on purpose, so such an effect re-fires on every host render. Use `[]` for "once per open".
811
+ 6. Stacking inside a slot is open order (later on top at equal z-index). Two slots never mix.
812
+ 7. Lifetime is the registering view's: unmounting the view removes its overlays.
813
+ 8. With no provider, or under `renderToStaticMarkup` (effects do not run), the children render IN PLACE.
814
+ That is what makes a view testable without a host; where an overlay LANDS is only ever asserted in a
815
+ mounted test.
816
+ 9. Under a `HostProvider` with NO slot mounted (a test that wraps a view in the provider with the fakes), the
817
+ overlay registers with nobody to show it: it never appears, and one `console.error` per mount names the
818
+ missing slot. Such a test mounts `<OverlaySlot name="dialog" />` and `<OverlaySlot name="toast" />` beside
819
+ the view, as the host does.
820
+
821
+ `WindowOverlay` is not `Panel`'s `overlay` prop, which decorates ONE view inside its own relative box
822
+ (History's "+" Fab) and is meant to be clipped to it.
823
+
824
+ ## Snapshots: a picture is data, capturing one is not
825
+
826
+ `snapshots.ts` splits a view's picture into two surfaces on purpose. `ViewId` (a panel's id, or a fixed
827
+ `'settings'`/`'history'`), `ViewSnapshot` (`src`/`width`/`height`/`extent`/`capturedAt`/`stale`) and
828
+ `SnapshotsAPI` (`get`/`subscribe`) are plain data plus a read/subscribe contract -- `Host.snapshots?:
829
+ SnapshotsAPI` (optional: a host mid-rollout, or one that never wires this, degrades to "no picture" rather
830
+ than being forced into a no-op implementation) and `useViewSnapshot(viewId)` (a `useSyncExternalStore` wrapper)
831
+ are the only way a view ever touches a picture. Neither this file nor any view ever decides WHEN a capture
832
+ happens or HOW pixels are produced -- that is a second, independent surface:
833
+
834
+ `CaptureRegistry`/`CaptureRegistryContext`/`CaptureRegistryProvider` let `Panel` (`Panel.tsx`) hand its own
835
+ root DOM node to whatever real registry the host mounts, without that capability ever becoming part of
836
+ `Host`/`SnapshotsAPI`. This is the same pattern `HostProvider` already uses internally for `OverlayProvider`
837
+ (`hostApi.tsx`: `<HostContext.Provider value={host}><OverlayProvider>{children}</OverlayProvider></HostContext.Provider>`)
838
+ -- a second context, mounted alongside the first, for a concern that is not part of the public host bag. A
839
+ view rendered under a bare `<HostProvider>` (the common case in a test) sees no `CaptureRegistryContext`
840
+ provider at all, so `Panel`'s registration effect is a no-op and nothing here ever throws for a missing
841
+ registry, the same "missing capability degrades gracefully" convention `Host.snapshots` itself uses.
842
+
843
+ The two contexts nest in opposite directions. `HostProvider` mounts no `CaptureRegistryProvider`, so the
844
+ real one may wrap it; but it always mounts its own `OverlayProvider`, so a caller's
845
+ `<OverlayProvider store={...}>` (a test reading `ids()`/`notifications`) must sit INSIDE `<HostProvider>`,
846
+ around the `OverlaySlot`s and the views. Placed as `HostProvider`'s ancestor instead, it is shadowed by the
847
+ nearer built-in provider and silently receives nothing, with no error.
848
+
849
+ Only a host (phonux-app's capture controller, for one; never this package -- see "Import boundary" above) implements a
850
+ real `CaptureRegistry`, the DOM-redraw pixel engine, and the `SnapshotsAPI` store those pixels land in, and
851
+ only it renders `<CaptureRegistryProvider registry={realRegistry}>`, wrapping `<HostProvider>`. `PanelSummary`
852
+ never gains a `snapshot` field for the same reason `PanelWindow`'s host-only fields never leak into it
853
+ (see "A resolved panel component only ever sees a `PanelSummary`" above): a picture is read on demand through
854
+ `useViewSnapshot`, not pushed into every row's own summary object.
855
+
856
+ `CaptureTrigger` -- what `useProvidePanels` calls at a park or close to (re)capture a view's picture or to drop
857
+ it -- is declared in `snapshots.ts`, beside `CaptureRegistry`. phonux only says WHEN. A provider with no `capture`
858
+ input uses `noopCaptureTrigger`, so phonux never captures by itself; a host that wants real pictures passes its own
859
+ trigger. The two eviction paths differ in timing: `clearAll` evicts every dropped panel's picture at once, because
860
+ a dropped panel can never be shown again, while a closed panel's picture waits for its undo window (Undo keeps it;
861
+ the window's natural elapse evicts it). Known limit: a capture already in flight can still store its picture after
862
+ an evict, because the store has no per-id generation, so a panel parked and then cleared within one engine call can
863
+ end up with a picture nothing evicts.
864
+
865
+ ## `useViewState`: per-view state that outlives a park, not a restart
866
+
867
+ `viewState.ts` gives a view a `[value, setValue]` pair -- the same tuple shape `React.useState` returns --
868
+ that survives its OWN panel being parked (`live: false`; `PanelRow.tsx`'s `present` filter fully unmounts a
869
+ parked entry) and revived. The store is a plain `Map<panelId, Map<key, cell>>` outside React state, copying
870
+ `windowOverlay.tsx`'s `OverlayStore` shape (a private `Map` plus per-cell subscribers via
871
+ `useSyncExternalStore`), mounted once by `HostProvider` as `ViewStateProvider`, alongside `OverlayProvider`,
872
+ the same way `HostProvider` already mounts a second context for a concern that is not part of the public host
873
+ bag. Close then `undoClose` inside the undo window keeps the value too: both keep the SAME panel id
874
+ (`panelWindow.ts`'s close/restore, `usePanelClosing.ts`'s `undoClose`), and the id stays in
875
+ `PanelsAPI.recentlyClosed` until then, so its cells are still held when the view remounts. It does not
876
+ survive an app restart: a pure in-memory store, no desktop bridge, no local storage. Surviving a park is the
877
+ whole contract; surviving a restart is not part of it.
878
+
879
+ A panel's state is released once nothing can show that panel again, as `OverlayStore.remove` releases an
880
+ overlay. Panel ids never repeat within a session, so that is when the id is in neither the row's own list nor
881
+ `PanelsAPI.recentlyClosed`: when its undo window elapses, or at once on `clearAll`. Without this, every panel
882
+ closed during a long-running session would keep its state until the app quits. `PanelRow` reports the ids
883
+ it can still show by calling `useViewState.useRetainOnly(ids)` on every render. The release runs one task
884
+ after that render's commit, and any newer commit cancels it. That delay is load-bearing: when restoring a
885
+ live panel parks another one to its right, `undoClose` commits once with `recentlyClosed` already cleared
886
+ and the restored id not yet back in the row. A view still mounted when its panel is released, such as one
887
+ mid exit animation after `clearAll`, keeps showing its value, and its cells go when it unmounts. Nothing is
888
+ stored for a key until its first `setValue`, so until then each mount reads its own `initial`.
889
+
890
+ `useViewState(key, initial)` takes no id argument at all: a view never wires its own identity. The id is
891
+ ambient, read from a context set exactly once, at the ONE place a resolved View is actually rendered
892
+ (`PanelRow.tsx`'s `<Component panel={renderedSummary(p, widths[index]!)} />`) -- never by the view itself. That setter is
893
+ exposed as `useViewState.Provider`, a property of the hook function, rather than a second value on the phonux
894
+ barrel, and `useViewState.useRetainOnly` travels the same way: the barrel's public surface stays the two names
895
+ a view genuinely depends on (`useViewState`, `ViewStateMissingProviderError`); the ambient id and the release
896
+ are row-render-loop wiring a view never touches directly. Their one caller, `PanelRow.tsx`, sits inside this
897
+ folder; anything outside it reaches this folder only through a bare `'phonux'` import (phonux-app's boundary
898
+ tests check this), so both travel through the SAME public entry point `useViewState` already does, not a separate one.
899
+
900
+ Types read exactly as `React.useState`'s do. With no type argument, `useViewState('count', 0)` infers
901
+ `number`, an object literal's `true` infers `boolean`, and a value already typed `'idle' | 'loading'` keeps that
902
+ union. An explicit type argument is kept as given, so `useViewState<'idle' | 'loading' | 'done'>('status',
903
+ 'idle')` rejects `setStatus('nonsense')`. As with `useState`, a type argument is needed only to name a type
904
+ wider than the initial value, e.g. `useViewState<number | null>('id', null)`. `setValue` keeps one identity
905
+ for the component's lifetime, as `useState`'s setter does, even when `initial` is a fresh object each render.
906
+
907
+ The JSON rule cannot be a constraint on the hook's type parameter. TypeScript keeps a literal as a literal
908
+ (`0`, not `number`) for any type parameter whose constraint includes primitives, and `JsonValue` does. Instead,
909
+ the type parameter stays unconstrained, which is the same inference `useState<S>` gets, and the first overload
910
+ checks `[T] extends [JsonValue]` through `key`'s parameter type. A second overload, `T extends JsonValue`,
911
+ serves a caller that is itself generic over `JsonValue`, where that check cannot resolve. A non-JSON `initial`
912
+ fails both overloads. `viewState.typecheck.ts` compares these types against `useState` directly and pins the
913
+ literal-union case.
914
+
915
+ ## The API reports in `etc/`
916
+
917
+ `etc/phonux.api.md`, `etc/phonux-testing.api.md`, `etc/phonux-internal.api.md` and
918
+ `etc/phonux-panelModel.api.md` are API Extractor's reading of this package's surface, one per `exports` key in
919
+ `package.json` (`.`, `./testing`, `./internal/directionalTransition`, `./internal/panelModel`). They are
920
+ committed so that a change to what a consumer can import shows up as a diff in review. Only
921
+ `npm run api-report:update` writes them. `npm run check:api-report` regenerates each one in a temp folder and
922
+ fails if it differs from the committed copy, so a hand edit fails the next check.
923
+
924
+ There are four `api-extractor*.json` configs, the fourth being `api-extractor.panelModel.json` for
925
+ `./internal/panelModel`, because API Extractor analyses one entry point per run. The
926
+ report script checks the `exports` map against the configs key by key, so an entry point with no config, or
927
+ two configs pointed at each other's entry, fails before anything runs. The declarations it reads are emitted
928
+ into `api-extractor-temp/` at the repository root (git-ignored), so they never reach `npm run typecheck` or the published package.
929
+
930
+ `./internal/directionalTransition` is tracked and tagged `@internal`, not excluded: it is the entry point for
931
+ its own internal API report (`PanelRowSlot.tsx` and the package's own tests import the module by relative path), so it is a
932
+ real entry point whose changes deserve the same diff. API Extractor wants every `@internal` name to start
933
+ with an underscore, but these names are already imported under their current spelling, so
934
+ `ae-internal-missing-underscore` is switched off in `api-extractor.internal.json` only, instead of renaming them. The switch covers that whole config, not one name, so a future `@internal` export
935
+ without an underscore passes silently there too. `.` and `./testing` still enforce the rule: nothing they
936
+ reach is `@internal`, and an underscore-less `@internal` tag on one of their exports fails the check.
937
+
938
+ `./internal/panelModel` is the same kind of entry point: `panelWindow.ts` (the `PanelWindow` record and its pure
939
+ transitions, plus `effectiveMaxPanels` re-exported from `panelCapacity.ts` so one config covers both),
940
+ which the package's own tests import by relative path. It is tracked and tagged `@internal`, and `api-extractor.panelModel.json` carries its own
941
+ `ae-internal-missing-underscore` switch-off, because a config does not inherit another's, so the caveat above
942
+ holds for it too. `panelWindow.ts` also re-exports `FormFactor`, `JsonValue`, `PanelSize`, `PanelAnchor` and
943
+ `NeighborWidths`, which its signatures name, so that the report has no `ae-forgotten-export`.
944
+
945
+ Neither `@internal` subpath ships. `npm run build` skips every key in `INTERNAL_ONLY_EXPORT_KEYS`
946
+ (`tools/phonux-build.mjs`), which holds `./internal/directionalTransition` and `./internal/panelModel`, so
947
+ `dist/package.json`'s `exports` lists only `.` and `./testing`: an outside consumer reaches the package only through `.` and `./testing`; the two keys exist only as the entry points for the two internal API reports.
948
+
949
+ Every declaration an entry point reaches carries exactly one release tag in its own doc comment: `@public`
950
+ for `.` and `./testing`, `@internal` for `./internal/directionalTransition` and for what `./internal/panelModel`
951
+ declares itself, plus `effectiveMaxPanels`, which is `@internal` in `panelCapacity.ts` (the five types it
952
+ re-exports keep their own `@public`, so that report lists them as `@public`). A deprecated alias keeps
953
+ `@deprecated` beside its tag. A tag above a re-export line (`export { X } from ...`) does nothing, because API
954
+ Extractor reads only the declaration's own comment.
955
+ `ViewStateProvider` and `PANEL_HEADER_GAP_PX` are exported for this package's own files and tests but reached
956
+ by no entry point, and are tagged `@internal` to say so.
957
+
958
+ A public signature pins every type it names. Each one must be exported by the same entry point (otherwise
959
+ `ae-forgotten-export`), which is why `fakeHost.ts` re-exports `Host`'s whole type closure and
960
+ `windowOverlay.tsx` exports `Item` and `Entry` for `OverlayStore`. Each one must also be at least as public as
961
+ the signature (otherwise `ae-incompatible-release-tags`), which is why `ReorderHandle`, named by
962
+ `DragToClose`'s public `reorder` prop, is `@public` rather than `@internal`. It is a type, so it never joins
963
+ the runtime values the barrel test pins.
964
+
965
+ The script fails on warnings written into the reports, not only on API Extractor's exit code or stdout.
966
+ Several messages, `ae-forgotten-export` among them, go into the report file instead of the console, some only
967
+ into its trailing "Warnings were encountered" footer. Once such a report is committed, the next check
968
+ regenerates the same text, matches it, and API Extractor exits 0. So the script also scans each committed
969
+ report for an embedded warning or message id, and fails on any.
970
+
971
+ ## History, releases and development
972
+
973
+ The library began as a folder inside phonux-app and was extracted with `git filter-repo`, renaming every path the folder ever had,
974
+ so `git log --follow` on a source file runs back through its years inside the app. The first entry of `CHANGELOG.md` names the
975
+ phonux-app commit the extraction was taken at.
976
+
977
+ A release is `npm run release:check`, then the maintainer's own `npm publish` from `dist/`. Nothing in this repository publishes,
978
+ and a published version is never edited: a fix is a new version, and `CHANGELOG.md` says what changed. `.github/workflows/ci.yml`
979
+ runs the typecheck, the tests, the build, the API report check and the smoke on every push and pull request.
980
+
981
+ phonux-app installs the published package from the registry. To try a change there before publishing, install a copy of this
982
+ checkout over it: `npm install --no-save --install-links <path to this checkout>` from the app leaves the app's `package.json`
983
+ and lockfile untouched, and `npm ci` restores the registry copy. Never `npm link` or a `file:` directory spec: a symlink resolves
984
+ this checkout's own `node_modules`, which can give the app a second copy of React. The copy is source (`.ts`); to rehearse exactly
985
+ what will be published, run `npm run build`, pack `dist/` and install that tarball instead.