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.
- package/DESIGN.md +985 -0
- package/LICENSE +21 -0
- package/Panel.d.ts +76 -0
- package/Panel.js +45 -0
- package/PanelFrame.d.ts +154 -0
- package/PanelFrame.js +160 -0
- package/PanelRow.d.ts +46 -0
- package/PanelRow.js +98 -0
- package/PanelRowSlot.d.ts +74 -0
- package/PanelRowSlot.js +68 -0
- package/PhoneDetectPrompt.d.ts +18 -0
- package/PhoneDetectPrompt.js +52 -0
- package/README.md +86 -0
- package/Workspace.d.ts +65 -0
- package/Workspace.js +47 -0
- package/defaultTheme.d.ts +10 -0
- package/defaultTheme.js +26 -0
- package/directionalTransition.d.ts +33 -0
- package/directionalTransition.js +24 -0
- package/dragToClose.d.ts +60 -0
- package/dragToClose.js +151 -0
- package/fakeHost.d.ts +33 -0
- package/fakeHost.js +85 -0
- package/hostApi.d.ts +247 -0
- package/hostApi.js +54 -0
- package/index.d.ts +46 -0
- package/index.js +30 -0
- package/package.json +30 -0
- package/panelCapacity.d.ts +15 -0
- package/panelCapacity.js +19 -0
- package/panelRowEntry.d.ts +37 -0
- package/panelRowEntry.js +11 -0
- package/panelRowLayout.d.ts +57 -0
- package/panelRowLayout.js +52 -0
- package/panelRowOrder.d.ts +46 -0
- package/panelRowOrder.js +105 -0
- package/panelTiers.d.ts +24 -0
- package/panelTiers.js +29 -0
- package/panelWindow.d.ts +170 -0
- package/panelWindow.js +243 -0
- package/panels/usePanelClosing.d.ts +58 -0
- package/panels/usePanelClosing.js +140 -0
- package/panels/usePanelManager.d.ts +74 -0
- package/panels/usePanelManager.js +403 -0
- package/panels/useProvidePanels.d.ts +82 -0
- package/panels/useProvidePanels.js +142 -0
- package/panels/useRowScrollGesture.d.ts +2 -0
- package/panels/useRowScrollGesture.js +70 -0
- package/panels/useWorkspacePersistence.d.ts +14 -0
- package/panels/useWorkspacePersistence.js +74 -0
- package/phoneModels.d.ts +26 -0
- package/phoneModels.js +43 -0
- package/snapshots.d.ts +58 -0
- package/snapshots.js +22 -0
- package/viewRegistry.d.ts +23 -0
- package/viewRegistry.js +30 -0
- package/viewState.d.ts +32 -0
- package/viewState.js +135 -0
- package/windowOverlay.d.ts +87 -0
- package/windowOverlay.js +137 -0
- package/workspaceState.d.ts +63 -0
- 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.
|