staffa 0.14.0 → 0.16.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 (82) hide show
  1. package/README.md +99 -271
  2. package/dist/components/autocomplete.js +4 -5
  3. package/dist/components/box.js +11 -21
  4. package/dist/components/button.d.ts +20 -5
  5. package/dist/components/button.js +55 -47
  6. package/dist/components/buttonChooser.js +1 -3
  7. package/dist/components/checkbox.js +1 -2
  8. package/dist/components/dialog.d.ts +9 -2
  9. package/dist/components/dialog.js +29 -35
  10. package/dist/components/field.d.ts +5 -8
  11. package/dist/components/field.js +4 -6
  12. package/dist/components/form.d.ts +5 -7
  13. package/dist/components/form.js +6 -9
  14. package/dist/components/keyhelp.d.ts +22 -0
  15. package/dist/components/keyhelp.js +91 -0
  16. package/dist/components/main.js +190 -318
  17. package/dist/components/menu.d.ts +36 -9
  18. package/dist/components/menu.js +193 -144
  19. package/dist/components/panels.d.ts +152 -232
  20. package/dist/components/panels.js +341 -556
  21. package/dist/components/select.js +1 -3
  22. package/dist/components/tabs.d.ts +10 -13
  23. package/dist/components/tabs.js +40 -63
  24. package/dist/components/textline.d.ts +3 -5
  25. package/dist/components/textline.js +3 -5
  26. package/dist/components/toast.d.ts +1 -3
  27. package/dist/components/toast.js +3 -6
  28. package/dist/components/tooltip.d.ts +4 -5
  29. package/dist/components/tooltip.js +13 -22
  30. package/dist/core.d.ts +17 -39
  31. package/dist/core.js +13 -35
  32. package/dist/icons-helpers.d.ts +3 -3
  33. package/dist/icons-helpers.js +6 -11
  34. package/dist/index.d.ts +3 -1
  35. package/dist/index.js +5 -4
  36. package/dist/keys.d.ts +92 -0
  37. package/dist/keys.js +279 -0
  38. package/dist/staffa.esm.js +1 -1
  39. package/dist/theme.d.ts +4 -10
  40. package/dist/theme.js +58 -123
  41. package/package.json +2 -2
  42. package/skill/ButtonOptions.md +12 -0
  43. package/skill/DialogOptions.md +11 -2
  44. package/skill/FieldOptions.md +3 -5
  45. package/skill/IconButtonOptions.md +8 -0
  46. package/skill/MenuItem.md +22 -3
  47. package/skill/Panel.md +8 -0
  48. package/skill/SKILL.md +161 -294
  49. package/skill/addTooltip.md +4 -5
  50. package/skill/bindKey.md +51 -0
  51. package/skill/box.md +1 -1
  52. package/skill/form.md +5 -7
  53. package/skill/formatKey.md +21 -0
  54. package/skill/iconButton.md +4 -5
  55. package/skill/scrollStrip.md +7 -9
  56. package/skill/showFloatingMenu.md +2 -2
  57. package/skill/showKeyHelp.md +17 -0
  58. package/skill/tabs.md +3 -4
  59. package/skill/textline.md +3 -5
  60. package/src/components/autocomplete.ts +4 -5
  61. package/src/components/box.ts +11 -21
  62. package/src/components/button.ts +70 -47
  63. package/src/components/buttonChooser.ts +1 -3
  64. package/src/components/checkbox.ts +1 -2
  65. package/src/components/dialog.ts +39 -37
  66. package/src/components/field.ts +7 -11
  67. package/src/components/form.ts +6 -9
  68. package/src/components/keyhelp.ts +96 -0
  69. package/src/components/main.ts +194 -318
  70. package/src/components/menu.ts +209 -146
  71. package/src/components/panels.ts +389 -618
  72. package/src/components/select.ts +1 -3
  73. package/src/components/tabs.ts +40 -63
  74. package/src/components/textline.ts +3 -5
  75. package/src/components/toast.ts +4 -9
  76. package/src/components/tooltip.ts +13 -22
  77. package/src/core.ts +17 -43
  78. package/src/icons-helpers.ts +6 -11
  79. package/src/index.ts +5 -4
  80. package/src/keys.ts +300 -0
  81. package/src/theme.ts +58 -123
  82. package/skill/Attributes.md +0 -10
@@ -1,6 +1,6 @@
1
1
  import A, { OPAQUE } from "aberdeen";
2
2
  import * as route from "aberdeen/route";
3
- import { drawSlot, cssZoom, MIN_PX } from "../core.js";
3
+ import { drawSlot } from "../core.js";
4
4
  import { circle as dotIcon, pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon, } from "../icons.js";
5
5
  import { PANEL_SHEEN } from "../theme.js";
6
6
  import { addContextMenu } from "./menu.js";
