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 { type Slot, drawSlot, cssZoom, MIN_PX } from "../core.js";
3
+ import { type Slot, drawSlot } from "../core.js";
4
4
  import {
5
5
  circle as dotIcon, pin as pinIcon, pinOff as pinOffIcon,
6
6
  slash as sepIcon, x as closeIcon,
@@ -12,31 +12,20 @@ import { scrollStrip, revealInStrip } from "./tabs.js";
12
12
  /**
13
13
  * Routed, multi-column panel navigation for {@link main}.
14
14
  *
15
- * Each route draws one screen of the app, called a *panel*. The open panels
16
- * form a **stack**, whose last panel is the **current** one: the panel the URL
17
- * names, and the rightmost column on screen. As many panels as fit are shown,
18
- * ending at the current one — on a phone that is one at a time, on a wider
19
- * screen the panels that would have covered each other sit side by side
20
- * instead. The app's own code is the same either way.
15
+ * Each route draws one screen, called a *panel*. The open panels form a
16
+ * **stack** whose last panel is the **current** one: the panel the URL names,
17
+ * and the rightmost column on screen. As many as fit are shown, ending there.
21
18
  *
22
- * Whatever a navigation lands on becomes the top of that stack, and the same
23
- * path is never in it twice. Opening a new panel closes everything after the
24
- * panel it came from; a plain link to a panel that is already open — a
25
- * breadcrumb, a nav item for the section you are in — returns to its own
26
- * place, closing whatever was stacked on top, while a `replace` or `open`
27
- * applies its usual shape, the open panel moving into it alive.
28
- * Two kinds of panel survive that: the ones the user pinned, which ride along,
29
- * and the ones holding unsaved work, which no navigation ever tears down.
30
- * Those wait parked out of sight past the rightmost column, which is the only
31
- * way a panel ever sits *after* the current one. Escape closes the current
32
- * panel — or just steps left, when it holds unsaved work or panels sit parked
33
- * beyond it.
19
+ * A navigation's target becomes the top of the stack, and the same path is
20
+ * never in it twice: opening a panel closes everything after the one it came
21
+ * from, while a plain link to an already-open panel returns to its place. Two
22
+ * kinds survive that — pinned panels, which ride along, and unsaved ones, which
23
+ * nothing tears down. Both wait parked past the rightmost column, the only way
24
+ * a panel ever sits *after* the current one.
34
25
  *
35
- * Navigation runs through `aberdeen/route`: the URL holds the current panel,
36
- * and the rest of the arrangement — the panels before it, the ones parked
37
- * after it, and which are pinned — is stored beside it in the history entry.
38
- * So back and forward step through whole arrangements of columns, and a reload
39
- * (or a shared link) brings the same columns back.
26
+ * Navigation runs through `aberdeen/route`: the URL holds the current panel and
27
+ * the rest of the arrangement is stored beside it in the history entry, so back
28
+ * and forward step through whole arrangements of columns.
40
29
  */
41
30
 
42
31
  // ─── Route table typing ──────────────────────────────────────────────────────
@@ -129,6 +118,10 @@ export interface Panel<P = Record<string, string | number | string[]>> {
129
118
  * The params matched from this panel's path, typed per its route key:
130
119
  * `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
131
120
  * Read-only.
121
+ *
122
+ * Single-segment params are percent-decoded; `[...x]` is not, since decoding
123
+ * it would make an encoded slash indistinguishable from a separator. Split it
124
+ * yourself: `x.split("/").map(decodeURIComponent)`.
132
125
  */
133
126
  readonly params: P;
134
127
  /**
@@ -202,6 +195,10 @@ export interface Panel<P = Record<string, string | number | string[]>> {
202
195
  * window, never on what else is open, so opening or closing a panel never
203
196
  * resizes another — the run of columns just recentres in the area.
204
197
  *
198
+ * The ask is a ceiling only; there is no matching floor, since the window can
199
+ * be any width. Aim your layout at 360px — about the narrowest phone still in
200
+ * common use — and let it degrade gracefully below that.
201
+ *
205
202
  * Ask only for what your content can actually use: a panel that would cap
206
203
  * its own content narrower than its ask is holding room that would have
207
204
  * let another column fit beside it.
@@ -417,32 +414,25 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
417
414
 
418
415
  /**
419
416
  * The one duration every bit of shell motion shares: the enter/exit fades, the
420
- * `left` moves of columns shifting sideways, and the narrow-screen nav panel's
421
- * slide. Published as the `--s-panel-ms` custom property below, so CSS and JS
422
- * can't drift apart.
423
- *
424
- * Short enough to read as *the screen responded*, rather than as an animation
425
- * being played at you: a panel arriving is navigation, and navigation should
426
- * feel instant even when it moves.
417
+ * sideways `left` moves, the narrow-screen nav slide. Published below as the
418
+ * `--s-panel-ms` custom property, so CSS and JS can't drift apart.
427
419
  */
428
420
  const PAGE_MS = 250;
429
421
  /** How long a freshly pushed `loading` panel holds its enter animation. */
430
422
  const LOADING_HOLD_MS = 300;
431
423
  /**
432
- * The bounds of a column. At least 360px — the phone width every panel must
433
- * handle anyway. And at most 540: that is the width the multi-column regime
434
- * never reaches (`area / floor(area / 360)` stays under it), so capping the
435
- * lone column of a 540–720px area to it too — centred, rather than stretched —
436
- * makes an ask's ceiling uniform: never wider than its column count × 540.
424
+ * The bounds of a column: at least 360px (about the narrowest phone in common
425
+ * use, so where a panel's layout is aimed), at most 540. The multi-column
426
+ * regime never reaches 540 on its own, so capping the lone column of a 540–720px
427
+ * area to it as well makes every ask's ceiling uniformly column count × 540.
437
428
  */
438
- const SMALL_MIN_PX = MIN_PX;
429
+ const SMALL_MIN_PX = 360;
439
430
  export const SMALL_MAX_PX = 540;
440
431
  /**
441
- * Panels are layered by their depth in the stack, two `z-index` steps per panel:
442
- * a panel sits on the odd layer for its depth, and a *closing* one drops to the
443
- * even layer just below, where it is frozen for the length of its fade. So a
444
- * panel that replaces another comes in over it, while one that closes fades out
445
- * over whatever it was covering — which is the way round both should read.
432
+ * Two `z-index` steps per panel: a panel sits on the odd layer for its depth in
433
+ * the stack, a *closing* one on the even layer just below. So a replacement
434
+ * comes in over the panel it replaces, while a closing panel fades out over
435
+ * whatever it was covering.
446
436
  */
447
437
  const LAYER_STEP = 2;
448
438
 
@@ -450,129 +440,75 @@ const LAYER_STEP = 2;
450
440
 
451
441
  A.insertGlobalCss({
452
442
  ":root": `--s-panel-ms:${PAGE_MS}ms`,
453
- // The clipping viewport that the columns slide through. Panels are absolutely
454
- // positioned inside it, with their width and x offset set from JS (see
455
- // `layout()`), so they can animate between arrangements. `isolation` keeps the
456
- // layers they stack themselves in (see LAYER_STEP) to themselves: the region
457
- // as a whole still sits under the shell's own chrome — the sticky top bar, and
458
- // the nav panel that slides across the body — however deep the stack gets.
459
- // The region paints the columns' sheen over its own box, and every panel
460
- // paints the very same one (see `.s-panel` below) — which, being a straight
461
- // vertical wash over boxes of one height, comes out identical whatever a
462
- // column's width, so the columns and the ground beside them are one
463
- // continuous surface.
443
+ // The clipping viewport the columns slide through; panels are absolutely
444
+ // positioned inside it, sized and offset from JS (see `layout()`).
445
+ // `isolation` keeps their z-index layers (see LAYER_STEP) below the shell's
446
+ // own chrome. It paints the same PANEL_SHEEN as every panel, so columns and
447
+ // the ground beside them read as one surface.
464
448
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
465
- // and anything that ever scrolls it — find-in-page reaching for text in a
466
- // parked column, an in-page anchor, an extension — shifts every column
467
- // sideways, permanently, because nothing here would ever scroll it back.
468
- // `clip` clips without being scrollable at all, closing the whole class.
449
+ // and anything that scrolls it (find-in-page, an in-page anchor) shifts every
450
+ // column sideways permanently, with nothing to scroll it back.
469
451
  ".s-panels":
470
452
  "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
471
453
  PANEL_SHEEN,
472
454
  ".s-panel": {
473
- // A panel rests at a plain `left` offset and carries no transform: a
474
- // transformed element is composited, which costs it subpixel text
475
- // antialiasing. `transform` is used only to play the enter/exit slides,
476
- // where the compositing is what makes them cheap. There is deliberately no
477
- // `width` transition: a width changes only when the window resizes or when
478
- // the panel itself asks for another layout, and animating one would reflow
479
- // the column's content on every frame of it.
480
- // Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
481
- // and the chrome recentering around them all run as one motion. The drift
482
- // eases out (it should read as a slow settle) while the fade runs *linear*
483
- // across the whole duration — an eased opacity spends its last stretch near
484
- // zero, which looks like the panel vanishing rather than fading.
485
- // No `overflow:hidden` here: the scroll container below clips the content
486
- // itself.
487
- // Layering is set from JS (`layout()` and `beginClose`) rather than left to
488
- // DOM order: a closing panel is no longer part of the reactive list, so
489
- // where its element sits among the live ones is Aberdeen's business, not a
490
- // thing to depend on. `LAYER_*` says what the numbers mean.
491
- //
492
- // Every panel paints an opaque ground, because panels animate over one
493
- // another — entering, leaving, being crowded out — and two transparent ones
494
- // mean text sliding over text. It takes {@link PANEL_SHEEN}, resolved here
495
- // against the inherited `--s-bg` (a panel is not a surface, so it has to
496
- // paint it itself).
497
- //
498
- // Painted per panel, over the panel's own box — and yet seamless with its
499
- // neighbours and with the ground beside them, because that wash runs
500
- // straight down: it takes its extent from the height these boxes all share,
501
- // never from their differing widths. See PANEL_SHEEN for why that matters.
455
+ // A panel rests at a plain `left` offset with no transform: a transformed
456
+ // element is composited, costing it subpixel text antialiasing. Transform
457
+ // is used only for the enter/exit slides. No `width` transition either —
458
+ // animating one reflows the column's content every frame.
459
+ // The fade is deliberately `linear` while the drift eases out: an eased
460
+ // opacity spends its last stretch near zero, reading as a vanish.
461
+ // Layering is set from JS (`layout()`, `beginClose`), not DOM order: a
462
+ // closing panel is no longer in the reactive list, so its element's
463
+ // position among the live ones is Aberdeen's business. See LAYER_STEP.
464
+ // PANEL_SHEEN gives every panel an opaque ground (panels animate over one
465
+ // another, and two transparent ones mean text sliding over text).
502
466
  "&":
503
467
  "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
504
468
  PANEL_SHEEN + " " +
505
469
  "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
506
- // The hairline between two columns, fading out at both ends — the same
507
- // treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
508
- // gutter between them: each already brings its own `$3` of padding, which
509
- // keeps two columns' *contents* comfortably apart, while a gutter on top
510
- // of that only opened a strip of the panel's own ground between two columns
511
- // painting theirs. So this sits exactly on the boundary — and an
512
- // edge-to-edge column (`A("p:0")`) really does reach the line bounding it.
470
+ // The hairline between two columns, fading at both ends (like the sidebar's
471
+ // `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
472
+ // padding — so this sits exactly on the boundary.
513
473
  "&.s-panel-sep::before":
514
474
  "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
515
475
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
516
- // One vocabulary for every arrival and departure: a gradual fade over a short,
517
- // slow drift — 8cqw (`cqw`: `.s-main` is the container). Panels appear and
518
- // leave at the right edge; being crowded out at the left edge is its mirror.
519
- //
520
- // The start state of an enter, adopted with transitions off and then
521
- // dropped, which is what makes the panel settle instead of jumping.
476
+ // Enter and exit share one vocabulary: a fade over a short 8cqw drift
477
+ // (`cqw`: `.s-main` is the container). This start state is applied with
478
+ // transitions off and then dropped, so the panel settles instead of jumping.
522
479
  "&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
523
- // On its way out: fading where it stands, drifting the same short distance,
524
- // and out of reach while it does. It leaves the DOM when the fade itself
525
- // ends (see `playExit`), never part-way through it.
480
+ // On its way out; leaves the DOM when the fade ends (see `playExit`).
526
481
  "&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
527
- // Off screen but open: crowded out from under the visible run at the left
528
- // edge (`hidden`), or right of the current panel, parked past the right
529
- // edge (`parked`) — the two are mirror images. Either keeps its DOM (and
530
- // thus its scroll position and half-typed forms), so `display:none` is out
531
- // — `visibility` takes it out of the rendering instead, but only once the
532
- // fade has played: a transitioned `visibility` counts as *visible* for the
533
- // whole duration and flips at the very end. Revealing it again uses the
534
- // rule above (`visibility 0s`), so it comes back instantly.
482
+ // Off screen but open: crowded out at the left edge (`hidden`) or parked
483
+ // past the right one (`parked`). Both keep their DOM — and so their scroll
484
+ // position and half-typed forms — hence `visibility`, not `display:none`.
485
+ // Transitioning it counts as *visible* for the whole fade, flipping at the
486
+ // very end; the rule above (`visibility 0s`) reveals it again instantly.
535
487
  "&.s-panel-hidden, &.s-panel-parked":
536
488
  "opacity:0 visibility:hidden " +
537
489
  "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);",
538
490
  "&.s-panel-hidden": "transform: translateX(-8cqw);",
539
491
  "&.s-panel-parked": "transform: translateX(8cqw);",
540
492
  },
541
- // The scroll container, with the column's own padding. A scrollbar here is
542
- // left flush against the column's right edge — unlike content mode's, which
543
- // insets one from the shell edge to line up with the bar above it. A column
544
- // has something better to line up with: the hairline the next column starts
545
- // at, or the edge of the content area. Both want the bar hard against them,
546
- // and an inset would leave a strip of nothing between the two.
493
+ // The scroll container, with the column's own padding. Its scrollbar sits
494
+ // flush against the column edge (unlike content mode's inset one), so it meets
495
+ // the next column's hairline with no strip of nothing between.
547
496
  ".s-panel > .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
548
497
  // A panel's actions on a wide shell: a quiet strip at the column's top-right,
549
498
  // above the scroll area (never sticky inside it). On a narrow shell the top
550
499
  // bar carries them instead, and no strip is drawn at all.
551
500
  ".s-panel-actions": "display:flex align-items:center justify-content:flex-end gap:$1 flex-shrink:0 padding: $3 $3 0;",
552
- // The breadcrumb stack the top bar shows: every open panel, oldest first,
553
- // the ones on screen right now in bold ink (see `drawCrumbs`). One row that
554
- // scrolls sideways when the bar is tight — scrollbarless, with a fade at
555
- // whichever edge has more stack behind it, so the cut-off reads as "keep
556
- // going" rather than as the stack simply ending.
557
- // The stack is a `.s-strip` (see tabs.ts), so the scrolling, the hidden
558
- // scrollbar and the ‹ / › come with it; all that is left to say is the gap
559
- // between a crumb and its chevron.
501
+ // The breadcrumb stack the top bar shows: every open panel, oldest first, the
502
+ // ones on screen in bold ink (see `drawCrumbs`). It is a `.s-strip` (see
503
+ // tabs.ts), so scrolling and the ‹ / › come with it; only the gap is left.
560
504
  ".s-crumbs > .s-strip-row": "gap:$m1",
561
505
  ".s-crumb": {
562
- // Quiet ink for the stack, full ink and weight for the panels on screen:
563
- // the weight change alone is ambiguous in a short crumb, the colour alone
564
- // too subtle. No padding of its own — the first crumb has to start on the
565
- // same pixel as the app's name above it, and the gap below spaces the row.
566
- // The flex is how a tight row is shared out. Every crumb grows from the
567
- // same 4rem basis in equal shares, freezing at its own text
568
- // (`max-width:max-content`) — so with room to spare every title shows in
569
- // full, and under pressure it is the *longest* crumbs that give way
570
- // first, equalising downward while short ones keep every character. No
571
- // crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
572
- // that point the row overflows and the strip scrolls — which is what
573
- // keeps a deep stack on a phone readable. (Crumbs allowed to shrink
574
- // would ellipsise to a row of stubs instead, and the stack would never
575
- // scroll.)
506
+ // Quiet ink for the stack, full ink and weight for the panels on screen.
507
+ // No padding of its own: the first crumb must start on the same pixel as
508
+ // the app's name above it. The flex shares out a tight row — every crumb
509
+ // grows from a 4rem basis and freezes at its own text, so the longest give
510
+ // way first. `flex-shrink:0` is what stops them ellipsising into a row of
511
+ // stubs: past that point the row overflows and the strip scrolls instead.
576
512
  "&":
577
513
  "flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
578
514
  "white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
@@ -581,23 +517,19 @@ A.insertGlobalCss({
581
517
  // The same hover treatment as a menu item. The panel you are on is a plain
582
518
  // span rather than a link, so it needs no `:not()` guard here.
583
519
  "a&:hover": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
584
- // The pin of a pinned panel, sitting inline just before its title. Filled:
585
- // at crumb size the icon's hairline strokes alias into a wobble, while the
586
- // body path filled solid reads as the classic pinned-tab pushpin.
520
+ // The pin of a pinned panel, inline before its title. Filled, because at
521
+ // crumb size the icon's hairline strokes alias into a wobble.
587
522
  "svg.s-crumb-pin": "vertical-align:-0.12em margin-right:0.3em opacity:0.8 fill:currentColor",
588
523
  // The ● of a panel holding unsaved work — the editors' dirty mark, filled
589
524
  // solid for the same reason as the pin.
590
525
  "svg.s-crumb-unsaved": "vertical-align:0.08em margin-right:0.3em fill:currentColor",
591
526
  },
592
- // A slash, not a chevron: the stack is a path, and a path's separator is what
593
- // the URL itself uses. It also has to stay clearly *unlike* the ‹ / › the
594
- // strip grows when the stack overflows (see `scrollStrip` in tabs.ts) — two
595
- // near-identical chevrons, one meaningful and one a button, read as a bug.
527
+ // A slash, not a chevron: the stack is a path, and a chevron would be
528
+ // confusable with the strip's own ‹ / › scroll buttons (see tabs.ts).
596
529
  "svg.s-crumb-sep": "flex-shrink:0 opacity:0.4",
597
- // A window resize (and the very first pass) must track the window instantly,
598
- // not rubber-band 450ms behind it: the layout engine raises this class on the
599
- // shell for exactly those passes, applies the new geometry, and drops it
600
- // after a forced reflow. Beats the standing transitions on specificity.
530
+ // A resize (and the first pass) must track the window instantly rather than
531
+ // rubber-band behind it: the layout engine raises this class, applies the new
532
+ // geometry, and drops it after a forced reflow.
601
533
  ".s-main.s-shell-snap .s-panel": "transition:none",
602
534
  // A minimal "still fetching" hint, centred over the panel's content (which
603
535
  // stays mounted underneath, so it can fill in reactively).
@@ -616,29 +548,25 @@ A.insertGlobalCss({
616
548
  // ─── Panel entries ───────────────────────────────────────────────────────────
617
549
 
618
550
  /**
619
- * A {@link Panel} as the controller holds it: the same object the handler gets,
620
- * minus the `readonly`s. `width` and `visible` are read-only *to the app* —
621
- * they are facts about the panel, not requests — but the shell keeps them up to
622
- * date by writing them, which is what makes reading them reactive.
551
+ * A {@link Panel} as the controller holds it: the handler's own object, minus
552
+ * the `readonly`s — `width` and `visible` are read-only to the app only; the
553
+ * shell writes them, which is what makes reading them reactive.
623
554
  */
624
555
  type PanelState = { -readonly [K in keyof Panel<any>]: Panel<any>[K] };
625
556
 
626
557
  interface PanelEntry {
627
558
  /**
628
- * Opaque to Aberdeen: an entry rides inside the reactive `$state` and
629
- * `$open` collections, but is itself the controller's plain state — its
630
- * reactive faces are `$panel` (the app's) and `$ui` (the shell's own).
631
- * Without this, reading an entry back out of either collection would wrap
632
- * it in a proxy, DOM element, draw function and all.
559
+ * Opaque to Aberdeen: without it, reading an entry back out of the reactive
560
+ * `$state`/`$open` collections would wrap the whole thing — DOM element and
561
+ * draw function included — in a proxy. Its reactive faces are `$panel`/`$ui`.
633
562
  */
634
563
  readonly [OPAQUE]: true;
635
564
  /**
636
565
  * The DOM sort key: one shared counter, so panels sit in creation order.
637
- * Deliberately never updated: rewriting it would make Aberdeen redraw the
638
- * panel, throwing away the scroll position and half-typed forms rule 5
639
- * promises to keep. So the DOM order drifts from the stack order once
640
- * panels are spliced or restored out of sequence — invisible, since panels
641
- * are absolutely positioned, beyond a small drift in tab order.
566
+ * Never updated — rewriting it would make Aberdeen redraw the panel, losing
567
+ * the scroll position and half-typed forms. DOM order therefore drifts from
568
+ * stack order once panels are spliced out of sequence; invisible beyond tab
569
+ * order, since panels are absolutely positioned.
642
570
  */
643
571
  order: number;
644
572
  path: string;
@@ -649,20 +577,18 @@ interface PanelEntry {
649
577
  holding: boolean;
650
578
  /**
651
579
  * The title borrowed from the panel's first line of text, for a panel that
652
- * never set one. Kept beside `$panel.title` rather than written into it,
653
- * so the app's own field only ever holds what the app wrote — and so a
654
- * body redraw can refresh the borrowed text, which a write into `title`
655
- * would have frozen.
580
+ * set none. Kept beside `$panel.title` rather than written into it, so the
581
+ * app's field only holds what the app wrote and a body redraw can refresh
582
+ * the borrowed text.
656
583
  */
657
584
  fallback?: string;
658
585
  };
659
586
  el?: HTMLElement;
660
587
  /**
661
- * The search params and hash the URL held while this panel was last the
662
- * current panel, stashed when the focus moves off it and restored when a
663
- * crumb (or any link) makes it current again. Search and hash belong to
664
- * the current panel only, so this is the one place they survive a visit
665
- * elsewhere along the stack.
588
+ * The search params and hash from while this panel was last current, stashed
589
+ * when the focus moves off it and restored when it becomes current again.
590
+ * Search and hash belong to the current panel only, so this is the one place
591
+ * they survive a visit elsewhere along the stack.
666
592
  */
667
593
  search?: Record<string, string>;
668
594
  hash?: string;
@@ -677,19 +603,17 @@ interface PanelEntry {
677
603
  /** What the panel asks for, kept in step with its `$panel.maxWidth`. */
678
604
  maxWidth: PanelSize;
679
605
  /**
680
- * The width it was last laid out at. Set before the panel's content is first
681
- * drawn, so that content has a real box to measure itself against. Visible
682
- * panels get a fresh value every pass (a width is a pure function of the
683
- * content area); hidden and closing panels keep this, so nothing invisible
606
+ * The width it was last laid out at, set before the content is first drawn so
607
+ * that content has a real box to measure against. Visible panels get a fresh
608
+ * value every pass; hidden and closing ones keep this, so nothing invisible
684
609
  * ever reflows.
685
610
  */
686
611
  width: number;
687
612
  }
688
613
 
689
614
  /**
690
- * What the shell measures out to, and with it the width every panel size gets.
691
- * A pure function of the content area, so it is the same for every panel in a
692
- * pass — and a panel never resizes because a neighbour came or went.
615
+ * What the shell measures out to, and with it every panel size. A pure function
616
+ * of the content area, so a panel never resizes because a neighbour came or went.
693
617
  */
694
618
  interface Geometry {
695
619
  /** The content area: all the room the columns have between them. */
@@ -699,11 +623,10 @@ interface Geometry {
699
623
  }
700
624
 
701
625
  /**
702
- * One state of the stack: the open paths, oldest first, and which of them is
703
- * the current panel. The panels before `focus` sit (or are crowded out) to the
704
- * current panel's left; the ones after it are parked out of sight — panels
705
- * that refused to be closed, which is the only way anything ends up there. What a history entry describes, and what every navigation is
706
- * expressed as a change to.
626
+ * One state of the stack: the open paths, oldest first, and which is current.
627
+ * Panels before `focus` sit to its left; those after it are parked out of sight,
628
+ * having refused to close — the only way anything ends up there. This is what a
629
+ * history entry describes, and what every navigation is expressed as a change to.
707
630
  */
708
631
  interface Arrangement {
709
632
  stack: string[];
@@ -725,18 +648,16 @@ export interface PanelStackOptions {
725
648
  /** The shell's own title, used as the suffix of `document.title`. */
726
649
  title?: unknown;
727
650
  /**
728
- * The shell's live narrow flag (see `main()`), which decides where a panel's
729
- * chrome goes: in its own column, or promoted into the top bar. Shared rather
730
- * than measured again here, so the bar and the columns can't disagree about
731
- * which regime they are in.
651
+ * The shell's live narrow flag (see `main()`), deciding whether a panel's
652
+ * chrome sits in its own column or is promoted into the top bar. Shared
653
+ * rather than measured again, so the two can't disagree about the regime.
732
654
  */
733
655
  $shell: { narrow: boolean };
734
656
  }
735
657
 
736
658
  /**
737
- * The URL is global, so two routed shells would fight over it. This is only a
738
- * guard against that — the stack is reached through the object `main()` hands
739
- * back, never through a module-level singleton.
659
+ * The URL is global, so two routed shells would fight over it; this only guards
660
+ * against that. The stack itself is reached through `main()`'s return value.
740
661
  */
741
662
  let mounted = false;
742
663
 
@@ -844,10 +765,9 @@ export interface PanelStack {
844
765
  */
845
766
  export class PanelStackController implements PanelStack {
846
767
  /**
847
- * Kept out of Aberdeen's proxy wrapping: this is a class instance holding
848
- * DOM nodes, timers and route handlers, and it rides inside every
849
- * {@link Panel.stack}. Its reactivity doesn't need the wrapper — it comes
850
- * from `$state` and the panels, which are proxies in their own right.
768
+ * Kept out of Aberdeen's proxy wrapping: a class instance holding DOM nodes,
769
+ * timers and route handlers, riding inside every {@link Panel.stack}. Its
770
+ * reactivity comes from `$state` and the panels, which are proxies already.
851
771
  */
852
772
  readonly [OPAQUE] = true;
853
773
  private compiled: CompiledRoute[];
@@ -855,35 +775,24 @@ export class PanelStackController implements PanelStack {
855
775
  private ancestors: { key: string; segs: Seg[]; fn: AncestorsHandler }[];
856
776
  private opts: PanelStackOptions;
857
777
  /**
858
- * The live stack, oldest first (closing panels are no longer part of it),
859
- * and which of its panels is current — the one reactive fact about the
860
- * stack's *shape*. The getters, the crumbs and `document.title` subscribe
861
- * to it simply by reading it; each commit publishes the next shape by
862
- * assigning a fresh `live` array. The entries themselves are opaque (see
863
- * {@link PanelEntry}), so the array carries their comings, goings and
864
- * order — nothing deeper; a panel's own facts stay separately reactive on
865
- * its `$panel`, which is what lets a panel rename itself without the
866
- * stack redrawing.
778
+ * The live stack, oldest first (closing panels have left it), and which panel
779
+ * is current — the one reactive fact about the stack's *shape*. Each commit
780
+ * publishes the next shape by assigning a fresh `live` array; the entries are
781
+ * opaque, so a panel renaming itself doesn't redraw the stack.
867
782
  *
868
- * One rule makes this safe to touch from anywhere: **queries subscribe,
869
- * commands peek**. The getters below are the queries. Every navigation
870
- * entry point (`navigate`, `closePath`, `back`, …) wraps itself in
871
- * `A.peek`, so an app calling one from inside a reactive scope (a
872
- * redirect in a route handler, say) can't subscribe that scope to the
873
- * very stack it is changing — and everything those commands call through
874
- * to, `propose` and `commit` included, inherits the same guarantee and
875
- * reads the stack plainly.
783
+ * The invariant that makes this safe to touch from anywhere: **queries
784
+ * subscribe, commands peek**. Every navigation entry point (`navigate`,
785
+ * `closePath`, `back`, …) wraps itself in `A.peek`, so an app calling one
786
+ * from a reactive scope can't subscribe that scope to the stack it is
787
+ * changing; `propose` and `commit` inherit that and read the stack plainly.
876
788
  */
877
789
  private $state = A.proxy({ live: [] as PanelEntry[], focus: 0 });
878
790
  /**
879
- * The open panels again, keyed by path — the shape as the DOM consumes it.
880
- * `drawColumns`' `onEach` mounts and unmounts panels by key, so a panel
881
- * spliced out of the middle of the stack touches exactly one key, and the
882
- * DOM of the retained columns — scroll positions, half-typed forms — is
883
- * left alone. (Iterating `live` itself would key panels by array index,
884
- * and a splice renumbers every index after it, redrawing them all.) A
885
- * key's value is its entry, by reference, and is never reassigned, so a
886
- * panel only ever redraws wholesale when its path closes.
791
+ * The open panels again, keyed by path — the shape the DOM consumes.
792
+ * `drawColumns`' `onEach` mounts by key, so splicing a panel out of the
793
+ * middle touches exactly one key and the retained columns keep their scroll
794
+ * positions and half-typed forms. (Iterating `live` would key by array index,
795
+ * which a splice renumbers, redrawing every panel after it.)
887
796
  */
888
797
  private $open = A.proxy<Record<string, PanelEntry>>({});
889
798
  /** Feeds {@link PanelEntry.order}: one shared counter, so keys never tie. */
@@ -915,25 +824,20 @@ export class PanelStackController implements PanelStack {
915
824
  .filter((entry): entry is [string, AncestorsHandler] => entry[1] != null)
916
825
  .map(([key, fn]) => ({ ...compileKey(key), fn }));
917
826
 
918
- // Commit the stack whenever the URL or its snapshot changes — the initial
919
- // load, our own navigations, and browser back/forward. There is no route
920
- // guard of the shell's own to pass first: closes are refused up front
921
- // (`closePath` on an unsaved panel) or repaired at the commit (`propose`
922
- // keeps unsaved panels a navigation would drop), so a guard the app
923
- // itself registered with `route.setGuard` — an auth redirect, say — is
924
- // left exactly where it is and keeps working untouched.
827
+ // Commit the stack whenever the URL or its snapshot changes — initial load,
828
+ // our own navigations, browser back/forward. The shell registers no route
829
+ // guard of its own (closes are refused in `closePath` or repaired in
830
+ // `propose`), so an app's `route.setGuard` keeps working untouched.
925
831
  A(() => {
926
832
  const target = this.computeTarget();
927
- // Subscribed (not peeked) deliberately: a search/hash change with the
928
- // path staying put must refresh `lastSeen` too, or the next stash would
929
- // restore stale ones.
833
+ // Subscribed, not peeked: a search/hash change with the path staying put
834
+ // must refresh `lastSeen` too, or the next stash restores stale ones.
930
835
  const search = { ...route.current.search };
931
836
  const hash = route.current.hash;
932
837
  A.peek(() => {
933
- // Stash the query of the panel the URL just left on that panel, for
934
- // when a crumb brings it back (see PanelEntry.search). Done here, on
935
- // the shared pipeline, so every origin is covered alike: the stack's
936
- // own navigations, an app's `route.go()`, and browser back/forward.
838
+ // Stash the query of the panel the URL just left, for when a crumb
839
+ // brings it back (see PanelEntry.search). Here on the shared pipeline,
840
+ // so our navigations, `route.go()` and back/forward are all covered.
937
841
  const prev = this.lastSeen;
938
842
  if (prev && prev.path !== route.current.path) {
939
843
  const entry = this.$state.live.find((e) => e.path === prev.path);
@@ -944,11 +848,10 @@ export class PanelStackController implements PanelStack {
944
848
  }
945
849
  this.lastSeen = { path: route.current.path, search, hash };
946
850
  this.propose(target);
947
- // A history entry that doesn't describe an arrangement — the initial
948
- // load, or an app's own `route.go()` — is stamped with the one it
949
- // just produced: a reload restores the same columns, and a later
950
- // `route.back()` can recognize the entry (its matching wants the
951
- // `panels`/`parked` keys present, not merely compatible).
851
+ // A history entry describing no arrangement (initial load, an app's own
852
+ // `route.go()`) is stamped with the one it just produced, so a reload
853
+ // restores the same columns and `route.back()` can recognize the entry
854
+ // — its matching wants the `panels`/`parked` keys actually present.
952
855
  if (!Array.isArray(route.current.state.panels)) {
953
856
  Object.assign(route.current.state, this.stateFor({ stack: this.paths(), focus: this.$state.focus }));
954
857
  }
@@ -962,8 +865,7 @@ export class PanelStackController implements PanelStack {
962
865
  A.clean(() => {
963
866
  for (const t of this.timers) clearTimeout(t);
964
867
  this.timers.clear();
965
- // Nothing is going to navigate a shell that isn't there: whatever was
966
- // waiting its turn is answered rather than left hanging.
868
+ // Answer whatever was waiting its turn, rather than leave it hanging.
967
869
  this.queued?.settle(false);
968
870
  this.queued = null;
969
871
  mounted = false;
@@ -989,17 +891,12 @@ export class PanelStackController implements PanelStack {
989
891
 
990
892
  /**
991
893
  * The stack for origin-less navigation: a cold deep link, a nav item, a
992
- * `route.go()` — anything arriving without a panel to build on and without a
993
- * snapshot to restore.
994
- *
995
- * The app's {@link PanelStackOptions.ancestors} gets first say, since only it
996
- * can know what belongs under a path that doesn't spell its own context out
997
- * (a `/thread/[id]` reached from a notification). Failing that — or when it
998
- * has no opinion — every prefix of the path is probed against the route table
999
- * and the matching ones become the stack. Either way, a path with no route is
1000
- * skipped rather than opened as a "not found" column, so an app that doesn't
1001
- * want one screen stacked under another simply doesn't route it. The path
1002
- * itself always ends the derived stack, matched or not.
894
+ * `route.go()` — anything with no panel to build on and no snapshot to
895
+ * restore. {@link PanelStackOptions.ancestors} gets first say; failing that,
896
+ * every prefix of the path is probed against the route table. A path with no
897
+ * route is skipped rather than opened as a "not found" column, so not routing
898
+ * a screen keeps it from appearing under another. The path itself always ends
899
+ * the derived stack, matched or not.
1003
900
  */
1004
901
  private deriveStack(path: string): string[] {
1005
902
  const top = normalizePath(path);
@@ -1014,10 +911,9 @@ export class PanelStackController implements PanelStack {
1014
911
  }
1015
912
 
1016
913
  /**
1017
- * Ask the `ancestors` table what belongs beneath `path`. The first key that
1018
- * matches answers — with its own matched params, so it never has to take the
1019
- * path apart itself — and `undefined` from it means "no opinion", leaving the
1020
- * path to the prefix derivation just as an unlisted one is.
914
+ * Ask the `ancestors` table what belongs beneath `path`. The first matching
915
+ * key answers, with its own matched params; `undefined` means "no opinion",
916
+ * leaving the path to the prefix derivation as an unlisted one would be.
1021
917
  */
1022
918
  private askAncestors(path: string): readonly string[] | undefined {
1023
919
  const segments = splitPath(path);
@@ -1054,23 +950,20 @@ export class PanelStackController implements PanelStack {
1054
950
  }
1055
951
 
1056
952
  /**
1057
- * The arrangement a route implies: its snapshot around its path, or —
1058
- * without a snapshot — derived, with the new panel current at the end and
1059
- * any pinned panels carried along beneath it.
953
+ * The arrangement a route implies: its snapshot around its path, or — without
954
+ * one — derived, the new panel current at the end with pinned panels beneath.
1060
955
  *
1061
- * The snapshot reads subscribe — they are the URL's, exactly what the
1062
- * route observer is for. The derivation is peeked instead: it reads the
1063
- * live stack for its pins, which is the very thing that observer rewrites,
1064
- * and subscribing to it would re-run the observer once per commit.
956
+ * The snapshot reads subscribe (they are the URL's). The derivation is peeked:
957
+ * it reads the live stack for its pins, which the route observer rewrites, so
958
+ * subscribing would re-run that observer once per commit.
1065
959
  */
1066
960
  private targetFor(path: string, state: Record<string, any>): Arrangement {
1067
961
  const before = Array.isArray(state?.panels) ? state.panels.map(String) : null;
1068
962
  if (before) {
1069
963
  const after = Array.isArray(state.parked) ? state.parked.map(String) : [];
1070
964
  // A stack never holds the same path twice: rendering reconciles by path,
1071
- // so a duplicate would leave a permanently element-less entry that stalls
1072
- // the layout. Our own states are clean, but `route.go` accepts
1073
- // hand-written ones — drop duplicates rather than wedge.
965
+ // so a duplicate leaves a permanently element-less entry that stalls the
966
+ // layout. `route.go` accepts hand-written states, so drop rather than wedge.
1074
967
  const cur = normalizePath(path);
1075
968
  const seen = new Set([cur]);
1076
969
  const uniq = (paths: string[]) =>
@@ -1097,13 +990,11 @@ export class PanelStackController implements PanelStack {
1097
990
  }
1098
991
 
1099
992
  /**
1100
- * Adopt an arrangement proposed by the URL — after repairing it: panels
1101
- * holding unsaved work are never torn down by a navigation, wherever it
1102
- * came from — a link, a nav item, even a browser back to an entry from
1103
- * before the panel existed. Whatever the target drops, they stay, parked
1104
- * after the current panel and wearing the ● that says why. (They are
1105
- * deliberately not written into history entries: the work they protect
1106
- * lives in the page's DOM, which a reload clears anyway.)
993
+ * Adopt an arrangement proposed by the URL, after repairing it: a panel holding
994
+ * unsaved work is never torn down by any navigation — including a back to an
995
+ * entry from before it existed — so whatever the target drops, it stays, parked
996
+ * after the current panel. Parked panels are deliberately kept out of history
997
+ * entries: the work they protect lives in DOM a reload clears anyway.
1107
998
  */
1108
999
  private propose(target: Arrangement): void {
1109
1000
  const kept = this.$state.live
@@ -1118,20 +1009,17 @@ export class PanelStackController implements PanelStack {
1118
1009
  * Apply a target arrangement: unmount what's gone, mount what's new, animate
1119
1010
  * the difference.
1120
1011
  *
1121
- * Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
1122
- * well-defined): a panel present in both stacks stays mounted *even if its
1123
- * index shifted*, which is what lets a panel be spliced out of the middle
1124
- * (§7) without disturbing the columns above it. A common-prefix diff would
1125
- * remount every one of them, throwing away exactly the scroll and form state
1126
- * rule 5 promises to keep.
1012
+ * Reconciliation is BY PATH, not by index: a panel in both stacks stays mounted
1013
+ * even if its index shifted, which is what lets one be spliced out of the middle
1014
+ * without disturbing the columns above it. A common-prefix diff would remount
1015
+ * them all, throwing away their scroll and form state.
1127
1016
  */
1128
1017
  private commit(target: Arrangement, nav: string): void {
1129
- // The panels this commit mounts size themselves as they draw, so make them
1130
- // measure the shell as it is now rather than trusting the last pass's numbers.
1018
+ // Panels size themselves as they draw, so make them measure the shell as it
1019
+ // is now rather than trust the last pass's numbers.
1131
1020
  this.geom = undefined;
1132
- // Pin flags for panels this commit *creates* — a reload, or a cold
1133
- // restore. Live panels keep their own flag: a pin is the user's mark on
1134
- // the panel, not part of where back/forward travel.
1021
+ // Pin flags for panels this commit *creates* (a reload or cold restore).
1022
+ // Live panels keep their own: a pin is the user's mark, not history state.
1135
1023
  const pinned = route.current.state.pinned;
1136
1024
  const seedPins = new Set<string>(Array.isArray(pinned) ? pinned.map(String) : []);
1137
1025
  const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
@@ -1139,22 +1027,18 @@ export class PanelStackController implements PanelStack {
1139
1027
  for (const path of target.stack) {
1140
1028
  const kept = existing.get(path);
1141
1029
  if (kept) {
1142
- // Retained: it just takes its new place in the stack. Its `order` (the
1143
- // DOM sort key) deliberately stays put — see PanelEntry.order.
1030
+ // Retained; its `order` deliberately stays put — see PanelEntry.order.
1144
1031
  existing.delete(path);
1145
1032
  next.push(kept);
1146
1033
  continue;
1147
1034
  }
1148
1035
  const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
1149
- // An initial load just appears, and so do panels *revealed* by a back —
1150
- // they belong underneath the ones sliding away. Everything else enters at
1151
- // the right edge, a replacement exactly like a push.
1036
+ // An initial load just appears, and so do panels *revealed* by a back:
1037
+ // they belong underneath the ones sliding away.
1152
1038
  if (nav !== "load" && nav !== "back") entry.enter = true;
1153
1039
  next.push(entry);
1154
1040
  this.$open[path] = entry;
1155
1041
  }
1156
- // Whatever the target no longer holds leaves the same way: fading out over
1157
- // the right edge, which is also where its replacement (if any) comes in from.
1158
1042
  for (const entry of existing.values()) this.beginClose(entry);
1159
1043
  this.$state.live = next;
1160
1044
  this.$state.focus = Math.min(target.focus, next.length - 1);
@@ -1172,17 +1056,11 @@ export class PanelStackController implements PanelStack {
1172
1056
  maxWidth: "medium" as const,
1173
1057
  width: 0,
1174
1058
  } as PanelEntry;
1175
- // `close` closes *this* panel, current or not, and `open` navigates
1176
- // *from* it, through the very implementation a link click uses (see
1177
- // `navigate`). Both resolve the panel's place in the stack at call
1178
- // time, so they keep working after a splice has moved it; an `open`
1179
- // from a panel that has since closed falls back to a derived stack,
1180
- // like a link from nowhere.
1181
- //
1182
- // `visible` starts at what the panel's place implies: shown when it sits
1183
- // at or before the current panel (a pushed panel always does), hidden when
1184
- // it is restored already parked. `width` is filled in by the sizing scope
1185
- // in `drawPanel` before the handler draws.
1059
+ // `close` and `open` resolve this panel's place in the stack at call time,
1060
+ // so they keep working after a splice has moved it; an `open` from a panel
1061
+ // that has since closed falls back to a derived stack, like a link from
1062
+ // nowhere. `width` is filled in by `drawPanel`'s sizing scope before the
1063
+ // handler draws.
1186
1064
  entry.$panel = A.proxy({
1187
1065
  stack: this,
1188
1066
  params,
@@ -1197,40 +1075,31 @@ export class PanelStackController implements PanelStack {
1197
1075
  }
1198
1076
 
1199
1077
  /**
1200
- * Take a panel out of the shell. The *scope* goes now: its cleaners run this
1201
- * tick, so whatever the panel registered with `A.clean` — subscriptions,
1202
- * timers, an open portal — is torn down when the panel closes, not when its
1203
- * animation is over. Only the element lingers, to play that animation, which
1204
- * is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
1205
- * element to {@link playExit} instead of removing it.
1078
+ * Take a panel out of the shell. The *scope* goes now — its `A.clean` hooks run
1079
+ * this tick, so subscriptions, timers and portals stop at the close rather than
1080
+ * at the end of the animation. Only the element lingers to play that animation,
1081
+ * which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
1206
1082
  */
1207
1083
  private beginClose(entry: PanelEntry): void {
1208
1084
  entry.closing = true;
1209
- // It is on its way out, so it is no longer "on screen" as far as anything
1210
- // hanging off `$panel.visible` is concerned — even though its element lingers
1211
- // to play the fade.
1212
1085
  entry.$panel.visible = false;
1213
- // Frozen one layer below where it was, which is still above everything it
1214
- // was covering: it fades out over the panel it uncovers, and under the one
1215
- // that takes its place (see LAYER_STEP). Set here, while the element is
1216
- // still ours — a moment later the scope, and with it `entry.el`, is gone.
1086
+ // Frozen one layer below where it was, so it fades out over the panel it
1087
+ // uncovers and under the one replacing it (see LAYER_STEP). Set here, while
1088
+ // the element is still ours — a moment later `entry.el` is gone.
1217
1089
  if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
1218
1090
  delete this.$open[entry.path];
1219
1091
  }
1220
1092
 
1221
1093
  /**
1222
- * A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
1223
- * the content it shows is frozen, which is exactly what a departing column
1224
- * should be): it fades where it stands, inert, and leaves the DOM when the fade
1225
- * itself ends. Removing it on a fixed timer instead would race the transition —
1226
- * pull the element a frame early and the panel appears to fade half-way and
1227
- * then vanish. The timeout is just a fallback for when no `transitionend` is
1228
- * coming at all (transitions off, or an element that never got placed).
1094
+ * A closed panel's send-off, run by Aberdeen once its scope is gone: it fades
1095
+ * where it stands, inert, and leaves the DOM on `transitionend`. A fixed timer
1096
+ * would race the transition and pull the element a frame early, making the
1097
+ * panel appear to fade half-way and vanish; the timeout is only a fallback for
1098
+ * when no `transitionend` is coming (transitions off, element never placed).
1229
1099
  */
1230
1100
  private playExit(entry: PanelEntry, el: HTMLElement): void {
1231
- // Only a close is worth animating. A panel being *redrawn* (a reactive
1232
- // dependency in its handler) replaces its element through here too, and that
1233
- // one simply goes, so the new one isn't drawn over a ghost of the old.
1101
+ // A panel being *redrawn* replaces its element through here too; that one
1102
+ // simply goes, so the new one isn't drawn over a ghost of the old.
1234
1103
  if (!entry.closing) { el.remove(); return; }
1235
1104
  el.classList.add("s-panel-closing");
1236
1105
  el.setAttribute("inert", "");
@@ -1250,13 +1119,10 @@ export class PanelStackController implements PanelStack {
1250
1119
 
1251
1120
  /**
1252
1121
  * The arrangement navigation works from: the one we're on the way to while a
1253
- * change is still settling, and the one on screen otherwise.
1254
- *
1255
- * Settling takes a moment more often than it looks: every `route.back()`
1256
- * travels through the browser's history and lands on a `popstate`, and an
1257
- * app-registered route guard may be async. Working from the committed
1258
- * arrangement in that window would make a second Escape aim at the panel the
1259
- * first one is already taking away — so two quick Escapes would peel one panel.
1122
+ * change is still settling, the one on screen otherwise. That window is common
1123
+ * (every `route.back()` waits for a `popstate`), and working from the committed
1124
+ * arrangement inside it would aim a second Escape at the panel the first is
1125
+ * already taking away — two quick Escapes would peel one panel.
1260
1126
  */
1261
1127
  private intended(): Arrangement {
1262
1128
  return this.intent ?? { stack: this.paths(), focus: this.$state.focus };
@@ -1277,15 +1143,10 @@ export class PanelStackController implements PanelStack {
1277
1143
  }
1278
1144
 
1279
1145
  /**
1280
- * Put a navigation to the router, or — while one is still settling — behind
1281
- * the one that is. Only the newest waits: each was worked out against
1282
- * {@link intended}, so the newest is the one that means what the user last
1283
- * asked for, and the one it displaces resolves `false`.
1284
- *
1285
- * A refusal empties the queue instead of running it: a navigation can still
1286
- * fail to land — an app-registered route guard vetoes it, or another one
1287
- * supersedes it — and what was queued behind it was worked out against the
1288
- * arrangement it would have produced.
1146
+ * Put a navigation to the router, or — while one is still settling — behind the
1147
+ * one that is. Only the newest waits; the one it displaces resolves `false`.
1148
+ * A refusal empties the queue rather than running it, since what was queued
1149
+ * was worked out against the arrangement the refused one would have produced.
1289
1150
  */
1290
1151
  private issue(target: Arrangement, run: () => boolean | Promise<boolean>): Promise<boolean> {
1291
1152
  this.intent = target;
@@ -1301,10 +1162,9 @@ export class PanelStackController implements PanelStack {
1301
1162
  this.settling = null;
1302
1163
  const next = this.queued;
1303
1164
  this.queued = null;
1304
- // The router applies a change (and runs Aberdeen's queue, so our own
1305
- // commit has happened) before it settles us, which is what lets the next
1306
- // one go straight out: it asks the guards of the panels it removes from
1307
- // the stack as it stands now, not the one it was queued against.
1165
+ // The router has already applied the change (and flushed Aberdeen's queue,
1166
+ // so our commit has happened) before settling us, so the next one can go
1167
+ // straight out against the stack as it now stands.
1308
1168
  if (ok && next) this.start(next.run).then(next.settle, () => next.settle(false));
1309
1169
  else { this.intent = null; next?.settle(false); }
1310
1170
  return ok;
@@ -1316,12 +1176,8 @@ export class PanelStackController implements PanelStack {
1316
1176
 
1317
1177
  /**
1318
1178
  * Make the stack's `index`th panel current without closing anything, leaving
1319
- * the panels right of it parked out of sight.
1320
- *
1321
- * Only ever a step around a panel that refuses to close — nothing else is
1322
- * left sitting after the current one — so this is Escape's way past an
1323
- * unsaved panel, and the way back to one. It is a history entry, so the
1324
- * browser's back button returns the focus to where it was.
1179
+ * the panels right of it parked out of sight — Escape's way past an unsaved
1180
+ * panel, and back to one. A history entry, so back returns the focus.
1325
1181
  */
1326
1182
  private focusAt(index: number): Promise<boolean> {
1327
1183
  const arr = this.intended();
@@ -1336,12 +1192,10 @@ export class PanelStackController implements PanelStack {
1336
1192
  }
1337
1193
 
1338
1194
  /**
1339
- * One step back along the stack — what Escape does (`main()` calls this;
1340
- * it is not {@link PanelStack} API). Normally that closes the current panel,
1341
- * which is the stack's end. When it holds {@link Panel.unsaved} work — or
1342
- * panels sit parked beyond it — it stays open instead, and the focus
1343
- * simply moves to the panel on its left.
1344
- * Resolves `false` at the stack's start, where there is no left to go.
1195
+ * One step back along the stack — what Escape does (`main()` calls this; it is
1196
+ * not {@link PanelStack} API). Normally that closes the current panel; when it
1197
+ * holds {@link Panel.unsaved} work, or panels sit parked beyond it, the focus
1198
+ * moves left instead. Resolves `false` at the stack's start.
1345
1199
  */
1346
1200
  back(): Promise<boolean> {
1347
1201
  return A.peek(() => {
@@ -1357,21 +1211,15 @@ export class PanelStackController implements PanelStack {
1357
1211
  /**
1358
1212
  * Close whichever panel is open at `path`, current or not — what
1359
1213
  * {@link Panel.close}, {@link PanelStack.closePanel} and the crumb menu's
1360
- * Close come down to. `false` when that path isn't open, is the stack's
1361
- * only panel, or holds {@link Panel.unsaved} work — nothing may close an
1362
- * unsaved panel; the app clears the flag first, which is its explicit
1363
- * "this is now discardable".
1214
+ * Close all come down to. `false` when that path isn't open, is the stack's
1215
+ * only panel, or holds {@link Panel.unsaved} work.
1364
1216
  *
1365
- * Closing the current panel at the stack's very end pops back through the
1366
- * browser's history to the entry beneath it, when it is there (restoring its
1367
- * scroll and search state); the arrangement is part of the match, so an
1368
- * entry where the closing panel was merely parked won't do. Every other
1369
- * close is a *splice*: the columns around the closed one keep their place
1370
- * and state (the commit reconciles by path). That still gets its own
1371
- * history entry, so the browser's back button restores the closed column
1372
- * like any other arrangement — which is why it goes through `route.go` here
1373
- * rather than through `navigate()`, whose "link to an open panel" check
1374
- * would turn it into a focus move.
1217
+ * Closing the current panel at the stack's end pops back through history to
1218
+ * the entry beneath it (restoring its scroll and search state); the
1219
+ * arrangement is part of the match, so an entry where the closing panel was
1220
+ * merely parked won't do. Every other close is a *splice*, which still earns
1221
+ * its own history entry — hence `route.go` here rather than `navigate()`,
1222
+ * whose "link to an open panel" check would turn it into a focus move.
1375
1223
  */
1376
1224
  private closePath(path: string): Promise<boolean> {
1377
1225
  return A.peek(() => {
@@ -1379,19 +1227,16 @@ export class PanelStackController implements PanelStack {
1379
1227
  const index = arr.stack.indexOf(normalizePath(path));
1380
1228
  if (index < 0 || arr.stack.length < 2 || this.unsavedAt(arr.stack[index])) return Promise.resolve(false);
1381
1229
  const stack = arr.stack.filter((_, i) => i !== index);
1382
- // Closing the current panel hands the focus to the panel on its left (or,
1383
- // at the stack's start, to the one that was parked beside it); closing
1384
- // any other panel moves the focus not at all.
1230
+ // Closing the current panel hands the focus to the panel on its left;
1231
+ // closing any other panel moves the focus not at all.
1385
1232
  const focus = index === arr.focus ? Math.max(0, index - 1) : arr.focus - (index < arr.focus ? 1 : 0);
1386
1233
  const target = { stack, focus };
1387
1234
 
1388
1235
  if (index === arr.focus && index === arr.stack.length - 1) {
1389
- // When no history entry matches and the current one is replaced
1390
- // instead, the panel beneath gets its stashed query back through the
1391
- // fallback (a match restores the matched entry's own). Pins can't
1392
- // ride the same way — a `state` in the fallback would be shadowed by
1393
- // the match target's — so they are re-stamped onto whatever entry we
1394
- // land on, the way `togglePin` writes them.
1236
+ // With no matching history entry the current one is replaced instead,
1237
+ // and the panel beneath gets its stashed query back via the fallback.
1238
+ // Pins can't ride along there (the match target's `state` would shadow
1239
+ // it), so they are re-stamped onto whatever entry we land on.
1395
1240
  const beneath = this.$state.live.find((e) => e.path === stack[focus]);
1396
1241
  const fallback: { search?: Record<string, string>; hash?: string } = {};
1397
1242
  if (beneath?.search) fallback.search = beneath.search;
@@ -1410,9 +1255,8 @@ export class PanelStackController implements PanelStack {
1410
1255
  const current = stack[focus];
1411
1256
  const moved = current !== arr.stack[arr.focus];
1412
1257
  return this.issue(target, () => {
1413
- // The current panel keeps its search params and hash: it isn't going
1414
- // anywhere, and `go()` would otherwise default them away. When the
1415
- // close *did* move the focus, the newly current panel gets its own back.
1258
+ // The current panel keeps its search and hash — `go()` would otherwise
1259
+ // default them away. If the focus moved, the new panel gets its own back.
1416
1260
  const entry = moved ? this.$state.live.find((e) => e.path === current) : undefined;
1417
1261
  return route.go({
1418
1262
  path: current,
@@ -1426,28 +1270,19 @@ export class PanelStackController implements PanelStack {
1426
1270
 
1427
1271
  /**
1428
1272
  * Navigate to `href` — the one implementation behind a link click,
1429
- * {@link Panel.open} and the stack's own methods, so none of them can
1430
- * behave differently.
1273
+ * {@link Panel.open} and the stack's own methods, so none can drift.
1431
1274
  *
1432
- * `from` is the path of the panel the navigation starts from — the one
1433
- * the link lives in — or absent when it has none: a nav item, or a call
1434
- * that means the whole stack, which is then built instead (see
1435
- * {@link deriveStack}), or taken outright from `beneath`, for callers
1436
- * that know it.
1275
+ * `from` is the panel the navigation starts from, absent when it has none (a
1276
+ * nav item), in which case the stack is derived or taken from `beneath`.
1437
1277
  *
1438
- * `how` is the link's `data-panel` attribute (or the caller's word for
1439
- * it), picking how much of `from`'s context the target keeps: a push (the
1440
- * default, and what unrecognised values fall back to) keeps `from` and
1441
- * builds on it, `"replace"` keeps only what is beneath `from`, and
1442
- * `"open"` keeps nothing — the target arrives with its own stack, the way
1443
- * a nav item's link does. Absent, it is the shell's `linkNavigation`
1444
- * default, like a link without the attribute. A target that is already
1445
- * open is returned to by a push, and *moved* — alive, state intact — by
1446
- * the other two: the stack never holds a path twice.
1278
+ * `how` is the link's `data-panel` attribute, picking how much of `from`'s
1279
+ * context the target keeps: a push (the default, and the fallback for
1280
+ * unrecognised values) builds on `from`, `"replace"` keeps only what is
1281
+ * beneath it, `"open"` keeps nothing. A target that is already open is
1282
+ * returned to by a push and *moved* — alive — by the other two, since the
1283
+ * stack never holds a path twice.
1447
1284
  *
1448
- * Resolves the way every {@link PanelStack} method does: `true` once the
1449
- * navigation lands, `false` when it doesn't (already there counts as
1450
- * landed).
1285
+ * Resolves `true` once the navigation lands (already being there counts).
1451
1286
  */
1452
1287
  private navigate(href: string, { from, how, beneath }: { from?: string; how?: string; beneath?: readonly string[] } = {}): Promise<boolean> {
1453
1288
  const mode = how ?? this.opts.linkNavigation;
@@ -1459,55 +1294,44 @@ export class PanelStackController implements PanelStack {
1459
1294
  const path = normalizePath(url.pathname);
1460
1295
  const arr = this.intended();
1461
1296
 
1462
- // Whatever we navigate to ends up on top of the stack; all that differs
1463
- // is what it lands on.
1297
+ // The target always ends up on top; only what it lands on differs.
1464
1298
  const open = beneath ? -1 : arr.stack.indexOf(path);
1465
1299
  let target: Arrangement;
1466
1300
  if (open >= 0 && mode !== "replace" && mode !== "open") {
1467
- // A push to a path that is already open is a return: the panel takes
1468
- // back its own place, and whatever was stacked on top of it closes.
1469
- // (A stack never holds the same path twice, so there is no second
1470
- // copy to open — and a breadcrumb is exactly such a link.) Pinned
1471
- // panels above it are the exception, as ever: they stay in their
1472
- // order, parked past the panel we return to, one crumb click away.
1301
+ // A push to an open path is a return: the panel takes back its place
1302
+ // and whatever was stacked on top closes (a breadcrumb is such a
1303
+ // link). Pinned panels above it park instead, keeping their order.
1473
1304
  const above = this.pinnedIn(arr.stack.slice(open + 1), []);
1474
1305
  target = { stack: [...arr.stack.slice(0, open + 1), ...above], focus: open };
1475
1306
  } else {
1476
- // The target opens on top of the panel the link sits in, or in its
1477
- // place for a `replace`, closing the panels after it. Without an
1478
- // originating panel there is no stack to build on, so it is the
1479
- // caller's own `beneath` or one derived from the path — which is what
1480
- // makes a nav click and a deep link to the same URL land identically
1481
- // (bar the pins, which a fresh tab doesn't have).
1307
+ // The target opens on top of the link's own panel — in its place for a
1308
+ // `replace` — closing the panels after it. With no originating panel
1309
+ // the base is the caller's `beneath` or derived from the path, which
1310
+ // is what makes a nav click and a cold deep link land identically.
1482
1311
  const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
1483
1312
  const raw = beneath
1484
1313
  ? beneath.map(normalizePath)
1485
1314
  : originIndex < 0
1486
1315
  ? this.deriveStack(path).slice(0, -1)
1487
1316
  : arr.stack.slice(0, replace ? originIndex : originIndex + 1);
1488
- // A stack never holds the same path twice (rendering reconciles by
1489
- // path), so the target is dropped from the base — a `replace` or
1490
- // `open` may well aim at a path that is open mid-stack, whose panel
1491
- // then simply *moves* to the top, alive — and a caller-supplied
1492
- // `beneath` is deduplicated for the same reason.
1317
+ // A stack never holds the same path twice, so the target is dropped
1318
+ // from the base (a `replace`/`open` aimed mid-stack moves that panel
1319
+ // to the top, alive) and `beneath` is deduplicated for the same reason.
1493
1320
  const base = raw.filter((p, i, all) => p !== path && all.indexOf(p) === i);
1494
- // Pinned panels ride along, keeping their order, beneath the new one
1495
- // (unsaved ones the commit itself keeps, parked — see `propose`). A
1496
- // replaced origin closes, pin or no pin: replacing is the panel's own
1497
- // doing, not somewhere else navigating over it.
1321
+ // Pinned panels ride along beneath the new one, keeping their order
1322
+ // (unsaved ones `propose` parks). A replaced origin closes pin or no
1323
+ // pin: replacing is the panel's own doing, not navigation over it.
1498
1324
  const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
1499
1325
  target = { stack: [...under, path], focus: under.length };
1500
1326
  }
1501
1327
 
1502
- // Search and hash belong to the current panel only, so a panel we return
1503
- // to gets its own back (see PanelEntry.search) — unless the link carries
1504
- // its own, which win.
1328
+ // A panel we return to gets its own search and hash back (see
1329
+ // PanelEntry.search), unless the link carries its own, which win.
1505
1330
  const returning = open >= 0 ? this.$state.live.find((e) => e.path === path) : undefined;
1506
1331
  const search = url.search ? Object.fromEntries(new URLSearchParams(url.search)) : returning?.search ?? {};
1507
1332
  const hash = url.hash || returning?.hash || "";
1508
1333
 
1509
- // Going nowhere at all: this panel, on this arrangement, with the query
1510
- // the link asks for. Not even a history entry.
1334
+ // Going nowhere at all — not even a history entry.
1511
1335
  if (target.focus === arr.focus && sameStack(target.stack, arr.stack)
1512
1336
  && url.search === location.search && (url.hash || "") === (location.hash || "")) {
1513
1337
  return Promise.resolve(true);
@@ -1527,24 +1351,26 @@ export class PanelStackController implements PanelStack {
1527
1351
  // ── Link interception ──────────────────────────────────────────────────
1528
1352
 
1529
1353
  /**
1530
- * Link handling through `route.interceptLinks()`, whose handler hook hands us
1531
- * the anchor so we can decide what the click *means*. The exclusion rules
1532
- * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
1533
- * close guards run in `checkChange` when our navigation reaches the router.
1354
+ * Link handling through `route.interceptLinks()`, whose hook hands us the
1355
+ * anchor so we can decide what the click means. The exclusion rules (targets,
1356
+ * downloads, modified clicks, external URLs) live in Aberdeen.
1534
1357
  *
1535
- * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
1536
- * attribute as its `how` (see {@link navigate}, the shared implementation).
1537
- * A link that isn't inside any panel — a nav item, one in a dialog — has no
1538
- * panel to build on, so it replaces the stack as a whole, exactly as a cold
1539
- * link to the same URL would open it.
1358
+ * A link inside a panel is that panel's {@link Panel.open}, with `data-panel`
1359
+ * as its `how`. A link outside every panel — a nav item, one in a dialog —
1360
+ * has nothing to build on, so it replaces the stack as a cold link would.
1540
1361
  */
1541
1362
  private interceptLinks(): void {
1542
- route.interceptLinks((url, anchor) => {
1363
+ route.interceptLinks((url, anchor, e) => {
1364
+ // A modified keystroke is not a plain activation. Aberdeen leaves a
1365
+ // ctrl/⌘/shift/alt *click* to the browser but not the Enter that stands
1366
+ // in for it, so routing this one would turn the keyboard's own
1367
+ // open-in-a-new-tab into an ordinary navigation. Declining leaves the
1368
+ // event untouched, for the browser to open where the click would have.
1369
+ if (e instanceof KeyboardEvent && (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)) return false;
1543
1370
  const how = anchor.getAttribute("data-panel") ?? undefined;
1544
- // The panel the link lives in: the enclosing `.s-panel`, or — for the
1545
- // current panel's actions, promoted into the top bar on a narrow shell
1546
- // (see main.ts), outside every `.s-panel` — the current panel, whose
1547
- // own chrome they remain at every width.
1371
+ // The panel the link lives in: the enclosing `.s-panel`, or the current
1372
+ // panel for its actions once a narrow shell has promoted them into the
1373
+ // top bar (see main.ts), outside every `.s-panel`.
1548
1374
  const panelEl = anchor.closest<HTMLElement>(".s-panel");
1549
1375
  const entry = panelEl
1550
1376
  ? this.$state.live.find((e) => e.el === panelEl)
@@ -1558,9 +1384,8 @@ export class PanelStackController implements PanelStack {
1558
1384
 
1559
1385
  // ── What the shell's top bar asks ──────────────────────────────────────
1560
1386
 
1561
- // The {@link PanelStack} face, where all the documentation lives. These are
1562
- // the *queries* of the "queries subscribe, commands peek" rule on `$state`:
1563
- // read one in a scope and that scope follows the stack's shape.
1387
+ // The {@link PanelStack} face, where the documentation lives. These are the
1388
+ // *queries* of the "queries subscribe, commands peek" rule on `$state`.
1564
1389
 
1565
1390
  get currentPanel(): Panel | undefined {
1566
1391
  return this.$state.live[this.$state.focus]?.$panel;
@@ -1594,13 +1419,11 @@ export class PanelStackController implements PanelStack {
1594
1419
  }
1595
1420
 
1596
1421
  // ── Live settings ──────────────────────────────────────────────────────
1597
- // `main()` keeps these fed from small reactive scopes of their own, so an
1598
- // app that reads them off a proxy (or through a getter) can change them at
1599
- // runtime and the shell adapts in place — nothing is redrawn, no panel
1600
- // loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
1601
- // options; these are how `main()` talks to the stack. The shell's *widths*
1602
- // need no counterpart here: `navWidth` and `maxWidth` both resize the
1603
- // column region, which the layout engine is already observing.
1422
+ // `main()` feeds these from small reactive scopes, so an app changing them at
1423
+ // runtime is adopted in place — nothing redrawn, no panel losing its state.
1424
+ // Not {@link PanelStack} API: this is how `main()` talks to the stack.
1425
+ // `navWidth`/`maxWidth` need no counterpart; they resize the column region,
1426
+ // which the layout engine already observes.
1604
1427
 
1605
1428
  /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
1606
1429
  setColumns(columns: "auto" | "single" | undefined): void {
@@ -1615,19 +1438,14 @@ export class PanelStackController implements PanelStack {
1615
1438
  }
1616
1439
 
1617
1440
  /**
1618
- * The breadcrumb stack, drawn by `main()` into the top bar: every open
1619
- * panel, oldest first, the ones on screen right now in bold, pinned ones
1620
- * wearing their pin. Every crumb but the current panel's is a plain link to
1621
- * that panel, and a link to an open panel returns to it (see `navigate`) —
1622
- * so clicking a crumb goes back to that panel and closes what was stacked on
1623
- * top of it, pinned and unsaved panels excepted. Right-click (or long-press)
1624
- * offers pinning, and closing just that one panel — the close that splices
1625
- * it out of the middle when it isn't last.
1441
+ * The breadcrumb stack, drawn by `main()` into the top bar: every open panel,
1442
+ * oldest first, the ones on screen in bold. Every crumb but the current one is
1443
+ * a plain link, and a link to an open panel returns to it (see `navigate`),
1444
+ * closing what was stacked on top. Right-click offers pinning and closing.
1626
1445
  */
1627
1446
  drawCrumbs(): void {
1628
- // The very same row `S.tabs` puts its tab strip in: it scrolls when the
1629
- // stack outgrows the bar, and shows a ‹ / › over whichever end still has
1630
- // crumbs to reach — which a mouse can use, unlike a bare scroll area.
1447
+ // The same row `S.tabs` uses: it scrolls when the stack outgrows the bar,
1448
+ // with a ‹ / › a mouse can use over whichever end has crumbs left to reach.
1631
1449
  scrollStrip({
1632
1450
  attrs: ".s-crumbs role=navigation aria-label=Breadcrumbs",
1633
1451
  content: () => {
@@ -1648,21 +1466,17 @@ export class PanelStackController implements PanelStack {
1648
1466
  }
1649
1467
 
1650
1468
  private drawCrumb(path: string, index: number, current: boolean): HTMLElement {
1651
- // Safe to close over: the crumb list is rebuilt whenever the stack (or
1652
- // its focus) changes, so this entry is `paths[index]`'s for the crumb's
1653
- // whole life.
1469
+ // Safe to close over: the crumb list is rebuilt whenever the stack or its
1470
+ // focus changes, so this stays `paths[index]`'s entry for the crumb's life.
1654
1471
  const entry = this.$state.live[index];
1655
- // A real link, for the panel it names — so it has an address to hover, to
1656
- // middle-click, to copy. No click handling of its own: the shell's link
1657
- // handling already makes any link to an open panel the focus move a crumb
1658
- // should be (see `navigate`). The panel you are on is a span: nowhere to go.
1472
+ // A real link, so it can be hovered, middle-clicked and copied. No click
1473
+ // handling of its own: link interception already turns a link to an open
1474
+ // panel into the focus move a crumb wants (see `navigate`).
1659
1475
  return A(current ? "span.s-crumb aria-current=page" : "a.s-crumb", () => {
1660
1476
  if (!current) A("href=", path);
1661
- // Bold = on screen right now, so the stack also says which of its panels
1662
- // are the visible columns — not just which one is current.
1477
+ // Bold = on screen right now, so the stack says which panels are the
1478
+ // visible columns, not just which one is current.
1663
1479
  A(() => { if (entry?.$panel.visible) A(".s-crumb-on"); });
1664
- // The ● of unsaved work — the mark that nothing can close this panel —
1665
- // and the pin of a panel that navigation elsewhere won't close.
1666
1480
  A(() => { if (entry?.$panel.unsaved) dotIcon({ size: "0.45em", attrs: ".s-crumb-unsaved" }); });
1667
1481
  A(() => { if (entry?.$panel.pinned) pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
1668
1482
  // `||`, not `??`: the root path's last segment is the empty string.
@@ -1678,10 +1492,8 @@ export class PanelStackController implements PanelStack {
1678
1492
  {
1679
1493
  label: "Close",
1680
1494
  icon: closeIcon,
1681
- // Greyed out while the panel holds unsaved work: nothing may
1682
- // close it (`closePath` would refuse anyway). Read here, in the
1683
- // crumb's own scope, so the flag flipping redraws the crumb —
1684
- // the ● above and this menu entry stay one truth.
1495
+ // Read here, in the crumb's own scope, so flipping the flag
1496
+ // redraws the crumb and the ● above stays in step with this.
1685
1497
  disabled: entry?.$panel.unsaved === true,
1686
1498
  click: () => void this.closePath(path),
1687
1499
  },
@@ -1690,9 +1502,8 @@ export class PanelStackController implements PanelStack {
1690
1502
  }
1691
1503
 
1692
1504
  /**
1693
- * Flip a panel's pin (see {@link Panel.pinned}). The flag lives on the panel;
1694
- * the current history entry's snapshot is rewritten too, so a reload keeps
1695
- * the pin — a same-panel state tweak, which the router applies unguarded.
1505
+ * Flip a panel's pin (see {@link Panel.pinned}). The current history entry's
1506
+ * snapshot is rewritten too, so a reload keeps the pin.
1696
1507
  */
1697
1508
  private togglePin(entry: PanelEntry): void {
1698
1509
  entry.$panel.pinned = !entry.$panel.pinned || undefined;
@@ -1702,10 +1513,9 @@ export class PanelStackController implements PanelStack {
1702
1513
  // ── document.title ─────────────────────────────────────────────────────
1703
1514
 
1704
1515
  /**
1705
- * `"<panel title> · <app title>"`, kept in sync with the current panel — and
1706
- * prefixed `"• "` while *any* open panel holds unsaved work, the way editors
1707
- * mark a dirty document. Any panel, not just the current one: the risk of
1708
- * losing the work is tab-wide, so the mark on the tab is too.
1516
+ * `"<panel title> · <app title>"`, kept in sync with the current panel, and
1517
+ * prefixed `"• "` while *any* open panel holds unsaved work — not just the
1518
+ * current one, since the risk of losing it is tab-wide.
1709
1519
  */
1710
1520
  private watchTitle(): void {
1711
1521
  const original = document.title;
@@ -1713,8 +1523,6 @@ export class PanelStackController implements PanelStack {
1713
1523
  const entry = this.$state.live[this.$state.focus];
1714
1524
  const panelTitle = entry?.$panel.title ?? entry?.$ui.fallback;
1715
1525
  const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
1716
- // Reading the stack re-runs this when its shape changes; each panel's
1717
- // own `unsaved` (up to the first dirty one) does the rest.
1718
1526
  const dirty = this.$state.live.some((e) => e.$panel.unsaved);
1719
1527
  const title = panelTitle && appTitle ? `${panelTitle} · ${appTitle}` : panelTitle || appTitle;
1720
1528
  if (title) document.title = (dirty ? "• " : "") + title;
@@ -1723,10 +1531,9 @@ export class PanelStackController implements PanelStack {
1723
1531
  }
1724
1532
 
1725
1533
  /**
1726
- * While any open panel holds unsaved work, closing the tab — or navigating
1727
- * the whole browser away — runs into the browser's own are-you-sure, with
1728
- * the unsaved panel brought on screen as the question is raised, so what is
1729
- * holding the tab is in front of the user rather than parked out of sight.
1534
+ * While any open panel holds unsaved work, closing the tab runs into the
1535
+ * browser's own are-you-sure, with that panel brought on screen as the
1536
+ * question is raised rather than left parked out of sight.
1730
1537
  */
1731
1538
  private guardTabClose(): void {
1732
1539
  if (typeof window === "undefined") return;
@@ -1735,18 +1542,18 @@ export class PanelStackController implements PanelStack {
1735
1542
  if (!dirty) return;
1736
1543
  e.preventDefault();
1737
1544
  e.returnValue = true; // Chrome/Edge < 119
1738
- // Bring the unsaved panel on screen right here, so what is holding the
1739
- // tab is in front of the user — behind the browser's dialog where the
1740
- // browser paints that early, and the moment they choose to stay
1741
- // otherwise. A confirmed leave unloads the document before any of it
1742
- // is seen; the history entry the move makes is then where a back
1743
- // navigation returns to, which is right: the panel that held the tab.
1545
+ // Bring the unsaved panel on screen, so what is holding the tab is in
1546
+ // front of the user the moment they choose to stay.
1547
+ // `visible` is written by the layout pass, which waits for an animation
1548
+ // frame that a tab on its way out may never get. Settle it first, or a
1549
+ // panel parked a moment ago still reads as on screen and the guard skips
1550
+ // the very move it exists to make.
1551
+ this.flushLayout();
1744
1552
  if (!dirty.$panel.visible) void this.focusAt(this.intended().stack.indexOf(dirty.path));
1745
1553
  };
1746
1554
  // Registered only while a panel actually holds unsaved work: a page with a
1747
- // `beforeunload` listener is shut out of the browser's back/forward cache,
1748
- // and that is a tax every navigation in the app would otherwise pay — for a
1749
- // guard that almost never has anything to guard.
1555
+ // `beforeunload` listener is shut out of the back/forward cache, a tax
1556
+ // every navigation in the app would otherwise pay.
1750
1557
  A(() => {
1751
1558
  if (!this.$state.live.some((entry) => entry.$panel.unsaved)) return;
1752
1559
  window.addEventListener("beforeunload", onBeforeUnload);
@@ -1759,20 +1566,17 @@ export class PanelStackController implements PanelStack {
1759
1566
  /**
1760
1567
  * Draw the column viewport into the current element. Called by `main()`.
1761
1568
  *
1762
- * A column is the panel's own content, plus the one bit of chrome the shell
1763
- * places for it: its {@link Panel.actions}, in a strip on wide shells and in
1764
- * the top bar on narrow ones (see {@link drawActions}).
1569
+ * A column is the panel's own content plus the one bit of chrome the shell
1570
+ * places for it: its {@link Panel.actions} (see {@link drawActions}).
1765
1571
  */
1766
1572
  drawColumns(): void {
1767
1573
  const container = A("div.s-panels role=main", () => {
1768
- // Published before the first panel draws, rather than from the return
1769
- // value below: a panel sizes itself from the shell's measurements (see
1770
- // `measure`), and the first ones do that while this very call is still
1771
- // running. `A()` without arguments is "the element we're in".
1574
+ // Published here rather than from the return value below: a panel sizes
1575
+ // itself from the shell's measurements, and the first ones do that while
1576
+ // this call is still running.
1772
1577
  this.containerEl = A() as HTMLElement;
1773
- // Mounted and unmounted by path (see `$open`); a panel's DOM position
1774
- // among its siblings is its creation order, which is all the layering
1775
- // needs — `layout()` places and stacks the columns itself.
1578
+ // Mounted by path (see `$open`); DOM order is creation order, which is
1579
+ // all that's needed — `layout()` places and stacks the columns itself.
1776
1580
  A.onEach(
1777
1581
  this.$open,
1778
1582
  (entry) => this.drawPanel(entry),
@@ -1781,9 +1585,9 @@ export class PanelStackController implements PanelStack {
1781
1585
  }) as HTMLElement;
1782
1586
 
1783
1587
  if (typeof ResizeObserver !== "undefined") {
1784
- // The region *is* the content area every width is measured from (see
1785
- // `measure`), so watching it catches the lot: a window resize, the
1786
- // sidebar coming or going, the shell's own `maxWidth` changing.
1588
+ // The region *is* the content area every width is measured from, so
1589
+ // watching it catches a window resize, the sidebar coming or going, and
1590
+ // the shell's own `maxWidth` changing alike.
1787
1591
  const ro = new ResizeObserver(() => this.layout());
1788
1592
  ro.observe(container);
1789
1593
  A.clean(() => ro.disconnect());
@@ -1795,14 +1599,10 @@ export class PanelStackController implements PanelStack {
1795
1599
  private drawPanel(entry: PanelEntry): void {
1796
1600
  let el: HTMLElement | undefined;
1797
1601
 
1798
- // How much room the panel wants, resolved *before* its content is drawn: an
1799
- // element that arrives without a width has no box for its content to measure
1800
- // itself against until the next frame's layout pass, which is a frame too
1801
- // late for anything that sizes itself from its container. So the panel is
1802
- // created at the width the window gives it — the "medium" width until the
1803
- // panel says otherwise. Reactively, too: a panel that changes its mind later
1804
- // (when its data arrives, say) reflows in place rather than being redrawn,
1805
- // and the columns beside it slide over to make room.
1602
+ // How much room the panel wants, resolved *before* its content is drawn:
1603
+ // an element that arrives width-less has no box for its content to measure
1604
+ // against until the next frame — a frame too late. Reactive, so a panel
1605
+ // changing its mind later reflows in place rather than being redrawn.
1806
1606
  A(() => {
1807
1607
  const asked = entry.$panel.maxWidth;
1808
1608
  entry.maxWidth = asked === "small" || asked === "large" || asked === "none" ? asked : "medium";
@@ -1812,36 +1612,31 @@ export class PanelStackController implements PanelStack {
1812
1612
  // Published to the panel too, so a handler can read the box it is about
1813
1613
  // to draw into without measuring it.
1814
1614
  if (A.peek(entry.$panel, "width") !== width) entry.$panel.width = width;
1815
- // The first run has no element to put it on yet — it's created with this
1816
- // width, just below. Later runs are the panel changing its mind.
1615
+ // The first run has no element yet — it's created with this width below.
1817
1616
  if (!el) return;
1818
1617
  el.style.width = `${width}px`;
1819
1618
  this.scheduleLayout();
1820
1619
  });
1821
1620
 
1822
1621
  el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node: HTMLElement) => this.playExit(entry, node), () => {
1823
- // The actions strip, in its own scope: chrome may redraw freely — when
1824
- // the panel changes its actions, when the shell crosses the narrow
1825
- // threshold — but the body below never may.
1622
+ // In its own scope: the chrome may redraw freely, the body below may not.
1826
1623
  A(() => this.drawActions(entry));
1827
1624
 
1828
1625
  A("div.s-content", () => {
1829
1626
  entry.draw(entry.$panel);
1830
1627
  // After the content, so there is something to scroll when restoring.
1831
1628
  route.persistScroll(entry.path);
1832
- // A panel that named itself is done; one that didn't lends its
1833
- // first line of text (the DOM is already built — Aberdeen draws
1834
- // synchronously) to the crumbs and document.title, so neither
1835
- // ever goes blank. Peeked: a rename must not redraw the panel.
1629
+ // A panel that set no title borrows its first line of text (the DOM
1630
+ // is built already — Aberdeen draws synchronously) so the crumbs and
1631
+ // document.title never go blank. Peeked: a rename must not redraw.
1836
1632
  if (A.peek(entry.$panel, "title") == null) {
1837
1633
  const text = firstText(A() as HTMLElement);
1838
1634
  if (text && A.peek(entry.$ui, "fallback") !== text) entry.$ui.fallback = text;
1839
1635
  }
1840
1636
  });
1841
1637
 
1842
- // The loading hint, in its own scope so flipping the flag doesn't
1843
- // redraw the panel's content. Held-back panels show nothing yet: they
1844
- // are still parked off screen, waiting to slide in with real content.
1638
+ // Its own scope, so flipping the flag doesn't redraw the panel's content.
1639
+ // A held-back panel shows nothing: it is still off screen.
1845
1640
  A(() => {
1846
1641
  if (!entry.$panel.loading || entry.$ui.holding) return;
1847
1642
  A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
@@ -1849,11 +1644,10 @@ export class PanelStackController implements PanelStack {
1849
1644
  }) as HTMLElement;
1850
1645
 
1851
1646
  entry.el = el;
1852
- // It has its width, but nothing animates from the arbitrary initial spot;
1853
- // `layout()` gives the panel its place in the run (and turns transitions
1854
- // back on) in the upcoming frame, before anything is painted. A redraw (a
1855
- // reactive dependency inside the handler) lands here too, with a brand-new
1856
- // element that has to be placed again before it may animate.
1647
+ // Nothing may animate from the arbitrary initial spot; `layout()` gives the
1648
+ // panel its place (and turns transitions back on) in the coming frame,
1649
+ // before anything is painted. A redraw lands here too, with a new element
1650
+ // that has to be placed again first.
1857
1651
  entry.placed = false;
1858
1652
  el.style.transition = "none";
1859
1653
  A.clean(() => { if (entry.el === el) entry.el = undefined; });
@@ -1870,9 +1664,7 @@ export class PanelStackController implements PanelStack {
1870
1664
  /**
1871
1665
  * The one bit of column chrome the shell draws: the panel's actions, in a
1872
1666
  * quiet strip above the scroll area — and only while the shell is wide, the
1873
- * top bar carrying them otherwise. Everything else in a column is the panel's
1874
- * own content: a screen that wants a heading or a card draws them itself.
1875
- * Going back isn't here either — that is the breadcrumbs' job, in the bar.
1667
+ * top bar carrying them otherwise. Everything else is the panel's own content.
1876
1668
  */
1877
1669
  private drawActions(entry: PanelEntry): void {
1878
1670
  if (this.opts.$shell.narrow || entry.$panel.actions == null) return;
@@ -1884,41 +1676,37 @@ export class PanelStackController implements PanelStack {
1884
1676
  scheduleLayout(): void {
1885
1677
  if (this.layoutQueued) return;
1886
1678
  this.layoutQueued = true;
1887
- requestAnimationFrame(() => {
1888
- this.layoutQueued = false;
1889
- this.layout();
1890
- });
1679
+ requestAnimationFrame(() => this.flushLayout());
1680
+ }
1681
+
1682
+ /**
1683
+ * Run the pending layout pass now instead of on the frame it waits for, for
1684
+ * callers that must read what only the pass knows (`$panel.visible`,
1685
+ * `$panel.width`) and can't wait. Does nothing when no pass is owed.
1686
+ */
1687
+ private flushLayout(): void {
1688
+ if (!this.layoutQueued) return;
1689
+ this.layoutQueued = false;
1690
+ this.layout();
1891
1691
  }
1892
1692
 
1893
1693
  /**
1894
1694
  * Measure the content area, and with it the width a panel of each size gets.
1695
+ * The column region *is* that area (CSS's doing), so there is nothing to add
1696
+ * up here that could drift from the bars above and below. Widths stay
1697
+ * fractional: a rounded column edge would drift a pixel away from that chrome.
1895
1698
  *
1896
- * The column region *is* the content area: it takes whatever the shell has
1897
- * left beside the sidebar, capped by the shell's own `maxWidth` — all of it
1898
- * CSS's doing, so there is nothing to add up here and nothing that could
1899
- * drift from the width the bars above and below line up with. Fractional
1900
- * widths throughout: a rounded column edge would drift a pixel away from that
1901
- * chrome.
1902
- *
1903
- * The area divides into the narrowest whole number of columns that keeps each
1904
- * at least {@link SMALL_MIN_PX} wide — the `"small"` unit every other size is
1905
- * a multiple of, capped at the area itself. So 1080px is three columns of 360
1906
- * and 1520px four of 380. An area too narrow for two is a single column,
1907
- * itself capped at {@link SMALL_MAX_PX}: a small centres there instead of
1908
- * stretching toward 720, so its ceiling holds, while the larger sizes still
1909
- * take the whole area. A width is thus a pure function of the window: a panel
1910
- * NEVER resizes because a neighbour came or went, and only a window resize
1911
- * (the snap pass in `layout`) changes one.
1699
+ * The area divides into the narrowest whole number of columns of at least
1700
+ * {@link SMALL_MIN_PX} — so 1080px is three of 360, 1520px four of 380 — each
1701
+ * capped at {@link SMALL_MAX_PX}. A width is therefore a pure function of the
1702
+ * window: a panel NEVER resizes because a neighbour came or went.
1912
1703
  *
1913
- * `undefined` while the shell has no width to speak of (it isn't in a document
1914
- * yet, or it's `display:none`); the next pass tries again.
1704
+ * `undefined` while the shell has no width yet (not in a document, or
1705
+ * `display:none`); the next pass tries again.
1915
1706
  */
1916
1707
  private measure(): Geometry | undefined {
1917
1708
  const el = this.containerEl;
1918
- // The rect is in window coordinates; the widths this yields are written
1919
- // back as CSS lengths, which live in the region's own space — different
1920
- // spaces when the shell has zoomed the page (see `watchScale` in main.ts).
1921
- const area = el ? el.getBoundingClientRect().width / cssZoom(el) : 0;
1709
+ const area = el ? el.getBoundingClientRect().width : 0;
1922
1710
  if (!area) return undefined;
1923
1711
  const small = Math.min(area / Math.max(1, Math.floor(area / SMALL_MIN_PX)), SMALL_MAX_PX);
1924
1712
  const units = (n: number) => Math.min(n * small, area);
@@ -1926,10 +1714,9 @@ export class PanelStackController implements PanelStack {
1926
1714
  }
1927
1715
 
1928
1716
  /**
1929
- * The measurements this pass runs on. Taken once per layout pass and per
1930
- * commit, and shared with the panels drawn in between — they all size
1931
- * themselves against the same shell, and a `getBoundingClientRect()` each
1932
- * would be a forced reflow each, in the middle of building their DOM.
1717
+ * The measurements this pass runs on, taken once per pass and per commit and
1718
+ * shared with the panels drawn in between: they must all size against the
1719
+ * same shell, and a `getBoundingClientRect()` each is a forced reflow each.
1933
1720
  */
1934
1721
  private geometry(): Geometry | undefined {
1935
1722
  return (this.geom ??= this.measure());
@@ -1941,24 +1728,20 @@ export class PanelStackController implements PanelStack {
1941
1728
  }
1942
1729
 
1943
1730
  /**
1944
- * Size and position every panel.
1945
- *
1946
- * This is everything CSS can't work out for itself: which panels exist, which
1947
- * of them are visible, how wide each one is and where it sits. All the motion
1948
- * between two of these arrangements is CSS's job.
1731
+ * Size and position every panel — everything CSS can't work out for itself:
1732
+ * which panels exist, which are visible, how wide each is and where it sits.
1733
+ * The motion between two such arrangements is CSS's job.
1949
1734
  */
1950
1735
  private layout(): void {
1951
1736
  const container = this.containerEl;
1952
1737
  const shell = container?.closest<HTMLElement>(".s-main");
1953
1738
  if (!container || !shell) return;
1954
- // This pass always runs from a fresh frame (rAF, a ResizeObserver) — no
1955
- // reactive scope is active, so the stack and the panels are read plainly:
1956
- // nothing here can subscribe to anything.
1739
+ // Always runs from a fresh frame (rAF, a ResizeObserver), so no reactive
1740
+ // scope is active and nothing read here can subscribe to anything.
1957
1741
  const live = this.$state.live;
1958
1742
  const n = live.length;
1959
- // A panel that hasn't drawn yet has no width to contribute, which would make
1960
- // this pass's arithmetic (and any enter animation it triggers) meaningless.
1961
- // Every mount schedules another pass, so simply wait for it.
1743
+ // A panel that hasn't drawn yet has no width to contribute, which would
1744
+ // make this pass meaningless. Every mount schedules another, so just wait.
1962
1745
  if (!n || live.some((entry) => !entry.el)) return;
1963
1746
 
1964
1747
  // This pass measures afresh — it is the one thing that runs after a resize.
@@ -1968,11 +1751,9 @@ export class PanelStackController implements PanelStack {
1968
1751
 
1969
1752
  const single = this.opts.columns === "single";
1970
1753
 
1971
- // A window resize — or the app resizing the shell itself, by changing
1972
- // `navWidth` or `maxWidth` — must be adopted instantly: geometry tracking
1973
- // the window through a 450ms transition reads as lag, and a shell
1974
- // animating itself into place on its first pass reads as a glitch. Only
1975
- // what a *panel* did is worth animating, and none of those three are.
1754
+ // A resize of the window (or of the shell, via `navWidth`/`maxWidth`) must
1755
+ // be adopted instantly — geometry chasing the window through a transition
1756
+ // reads as lag. Only what a *panel* did is worth animating, so
1976
1757
  // `.s-shell-snap` suppresses every standing transition for this one pass.
1977
1758
  const snap = this.lastGeom?.area !== geom.area;
1978
1759
  if (snap) {
@@ -1982,9 +1763,8 @@ export class PanelStackController implements PanelStack {
1982
1763
 
1983
1764
  const width = (entry: PanelEntry) => geom.size[entry.maxWidth];
1984
1765
 
1985
- // The visible run: as many columns as the window fits, at the sizes the
1986
- // window gives them, ending at the current panel — which always shows.
1987
- // Panels beyond it are parked past the right edge (see phase 1).
1766
+ // The visible run: as many columns as fit, ending at the current panel,
1767
+ // which always shows. Panels beyond it are parked (see phase 1).
1988
1768
  const cur = Math.min(this.$state.focus, n - 1);
1989
1769
  let first = cur;
1990
1770
  let runSum = width(live[cur]);
@@ -1997,45 +1777,37 @@ export class PanelStackController implements PanelStack {
1997
1777
  }
1998
1778
  }
1999
1779
 
2000
- // The content area is a fixed width, so a run that doesn't fill it sits
2001
- // centred in it rather than hanging off its left edge. Everything around
2002
- // the columns holds still meanwhile: the sidebar, the top bar and the
2003
- // footer never move, however many columns come and go.
1780
+ // The content area is a fixed width, so a run that doesn't fill it centres
1781
+ // rather than hanging off the left edge — the chrome around it never moves.
2004
1782
  const left = (geom.area - runSum) / 2;
2005
1783
 
2006
1784
  for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
2007
- // Panels that have never been visible get their would-be width too, so a
2008
- // reveal doesn't start from nothing.
1785
+ // Never-visible panels get their would-be width, so a reveal doesn't start
1786
+ // from nothing.
2009
1787
  for (const entry of live) {
2010
1788
  if (!entry.width) entry.width = width(entry);
2011
1789
  }
2012
1790
 
2013
- // Phase 1 — every panel's *start* state for this frame. Panels already on
2014
- // screen simply move (their standing transition animates it); freshly
2015
- // mounted ones still have transitions switched off, so what we set here is
2016
- // adopted instantly and becomes the "before" of their enter animation.
1791
+ // Phase 1 — every panel's *start* state for this frame. Panels on screen
1792
+ // simply move; freshly mounted ones still have transitions off, so what is
1793
+ // set here is adopted instantly and becomes the "before" of their enter.
2017
1794
  const fresh: PanelEntry[] = [];
2018
1795
  let x = left;
2019
1796
  for (let i = 0; i < n; i++) {
2020
1797
  const entry = live[i];
2021
1798
  const el = entry.el!;
2022
1799
  const shown = i >= first && i <= cur;
2023
- // Visible columns tile the run, left to right. Panels crowded out from
2024
- // under it rest at its left edge; panels beyond the current panel park
2025
- // just past its right edge — both keep their last width. Deeper panels
2026
- // layer over shallower ones, each on the odd layer for its depth (see
2027
- // LAYER_STEP).
1800
+ // Visible columns tile the run left to right; crowded-out ones rest at
1801
+ // its left edge and parked ones just past its right, both keeping their
1802
+ // last width. Deeper panels layer over shallower (see LAYER_STEP).
2028
1803
  place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
2029
- // What `$panel.visible` and `$panel.width` report: this pass is the one
2030
- // thing that knows them, window resizes included. Written only on a
2031
- // change, so per-panel UI hanging off them isn't rebuilt by every pass.
1804
+ // Written only on a change, so per-panel UI hanging off `visible` or
1805
+ // `width` isn't rebuilt by every pass.
2032
1806
  if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
2033
1807
  if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
2034
1808
  if (shown) x += entry.width;
2035
1809
  el.classList.toggle("s-panel-sep", shown && i > first);
2036
- // Off-screen panels fade out over the edge they park at and, once
2037
- // faded, stop being rendered at all — but they keep their DOM, and
2038
- // their scroll position.
1810
+ // Off-screen panels fade out and stop rendering, keeping their DOM.
2039
1811
  el.classList.toggle("s-panel-hidden", i < first);
2040
1812
  el.classList.toggle("s-panel-parked", i > cur);
2041
1813
  el.toggleAttribute("inert", !shown);
@@ -2045,14 +1817,13 @@ export class PanelStackController implements PanelStack {
2045
1817
  // it can enter with real content instead of an empty column.
2046
1818
  if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
2047
1819
  else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
2048
- // Already at its resting place; the enter animation is the offset (and
2049
- // the transparency) it starts from, one edge to the right.
1820
+ // Already at its resting place; the enter is the offset and transparency
1821
+ // it starts from, one edge to the right.
2050
1822
  if (entry.enter && shown) el.classList.add("s-panel-enter");
2051
1823
  }
2052
1824
 
2053
- // Phase 2 — force the browser to adopt those start states (and, on a snap
2054
- // pass, the transition-free geometry) as the ones to animate *from*.
2055
- // (Reading a layout property is what does it.)
1825
+ // Phase 2 — reading a layout property forces the browser to adopt those
1826
+ // start states (and a snap pass's transition-free geometry) to animate from.
2056
1827
  if (fresh.length || snap) void container.offsetWidth;
2057
1828
  if (snap) shell.classList.remove("s-shell-snap");
2058
1829
  // Phase 3 — transitions back on, start state dropped, and off they go.