@@ -110,156 +110,95 @@ function matchRoute(r, segments) {
110
110
  // ─── Constants ───────────────────────────────────────────────────────────────
111
111
  /**
112
112
  * The one duration every bit of shell motion shares: the enter/exit fades, the
113
- * `left` moves of columns shifting sideways, and the narrow-screen nav panel's
114
- * slide. Published as the `--s-panel-ms` custom property below, so CSS and JS
115
- * can't drift apart.
116
- *
117
- * Short enough to read as *the screen responded*, rather than as an animation
118
- * being played at you: a panel arriving is navigation, and navigation should
119
- * feel instant even when it moves.
113
+ * sideways `left` moves, the narrow-screen nav slide. Published below as the
114
+ * `--s-panel-ms` custom property, so CSS and JS can't drift apart.
120
115
  */
121
116
  const PAGE_MS = 250;
122
117
  /** How long a freshly pushed `loading` panel holds its enter animation. */
123
118
  const LOADING_HOLD_MS = 300;
124
119
  /**
125
- * The bounds of a column. At least 360px — the phone width every panel must
126
- * handle anyway. And at most 540: that is the width the multi-column regime
127
- * never reaches (`area / floor(area / 360)` stays under it), so capping the
128
- * lone column of a 540–720px area to it too — centred, rather than stretched —
129
- * makes an ask's ceiling uniform: never wider than its column count × 540.
120
+ * The bounds of a column: at least 360px (about the narrowest phone in common
121
+ * use, so where a panel's layout is aimed), at most 540. The multi-column
122
+ * regime never reaches 540 on its own, so capping the lone column of a 540–720px
123
+ * area to it as well makes every ask's ceiling uniformly column count × 540.
130
124
  */
131
- const SMALL_MIN_PX = MIN_PX;
125
+ const SMALL_MIN_PX = 360;
132
126
  export const SMALL_MAX_PX = 540;
133
127
  /**
134
- * Panels are layered by their depth in the stack, two `z-index` steps per panel:
135
- * a panel sits on the odd layer for its depth, and a *closing* one drops to the
136
- * even layer just below, where it is frozen for the length of its fade. So a
137
- * panel that replaces another comes in over it, while one that closes fades out
138
- * over whatever it was covering — which is the way round both should read.
128
+ * Two `z-index` steps per panel: a panel sits on the odd layer for its depth in
129
+ * the stack, a *closing* one on the even layer just below. So a replacement
130
+ * comes in over the panel it replaces, while a closing panel fades out over
131
+ * whatever it was covering.
139
132
  */
140
133
  const LAYER_STEP = 2;
141
134
  // ─── Module-level styling ────────────────────────────────────────────────────
142
135
  A.insertGlobalCss({
143
136
  ":root": `--s-panel-ms:${PAGE_MS}ms`,
144
- // The clipping viewport that the columns slide through. Panels are absolutely
145
- // positioned inside it, with their width and x offset set from JS (see
146
- // `layout()`), so they can animate between arrangements. `isolation` keeps the
147
- // layers they stack themselves in (see LAYER_STEP) to themselves: the region
148
- // as a whole still sits under the shell's own chrome — the sticky top bar, and
149
- // the nav panel that slides across the body — however deep the stack gets.
150
- // The region paints the columns' sheen over its own box, and every panel
151
- // paints the very same one (see `.s-panel` below) — which, being a straight
152
- // vertical wash over boxes of one height, comes out identical whatever a
153
- // column's width, so the columns and the ground beside them are one
154
- // continuous surface.
137
+ // The clipping viewport the columns slide through; panels are absolutely
138
+ // positioned inside it, sized and offset from JS (see `layout()`).
139
+ // `isolation` keeps their z-index layers (see LAYER_STEP) below the shell's
140
+ // own chrome. It paints the same PANEL_SHEEN as every panel, so columns and
141
+ // the ground beside them read as one surface.
155
142
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
156
- // and anything that ever scrolls it — find-in-page reaching for text in a
157
- // parked column, an in-page anchor, an extension — shifts every column
158
- // sideways, permanently, because nothing here would ever scroll it back.
159
- // `clip` clips without being scrollable at all, closing the whole class.
143
+ // and anything that scrolls it (find-in-page, an in-page anchor) shifts every
144
+ // column sideways permanently, with nothing to scroll it back.
160
145
  ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
161
146
  PANEL_SHEEN,
162
147
  ".s-panel": {
163
- // A panel rests at a plain `left` offset and carries no transform: a
164
- // transformed element is composited, which costs it subpixel text
165
- // antialiasing. `transform` is used only to play the enter/exit slides,
166
- // where the compositing is what makes them cheap. There is deliberately no
167
- // `width` transition: a width changes only when the window resizes or when
168
- // the panel itself asks for another layout, and animating one would reflow
169
- // the column's content on every frame of it.
170
- // Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
171
- // and the chrome recentering around them all run as one motion. The drift
172
- // eases out (it should read as a slow settle) while the fade runs *linear*
173
- // across the whole duration — an eased opacity spends its last stretch near
174
- // zero, which looks like the panel vanishing rather than fading.
175
- // No `overflow:hidden` here: the scroll container below clips the content
176
- // itself.
177
- // Layering is set from JS (`layout()` and `beginClose`) rather than left to
178
- // DOM order: a closing panel is no longer part of the reactive list, so
179
- // where its element sits among the live ones is Aberdeen's business, not a
180
- // thing to depend on. `LAYER_*` says what the numbers mean.
181
- //
182
- // Every panel paints an opaque ground, because panels animate over one
183
- // another — entering, leaving, being crowded out — and two transparent ones
184
- // mean text sliding over text. It takes {@link PANEL_SHEEN}, resolved here
185
- // against the inherited `--s-bg` (a panel is not a surface, so it has to
186
- // paint it itself).
187
- //
188
- // Painted per panel, over the panel's own box — and yet seamless with its
189
- // neighbours and with the ground beside them, because that wash runs
190
- // straight down: it takes its extent from the height these boxes all share,
191
- // never from their differing widths. See PANEL_SHEEN for why that matters.
148
+ // A panel rests at a plain `left` offset with no transform: a transformed
149
+ // element is composited, costing it subpixel text antialiasing. Transform
150
+ // is used only for the enter/exit slides. No `width` transition either —
151
+ // animating one reflows the column's content every frame.
152
+ // The fade is deliberately `linear` while the drift eases out: an eased
153
+ // opacity spends its last stretch near zero, reading as a vanish.
154
+ // Layering is set from JS (`layout()`, `beginClose`), not DOM order: a
155
+ // closing panel is no longer in the reactive list, so its element's
156
+ // position among the live ones is Aberdeen's business. See LAYER_STEP.
157
+ // PANEL_SHEEN gives every panel an opaque ground (panels animate over one
158
+ // another, and two transparent ones mean text sliding over text).
192
159
  "&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
193
160
  PANEL_SHEEN + " " +
194
161
  "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
195
- // The hairline between two columns, fading out at both ends — the same
196
- // treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
197
- // gutter between them: each already brings its own `$3` of padding, which
198
- // keeps two columns' *contents* comfortably apart, while a gutter on top
199
- // of that only opened a strip of the panel's own ground between two columns
200
- // painting theirs. So this sits exactly on the boundary — and an
201
- // edge-to-edge column (`A("p:0")`) really does reach the line bounding it.
162
+ // The hairline between two columns, fading at both ends (like the sidebar's
163
+ // `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
164
+ // padding — so this sits exactly on the boundary.
202
165
  "&.s-panel-sep::before": "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
203
166
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
204
- // One vocabulary for every arrival and departure: a gradual fade over a short,
205
- // slow drift — 8cqw (`cqw`: `.s-main` is the container). Panels appear and
206
- // leave at the right edge; being crowded out at the left edge is its mirror.
207
- //
208
- // The start state of an enter, adopted with transitions off and then
209
- // dropped, which is what makes the panel settle instead of jumping.
167
+ // Enter and exit share one vocabulary: a fade over a short 8cqw drift
168
+ // (`cqw`: `.s-main` is the container). This start state is applied with
169
+ // transitions off and then dropped, so the panel settles instead of jumping.
210
170
  "&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
211
- // On its way out: fading where it stands, drifting the same short distance,
212
- // and out of reach while it does. It leaves the DOM when the fade itself
213
- // ends (see `playExit`), never part-way through it.
171
+ // On its way out; leaves the DOM when the fade ends (see `playExit`).
214
172
  "&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
215
- // Off screen but open: crowded out from under the visible run at the left
216
- // edge (`hidden`), or right of the current panel, parked past the right
217
- // edge (`parked`) — the two are mirror images. Either keeps its DOM (and
218
- // thus its scroll position and half-typed forms), so `display:none` is out
219
- // — `visibility` takes it out of the rendering instead, but only once the
220
- // fade has played: a transitioned `visibility` counts as *visible* for the
221
- // whole duration and flips at the very end. Revealing it again uses the
222
- // rule above (`visibility 0s`), so it comes back instantly.
173
+ // Off screen but open: crowded out at the left edge (`hidden`) or parked
174
+ // past the right one (`parked`). Both keep their DOM — and so their scroll
175
+ // position and half-typed forms — hence `visibility`, not `display:none`.
176
+ // Transitioning it counts as *visible* for the whole fade, flipping at the
177
+ // very end; the rule above (`visibility 0s`) reveals it again instantly.
223
178
  "&.s-panel-hidden, &.s-panel-parked": "opacity:0 visibility:hidden " +
224
179
  "transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
225
180
  "&.s-panel-hidden": "transform: translateX(-8cqw);",
226
181
  "&.s-panel-parked": "transform: translateX(8cqw);",
227
182
  },
228
- // The scroll container, with the column's own padding. A scrollbar here is
229
- // left flush against the column's right edge — unlike content mode's, which
230
- // insets one from the shell edge to line up with the bar above it. A column
231
- // has something better to line up with: the hairline the next column starts
232
- // at, or the edge of the content area. Both want the bar hard against them,
233
- // and an inset would leave a strip of nothing between the two.
183
+ // The scroll container, with the column's own padding. Its scrollbar sits
184
+ // flush against the column edge (unlike content mode's inset one), so it meets
185
+ // the next column's hairline with no strip of nothing between.
234
186
  ".s-panel > .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
235
187
  // A panel's actions on a wide shell: a quiet strip at the column's top-right,
236
188
  // above the scroll area (never sticky inside it). On a narrow shell the top
237
189
  // bar carries them instead, and no strip is drawn at all.
238
190
  ".s-panel-actions": "display:flex align-items:center justify-content:flex-end gap:$1 flex-shrink:0 padding: $3 $3 0;",
239
- // The breadcrumb stack the top bar shows: every open panel, oldest first,
240
- // the ones on screen right now in bold ink (see `drawCrumbs`). One row that
241
- // scrolls sideways when the bar is tight — scrollbarless, with a fade at
242
- // whichever edge has more stack behind it, so the cut-off reads as "keep
243
- // going" rather than as the stack simply ending.
244
- // The stack is a `.s-strip` (see tabs.ts), so the scrolling, the hidden
245
- // scrollbar and the ‹ / › come with it; all that is left to say is the gap
246
- // between a crumb and its chevron.
191
+ // The breadcrumb stack the top bar shows: every open panel, oldest first, the
192
+ // ones on screen in bold ink (see `drawCrumbs`). It is a `.s-strip` (see
193
+ // tabs.ts), so scrolling and the ‹ / › come with it; only the gap is left.
247
194
  ".s-crumbs > .s-strip-row": "gap:$m1",
248
195
  ".s-crumb": {
249
- // Quiet ink for the stack, full ink and weight for the panels on screen:
250
- // the weight change alone is ambiguous in a short crumb, the colour alone
251
- // too subtle. No padding of its own — the first crumb has to start on the
252
- // same pixel as the app's name above it, and the gap below spaces the row.
253
- // The flex is how a tight row is shared out. Every crumb grows from the
254
- // same 4rem basis in equal shares, freezing at its own text
255
- // (`max-width:max-content`) — so with room to spare every title shows in
256
- // full, and under pressure it is the *longest* crumbs that give way
257
- // first, equalising downward while short ones keep every character. No
258
- // crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
259
- // that point the row overflows and the strip scrolls — which is what
260
- // keeps a deep stack on a phone readable. (Crumbs allowed to shrink
261
- // would ellipsise to a row of stubs instead, and the stack would never
262
- // scroll.)
196
+ // Quiet ink for the stack, full ink and weight for the panels on screen.
197
+ // No padding of its own: the first crumb must start on the same pixel as
198
+ // the app's name above it. The flex shares out a tight row — every crumb
199
+ // grows from a 4rem basis and freezes at its own text, so the longest give
200
+ // way first. `flex-shrink:0` is what stops them ellipsising into a row of
201
+ // stubs: past that point the row overflows and the strip scrolls instead.
263
202
  "&": "flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
264
203
  "white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
265
204
  "transition: color 0.12s;",
@@ -267,23 +206,19 @@ A.insertGlobalCss({
267
206
  // The same hover treatment as a menu item. The panel you are on is a plain
268
207
  // span rather than a link, so it needs no `:not()` guard here.
269
208
  "a&:hover": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
270
- // The pin of a pinned panel, sitting inline just before its title. Filled:
271
- // at crumb size the icon's hairline strokes alias into a wobble, while the
272
- // body path filled solid reads as the classic pinned-tab pushpin.
209
+ // The pin of a pinned panel, inline before its title. Filled, because at
210
+ // crumb size the icon's hairline strokes alias into a wobble.
273
211
  "svg.s-crumb-pin": "vertical-align:-0.12em margin-right:0.3em opacity:0.8 fill:currentColor",
274
212
  // The ● of a panel holding unsaved work — the editors' dirty mark, filled
275
213
  // solid for the same reason as the pin.
276
214
  "svg.s-crumb-unsaved": "vertical-align:0.08em margin-right:0.3em fill:currentColor",
277
215
  },
278
- // A slash, not a chevron: the stack is a path, and a path's separator is what
279
- // the URL itself uses. It also has to stay clearly *unlike* the ‹ / › the
280
- // strip grows when the stack overflows (see `scrollStrip` in tabs.ts) — two
281
- // near-identical chevrons, one meaningful and one a button, read as a bug.
216
+ // A slash, not a chevron: the stack is a path, and a chevron would be
217
+ // confusable with the strip's own ‹ / › scroll buttons (see tabs.ts).
282
218
  "svg.s-crumb-sep": "flex-shrink:0 opacity:0.4",
283
- // A window resize (and the very first pass) must track the window instantly,
284
- // not rubber-band 450ms behind it: the layout engine raises this class on the
285
- // shell for exactly those passes, applies the new geometry, and drops it
286
- // after a forced reflow. Beats the standing transitions on specificity.
219
+ // A resize (and the first pass) must track the window instantly rather than
220
+ // rubber-band behind it: the layout engine raises this class, applies the new
221
+ // geometry, and drops it after a forced reflow.
287
222
  ".s-main.s-shell-snap .s-panel": "transition:none",
288
223
  // A minimal "still fetching" hint, centred over the panel's content (which
289
224
  // stays mounted underneath, so it can fill in reactively).
@@ -299,9 +234,8 @@ A.insertGlobalCss({
299
234
  },
300
235
  });
301
236
  /**
302
- * The URL is global, so two routed shells would fight over it. This is only a
303
- * guard against that — the stack is reached through the object `main()` hands
304
- * back, never through a module-level singleton.
237
+ * The URL is global, so two routed shells would fight over it; this only guards
238
+ * against that. The stack itself is reached through `main()`'s return value.
305
239
  */
306
240
  let mounted = false;
307
241
  /**
@@ -312,10 +246,9 @@ let mounted = false;
312
246
  */
313
247
  export class PanelStackController {
314
248
  /**
315
- * Kept out of Aberdeen's proxy wrapping: this is a class instance holding
316
- * DOM nodes, timers and route handlers, and it rides inside every
317
- * {@link Panel.stack}. Its reactivity doesn't need the wrapper — it comes
318
- * from `$state` and the panels, which are proxies in their own right.
249
+ * Kept out of Aberdeen's proxy wrapping: a class instance holding DOM nodes,
250
+ * timers and route handlers, riding inside every {@link Panel.stack}. Its
251
+ * reactivity comes from `$state` and the panels, which are proxies already.
319
252
  */
320
253
  [OPAQUE] = true;
321
254
  compiled;
@@ -323,35 +256,24 @@ export class PanelStackController {
323
256
  ancestors;
324
257
  opts;
325
258
  /**
326
- * The live stack, oldest first (closing panels are no longer part of it),
327
- * and which of its panels is current — the one reactive fact about the
328
- * stack's *shape*. The getters, the crumbs and `document.title` subscribe
329
- * to it simply by reading it; each commit publishes the next shape by
330
- * assigning a fresh `live` array. The entries themselves are opaque (see
331
- * {@link PanelEntry}), so the array carries their comings, goings and
332
- * order — nothing deeper; a panel's own facts stay separately reactive on
333
- * its `$panel`, which is what lets a panel rename itself without the
334
- * stack redrawing.
259
+ * The live stack, oldest first (closing panels have left it), and which panel
260
+ * is current — the one reactive fact about the stack's *shape*. Each commit
261
+ * publishes the next shape by assigning a fresh `live` array; the entries are
262
+ * opaque, so a panel renaming itself doesn't redraw the stack.
335
263
  *
336
- * One rule makes this safe to touch from anywhere: **queries subscribe,
337
- * commands peek**. The getters below are the queries. Every navigation
338
- * entry point (`navigate`, `closePath`, `back`, …) wraps itself in
339
- * `A.peek`, so an app calling one from inside a reactive scope (a
340
- * redirect in a route handler, say) can't subscribe that scope to the
341
- * very stack it is changing — and everything those commands call through
342
- * to, `propose` and `commit` included, inherits the same guarantee and
343
- * reads the stack plainly.
264
+ * The invariant that makes this safe to touch from anywhere: **queries
265
+ * subscribe, commands peek**. Every navigation entry point (`navigate`,
266
+ * `closePath`, `back`, …) wraps itself in `A.peek`, so an app calling one
267
+ * from a reactive scope can't subscribe that scope to the stack it is
268
+ * changing; `propose` and `commit` inherit that and read the stack plainly.
344
269
  */
345
270
  $state = A.proxy({ live: [], focus: 0 });
346
271
  /**
347
- * The open panels again, keyed by path — the shape as the DOM consumes it.
348
- * `drawColumns`' `onEach` mounts and unmounts panels by key, so a panel
349
- * spliced out of the middle of the stack touches exactly one key, and the
350
- * DOM of the retained columns — scroll positions, half-typed forms — is
351
- * left alone. (Iterating `live` itself would key panels by array index,
352
- * and a splice renumbers every index after it, redrawing them all.) A
353
- * key's value is its entry, by reference, and is never reassigned, so a
354
- * panel only ever redraws wholesale when its path closes.
272
+ * The open panels again, keyed by path — the shape the DOM consumes.
273
+ * `drawColumns`' `onEach` mounts by key, so splicing a panel out of the
274
+ * middle touches exactly one key and the retained columns keep their scroll
275
+ * positions and half-typed forms. (Iterating `live` would key by array index,
276
+ * which a splice renumbers, redrawing every panel after it.)
355
277
  */
356
278
  $open = A.proxy({});
357
279
  /** Feeds {@link PanelEntry.order}: one shared counter, so keys never tie. */
@@ -381,25 +303,20 @@ export class PanelStackController {
381
303
  this.ancestors = Object.entries(opts.ancestors ?? {})
382
304
  .filter((entry) => entry[1] != null)
383
305
  .map(([key, fn]) => ({ ...compileKey(key), fn }));
384
- // Commit the stack whenever the URL or its snapshot changes — the initial
385
- // load, our own navigations, and browser back/forward. There is no route
386
- // guard of the shell's own to pass first: closes are refused up front
387
- // (`closePath` on an unsaved panel) or repaired at the commit (`propose`
388
- // keeps unsaved panels a navigation would drop), so a guard the app
389
- // itself registered with `route.setGuard` — an auth redirect, say — is
390
- // left exactly where it is and keeps working untouched.
306
+ // Commit the stack whenever the URL or its snapshot changes — initial load,
307
+ // our own navigations, browser back/forward. The shell registers no route
308
+ // guard of its own (closes are refused in `closePath` or repaired in
309
+ // `propose`), so an app's `route.setGuard` keeps working untouched.
391
310
  A(() => {
392
311
  const target = this.computeTarget();
393
- // Subscribed (not peeked) deliberately: a search/hash change with the
394
- // path staying put must refresh `lastSeen` too, or the next stash would
395
- // restore stale ones.
312
+ // Subscribed, not peeked: a search/hash change with the path staying put
313
+ // must refresh `lastSeen` too, or the next stash restores stale ones.
396
314
  const search = { ...route.current.search };
397
315
  const hash = route.current.hash;
398
316
  A.peek(() => {
399
- // Stash the query of the panel the URL just left on that panel, for
400
- // when a crumb brings it back (see PanelEntry.search). Done here, on
401
- // the shared pipeline, so every origin is covered alike: the stack's
402
- // own navigations, an app's `route.go()`, and browser back/forward.
317
+ // Stash the query of the panel the URL just left, for when a crumb
318
+ // brings it back (see PanelEntry.search). Here on the shared pipeline,
319
+ // so our navigations, `route.go()` and back/forward are all covered.
403
320
  const prev = this.lastSeen;
404
321
  if (prev && prev.path !== route.current.path) {
405
322
  const entry = this.$state.live.find((e) => e.path === prev.path);
@@ -410,11 +327,10 @@ export class PanelStackController {
410
327
  }
411
328
  this.lastSeen = { path: route.current.path, search, hash };
412
329
  this.propose(target);
413
- // A history entry that doesn't describe an arrangement — the initial
414
- // load, or an app's own `route.go()` — is stamped with the one it
415
- // just produced: a reload restores the same columns, and a later
416
- // `route.back()` can recognize the entry (its matching wants the
417
- // `panels`/`parked` keys present, not merely compatible).
330
+ // A history entry describing no arrangement (initial load, an app's own
331
+ // `route.go()`) is stamped with the one it just produced, so a reload
332
+ // restores the same columns and `route.back()` can recognize the entry
333
+ // — its matching wants the `panels`/`parked` keys actually present.
418
334
  if (!Array.isArray(route.current.state.panels)) {
419
335
  Object.assign(route.current.state, this.stateFor({ stack: this.paths(), focus: this.$state.focus }));
420
336
  }
@@ -427,8 +343,7 @@ export class PanelStackController {
427
343
  for (const t of this.timers)
428
344
  clearTimeout(t);
429
345
  this.timers.clear();
430
- // Nothing is going to navigate a shell that isn't there: whatever was
431
- // waiting its turn is answered rather than left hanging.
346
+ // Answer whatever was waiting its turn, rather than leave it hanging.
432
347
  this.queued?.settle(false);
433
348
  this.queued = null;
434
349
  mounted = false;
@@ -451,17 +366,12 @@ export class PanelStackController {
451
366
  }
452
367
  /**
453
368
  * The stack for origin-less navigation: a cold deep link, a nav item, a
454
- * `route.go()` — anything arriving without a panel to build on and without a
455
- * snapshot to restore.
456
- *
457
- * The app's {@link PanelStackOptions.ancestors} gets first say, since only it
458
- * can know what belongs under a path that doesn't spell its own context out
459
- * (a `/thread/[id]` reached from a notification). Failing that — or when it
460
- * has no opinion — every prefix of the path is probed against the route table
461
- * and the matching ones become the stack. Either way, a path with no route is
462
- * skipped rather than opened as a "not found" column, so an app that doesn't
463
- * want one screen stacked under another simply doesn't route it. The path
464
- * itself always ends the derived stack, matched or not.
369
+ * `route.go()` — anything with no panel to build on and no snapshot to
370
+ * restore. {@link PanelStackOptions.ancestors} gets first say; failing that,
371
+ * every prefix of the path is probed against the route table. A path with no
372
+ * route is skipped rather than opened as a "not found" column, so not routing
373
+ * a screen keeps it from appearing under another. The path itself always ends
374
+ * the derived stack, matched or not.
465
375
  */
466
376
  deriveStack(path) {
467
377
  const top = normalizePath(path);
@@ -476,10 +386,9 @@ export class PanelStackController {
476
386
  return stack;
477
387
  }
478
388
  /**
479
- * Ask the `ancestors` table what belongs beneath `path`. The first key that
480
- * matches answers — with its own matched params, so it never has to take the
481
- * path apart itself — and `undefined` from it means "no opinion", leaving the
482
- * path to the prefix derivation just as an unlisted one is.
389
+ * Ask the `ancestors` table what belongs beneath `path`. The first matching
390
+ * key answers, with its own matched params; `undefined` means "no opinion",
391
+ * leaving the path to the prefix derivation as an unlisted one would be.
483
392
  */
484
393
  askAncestors(path) {
485
394
  const segments = splitPath(path);
@@ -515,23 +424,20 @@ export class PanelStackController {
515
424
  return this.$state.live.find((e) => e.path === path)?.$panel.unsaved === true;
516
425
  }
517
426
  /**
518
- * The arrangement a route implies: its snapshot around its path, or —
519
- * without a snapshot — derived, with the new panel current at the end and
520
- * any pinned panels carried along beneath it.
427
+ * The arrangement a route implies: its snapshot around its path, or — without
428
+ * one — derived, the new panel current at the end with pinned panels beneath.
521
429
  *
522
- * The snapshot reads subscribe — they are the URL's, exactly what the
523
- * route observer is for. The derivation is peeked instead: it reads the
524
- * live stack for its pins, which is the very thing that observer rewrites,
525
- * and subscribing to it would re-run the observer once per commit.
430
+ * The snapshot reads subscribe (they are the URL's). The derivation is peeked:
431
+ * it reads the live stack for its pins, which the route observer rewrites, so
432
+ * subscribing would re-run that observer once per commit.
526
433
  */
527
434
  targetFor(path, state) {
528
435
  const before = Array.isArray(state?.panels) ? state.panels.map(String) : null;
529
436
  if (before) {
530
437
  const after = Array.isArray(state.parked) ? state.parked.map(String) : [];
531
438
  // A stack never holds the same path twice: rendering reconciles by path,
532
- // so a duplicate would leave a permanently element-less entry that stalls
533
- // the layout. Our own states are clean, but `route.go` accepts
534
- // hand-written ones — drop duplicates rather than wedge.
439
+ // so a duplicate leaves a permanently element-less entry that stalls the
440
+ // layout. `route.go` accepts hand-written states, so drop rather than wedge.
535
441
  const cur = normalizePath(path);
536
442
  const seen = new Set([cur]);
537
443
  const uniq = (paths) => paths.map(normalizePath).filter((p) => !seen.has(p) && !!seen.add(p));
@@ -553,13 +459,11 @@ export class PanelStackController {
553
459
  return this.$state.live.map((e) => e.path);
554
460
  }
555
461
  /**
556
- * Adopt an arrangement proposed by the URL — after repairing it: panels
557
- * holding unsaved work are never torn down by a navigation, wherever it
558
- * came from — a link, a nav item, even a browser back to an entry from
559
- * before the panel existed. Whatever the target drops, they stay, parked
560
- * after the current panel and wearing the ● that says why. (They are
561
- * deliberately not written into history entries: the work they protect
562
- * lives in the page's DOM, which a reload clears anyway.)
462
+ * Adopt an arrangement proposed by the URL, after repairing it: a panel holding
463
+ * unsaved work is never torn down by any navigation — including a back to an
464
+ * entry from before it existed — so whatever the target drops, it stays, parked
465
+ * after the current panel. Parked panels are deliberately kept out of history
466
+ * entries: the work they protect lives in DOM a reload clears anyway.
563
467
  */
564
468
  propose(target) {
565
469
  const kept = this.$state.live
@@ -575,20 +479,17 @@ export class PanelStackController {
575
479
  * Apply a target arrangement: unmount what's gone, mount what's new, animate
576
480
  * the difference.
577
481
  *
578
- * Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
579
- * well-defined): a panel present in both stacks stays mounted *even if its
580
- * index shifted*, which is what lets a panel be spliced out of the middle
581
- * (§7) without disturbing the columns above it. A common-prefix diff would
582
- * remount every one of them, throwing away exactly the scroll and form state
583
- * rule 5 promises to keep.
482
+ * Reconciliation is BY PATH, not by index: a panel in both stacks stays mounted
483
+ * even if its index shifted, which is what lets one be spliced out of the middle
484
+ * without disturbing the columns above it. A common-prefix diff would remount
485
+ * them all, throwing away their scroll and form state.
584
486
  */
585
487
  commit(target, nav) {
586
- // The panels this commit mounts size themselves as they draw, so make them
587
- // measure the shell as it is now rather than trusting the last pass's numbers.
488
+ // Panels size themselves as they draw, so make them measure the shell as it
489
+ // is now rather than trust the last pass's numbers.
588
490
  this.geom = undefined;
589
- // Pin flags for panels this commit *creates* — a reload, or a cold
590
- // restore. Live panels keep their own flag: a pin is the user's mark on
591
- // the panel, not part of where back/forward travel.
491
+ // Pin flags for panels this commit *creates* (a reload or cold restore).
492
+ // Live panels keep their own: a pin is the user's mark, not history state.
592
493
  const pinned = route.current.state.pinned;
593
494
  const seedPins = new Set(Array.isArray(pinned) ? pinned.map(String) : []);
594
495
  const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
@@ -596,23 +497,19 @@ export class PanelStackController {
596
497
  for (const path of target.stack) {
597
498
  const kept = existing.get(path);
598
499
  if (kept) {
599
- // Retained: it just takes its new place in the stack. Its `order` (the
600
- // DOM sort key) deliberately stays put — see PanelEntry.order.
500
+ // Retained; its `order` deliberately stays put — see PanelEntry.order.
601
501
  existing.delete(path);
602
502
  next.push(kept);
603
503
  continue;
604
504
  }
605
505
  const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
606
- // An initial load just appears, and so do panels *revealed* by a back —
607
- // they belong underneath the ones sliding away. Everything else enters at
608
- // the right edge, a replacement exactly like a push.
506
+ // An initial load just appears, and so do panels *revealed* by a back:
507
+ // they belong underneath the ones sliding away.
609
508
  if (nav !== "load" && nav !== "back")
610
509
  entry.enter = true;
611
510
  next.push(entry);
612
511
  this.$open[path] = entry;
613
512
  }
614
- // Whatever the target no longer holds leaves the same way: fading out over
615
- // the right edge, which is also where its replacement (if any) comes in from.
616
513
  for (const entry of existing.values())
617
514
  this.beginClose(entry);
618
515
  this.$state.live = next;
@@ -630,17 +527,11 @@ export class PanelStackController {
630
527
  maxWidth: "medium",
631
528
  width: 0,
632
529
  };
633
- // `close` closes *this* panel, current or not, and `open` navigates
634
- // *from* it, through the very implementation a link click uses (see
635
- // `navigate`). Both resolve the panel's place in the stack at call
636
- // time, so they keep working after a splice has moved it; an `open`
637
- // from a panel that has since closed falls back to a derived stack,
638
- // like a link from nowhere.
639
- //
640
- // `visible` starts at what the panel's place implies: shown when it sits
641
- // at or before the current panel (a pushed panel always does), hidden when
642
- // it is restored already parked. `width` is filled in by the sizing scope
643
- // in `drawPanel` before the handler draws.
530
+ // `close` and `open` resolve this panel's place in the stack at call time,
531
+ // so they keep working after a splice has moved it; an `open` from a panel
532
+ // that has since closed falls back to a derived stack, like a link from
533
+ // nowhere. `width` is filled in by `drawPanel`'s sizing scope before the
534
+ // handler draws.
644
535
  entry.$panel = A.proxy({
645
536
  stack: this,
646
537
  params,
@@ -654,40 +545,31 @@ export class PanelStackController {
654
545
  return entry;
655
546
  }
656
547
  /**
657
- * Take a panel out of the shell. The *scope* goes now: its cleaners run this
658
- * tick, so whatever the panel registered with `A.clean` — subscriptions,
659
- * timers, an open portal — is torn down when the panel closes, not when its
660
- * animation is over. Only the element lingers, to play that animation, which
661
- * is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
662
- * element to {@link playExit} instead of removing it.
548
+ * Take a panel out of the shell. The *scope* goes now — its `A.clean` hooks run
549
+ * this tick, so subscriptions, timers and portals stop at the close rather than
550
+ * at the end of the animation. Only the element lingers to play that animation,
551
+ * which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
663
552
  */
664
553
  beginClose(entry) {
665
554
  entry.closing = true;
666
- // It is on its way out, so it is no longer "on screen" as far as anything
667
- // hanging off `$panel.visible` is concerned — even though its element lingers
668
- // to play the fade.
669
555
  entry.$panel.visible = false;
670
- // Frozen one layer below where it was, which is still above everything it
671
- // was covering: it fades out over the panel it uncovers, and under the one
672
- // that takes its place (see LAYER_STEP). Set here, while the element is
673
- // still ours — a moment later the scope, and with it `entry.el`, is gone.
556
+ // Frozen one layer below where it was, so it fades out over the panel it
557
+ // uncovers and under the one replacing it (see LAYER_STEP). Set here, while
558
+ // the element is still ours — a moment later `entry.el` is gone.
674
559
  if (entry.el)
675
560
  entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
676
561
  delete this.$open[entry.path];
677
562
  }
678
563
  /**
679
- * A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
680
- * the content it shows is frozen, which is exactly what a departing column
681
- * should be): it fades where it stands, inert, and leaves the DOM when the fade
682
- * itself ends. Removing it on a fixed timer instead would race the transition —
683
- * pull the element a frame early and the panel appears to fade half-way and
684
- * then vanish. The timeout is just a fallback for when no `transitionend` is
685
- * coming at all (transitions off, or an element that never got placed).
564
+ * A closed panel's send-off, run by Aberdeen once its scope is gone: it fades
565
+ * where it stands, inert, and leaves the DOM on `transitionend`. A fixed timer
566
+ * would race the transition and pull the element a frame early, making the
567
+ * panel appear to fade half-way and vanish; the timeout is only a fallback for
568
+ * when no `transitionend` is coming (transitions off, element never placed).
686
569
  */
687
570
  playExit(entry, el) {
688
- // Only a close is worth animating. A panel being *redrawn* (a reactive
689
- // dependency in its handler) replaces its element through here too, and that
690
- // one simply goes, so the new one isn't drawn over a ghost of the old.
571
+ // A panel being *redrawn* replaces its element through here too; that one
572
+ // simply goes, so the new one isn't drawn over a ghost of the old.
691
573
  if (!entry.closing) {
692
574
  el.remove();
693
575
  return;
@@ -709,13 +591,10 @@ export class PanelStackController {
709
591
  // ── Navigation ─────────────────────────────────────────────────────────
710
592
  /**
711
593
  * The arrangement navigation works from: the one we're on the way to while a
712
- * change is still settling, and the one on screen otherwise.
713
- *
714
- * Settling takes a moment more often than it looks: every `route.back()`
715
- * travels through the browser's history and lands on a `popstate`, and an
716
- * app-registered route guard may be async. Working from the committed
717
- * arrangement in that window would make a second Escape aim at the panel the
718
- * first one is already taking away — so two quick Escapes would peel one panel.
594
+ * change is still settling, the one on screen otherwise. That window is common
595
+ * (every `route.back()` waits for a `popstate`), and working from the committed
596
+ * arrangement inside it would aim a second Escape at the panel the first is
597
+ * already taking away — two quick Escapes would peel one panel.
719
598
  */
720
599
  intended() {
721
600
  return this.intent ?? { stack: this.paths(), focus: this.$state.focus };
@@ -733,15 +612,10 @@ export class PanelStackController {
733
612
  return this.$state.live.filter((e) => e.$panel.pinned).map((e) => e.path);
734
613
  }
735
614
  /**
736
- * Put a navigation to the router, or — while one is still settling — behind
737
- * the one that is. Only the newest waits: each was worked out against
738
- * {@link intended}, so the newest is the one that means what the user last
739
- * asked for, and the one it displaces resolves `false`.
740
- *
741
- * A refusal empties the queue instead of running it: a navigation can still
742
- * fail to land — an app-registered route guard vetoes it, or another one
743
- * supersedes it — and what was queued behind it was worked out against the
744
- * arrangement it would have produced.
615
+ * Put a navigation to the router, or — while one is still settling — behind the
616
+ * one that is. Only the newest waits; the one it displaces resolves `false`.
617
+ * A refusal empties the queue rather than running it, since what was queued
618
+ * was worked out against the arrangement the refused one would have produced.
745
619
  */
746
620
  issue(target, run) {
747
621
  this.intent = target;
@@ -756,10 +630,9 @@ export class PanelStackController {
756
630
  this.settling = null;
757
631
  const next = this.queued;
758
632
  this.queued = null;
759
- // The router applies a change (and runs Aberdeen's queue, so our own
760
- // commit has happened) before it settles us, which is what lets the next
761
- // one go straight out: it asks the guards of the panels it removes from
762
- // the stack as it stands now, not the one it was queued against.
633
+ // The router has already applied the change (and flushed Aberdeen's queue,
634
+ // so our commit has happened) before settling us, so the next one can go
635
+ // straight out against the stack as it now stands.
763
636
  if (ok && next)
764
637
  this.start(next.run).then(next.settle, () => next.settle(false));
765
638
  else {
@@ -774,12 +647,8 @@ export class PanelStackController {
774
647
  }
775
648
  /**
776
649
  * Make the stack's `index`th panel current without closing anything, leaving
777
- * the panels right of it parked out of sight.
778
- *
779
- * Only ever a step around a panel that refuses to close — nothing else is
780
- * left sitting after the current one — so this is Escape's way past an
781
- * unsaved panel, and the way back to one. It is a history entry, so the
782
- * browser's back button returns the focus to where it was.
650
+ * the panels right of it parked out of sight — Escape's way past an unsaved
651
+ * panel, and back to one. A history entry, so back returns the focus.
783
652
  */
784
653
  focusAt(index) {
785
654
  const arr = this.intended();
@@ -794,12 +663,10 @@ export class PanelStackController {
794
663
  });
795
664
  }
796
665
  /**
797
- * One step back along the stack — what Escape does (`main()` calls this;
798
- * it is not {@link PanelStack} API). Normally that closes the current panel,
799
- * which is the stack's end. When it holds {@link Panel.unsaved} work — or
800
- * panels sit parked beyond it — it stays open instead, and the focus
801
- * simply moves to the panel on its left.
802
- * Resolves `false` at the stack's start, where there is no left to go.
666
+ * One step back along the stack — what Escape does (`main()` calls this; it is
667
+ * not {@link PanelStack} API). Normally that closes the current panel; when it
668
+ * holds {@link Panel.unsaved} work, or panels sit parked beyond it, the focus
669
+ * moves left instead. Resolves `false` at the stack's start.
803
670
  */
804
671
  back() {
805
672
  return A.peek(() => {
@@ -815,21 +682,15 @@ export class PanelStackController {
815
682
  /**
816
683
  * Close whichever panel is open at `path`, current or not — what
817
684
  * {@link Panel.close}, {@link PanelStack.closePanel} and the crumb menu's
818
- * Close come down to. `false` when that path isn't open, is the stack's
819
- * only panel, or holds {@link Panel.unsaved} work — nothing may close an
820
- * unsaved panel; the app clears the flag first, which is its explicit
821
- * "this is now discardable".
685
+ * Close all come down to. `false` when that path isn't open, is the stack's
686
+ * only panel, or holds {@link Panel.unsaved} work.
822
687
  *
823
- * Closing the current panel at the stack's very end pops back through the
824
- * browser's history to the entry beneath it, when it is there (restoring its
825
- * scroll and search state); the arrangement is part of the match, so an
826
- * entry where the closing panel was merely parked won't do. Every other
827
- * close is a *splice*: the columns around the closed one keep their place
828
- * and state (the commit reconciles by path). That still gets its own
829
- * history entry, so the browser's back button restores the closed column
830
- * like any other arrangement — which is why it goes through `route.go` here
831
- * rather than through `navigate()`, whose "link to an open panel" check
832
- * would turn it into a focus move.
688
+ * Closing the current panel at the stack's end pops back through history to
689
+ * the entry beneath it (restoring its scroll and search state); the
690
+ * arrangement is part of the match, so an entry where the closing panel was
691
+ * merely parked won't do. Every other close is a *splice*, which still earns
692
+ * its own history entry — hence `route.go` here rather than `navigate()`,
693
+ * whose "link to an open panel" check would turn it into a focus move.
833
694
  */
834
695
  closePath(path) {
835
696
  return A.peek(() => {
@@ -838,18 +699,15 @@ export class PanelStackController {
838
699
  if (index < 0 || arr.stack.length < 2 || this.unsavedAt(arr.stack[index]))
839
700
  return Promise.resolve(false);
840
701
  const stack = arr.stack.filter((_, i) => i !== index);
841
- // Closing the current panel hands the focus to the panel on its left (or,
842
- // at the stack's start, to the one that was parked beside it); closing
843
- // any other panel moves the focus not at all.
702
+ // Closing the current panel hands the focus to the panel on its left;
703
+ // closing any other panel moves the focus not at all.
844
704
  const focus = index === arr.focus ? Math.max(0, index - 1) : arr.focus - (index < arr.focus ? 1 : 0);
845
705
  const target = { stack, focus };
846
706
  if (index === arr.focus && index === arr.stack.length - 1) {
847
- // When no history entry matches and the current one is replaced
848
- // instead, the panel beneath gets its stashed query back through the
849
- // fallback (a match restores the matched entry's own). Pins can't
850
- // ride the same way — a `state` in the fallback would be shadowed by
851
- // the match target's — so they are re-stamped onto whatever entry we
852
- // land on, the way `togglePin` writes them.
707
+ // With no matching history entry the current one is replaced instead,
708
+ // and the panel beneath gets its stashed query back via the fallback.
709
+ // Pins can't ride along there (the match target's `state` would shadow
710
+ // it), so they are re-stamped onto whatever entry we land on.
853
711
  const beneath = this.$state.live.find((e) => e.path === stack[focus]);
854
712
  const fallback = {};
855
713
  if (beneath?.search)
@@ -866,9 +724,8 @@ export class PanelStackController {
866
724
  const current = stack[focus];
867
725
  const moved = current !== arr.stack[arr.focus];
868
726
  return this.issue(target, () => {
869
- // The current panel keeps its search params and hash: it isn't going
870
- // anywhere, and `go()` would otherwise default them away. When the
871
- // close *did* move the focus, the newly current panel gets its own back.
727
+ // The current panel keeps its search and hash — `go()` would otherwise
728
+ // default them away. If the focus moved, the new panel gets its own back.
872
729
  const entry = moved ? this.$state.live.find((e) => e.path === current) : undefined;
873
730
  return route.go({
874
731
  path: current,
@@ -881,28 +738,19 @@ export class PanelStackController {
881
738
  }
882
739
  /**
883
740
  * Navigate to `href` — the one implementation behind a link click,
884
- * {@link Panel.open} and the stack's own methods, so none of them can
885
- * behave differently.
741
+ * {@link Panel.open} and the stack's own methods, so none can drift.
886
742
  *
887
- * `from` is the path of the panel the navigation starts from — the one
888
- * the link lives in — or absent when it has none: a nav item, or a call
889
- * that means the whole stack, which is then built instead (see
890
- * {@link deriveStack}), or taken outright from `beneath`, for callers
891
- * that know it.
743
+ * `from` is the panel the navigation starts from, absent when it has none (a
744
+ * nav item), in which case the stack is derived or taken from `beneath`.
892
745
  *
893
- * `how` is the link's `data-panel` attribute (or the caller's word for
894
- * it), picking how much of `from`'s context the target keeps: a push (the
895
- * default, and what unrecognised values fall back to) keeps `from` and
896
- * builds on it, `"replace"` keeps only what is beneath `from`, and
897
- * `"open"` keeps nothing — the target arrives with its own stack, the way
898
- * a nav item's link does. Absent, it is the shell's `linkNavigation`
899
- * default, like a link without the attribute. A target that is already
900
- * open is returned to by a push, and *moved* — alive, state intact — by
901
- * the other two: the stack never holds a path twice.
746
+ * `how` is the link's `data-panel` attribute, picking how much of `from`'s
747
+ * context the target keeps: a push (the default, and the fallback for
748
+ * unrecognised values) builds on `from`, `"replace"` keeps only what is
749
+ * beneath it, `"open"` keeps nothing. A target that is already open is
750
+ * returned to by a push and *moved* — alive — by the other two, since the
751
+ * stack never holds a path twice.
902
752
  *
903
- * Resolves the way every {@link PanelStack} method does: `true` once the
904
- * navigation lands, `false` when it doesn't (already there counts as
905
- * landed).
753
+ * Resolves `true` once the navigation lands (already being there counts).
906
754
  */
907
755
  navigate(href, { from, how, beneath } = {}) {
908
756
  const mode = how ?? this.opts.linkNavigation;
@@ -918,54 +766,43 @@ export class PanelStackController {
918
766
  }
919
767
  const path = normalizePath(url.pathname);
920
768
  const arr = this.intended();
921
- // Whatever we navigate to ends up on top of the stack; all that differs
922
- // is what it lands on.
769
+ // The target always ends up on top; only what it lands on differs.
923
770
  const open = beneath ? -1 : arr.stack.indexOf(path);
924
771
  let target;
925
772
  if (open >= 0 && mode !== "replace" && mode !== "open") {
926
- // A push to a path that is already open is a return: the panel takes
927
- // back its own place, and whatever was stacked on top of it closes.
928
- // (A stack never holds the same path twice, so there is no second
929
- // copy to open — and a breadcrumb is exactly such a link.) Pinned
930
- // panels above it are the exception, as ever: they stay in their
931
- // order, parked past the panel we return to, one crumb click away.
773
+ // A push to an open path is a return: the panel takes back its place
774
+ // and whatever was stacked on top closes (a breadcrumb is such a
775
+ // link). Pinned panels above it park instead, keeping their order.
932
776
  const above = this.pinnedIn(arr.stack.slice(open + 1), []);
933
777
  target = { stack: [...arr.stack.slice(0, open + 1), ...above], focus: open };
934
778
  }
935
779
  else {
936
- // The target opens on top of the panel the link sits in, or in its
937
- // place for a `replace`, closing the panels after it. Without an
938
- // originating panel there is no stack to build on, so it is the
939
- // caller's own `beneath` or one derived from the path — which is what
940
- // makes a nav click and a deep link to the same URL land identically
941
- // (bar the pins, which a fresh tab doesn't have).
780
+ // The target opens on top of the link's own panel — in its place for a
781
+ // `replace` — closing the panels after it. With no originating panel
782
+ // the base is the caller's `beneath` or derived from the path, which
783
+ // is what makes a nav click and a cold deep link land identically.
942
784
  const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
943
785
  const raw = beneath
944
786
  ? beneath.map(normalizePath)
945
787
  : originIndex < 0
946
788
  ? this.deriveStack(path).slice(0, -1)
947
789
  : arr.stack.slice(0, replace ? originIndex : originIndex + 1);
948
- // A stack never holds the same path twice (rendering reconciles by
949
- // path), so the target is dropped from the base — a `replace` or
950
- // `open` may well aim at a path that is open mid-stack, whose panel
951
- // then simply *moves* to the top, alive — and a caller-supplied
952
- // `beneath` is deduplicated for the same reason.
790
+ // A stack never holds the same path twice, so the target is dropped
791
+ // from the base (a `replace`/`open` aimed mid-stack moves that panel
792
+ // to the top, alive) and `beneath` is deduplicated for the same reason.
953
793
  const base = raw.filter((p, i, all) => p !== path && all.indexOf(p) === i);
954
- // Pinned panels ride along, keeping their order, beneath the new one
955
- // (unsaved ones the commit itself keeps, parked — see `propose`). A
956
- // replaced origin closes, pin or no pin: replacing is the panel's own
957
- // doing, not somewhere else navigating over it.
794
+ // Pinned panels ride along beneath the new one, keeping their order
795
+ // (unsaved ones `propose` parks). A replaced origin closes pin or no
796
+ // pin: replacing is the panel's own doing, not navigation over it.
958
797
  const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
959
798
  target = { stack: [...under, path], focus: under.length };
960
799
  }
961
- // Search and hash belong to the current panel only, so a panel we return
962
- // to gets its own back (see PanelEntry.search) — unless the link carries
963
- // its own, which win.
800
+ // A panel we return to gets its own search and hash back (see
801
+ // PanelEntry.search), unless the link carries its own, which win.
964
802
  const returning = open >= 0 ? this.$state.live.find((e) => e.path === path) : undefined;
965
803
  const search = url.search ? Object.fromEntries(new URLSearchParams(url.search)) : returning?.search ?? {};
966
804
  const hash = url.hash || returning?.hash || "";
967
- // Going nowhere at all: this panel, on this arrangement, with the query
968
- // the link asks for. Not even a history entry.
805
+ // Going nowhere at all — not even a history entry.
969
806
  if (target.focus === arr.focus && sameStack(target.stack, arr.stack)
970
807
  && url.search === location.search && (url.hash || "") === (location.hash || "")) {
971
808
  return Promise.resolve(true);
@@ -982,24 +819,27 @@ export class PanelStackController {
982
819
  }
983
820
  // ── Link interception ──────────────────────────────────────────────────
984
821
  /**
985
- * Link handling through `route.interceptLinks()`, whose handler hook hands us
986
- * the anchor so we can decide what the click *means*. The exclusion rules
987
- * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
988
- * close guards run in `checkChange` when our navigation reaches the router.
822
+ * Link handling through `route.interceptLinks()`, whose hook hands us the
823
+ * anchor so we can decide what the click means. The exclusion rules (targets,
824
+ * downloads, modified clicks, external URLs) live in Aberdeen.
989
825
  *
990
- * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
991
- * attribute as its `how` (see {@link navigate}, the shared implementation).
992
- * A link that isn't inside any panel — a nav item, one in a dialog — has no
993
- * panel to build on, so it replaces the stack as a whole, exactly as a cold
994
- * link to the same URL would open it.
826
+ * A link inside a panel is that panel's {@link Panel.open}, with `data-panel`
827
+ * as its `how`. A link outside every panel — a nav item, one in a dialog —
828
+ * has nothing to build on, so it replaces the stack as a cold link would.
995
829
  */
996
830
  interceptLinks() {
997
- route.interceptLinks((url, anchor) => {
831
+ route.interceptLinks((url, anchor, e) => {
832
+ // A modified keystroke is not a plain activation. Aberdeen leaves a
833
+ // ctrl/⌘/shift/alt *click* to the browser but not the Enter that stands
834
+ // in for it, so routing this one would turn the keyboard's own
835
+ // open-in-a-new-tab into an ordinary navigation. Declining leaves the
836
+ // event untouched, for the browser to open where the click would have.
837
+ if (e instanceof KeyboardEvent && (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey))
838
+ return false;
998
839
  const how = anchor.getAttribute("data-panel") ?? undefined;
999
- // The panel the link lives in: the enclosing `.s-panel`, or — for the
1000
- // current panel's actions, promoted into the top bar on a narrow shell
1001
- // (see main.ts), outside every `.s-panel` — the current panel, whose
1002
- // own chrome they remain at every width.
840
+ // The panel the link lives in: the enclosing `.s-panel`, or the current
841
+ // panel for its actions once a narrow shell has promoted them into the
842
+ // top bar (see main.ts), outside every `.s-panel`.
1003
843
  const panelEl = anchor.closest(".s-panel");
1004
844
  const entry = panelEl
1005
845
  ? this.$state.live.find((e) => e.el === panelEl)
@@ -1011,9 +851,8 @@ export class PanelStackController {
1011
851
  });
1012
852
  }
1013
853
  // ── What the shell's top bar asks ──────────────────────────────────────
1014
- // The {@link PanelStack} face, where all the documentation lives. These are
1015
- // the *queries* of the "queries subscribe, commands peek" rule on `$state`:
1016
- // read one in a scope and that scope follows the stack's shape.
854
+ // The {@link PanelStack} face, where the documentation lives. These are the
855
+ // *queries* of the "queries subscribe, commands peek" rule on `$state`.
1017
856
  get currentPanel() {
1018
857
  return this.$state.live[this.$state.focus]?.$panel;
1019
858
  }
@@ -1039,13 +878,11 @@ export class PanelStackController {
1039
878
  });
1040
879
  }
1041
880
  // ── Live settings ──────────────────────────────────────────────────────
1042
- // `main()` keeps these fed from small reactive scopes of their own, so an
1043
- // app that reads them off a proxy (or through a getter) can change them at
1044
- // runtime and the shell adapts in place — nothing is redrawn, no panel
1045
- // loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
1046
- // options; these are how `main()` talks to the stack. The shell's *widths*
1047
- // need no counterpart here: `navWidth` and `maxWidth` both resize the
1048
- // column region, which the layout engine is already observing.
881
+ // `main()` feeds these from small reactive scopes, so an app changing them at
882
+ // runtime is adopted in place — nothing redrawn, no panel losing its state.
883
+ // Not {@link PanelStack} API: this is how `main()` talks to the stack.
884
+ // `navWidth`/`maxWidth` need no counterpart; they resize the column region,
885
+ // which the layout engine already observes.
1049
886
  /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
1050
887
  setColumns(columns) {
1051
888
  if (this.opts.columns === columns)
@@ -1058,19 +895,14 @@ export class PanelStackController {
1058
895
  this.opts.linkNavigation = mode;
1059
896
  }
1060
897
  /**
1061
- * The breadcrumb stack, drawn by `main()` into the top bar: every open
1062
- * panel, oldest first, the ones on screen right now in bold, pinned ones
1063
- * wearing their pin. Every crumb but the current panel's is a plain link to
1064
- * that panel, and a link to an open panel returns to it (see `navigate`) —
1065
- * so clicking a crumb goes back to that panel and closes what was stacked on
1066
- * top of it, pinned and unsaved panels excepted. Right-click (or long-press)
1067
- * offers pinning, and closing just that one panel — the close that splices
1068
- * it out of the middle when it isn't last.
898
+ * The breadcrumb stack, drawn by `main()` into the top bar: every open panel,
899
+ * oldest first, the ones on screen in bold. Every crumb but the current one is
900
+ * a plain link, and a link to an open panel returns to it (see `navigate`),
901
+ * closing what was stacked on top. Right-click offers pinning and closing.
1069
902
  */
1070
903
  drawCrumbs() {
1071
- // The very same row `S.tabs` puts its tab strip in: it scrolls when the
1072
- // stack outgrows the bar, and shows a ‹ / › over whichever end still has
1073
- // crumbs to reach — which a mouse can use, unlike a bare scroll area.
904
+ // The same row `S.tabs` uses: it scrolls when the stack outgrows the bar,
905
+ // with a ‹ / › a mouse can use over whichever end has crumbs left to reach.
1074
906
  scrollStrip({
1075
907
  attrs: ".s-crumbs role=navigation aria-label=Breadcrumbs",
1076
908
  content: () => {
@@ -1093,23 +925,19 @@ export class PanelStackController {
1093
925
  });
1094
926
  }
1095
927
  drawCrumb(path, index, current) {
1096
- // Safe to close over: the crumb list is rebuilt whenever the stack (or
1097
- // its focus) changes, so this entry is `paths[index]`'s for the crumb's
1098
- // whole life.
928
+ // Safe to close over: the crumb list is rebuilt whenever the stack or its
929
+ // focus changes, so this stays `paths[index]`'s entry for the crumb's life.
1099
930
  const entry = this.$state.live[index];
1100
- // A real link, for the panel it names — so it has an address to hover, to
1101
- // middle-click, to copy. No click handling of its own: the shell's link
1102
- // handling already makes any link to an open panel the focus move a crumb
1103
- // should be (see `navigate`). The panel you are on is a span: nowhere to go.
931
+ // A real link, so it can be hovered, middle-clicked and copied. No click
932
+ // handling of its own: link interception already turns a link to an open
933
+ // panel into the focus move a crumb wants (see `navigate`).
1104
934
  return A(current ? "span.s-crumb aria-current=page" : "a.s-crumb", () => {
1105
935
  if (!current)
1106
936
  A("href=", path);
1107
- // Bold = on screen right now, so the stack also says which of its panels
1108
- // are the visible columns — not just which one is current.
937
+ // Bold = on screen right now, so the stack says which panels are the
938
+ // visible columns, not just which one is current.
1109
939
  A(() => { if (entry?.$panel.visible)
1110
940
  A(".s-crumb-on"); });
1111
- // The ● of unsaved work — the mark that nothing can close this panel —
1112
- // and the pin of a panel that navigation elsewhere won't close.
1113
941
  A(() => { if (entry?.$panel.unsaved)
1114
942
  dotIcon({ size: "0.45em", attrs: ".s-crumb-unsaved" }); });
1115
943
  A(() => { if (entry?.$panel.pinned)
@@ -1128,10 +956,8 @@ export class PanelStackController {
1128
956
  {
1129
957
  label: "Close",
1130
958
  icon: closeIcon,
1131
- // Greyed out while the panel holds unsaved work: nothing may
1132
- // close it (`closePath` would refuse anyway). Read here, in the
1133
- // crumb's own scope, so the flag flipping redraws the crumb —
1134
- // the ● above and this menu entry stay one truth.
959
+ // Read here, in the crumb's own scope, so flipping the flag
960
+ // redraws the crumb and the ● above stays in step with this.
1135
961
  disabled: entry?.$panel.unsaved === true,
1136
962
  click: () => void this.closePath(path),
1137
963
  },
@@ -1139,9 +965,8 @@ export class PanelStackController {
1139
965
  });
1140
966
  }
1141
967
  /**
1142
- * Flip a panel's pin (see {@link Panel.pinned}). The flag lives on the panel;
1143
- * the current history entry's snapshot is rewritten too, so a reload keeps
1144
- * the pin — a same-panel state tweak, which the router applies unguarded.
968
+ * Flip a panel's pin (see {@link Panel.pinned}). The current history entry's
969
+ * snapshot is rewritten too, so a reload keeps the pin.
1145
970
  */
1146
971
  togglePin(entry) {
1147
972
  entry.$panel.pinned = !entry.$panel.pinned || undefined;
@@ -1149,10 +974,9 @@ export class PanelStackController {
1149
974
  }
1150
975
  // ── document.title ─────────────────────────────────────────────────────
1151
976
  /**
1152
- * `"<panel title> · <app title>"`, kept in sync with the current panel — and
1153
- * prefixed `"• "` while *any* open panel holds unsaved work, the way editors
1154
- * mark a dirty document. Any panel, not just the current one: the risk of
1155
- * losing the work is tab-wide, so the mark on the tab is too.
977
+ * `"<panel title> · <app title>"`, kept in sync with the current panel, and
978
+ * prefixed `"• "` while *any* open panel holds unsaved work — not just the
979
+ * current one, since the risk of losing it is tab-wide.
1156
980
  */
1157
981
  watchTitle() {
1158
982
  const original = document.title;
@@ -1160,8 +984,6 @@ export class PanelStackController {
1160
984
  const entry = this.$state.live[this.$state.focus];
1161
985
  const panelTitle = entry?.$panel.title ?? entry?.$ui.fallback;
1162
986
  const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
1163
- // Reading the stack re-runs this when its shape changes; each panel's
1164
- // own `unsaved` (up to the first dirty one) does the rest.
1165
987
  const dirty = this.$state.live.some((e) => e.$panel.unsaved);
1166
988
  const title = panelTitle && appTitle ? `${panelTitle} · ${appTitle}` : panelTitle || appTitle;
1167
989
  if (title)
@@ -1170,10 +992,9 @@ export class PanelStackController {
1170
992
  A.clean(() => { document.title = original; });
1171
993
  }
1172
994
  /**
1173
- * While any open panel holds unsaved work, closing the tab — or navigating
1174
- * the whole browser away — runs into the browser's own are-you-sure, with
1175
- * the unsaved panel brought on screen as the question is raised, so what is
1176
- * holding the tab is in front of the user rather than parked out of sight.
995
+ * While any open panel holds unsaved work, closing the tab runs into the
996
+ * browser's own are-you-sure, with that panel brought on screen as the
997
+ * question is raised rather than left parked out of sight.
1177
998
  */
1178
999
  guardTabClose() {
1179
1000
  if (typeof window === "undefined")
@@ -1184,19 +1005,19 @@ export class PanelStackController {
1184
1005
  return;
1185
1006
  e.preventDefault();
1186
1007
  e.returnValue = true; // Chrome/Edge < 119
1187
- // Bring the unsaved panel on screen right here, so what is holding the
1188
- // tab is in front of the user — behind the browser's dialog where the
1189
- // browser paints that early, and the moment they choose to stay
1190
- // otherwise. A confirmed leave unloads the document before any of it
1191
- // is seen; the history entry the move makes is then where a back
1192
- // navigation returns to, which is right: the panel that held the tab.
1008
+ // Bring the unsaved panel on screen, so what is holding the tab is in
1009
+ // front of the user the moment they choose to stay.
1010
+ // `visible` is written by the layout pass, which waits for an animation
1011
+ // frame that a tab on its way out may never get. Settle it first, or a
1012
+ // panel parked a moment ago still reads as on screen and the guard skips
1013
+ // the very move it exists to make.
1014
+ this.flushLayout();
1193
1015
  if (!dirty.$panel.visible)
1194
1016
  void this.focusAt(this.intended().stack.indexOf(dirty.path));
1195
1017
  };
1196
1018
  // Registered only while a panel actually holds unsaved work: a page with a
1197
- // `beforeunload` listener is shut out of the browser's back/forward cache,
1198
- // and that is a tax every navigation in the app would otherwise pay — for a
1199
- // guard that almost never has anything to guard.
1019
+ // `beforeunload` listener is shut out of the back/forward cache, a tax
1020
+ // every navigation in the app would otherwise pay.
1200
1021
  A(() => {
1201
1022
  if (!this.$state.live.some((entry) => entry.$panel.unsaved))
1202
1023
  return;
@@ -1208,26 +1029,23 @@ export class PanelStackController {
1208
1029
  /**
1209
1030
  * Draw the column viewport into the current element. Called by `main()`.
1210
1031
  *
1211
- * A column is the panel's own content, plus the one bit of chrome the shell
1212
- * places for it: its {@link Panel.actions}, in a strip on wide shells and in
1213
- * the top bar on narrow ones (see {@link drawActions}).
1032
+ * A column is the panel's own content plus the one bit of chrome the shell
1033
+ * places for it: its {@link Panel.actions} (see {@link drawActions}).
1214
1034
  */
1215
1035
  drawColumns() {
1216
1036
  const container = A("div.s-panels role=main", () => {
1217
- // Published before the first panel draws, rather than from the return
1218
- // value below: a panel sizes itself from the shell's measurements (see
1219
- // `measure`), and the first ones do that while this very call is still
1220
- // running. `A()` without arguments is "the element we're in".
1037
+ // Published here rather than from the return value below: a panel sizes
1038
+ // itself from the shell's measurements, and the first ones do that while
1039
+ // this call is still running.
1221
1040
  this.containerEl = A();
1222
- // Mounted and unmounted by path (see `$open`); a panel's DOM position
1223
- // among its siblings is its creation order, which is all the layering
1224
- // needs — `layout()` places and stacks the columns itself.
1041
+ // Mounted by path (see `$open`); DOM order is creation order, which is
1042
+ // all that's needed — `layout()` places and stacks the columns itself.
1225
1043
  A.onEach(this.$open, (entry) => this.drawPanel(entry), (entry) => entry.order);
1226
1044
  });
1227
1045
  if (typeof ResizeObserver !== "undefined") {
1228
- // The region *is* the content area every width is measured from (see
1229
- // `measure`), so watching it catches the lot: a window resize, the
1230
- // sidebar coming or going, the shell's own `maxWidth` changing.
1046
+ // The region *is* the content area every width is measured from, so
1047
+ // watching it catches a window resize, the sidebar coming or going, and
1048
+ // the shell's own `maxWidth` changing alike.
1231
1049
  const ro = new ResizeObserver(() => this.layout());
1232
1050
  ro.observe(container);
1233
1051
  A.clean(() => ro.disconnect());
@@ -1238,14 +1056,10 @@ export class PanelStackController {
1238
1056
  }
1239
1057
  drawPanel(entry) {
1240
1058
  let el;
1241
- // How much room the panel wants, resolved *before* its content is drawn: an
1242
- // element that arrives without a width has no box for its content to measure
1243
- // itself against until the next frame's layout pass, which is a frame too
1244
- // late for anything that sizes itself from its container. So the panel is
1245
- // created at the width the window gives it — the "medium" width until the
1246
- // panel says otherwise. Reactively, too: a panel that changes its mind later
1247
- // (when its data arrives, say) reflows in place rather than being redrawn,
1248
- // and the columns beside it slide over to make room.
1059
+ // How much room the panel wants, resolved *before* its content is drawn:
1060
+ // an element that arrives width-less has no box for its content to measure
1061
+ // against until the next frame — a frame too late. Reactive, so a panel
1062
+ // changing its mind later reflows in place rather than being redrawn.
1249
1063
  A(() => {
1250
1064
  const asked = entry.$panel.maxWidth;
1251
1065
  entry.maxWidth = asked === "small" || asked === "large" || asked === "none" ? asked : "medium";
@@ -1257,35 +1071,30 @@ export class PanelStackController {
1257
1071
  // to draw into without measuring it.
1258
1072
  if (A.peek(entry.$panel, "width") !== width)
1259
1073
  entry.$panel.width = width;
1260
- // The first run has no element to put it on yet — it's created with this
1261
- // width, just below. Later runs are the panel changing its mind.
1074
+ // The first run has no element yet — it's created with this width below.
1262
1075
  if (!el)
1263
1076
  return;
1264
1077
  el.style.width = `${width}px`;
1265
1078
  this.scheduleLayout();
1266
1079
  });
1267
1080
  el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node) => this.playExit(entry, node), () => {
1268
- // The actions strip, in its own scope: chrome may redraw freely — when
1269
- // the panel changes its actions, when the shell crosses the narrow
1270
- // threshold — but the body below never may.
1081
+ // In its own scope: the chrome may redraw freely, the body below may not.
1271
1082
  A(() => this.drawActions(entry));
1272
1083
  A("div.s-content", () => {
1273
1084
  entry.draw(entry.$panel);
1274
1085
  // After the content, so there is something to scroll when restoring.
1275
1086
  route.persistScroll(entry.path);
1276
- // A panel that named itself is done; one that didn't lends its
1277
- // first line of text (the DOM is already built — Aberdeen draws
1278
- // synchronously) to the crumbs and document.title, so neither
1279
- // ever goes blank. Peeked: a rename must not redraw the panel.
1087
+ // A panel that set no title borrows its first line of text (the DOM
1088
+ // is built already — Aberdeen draws synchronously) so the crumbs and
1089
+ // document.title never go blank. Peeked: a rename must not redraw.
1280
1090
  if (A.peek(entry.$panel, "title") == null) {
1281
1091
  const text = firstText(A());
1282
1092
  if (text && A.peek(entry.$ui, "fallback") !== text)
1283
1093
  entry.$ui.fallback = text;
1284
1094
  }
1285
1095
  });
1286
- // The loading hint, in its own scope so flipping the flag doesn't
1287
- // redraw the panel's content. Held-back panels show nothing yet: they
1288
- // are still parked off screen, waiting to slide in with real content.
1096
+ // Its own scope, so flipping the flag doesn't redraw the panel's content.
1097
+ // A held-back panel shows nothing: it is still off screen.
1289
1098
  A(() => {
1290
1099
  if (!entry.$panel.loading || entry.$ui.holding)
1291
1100
  return;
@@ -1293,11 +1102,10 @@ export class PanelStackController {
1293
1102
  });
1294
1103
  });
1295
1104
  entry.el = el;
1296
- // It has its width, but nothing animates from the arbitrary initial spot;
1297
- // `layout()` gives the panel its place in the run (and turns transitions
1298
- // back on) in the upcoming frame, before anything is painted. A redraw (a
1299
- // reactive dependency inside the handler) lands here too, with a brand-new
1300
- // element that has to be placed again before it may animate.
1105
+ // Nothing may animate from the arbitrary initial spot; `layout()` gives the
1106
+ // panel its place (and turns transitions back on) in the coming frame,
1107
+ // before anything is painted. A redraw lands here too, with a new element
1108
+ // that has to be placed again first.
1301
1109
  entry.placed = false;
1302
1110
  el.style.transition = "none";
1303
1111
  A.clean(() => { if (entry.el === el)
@@ -1312,9 +1120,7 @@ export class PanelStackController {
1312
1120
  /**
1313
1121
  * The one bit of column chrome the shell draws: the panel's actions, in a
1314
1122
  * quiet strip above the scroll area — and only while the shell is wide, the
1315
- * top bar carrying them otherwise. Everything else in a column is the panel's
1316
- * own content: a screen that wants a heading or a card draws them itself.
1317
- * Going back isn't here either — that is the breadcrumbs' job, in the bar.
1123
+ * top bar carrying them otherwise. Everything else is the panel's own content.
1318
1124
  */
1319
1125
  drawActions(entry) {
1320
1126
  if (this.opts.$shell.narrow || entry.$panel.actions == null)
@@ -1326,40 +1132,36 @@ export class PanelStackController {
1326
1132
  if (this.layoutQueued)
1327
1133
  return;
1328
1134
  this.layoutQueued = true;
1329
- requestAnimationFrame(() => {
1330
- this.layoutQueued = false;
1331
- this.layout();
1332
- });
1135
+ requestAnimationFrame(() => this.flushLayout());
1136
+ }
1137
+ /**
1138
+ * Run the pending layout pass now instead of on the frame it waits for, for
1139
+ * callers that must read what only the pass knows (`$panel.visible`,
1140
+ * `$panel.width`) and can't wait. Does nothing when no pass is owed.
1141
+ */
1142
+ flushLayout() {
1143
+ if (!this.layoutQueued)
1144
+ return;
1145
+ this.layoutQueued = false;
1146
+ this.layout();
1333
1147
  }
1334
1148
  /**
1335
1149
  * Measure the content area, and with it the width a panel of each size gets.
1150
+ * The column region *is* that area (CSS's doing), so there is nothing to add
1151
+ * up here that could drift from the bars above and below. Widths stay
1152
+ * fractional: a rounded column edge would drift a pixel away from that chrome.
1336
1153
  *
1337
- * The column region *is* the content area: it takes whatever the shell has
1338
- * left beside the sidebar, capped by the shell's own `maxWidth` — all of it
1339
- * CSS's doing, so there is nothing to add up here and nothing that could
1340
- * drift from the width the bars above and below line up with. Fractional
1341
- * widths throughout: a rounded column edge would drift a pixel away from that
1342
- * chrome.
1343
- *
1344
- * The area divides into the narrowest whole number of columns that keeps each
1345
- * at least {@link SMALL_MIN_PX} wide — the `"small"` unit every other size is
1346
- * a multiple of, capped at the area itself. So 1080px is three columns of 360
1347
- * and 1520px four of 380. An area too narrow for two is a single column,
1348
- * itself capped at {@link SMALL_MAX_PX}: a small centres there instead of
1349
- * stretching toward 720, so its ceiling holds, while the larger sizes still
1350
- * take the whole area. A width is thus a pure function of the window: a panel
1351
- * NEVER resizes because a neighbour came or went, and only a window resize
1352
- * (the snap pass in `layout`) changes one.
1154
+ * The area divides into the narrowest whole number of columns of at least
1155
+ * {@link SMALL_MIN_PX} — so 1080px is three of 360, 1520px four of 380 — each
1156
+ * capped at {@link SMALL_MAX_PX}. A width is therefore a pure function of the
1157
+ * window: a panel NEVER resizes because a neighbour came or went.
1353
1158
  *
1354
- * `undefined` while the shell has no width to speak of (it isn't in a document
1355
- * yet, or it's `display:none`); the next pass tries again.
1159
+ * `undefined` while the shell has no width yet (not in a document, or
1160
+ * `display:none`); the next pass tries again.
1356
1161
  */
1357
1162
  measure() {
1358
1163
  const el = this.containerEl;
1359
- // The rect is in window coordinates; the widths this yields are written
1360
- // back as CSS lengths, which live in the region's own space — different
1361
- // spaces when the shell has zoomed the page (see `watchScale` in main.ts).
1362
- const area = el ? el.getBoundingClientRect().width / cssZoom(el) : 0;
1164
+ const area = el ? el.getBoundingClientRect().width : 0;
1363
1165
  if (!area)
1364
1166
  return undefined;
1365
1167
  const small = Math.min(area / Math.max(1, Math.floor(area / SMALL_MIN_PX)), SMALL_MAX_PX);
@@ -1367,10 +1169,9 @@ export class PanelStackController {
1367
1169
  return { area, size: { small, medium: units(2), large: units(3), none: area } };
1368
1170
  }
1369
1171
  /**
1370
- * The measurements this pass runs on. Taken once per layout pass and per
1371
- * commit, and shared with the panels drawn in between — they all size
1372
- * themselves against the same shell, and a `getBoundingClientRect()` each
1373
- * would be a forced reflow each, in the middle of building their DOM.
1172
+ * The measurements this pass runs on, taken once per pass and per commit and
1173
+ * shared with the panels drawn in between: they must all size against the
1174
+ * same shell, and a `getBoundingClientRect()` each is a forced reflow each.
1374
1175
  */
1375
1176
  geometry() {
1376
1177
  return (this.geom ??= this.measure());
@@ -1380,25 +1181,21 @@ export class PanelStackController {
1380
1181
  return this.geometry()?.size[size] ?? 0;
1381
1182
  }
1382
1183
  /**
1383
- * Size and position every panel.
1384
- *
1385
- * This is everything CSS can't work out for itself: which panels exist, which
1386
- * of them are visible, how wide each one is and where it sits. All the motion
1387
- * between two of these arrangements is CSS's job.
1184
+ * Size and position every panel — everything CSS can't work out for itself:
1185
+ * which panels exist, which are visible, how wide each is and where it sits.
1186
+ * The motion between two such arrangements is CSS's job.
1388
1187
  */
1389
1188
  layout() {
1390
1189
  const container = this.containerEl;
1391
1190
  const shell = container?.closest(".s-main");
1392
1191
  if (!container || !shell)
1393
1192
  return;
1394
- // This pass always runs from a fresh frame (rAF, a ResizeObserver) — no
1395
- // reactive scope is active, so the stack and the panels are read plainly:
1396
- // nothing here can subscribe to anything.
1193
+ // Always runs from a fresh frame (rAF, a ResizeObserver), so no reactive
1194
+ // scope is active and nothing read here can subscribe to anything.
1397
1195
  const live = this.$state.live;
1398
1196
  const n = live.length;
1399
- // A panel that hasn't drawn yet has no width to contribute, which would make
1400
- // this pass's arithmetic (and any enter animation it triggers) meaningless.
1401
- // Every mount schedules another pass, so simply wait for it.
1197
+ // A panel that hasn't drawn yet has no width to contribute, which would
1198
+ // make this pass meaningless. Every mount schedules another, so just wait.
1402
1199
  if (!n || live.some((entry) => !entry.el))
1403
1200
  return;
1404
1201
  // This pass measures afresh — it is the one thing that runs after a resize.
@@ -1407,11 +1204,9 @@ export class PanelStackController {
1407
1204
  if (!geom)
1408
1205
  return;
1409
1206
  const single = this.opts.columns === "single";
1410
- // A window resize — or the app resizing the shell itself, by changing
1411
- // `navWidth` or `maxWidth` — must be adopted instantly: geometry tracking
1412
- // the window through a 450ms transition reads as lag, and a shell
1413
- // animating itself into place on its first pass reads as a glitch. Only
1414
- // what a *panel* did is worth animating, and none of those three are.
1207
+ // A resize of the window (or of the shell, via `navWidth`/`maxWidth`) must
1208
+ // be adopted instantly — geometry chasing the window through a transition
1209
+ // reads as lag. Only what a *panel* did is worth animating, so
1415
1210
  // `.s-shell-snap` suppresses every standing transition for this one pass.
1416
1211
  const snap = this.lastGeom?.area !== geom.area;
1417
1212
  if (snap) {
@@ -1419,9 +1214,8 @@ export class PanelStackController {
1419
1214
  shell.classList.add("s-shell-snap");
1420
1215
  }
1421
1216
  const width = (entry) => geom.size[entry.maxWidth];
1422
- // The visible run: as many columns as the window fits, at the sizes the
1423
- // window gives them, ending at the current panel — which always shows.
1424
- // Panels beyond it are parked past the right edge (see phase 1).
1217
+ // The visible run: as many columns as fit, ending at the current panel,
1218
+ // which always shows. Panels beyond it are parked (see phase 1).
1425
1219
  const cur = Math.min(this.$state.focus, n - 1);
1426
1220
  let first = cur;
1427
1221
  let runSum = width(live[cur]);
@@ -1434,38 +1228,32 @@ export class PanelStackController {
1434
1228
  first = i;
1435
1229
  }
1436
1230
  }
1437
- // The content area is a fixed width, so a run that doesn't fill it sits
1438
- // centred in it rather than hanging off its left edge. Everything around
1439
- // the columns holds still meanwhile: the sidebar, the top bar and the
1440
- // footer never move, however many columns come and go.
1231
+ // The content area is a fixed width, so a run that doesn't fill it centres
1232
+ // rather than hanging off the left edge — the chrome around it never moves.
1441
1233
  const left = (geom.area - runSum) / 2;
1442
1234
  for (let i = first; i <= cur; i++)
1443
1235
  live[i].width = width(live[i]);
1444
- // Panels that have never been visible get their would-be width too, so a
1445
- // reveal doesn't start from nothing.
1236
+ // Never-visible panels get their would-be width, so a reveal doesn't start
1237
+ // from nothing.
1446
1238
  for (const entry of live) {
1447
1239
  if (!entry.width)
1448
1240
  entry.width = width(entry);
1449
1241
  }
1450
- // Phase 1 — every panel's *start* state for this frame. Panels already on
1451
- // screen simply move (their standing transition animates it); freshly
1452
- // mounted ones still have transitions switched off, so what we set here is
1453
- // adopted instantly and becomes the "before" of their enter animation.
1242
+ // Phase 1 — every panel's *start* state for this frame. Panels on screen
1243
+ // simply move; freshly mounted ones still have transitions off, so what is
1244
+ // set here is adopted instantly and becomes the "before" of their enter.
1454
1245
  const fresh = [];
1455
1246
  let x = left;
1456
1247
  for (let i = 0; i < n; i++) {
1457
1248
  const entry = live[i];
1458
1249
  const el = entry.el;
1459
1250
  const shown = i >= first && i <= cur;
1460
- // Visible columns tile the run, left to right. Panels crowded out from
1461
- // under it rest at its left edge; panels beyond the current panel park
1462
- // just past its right edge — both keep their last width. Deeper panels
1463
- // layer over shallower ones, each on the odd layer for its depth (see
1464
- // LAYER_STEP).
1251
+ // Visible columns tile the run left to right; crowded-out ones rest at
1252
+ // its left edge and parked ones just past its right, both keeping their
1253
+ // last width. Deeper panels layer over shallower (see LAYER_STEP).
1465
1254
  place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
1466
- // What `$panel.visible` and `$panel.width` report: this pass is the one
1467
- // thing that knows them, window resizes included. Written only on a
1468
- // change, so per-panel UI hanging off them isn't rebuilt by every pass.
1255
+ // Written only on a change, so per-panel UI hanging off `visible` or
1256
+ // `width` isn't rebuilt by every pass.
1469
1257
  if (entry.$panel.visible !== shown)
1470
1258
  entry.$panel.visible = shown;
1471
1259
  if (entry.$panel.width !== entry.width)
@@ -1473,9 +1261,7 @@ export class PanelStackController {
1473
1261
  if (shown)
1474
1262
  x += entry.width;
1475
1263
  el.classList.toggle("s-panel-sep", shown && i > first);
1476
- // Off-screen panels fade out over the edge they park at and, once
1477
- // faded, stop being rendered at all — but they keep their DOM, and
1478
- // their scroll position.
1264
+ // Off-screen panels fade out and stop rendering, keeping their DOM.
1479
1265
  el.classList.toggle("s-panel-hidden", i < first);
1480
1266
  el.classList.toggle("s-panel-parked", i > cur);
1481
1267
  el.toggleAttribute("inert", !shown);
@@ -1490,14 +1276,13 @@ export class PanelStackController {
1490
1276
  entry.$ui.holding = true;
1491
1277
  this.holdEnter(entry);
1492
1278
  }
1493
- // Already at its resting place; the enter animation is the offset (and
1494
- // the transparency) it starts from, one edge to the right.
1279
+ // Already at its resting place; the enter is the offset and transparency
1280
+ // it starts from, one edge to the right.
1495
1281
  if (entry.enter && shown)
1496
1282
  el.classList.add("s-panel-enter");
1497
1283
  }
1498
- // Phase 2 — force the browser to adopt those start states (and, on a snap
1499
- // pass, the transition-free geometry) as the ones to animate *from*.
1500
- // (Reading a layout property is what does it.)
1284
+ // Phase 2 — reading a layout property forces the browser to adopt those
1285
+ // start states (and a snap pass's transition-free geometry) to animate from.
1501
1286
  if (fresh.length || snap)
1502
1287
  void container.offsetWidth;
1503
1288
  if (snap)