staffa 0.9.0 → 0.10.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 (60) hide show
  1. package/README.md +106 -48
  2. package/dist/components/autocomplete.js +1 -1
  3. package/dist/components/box.d.ts +8 -16
  4. package/dist/components/box.js +21 -27
  5. package/dist/components/button.d.ts +40 -0
  6. package/dist/components/button.js +85 -12
  7. package/dist/components/buttonChooser.js +1 -1
  8. package/dist/components/checkbox.js +3 -3
  9. package/dist/components/field.js +3 -3
  10. package/dist/components/main.d.ts +134 -71
  11. package/dist/components/main.js +245 -174
  12. package/dist/components/menu.d.ts +72 -14
  13. package/dist/components/menu.js +231 -34
  14. package/dist/components/pages.d.ts +638 -0
  15. package/dist/components/pages.js +1510 -0
  16. package/dist/components/panels.d.ts +448 -225
  17. package/dist/components/panels.js +819 -435
  18. package/dist/components/tabs.d.ts +37 -0
  19. package/dist/components/tabs.js +128 -69
  20. package/dist/core.d.ts +1 -1
  21. package/dist/core.js +1 -1
  22. package/dist/glyphs.d.ts +24 -0
  23. package/dist/glyphs.js +25 -0
  24. package/dist/index.d.ts +4 -4
  25. package/dist/index.js +3 -4
  26. package/dist/staffa.esm.js +1 -1
  27. package/dist/theme.d.ts +67 -0
  28. package/dist/theme.js +12 -2
  29. package/package.json +2 -2
  30. package/skill/BoxOptions.md +7 -12
  31. package/skill/IconButtonOptions.md +41 -0
  32. package/skill/MainOptions.md +106 -58
  33. package/skill/MenuItem.md +16 -1
  34. package/skill/MenuListOptions.md +24 -0
  35. package/skill/MenuOptions.md +3 -2
  36. package/skill/Panel.md +190 -0
  37. package/skill/PanelStack.md +106 -0
  38. package/skill/SKILL.md +172 -64
  39. package/skill/ScrollStripOptions.md +21 -0
  40. package/skill/box.md +1 -4
  41. package/skill/closeNav.md +3 -3
  42. package/skill/iconButton.md +27 -0
  43. package/skill/main.md +13 -9
  44. package/skill/menu.md +29 -0
  45. package/skill/scrollStrip.md +28 -0
  46. package/src/components/autocomplete.ts +1 -1
  47. package/src/components/box.ts +29 -39
  48. package/src/components/button.ts +109 -8
  49. package/src/components/buttonChooser.ts +1 -1
  50. package/src/components/checkbox.ts +3 -3
  51. package/src/components/field.ts +3 -3
  52. package/src/components/main.ts +381 -188
  53. package/src/components/menu.ts +265 -37
  54. package/src/components/panels.ts +1136 -526
  55. package/src/components/tabs.ts +134 -68
  56. package/src/core.ts +1 -1
  57. package/src/index.ts +4 -4
  58. package/src/theme.ts +14 -3
  59. package/skill/Page.md +0 -119
  60. package/skill/panels.md +0 -10
@@ -1,6 +1,11 @@
1
- import A from "aberdeen";
1
+ import A, { OPAQUE } from "aberdeen";
2
2
  import * as route from "aberdeen/route";
3
- import { NARROW_PX } from "../core.js";
3
+ import { drawSlot } from "../core.js";
4
+ import { circle as dotIcon, externalLink as newTabIcon, link as linkIcon, pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon, } from "../icons.js";
5
+ import { SURFACE_SHEEN } from "../theme.js";
6
+ import { addContextMenu } from "./menu.js";
7
+ import { scrollStrip, revealInStrip } from "./tabs.js";
8
+ import { toast } from "./toast.js";
4
9
  /**
5
10
  * The matchers a `[name=matcher]` segment can use. A matcher returns the param's
6
11
  * value, or `undefined` to fail the match, in which case the path falls through
@@ -10,7 +15,7 @@ import { NARROW_PX } from "../core.js";
10
15
  * back to the same URL: no leading zeroes ("007"), no "-0", no "1.5", "1e3" or
11
16
  * "0x10", and nothing past `Number.MAX_SAFE_INTEGER` (where the number would no
12
17
  * longer hold the id it came from). Two spellings of one id would otherwise be
13
- * two different paths, so the same record could sit open in two panels at once.
18
+ * two different paths, so the same record could sit open in two columns at once.
14
19
  * Use a plain `[id]` for ids that aren't safe integers, such as snowflakes.
15
20
  */
16
21
  const MATCHERS = {
@@ -105,22 +110,25 @@ function matchRoute(r, segments) {
105
110
  }
106
111
  // ─── Constants ───────────────────────────────────────────────────────────────
107
112
  /**
108
- * The one duration every bit of panel motion shares: the enter/exit fades, the
109
- * `left` moves of columns shifting sideways, and the ensemble-width transition
110
- * the chrome follows (see `--s-shell-w` in main.ts). Published as the
111
- * `--s-panel-ms` custom property below, so CSS and JS can't drift apart.
113
+ * The one duration every bit of shell motion shares: the enter/exit fades, the
114
+ * `left` moves of columns shifting sideways, the ensemble-width transition the
115
+ * chrome follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
116
+ * panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
117
+ * and JS can't drift apart.
118
+ *
119
+ * Short enough to read as *the screen responded*, rather than as an animation
120
+ * being played at you: a panel arriving is navigation, and navigation should
121
+ * feel instant even when it moves.
112
122
  */
113
- const PANEL_MS = 450;
123
+ const PAGE_MS = 250;
114
124
  /** How long a freshly pushed `loading` panel holds its enter animation. */
115
125
  const LOADING_HOLD_MS = 300;
116
126
  /**
117
127
  * The standard page width: sidebar plus content area, capped by the window.
118
- * `"medium"` fills the content-area part of this exactly; only a `"large"`
119
- * panel makes the shell grow past it.
128
+ * `"full"` fills the content-area part of this exactly; only a `"screen"`
129
+ * page makes the shell grow past it.
120
130
  */
121
131
  const SHELL_PX = 1280;
122
- /** The gap between two `"small"` panels sitting two-up. */
123
- const GUTTER_PX = 24;
124
132
  /** Don't pair smalls when half the content area would be narrower than this. */
125
133
  const PAIR_MIN_PX = 360;
126
134
  /**
@@ -133,21 +141,25 @@ const PAIR_MIN_PX = 360;
133
141
  const LAYER_STEP = 2;
134
142
  // ─── Module-level styling ────────────────────────────────────────────────────
135
143
  A.insertGlobalCss({
136
- ":root": `--s-panel-ms:${PANEL_MS}ms`,
144
+ ":root": `--s-panel-ms:${PAGE_MS}ms`,
137
145
  // The clipping viewport that the columns slide through. Panels are absolutely
138
146
  // positioned inside it, with their width and x offset set from JS (see
139
147
  // `layout()`), so they can animate between arrangements. `isolation` keeps the
140
148
  // layers they stack themselves in (see LAYER_STEP) to themselves: the region
141
149
  // as a whole still sits under the shell's own chrome — the sticky top bar, and
142
- // the nav page that slides across the body — however deep the stack gets.
143
- ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate",
150
+ // the nav panel that slides across the body — however deep the stack gets.
151
+ // The region paints the panel's sheen over its own box, and every panel shows
152
+ // a slice of that same gradient (see `.s-panel` below), so the columns and
153
+ // the ground beside them are one continuous surface.
154
+ ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
155
+ SURFACE_SHEEN,
144
156
  ".s-panel": {
145
157
  // A panel rests at a plain `left` offset and carries no transform: a
146
158
  // transformed element is composited, which costs it subpixel text
147
159
  // antialiasing. `transform` is used only to play the enter/exit slides,
148
160
  // where the compositing is what makes them cheap. There is deliberately no
149
161
  // `width` transition: a width changes only when the window resizes or when
150
- // the page itself asks for another layout, and animating one would reflow
162
+ // the panel itself asks for another layout, and animating one would reflow
151
163
  // the column's content on every frame of it.
152
164
  // Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
153
165
  // and the chrome recentering around them all run as one motion. The drift
@@ -155,21 +167,34 @@ A.insertGlobalCss({
155
167
  // across the whole duration — an eased opacity spends its last stretch near
156
168
  // zero, which looks like the panel vanishing rather than fading.
157
169
  // No `overflow:hidden` here: the scroll container below clips the content
158
- // itself, and the pair hairline sits in the gutter *outside* the panel.
170
+ // itself.
159
171
  // Layering is set from JS (`layout()` and `beginClose`) rather than left to
160
172
  // DOM order: a closing panel is no longer part of the reactive list, so
161
173
  // where its element sits among the live ones is Aberdeen's business, not a
162
174
  // thing to depend on. `LAYER_*` says what the numbers mean.
175
+ //
176
+ // Every panel paints an opaque ground, because panels animate over one
177
+ // another — entering, leaving, being crowded out — and two transparent ones
178
+ // mean text sliding over text. It takes the panel's own sheen, the one
179
+ // `.s-s, body` paints in theme.ts, resolved here against the inherited
180
+ // `--s-bg` (a panel is not a surface, so it has to paint it itself).
181
+ //
182
+ // Painted per panel, over the panel's own box, which is as good as it
183
+ // needs to be: the sheen is a 9%-either-way wash over a whole column, so
184
+ // two columns' worth of it meeting at a hairline is not something the eye
185
+ // picks out. The region (`.s-panels` above) paints the same wash, so the
186
+ // ground beside a lone column matches it just as closely.
163
187
  "&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
188
+ SURFACE_SHEEN + " " +
164
189
  "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
165
- // The scroll container. Mirrors content mode's `main > .s-content`: same
166
- // padding, and the same scrollbar inset (see `.s-scroll-y` in main.ts) so a
167
- // single-column shell is pixel-identical to a non-routed one.
168
- "> .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
169
- "> .s-content.s-scroll-y": "margin-right:$3",
170
- // A vertical hairline centred in the gutter between two paired smalls,
171
- // fading out at both ends — the same treatment as the sidebar's `.s-nav-sep`.
172
- "&.s-panel-sep::before": `content:'' position:absolute left:-${GUTTER_PX / 2}px top:0.6rem bottom:0.6rem width:1px ` +
190
+ // The hairline between two columns, fading out at both ends — the same
191
+ // treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
192
+ // gutter between them: each already brings its own `$3` of padding, which
193
+ // keeps two columns' *contents* comfortably apart, while a gutter on top
194
+ // of that only opened a strip of the panel's own ground between two columns
195
+ // painting theirs. So this sits exactly on the boundary — and an
196
+ // edge-to-edge column (`A("p:0")`) really does reach the line bounding it.
197
+ "&.s-panel-sep::before": "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
173
198
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
174
199
  // One vocabulary for every arrival and departure: a gradual fade over a short,
175
200
  // slow drift — 8cqw (`cqw`: `.s-main` is the container). Panels appear and
@@ -182,26 +207,74 @@ A.insertGlobalCss({
182
207
  // and out of reach while it does. It leaves the DOM when the fade itself
183
208
  // ends (see `playExit`), never part-way through it.
184
209
  "&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
185
- // Crowded out from under the visible run. It keeps its DOM (and thus its
186
- // scroll position and half-typed forms), so `display:none` is out —
187
- // `visibility` takes it out of the rendering instead, but only once the fade
188
- // has played: a transitioned `visibility` counts as *visible* for the whole
189
- // duration and flips at the very end. Revealing it again uses the rule above
190
- // (`visibility 0s`), so it comes back instantly.
191
- "&.s-panel-hidden": "opacity:0 visibility:hidden transform: translateX(-8cqw); " +
210
+ // Off screen but open: crowded out from under the visible run at the left
211
+ // edge (`hidden`), or right of the current panel, parked past the right
212
+ // edge (`parked`) — the two are mirror images. Either keeps its DOM (and
213
+ // thus its scroll position and half-typed forms), so `display:none` is out
214
+ // — `visibility` takes it out of the rendering instead, but only once the
215
+ // fade has played: a transitioned `visibility` counts as *visible* for the
216
+ // whole duration and flips at the very end. Revealing it again uses the
217
+ // rule above (`visibility 0s`), so it comes back instantly.
218
+ "&.s-panel-hidden, &.s-panel-parked": "opacity:0 visibility:hidden " +
192
219
  "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);",
220
+ "&.s-panel-hidden": "transform: translateX(-8cqw);",
221
+ "&.s-panel-parked": "transform: translateX(8cqw);",
193
222
  },
223
+ // The scroll container, with the column's own padding. A scrollbar here is
224
+ // left flush against the column's right edge — unlike content mode's, which
225
+ // insets one from the shell edge to line up with the bar above it. A column
226
+ // has something better to line up with: the hairline the next column starts
227
+ // at, or the edge of the content area. Both want the bar hard against them,
228
+ // and an inset would leave a strip of nothing between the two.
229
+ ".s-panel > .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
230
+ // A panel's actions on a wide shell: a quiet strip at the column's top-right,
231
+ // above the scroll area (never sticky inside it). On a narrow shell the top
232
+ // bar carries them instead, and no strip is drawn at all.
233
+ ".s-panel-actions": "display:flex align-items:center justify-content:flex-end gap:$1 flex-shrink:0 padding: $3 $3 0;",
234
+ // The breadcrumb stack the top bar shows: every open panel, oldest first,
235
+ // the ones on screen right now in bold ink (see `drawCrumbs`). One row that
236
+ // scrolls sideways when the bar is tight — scrollbarless, with a fade at
237
+ // whichever edge has more stack behind it, so the cut-off reads as "keep
238
+ // going" rather than as the stack simply ending.
239
+ // The stack is a `.s-strip` (see tabs.ts), so the scrolling, the hidden
240
+ // scrollbar and the ‹ / › come with it; all that is left to say is the gap
241
+ // between a crumb and its chevron.
242
+ ".s-crumbs > .s-strip-row": "gap:$m1",
243
+ ".s-crumb": {
244
+ // Quiet ink for the stack, full ink and weight for the panels on screen:
245
+ // the weight change alone is ambiguous in a short crumb, the colour alone
246
+ // too subtle. No padding of its own — the first crumb has to start on the
247
+ // same pixel as the app's name above it, and the gap below spaces the row.
248
+ // `flex-shrink:0` because the crumbs are the strip's flex items and carry
249
+ // `overflow:hidden`, which resolves their automatic minimum size to zero:
250
+ // left to shrink they would ellipsise themselves down to stubs rather than
251
+ // overflow, and the stack would never scroll. One long title still caps at
252
+ // 14rem — that is this crumb's own business, not the row running out.
253
+ "&": "flex-shrink:0 font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
254
+ "white-space:nowrap max-width:14rem overflow:hidden text-overflow:ellipsis " +
255
+ "transition: color 0.12s;",
256
+ "&.s-crumb-on": "font-weight:600 fg:$s-text",
257
+ // The same hover treatment as a menu item. The panel you are on is a plain
258
+ // span rather than a link, so it needs no `:not()` guard here.
259
+ "a&:hover": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
260
+ // The pin of a pinned panel, sitting inline just before its title. Filled:
261
+ // at crumb size the icon's hairline strokes alias into a wobble, while the
262
+ // body path filled solid reads as the classic pinned-tab pushpin.
263
+ "svg.s-crumb-pin": "vertical-align:-0.12em margin-right:0.3em opacity:0.8 fill:currentColor",
264
+ // The ● of a panel holding unsaved work — the editors' dirty mark, filled
265
+ // solid for the same reason as the pin.
266
+ "svg.s-crumb-unsaved": "vertical-align:0.08em margin-right:0.3em fill:currentColor",
267
+ },
268
+ // A slash, not a chevron: the stack is a path, and a path's separator is what
269
+ // the URL itself uses. It also has to stay clearly *unlike* the ‹ / › the
270
+ // strip grows when the stack overflows (see `scrollStrip` in tabs.ts) — two
271
+ // near-identical chevrons, one meaningful and one a button, read as a bug.
272
+ "svg.s-crumb-sep": "flex-shrink:0 opacity:0.4",
194
273
  // A window resize (and the very first pass) must track the window instantly,
195
274
  // not rubber-band 450ms behind it: the layout engine raises this class on the
196
275
  // shell for exactly those passes, applies the new geometry, and drops it
197
276
  // after a forced reflow. Beats the standing transitions on specificity.
198
277
  ".s-main.s-shell-snap .s-panel": "transition:none",
199
- // On a narrow shell the single column is edge-to-edge, so there is no inset
200
- // chrome for the scrollbar to line up with — cancel the `.s-scroll-y` margin
201
- // (the twin of content mode's rule in main.ts).
202
- [`@container (max-width: ${NARROW_PX}px)`]: {
203
- ".s-panel > .s-content.s-scroll-y": "margin-right:0",
204
- },
205
278
  // A minimal "still fetching" hint, centred over the panel's content (which
206
279
  // stays mounted underneath, so it can fill in reactively).
207
280
  ".s-panel-loading": {
@@ -215,24 +288,64 @@ A.insertGlobalCss({
215
288
  "50%": "opacity:0.7 transform:scale(1)",
216
289
  },
217
290
  });
218
- /** At most one routed shell per app — that's what `S.panels` is bound to. */
219
- let active = null;
220
- export class PanelController {
291
+ /**
292
+ * The URL is global, so two routed shells would fight over it. This is only a
293
+ * guard against that — the stack is reached through the object `main()` hands
294
+ * back, never through a module-level singleton.
295
+ */
296
+ let mounted = false;
297
+ /**
298
+ * The controller behind a routed shell. It implements {@link PanelStack} —
299
+ * the app-facing face, and the only part of it that is Staffa API — and on
300
+ * top of that draws the columns and the breadcrumbs for `main()`, which
301
+ * constructs it.
302
+ */
303
+ export class PanelStackController {
304
+ /**
305
+ * Kept out of Aberdeen's proxy wrapping: this is a class instance holding
306
+ * DOM nodes, timers and route handlers, and it rides inside every
307
+ * {@link Panel.stack}. Its reactivity doesn't need the wrapper — it comes
308
+ * from `$state` and the panels, which are proxies in their own right.
309
+ */
310
+ [OPAQUE] = true;
221
311
  compiled;
222
312
  /** The `ancestors` table, compiled like the routes it is keyed by. */
223
313
  ancestors;
224
314
  opts;
225
- /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
226
- live = [];
227
- byId = new Map();
228
- nextId = 1;
229
- /** Drives rendering: panel id → its `order` (used only as the sort key). */
230
- $ids = A.proxy({});
231
315
  /**
232
- * The live stack's paths and its top panel, for reactive readers: the
233
- * `document.title` watcher, `main()`'s Escape handling and `S.panels.stack`.
316
+ * The live stack, oldest first (closing panels are no longer part of it),
317
+ * and which of its panels is current — the one reactive fact about the
318
+ * stack's *shape*. The getters, the crumbs and `document.title` subscribe
319
+ * to it simply by reading it; each commit publishes the next shape by
320
+ * assigning a fresh `live` array. The entries themselves are opaque (see
321
+ * {@link PanelEntry}), so the array carries their comings, goings and
322
+ * order — nothing deeper; a panel's own facts stay separately reactive on
323
+ * its `$panel`, which is what lets a panel rename itself without the
324
+ * stack redrawing.
325
+ *
326
+ * One rule makes this safe to touch from anywhere: **queries subscribe,
327
+ * commands peek**. The getters below are the queries. Every navigation
328
+ * entry point (`navigate`, `closePath`, `back`, …) wraps itself in
329
+ * `A.peek`, so an app calling one from inside a reactive scope (a
330
+ * redirect in a route handler, say) can't subscribe that scope to the
331
+ * very stack it is changing — and everything those commands call through
332
+ * to, `propose` and `commit` included, inherits the same guarantee and
333
+ * reads the stack plainly.
334
+ */
335
+ $state = A.proxy({ live: [], focus: 0 });
336
+ /**
337
+ * The open panels again, keyed by path — the shape as the DOM consumes it.
338
+ * `drawColumns`' `onEach` mounts and unmounts panels by key, so a panel
339
+ * spliced out of the middle of the stack touches exactly one key, and the
340
+ * DOM of the retained columns — scroll positions, half-typed forms — is
341
+ * left alone. (Iterating `live` itself would key panels by array index,
342
+ * and a splice renumbers every index after it, redrawing them all.) A
343
+ * key's value is its entry, by reference, and is never reassigned, so a
344
+ * panel only ever redraws wholesale when its path closes.
234
345
  */
235
- $state = A.proxy({ paths: [], topId: 0 });
346
+ $open = A.proxy({});
347
+ /** Feeds {@link PanelEntry.order}: one shared counter, so keys never tie. */
348
+ nextOrder = 0;
236
349
  containerEl;
237
350
  /** The shell's measurements, shared by everything drawn since they were taken. */
238
351
  geom;
@@ -240,48 +353,66 @@ export class PanelController {
240
353
  lastBodyW = -1;
241
354
  layoutQueued = false;
242
355
  timers = new Set();
243
- /** The stack the navigation in flight is heading for; see {@link intended}. */
356
+ /** The arrangement the navigation in flight is heading for; see {@link intended}. */
244
357
  intent = null;
245
358
  /** The navigation the router hasn't settled yet, if any. */
246
359
  settling = null;
360
+ /** The route last seen by the observer, for stashing a left panel's query. */
361
+ lastSeen = null;
247
362
  /** The one navigation waiting behind it; see {@link issue}. */
248
363
  queued = null;
249
364
  constructor(opts) {
250
- if (active) {
365
+ if (mounted) {
251
366
  throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
252
367
  }
253
- active = this;
368
+ mounted = true;
254
369
  this.opts = opts;
255
370
  this.compiled = Object.entries(opts.routes).map(([key, draw]) => ({ ...compileKey(key), draw }));
256
371
  this.ancestors = Object.entries(opts.ancestors ?? {})
257
372
  .filter((entry) => entry[1] != null)
258
373
  .map(([key, fn]) => ({ ...compileKey(key), fn }));
259
- // The router consults this guard before any navigation is applied — ours,
260
- // a link's, browser back/forward, even a direct route.go() by app code —
261
- // so every panel the change would remove gets its requestClose asked,
262
- // exactly once, and a veto leaves the URL and the stack untouched (the
263
- // router holds the route steady while an async guard is pending, and
264
- // knows the exact history depth to restore on a vetoed popstate). A
265
- // guard the app registered before mounting keeps working: it is chained
266
- // in front of ours — an app veto (or redirect) wins without the panels
267
- // being asked — and handed back when the shell unmounts.
268
- const appGuard = route.setGuard((to, from) => {
269
- const outer = appGuard ? appGuard(to, from) : true;
270
- if (outer === false)
271
- return false;
272
- if (outer === true)
273
- return this.checkChange(to);
274
- return outer.then((ok) => (ok === false ? false : this.checkChange(to)));
275
- });
276
374
  // Commit the stack whenever the URL or its snapshot changes — the initial
277
- // load, our own navigations, and browser back/forward. Anything that
278
- // reaches this point has already passed the guard above.
375
+ // load, our own navigations, and browser back/forward. There is no route
376
+ // guard of the shell's own to pass first: closes are refused up front
377
+ // (`closePath` on an unsaved panel) or repaired at the commit (`propose`
378
+ // keeps unsaved panels a navigation would drop), so a guard the app
379
+ // itself registered with `route.setGuard` — an auth redirect, say — is
380
+ // left exactly where it is and keeps working untouched.
279
381
  A(() => {
280
382
  const target = this.computeTarget();
281
- A.peek(() => this.propose(target));
383
+ // Subscribed (not peeked) deliberately: a search/hash change with the
384
+ // path staying put must refresh `lastSeen` too, or the next stash would
385
+ // restore stale ones.
386
+ const search = { ...route.current.search };
387
+ const hash = route.current.hash;
388
+ A.peek(() => {
389
+ // Stash the query of the panel the URL just left on that panel, for
390
+ // when a crumb brings it back (see PanelEntry.search). Done here, on
391
+ // the shared pipeline, so every origin is covered alike: the stack's
392
+ // own navigations, an app's `route.go()`, and browser back/forward.
393
+ const prev = this.lastSeen;
394
+ if (prev && prev.path !== route.current.path) {
395
+ const entry = this.$state.live.find((e) => e.path === prev.path);
396
+ if (entry) {
397
+ entry.search = prev.search;
398
+ entry.hash = prev.hash;
399
+ }
400
+ }
401
+ this.lastSeen = { path: route.current.path, search, hash };
402
+ this.propose(target);
403
+ // A history entry that doesn't describe an arrangement — the initial
404
+ // load, or an app's own `route.go()` — is stamped with the one it
405
+ // just produced: a reload restores the same columns, and a later
406
+ // `route.back()` can recognize the entry (its matching wants the
407
+ // `panels`/`parked` keys present, not merely compatible).
408
+ if (!Array.isArray(route.current.state.panels)) {
409
+ Object.assign(route.current.state, this.stateFor({ stack: this.paths(), focus: this.$state.focus }));
410
+ }
411
+ });
282
412
  });
283
413
  this.interceptLinks();
284
414
  this.watchTitle();
415
+ this.guardTabClose();
285
416
  A.clean(() => {
286
417
  for (const t of this.timers)
287
418
  clearTimeout(t);
@@ -290,9 +421,7 @@ export class PanelController {
290
421
  // waiting its turn is answered rather than left hanging.
291
422
  this.queued?.settle(false);
292
423
  this.queued = null;
293
- route.setGuard(appGuard);
294
- if (active === this)
295
- active = null;
424
+ mounted = false;
296
425
  });
297
426
  }
298
427
  // ── Stack derivation ───────────────────────────────────────────────────
@@ -322,7 +451,7 @@ export class PanelController {
322
451
  * and the matching ones become the stack. Either way, a path with no route is
323
452
  * skipped rather than opened as a "not found" column, so an app that doesn't
324
453
  * want one screen stacked under another simply doesn't route it. The path
325
- * itself is always the top panel, matched or not.
454
+ * itself always ends the derived stack, matched or not.
326
455
  */
327
456
  deriveStack(path) {
328
457
  const top = normalizePath(path);
@@ -359,48 +488,82 @@ export class PanelController {
359
488
  found.push("/" + segments.slice(0, i).join("/"));
360
489
  return found;
361
490
  }
362
- /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
363
- targetFor(path, snapshot) {
364
- if (Array.isArray(snapshot))
365
- return snapshot.map(String).concat(normalizePath(path));
366
- return this.deriveStack(path);
491
+ /**
492
+ * The pinned panels among `stack`, in order, minus `omit` — the ones a
493
+ * navigation must carry along rather than close. Pin flags live on the
494
+ * panels themselves, so a path without a live panel can't be pinned.
495
+ */
496
+ pinnedIn(stack, omit) {
497
+ return stack.filter((path) => {
498
+ if (omit.includes(path))
499
+ return false;
500
+ return this.$state.live.find((e) => e.path === path)?.$panel.pinned === true;
501
+ });
367
502
  }
368
- /** The stack the current history entry asks for. Subscribes to path + snapshot. */
369
- computeTarget() {
370
- return this.targetFor(route.current.path, route.current.state.panels);
503
+ /** Whether the panel open at `path` (if any) holds unsaved work. */
504
+ unsavedAt(path) {
505
+ return this.$state.live.find((e) => e.path === path)?.$panel.unsaved === true;
371
506
  }
372
507
  /**
373
- * The route guard (see `route.setGuard` in the constructor): asked before any
374
- * route change lands, wherever it came from. Runs the {@link Page.requestClose}
375
- * guard of every panel the new route's stack would remove — a set defined by
376
- * the target (the commit reconciles by path), so a derived stack that shares
377
- * nothing with the live one still asks exactly the panels that are closing.
508
+ * The arrangement a route implies: its snapshot around its path, or —
509
+ * without a snapshot — derived, with the new panel current at the end and
510
+ * any pinned panels carried along beneath it.
511
+ *
512
+ * The snapshot reads subscribe — they are the URL's, exactly what the
513
+ * route observer is for. The derivation is peeked instead: it reads the
514
+ * live stack for its pins, which is the very thing that observer rewrites,
515
+ * and subscribing to it would re-run the observer once per commit.
378
516
  */
379
- checkChange(to) {
380
- const removed = this.removedBy(this.targetFor(to.path, to.state.panels));
381
- return removed.length ? runGuards(removed) : true;
517
+ targetFor(path, state) {
518
+ const before = Array.isArray(state?.panels) ? state.panels.map(String) : null;
519
+ if (before) {
520
+ const after = Array.isArray(state.parked) ? state.parked.map(String) : [];
521
+ // A stack never holds the same path twice: rendering reconciles by path,
522
+ // so a duplicate would leave a permanently element-less entry that stalls
523
+ // the layout. Our own states are clean, but `route.go` accepts
524
+ // hand-written ones — drop duplicates rather than wedge.
525
+ const cur = normalizePath(path);
526
+ const seen = new Set([cur]);
527
+ const uniq = (paths) => paths.map(normalizePath).filter((p) => !seen.has(p) && !!seen.add(p));
528
+ const beforeUnique = uniq(before);
529
+ return { stack: [...beforeUnique, cur, ...uniq(after)], focus: beforeUnique.length };
530
+ }
531
+ return A.peek(() => {
532
+ const base = this.deriveStack(path).slice(0, -1);
533
+ const stack = [...base, ...this.pinnedIn(this.paths(), [...base, normalizePath(path)]), normalizePath(path)];
534
+ return { stack, focus: stack.length - 1 };
535
+ });
536
+ }
537
+ /** The arrangement the current history entry asks for. Subscribes to path + snapshot. */
538
+ computeTarget() {
539
+ return this.targetFor(route.current.path, route.current.state);
382
540
  }
383
541
  // ── Commit pipeline ────────────────────────────────────────────────────
384
542
  paths() {
385
- return this.live.map((e) => e.path);
386
- }
387
- /** The live panels a target stack drops — by path, so a splice removes only its own column. */
388
- removedBy(target) {
389
- return this.live.filter((entry) => !target.includes(entry.path));
543
+ return this.$state.live.map((e) => e.path);
390
544
  }
391
545
  /**
392
- * Adopt a stack proposed by the URL. Close guards have already been run (and
393
- * have passed) by the time a route change is visible here — `checkChange` is
394
- * consulted by the router itself, before anything is applied.
546
+ * Adopt an arrangement proposed by the URL — after repairing it: panels
547
+ * holding unsaved work are never torn down by a navigation, wherever it
548
+ * came from — a link, a nav item, even a browser back to an entry from
549
+ * before the panel existed. Whatever the target drops, they stay, parked
550
+ * after the current panel and wearing the ● that says why. (They are
551
+ * deliberately not written into history entries: the work they protect
552
+ * lives in the page's DOM, which a reload clears anyway.)
395
553
  */
396
554
  propose(target) {
397
- if (sameStack(this.paths(), target))
555
+ const kept = this.$state.live
556
+ .filter((e) => !target.stack.includes(e.path) && e.$panel.unsaved)
557
+ .map((e) => e.path);
558
+ if (kept.length)
559
+ target = { stack: [...target.stack, ...kept], focus: target.focus };
560
+ if (sameStack(this.paths(), target.stack) && target.focus === this.$state.focus)
398
561
  return;
399
- this.commit(target, A.peek(route.current, "nav"));
562
+ this.commit(target, route.current.nav);
400
563
  }
401
564
  /**
402
- * Apply a target stack: unmount what's gone, mount what's new, animate the
403
- * difference.
565
+ * Apply a target arrangement: unmount what's gone, mount what's new, animate
566
+ * the difference.
404
567
  *
405
568
  * Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
406
569
  * well-defined): a panel present in both stacks stays mounted *even if its
@@ -413,9 +576,14 @@ export class PanelController {
413
576
  // The panels this commit mounts size themselves as they draw, so make them
414
577
  // measure the shell as it is now rather than trusting the last pass's numbers.
415
578
  this.geom = undefined;
416
- const existing = new Map(this.live.map((entry) => [entry.path, entry]));
579
+ // Pin flags for panels this commit *creates* — a reload, or a cold
580
+ // restore. Live panels keep their own flag: a pin is the user's mark on
581
+ // the panel, not part of where back/forward travel.
582
+ const pinned = route.current.state.pinned;
583
+ const seedPins = new Set(Array.isArray(pinned) ? pinned.map(String) : []);
584
+ const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
417
585
  const next = [];
418
- for (const path of target) {
586
+ for (const path of target.stack) {
419
587
  const kept = existing.get(path);
420
588
  if (kept) {
421
589
  // Retained: it just takes its new place in the stack. Its `order` (the
@@ -424,42 +592,49 @@ export class PanelController {
424
592
  next.push(kept);
425
593
  continue;
426
594
  }
427
- const entry = this.createEntry(path, next.length);
595
+ const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
428
596
  // An initial load just appears, and so do panels *revealed* by a back —
429
597
  // they belong underneath the ones sliding away. Everything else enters at
430
598
  // the right edge, a replacement exactly like a push.
431
599
  if (nav !== "load" && nav !== "back")
432
600
  entry.enter = true;
433
601
  next.push(entry);
434
- this.byId.set(entry.id, entry);
602
+ this.$open[path] = entry;
435
603
  }
436
604
  // Whatever the target no longer holds leaves the same way: fading out over
437
605
  // the right edge, which is also where its replacement (if any) comes in from.
438
606
  for (const entry of existing.values())
439
607
  this.beginClose(entry);
440
- this.live = next;
441
- A.merge(this.$state, { paths: this.paths(), topId: this.live.length ? this.live[this.live.length - 1].id : 0 });
442
- for (const entry of this.live)
443
- this.$ids[String(entry.id)] = entry.order;
608
+ this.$state.live = next;
609
+ this.$state.focus = Math.min(target.focus, next.length - 1);
444
610
  this.scheduleLayout();
445
611
  }
446
- createEntry(path, order) {
612
+ createEntry(path, visible, pinned) {
447
613
  const { draw, params } = this.resolve(path);
448
614
  const entry = {
449
- id: this.nextId++,
450
- order,
615
+ [OPAQUE]: true,
616
+ order: this.nextOrder++,
451
617
  path,
452
618
  draw,
453
619
  $ui: A.proxy({ holding: false }),
454
- layout: "medium",
620
+ maxWidth: "full",
455
621
  width: 0,
456
622
  };
457
- // `close` closes *this* panel, top of the stack or not. It resolves the
458
- // panel's current depth at call time, so it keeps working after a splice has
623
+ // `close` closes *this* panel, current or not. It resolves the panel's
624
+ // place in the stack at call time, so it keeps working after a splice has
459
625
  // moved it — and quietly resolves false once the panel is gone.
460
- entry.$page = A.proxy({
626
+ //
627
+ // `visible` starts at what the panel's place implies: shown when it sits
628
+ // at or before the current panel (a pushed panel always does), hidden when
629
+ // it is restored already parked. `width` is filled in by the sizing scope
630
+ // in `drawPanel` before the handler draws.
631
+ entry.$panel = A.proxy({
632
+ stack: this,
461
633
  params,
462
634
  path,
635
+ width: 0,
636
+ visible,
637
+ pinned: pinned || undefined,
463
638
  close: () => this.closePath(entry.path),
464
639
  });
465
640
  return entry;
@@ -474,14 +649,17 @@ export class PanelController {
474
649
  */
475
650
  beginClose(entry) {
476
651
  entry.closing = true;
652
+ // It is on its way out, so it is no longer "on screen" as far as anything
653
+ // hanging off `$panel.visible` is concerned — even though its element lingers
654
+ // to play the fade.
655
+ entry.$panel.visible = false;
477
656
  // Frozen one layer below where it was, which is still above everything it
478
657
  // was covering: it fades out over the panel it uncovers, and under the one
479
658
  // that takes its place (see LAYER_STEP). Set here, while the element is
480
659
  // still ours — a moment later the scope, and with it `entry.el`, is gone.
481
660
  if (entry.el)
482
- entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
483
- this.byId.delete(entry.id);
484
- delete this.$ids[String(entry.id)];
661
+ entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
662
+ delete this.$open[entry.path];
485
663
  }
486
664
  /**
487
665
  * A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
@@ -511,22 +689,34 @@ export class PanelController {
511
689
  if (e.target === el && e.propertyName === "opacity")
512
690
  drop();
513
691
  });
514
- const timer = setTimeout(drop, PANEL_MS + 80);
692
+ const timer = setTimeout(drop, PAGE_MS + 80);
515
693
  this.timers.add(timer);
516
694
  }
517
695
  // ── Navigation ─────────────────────────────────────────────────────────
518
696
  /**
519
- * The stack navigation works from: the one we're on the way to while a change
520
- * is still settling, and the one on screen otherwise.
697
+ * The arrangement navigation works from: the one we're on the way to while a
698
+ * change is still settling, and the one on screen otherwise.
521
699
  *
522
- * Settling takes a moment more often than it looks: an async
523
- * {@link Page.requestClose}, and every `route.back()`, which travels through
524
- * the browser's history and lands on a `popstate`. Working from the committed
525
- * stack in that window would make a second Escape ask for the panel the first
526
- * one is already taking away — so two quick Escapes would peel one panel.
700
+ * Settling takes a moment more often than it looks: every `route.back()`
701
+ * travels through the browser's history and lands on a `popstate`, and an
702
+ * app-registered route guard may be async. Working from the committed
703
+ * arrangement in that window would make a second Escape aim at the panel the
704
+ * first one is already taking away — so two quick Escapes would peel one panel.
527
705
  */
528
706
  intended() {
529
- return this.intent ?? this.paths();
707
+ return this.intent ?? { stack: this.paths(), focus: this.$state.focus };
708
+ }
709
+ /** The history `state` describing `arr` — exactly what `targetFor` reads back. */
710
+ stateFor(arr) {
711
+ return {
712
+ panels: arr.stack.slice(0, arr.focus),
713
+ parked: arr.stack.slice(arr.focus + 1),
714
+ pinned: this.pinnedPaths(),
715
+ };
716
+ }
717
+ /** The paths of the pinned panels — the pin list history entries persist. */
718
+ pinnedPaths() {
719
+ return this.$state.live.filter((e) => e.$panel.pinned).map((e) => e.path);
530
720
  }
531
721
  /**
532
722
  * Put a navigation to the router, or — while one is still settling — behind
@@ -534,9 +724,10 @@ export class PanelController {
534
724
  * {@link intended}, so the newest is the one that means what the user last
535
725
  * asked for, and the one it displaces resolves `false`.
536
726
  *
537
- * A refusal empties the queue instead of running it. A veto is a "no, keep
538
- * this open", and the Escape queued behind it was aimed a panel deeper — with
539
- * the veto standing, running it would close the very panel that just said no.
727
+ * A refusal empties the queue instead of running it: a navigation can still
728
+ * fail to land — an app-registered route guard vetoes it, or another one
729
+ * supersedes it — and what was queued behind it was worked out against the
730
+ * arrangement it would have produced.
540
731
  */
541
732
  issue(target, run) {
542
733
  this.intent = target;
@@ -568,62 +759,110 @@ export class PanelController {
568
759
  return settling;
569
760
  }
570
761
  /**
571
- * Navigate back to a stack that is a truncation of the current one — the shared
572
- * implementation of Escape, a page closing itself, return-links and
573
- * `S.panels.close()`. `route.back()` prefers the history entry where that
574
- * panel was on top (with its scroll state intact); when there is no such entry
575
- * it replaces the current one, carrying the snapshot passed as the fallback.
576
- * Either way the route guard asks the closing panels first, and the returned
577
- * promise reports its verdict.
762
+ * Make the stack's `index`th panel current: the URL and the visible run move
763
+ * to it, while the panels right of it stay open, parked past the right edge
764
+ * of the viewport. Nothing closes; it is a history entry, so the browser's
765
+ * back button returns the focus to where it was. What a click on a
766
+ * breadcrumb — any link to an open panel — comes down to.
578
767
  */
579
- goBackTo(target) {
580
- return this.issue(target, () => route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } }));
581
- }
582
- /** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
583
- closeDownTo(index) {
584
- const paths = this.intended();
585
- if (index < 0 || index >= paths.length - 1)
768
+ focusAt(index, search, hash) {
769
+ const arr = this.intended();
770
+ if (index < 0 || index >= arr.stack.length || index === arr.focus)
586
771
  return Promise.resolve(false);
587
- return this.goBackTo(paths.slice(0, index + 1));
772
+ const target = { stack: arr.stack, focus: index };
773
+ const path = arr.stack[index];
774
+ return this.issue(target, () => {
775
+ // The panel gets its own last search and hash back, unless the link
776
+ // that brought us here carries its own.
777
+ const entry = this.$state.live.find((e) => e.path === path);
778
+ return route.go({ path, search: search ?? entry?.search, hash: hash ?? entry?.hash, state: this.stateFor(target) });
779
+ });
588
780
  }
589
- /** Guarded close of the top panel. */
590
- closeTop() {
591
- return this.closeDownTo(this.intended().length - 2);
781
+ /**
782
+ * One step back along the stack — what Escape does (`main()` calls this;
783
+ * it is not {@link PanelStack} API). At the stack's end this closes the
784
+ * current panel; mid-stack — with panels parked to the right — or when the
785
+ * panel holds {@link Panel.unsaved} work, the panel stays open and the
786
+ * focus just moves to the panel on its left, parking the one it leaves.
787
+ * Resolves `false` at the stack's start, where there is no left to go.
788
+ */
789
+ back() {
790
+ return A.peek(() => {
791
+ const arr = this.intended();
792
+ if (arr.focus === arr.stack.length - 1 && !this.unsavedAt(arr.stack[arr.focus])) {
793
+ return this.closePath(arr.stack[arr.focus] ?? "");
794
+ }
795
+ if (arr.focus === 0)
796
+ return Promise.resolve(false);
797
+ return this.focusAt(arr.focus - 1);
798
+ });
592
799
  }
593
800
  /**
594
- * Guarded close of whichever panel is open at `path`, top of the stack or not
595
- * — what a page's own close affordances ({@link Page.close}, a box's ✕) come
596
- * down to. `false` when that path isn't open.
801
+ * Close whichever panel is open at `path`, current or not — what
802
+ * {@link Panel.close}, {@link PanelStack.closePanel} and the crumb menu's
803
+ * Close come down to. `false` when that path isn't open, is the stack's
804
+ * only panel, or holds {@link Panel.unsaved} work — nothing may close an
805
+ * unsaved panel; the app clears the flag first, which is its explicit
806
+ * "this is now discardable".
597
807
  *
598
- * The top panel pops back to the snapshot beneath it. Any other panel is
599
- * *spliced* out: its guard runs, the columns above it keep their place and
600
- * state (the commit reconciles by path), and the URL doesn't change, since the
601
- * top panel didn't. That still gets its own history entry, so the browser's
602
- * back button restores the closed column like any other snapshot — which is
603
- * why it goes through `route.go` here rather than through `navigate()`, whose
604
- * "link to the panel we're already on" check would see a no-op.
808
+ * Closing the current panel at the stack's very end pops back through the
809
+ * browser's history to the entry beneath it, when it is there (restoring its
810
+ * scroll and search state); the arrangement is part of the match, so an
811
+ * entry where the closing panel was merely parked won't do. Every other
812
+ * close is a *splice*: the columns around the closed one keep their place
813
+ * and state (the commit reconciles by path). That still gets its own
814
+ * history entry, so the browser's back button restores the closed column
815
+ * like any other arrangement — which is why it goes through `route.go` here
816
+ * rather than through `navigate()`, whose "link to an open panel" check
817
+ * would turn it into a focus move.
605
818
  */
606
819
  closePath(path) {
607
- const paths = this.intended();
608
- const index = paths.indexOf(normalizePath(path));
609
- if (index < 0)
610
- return Promise.resolve(false);
611
- if (index === paths.length - 1)
612
- return this.closeDownTo(index - 1);
613
- const target = paths.filter((_, i) => i !== index);
614
- return this.issue(target, () => route.go({
615
- path: target[target.length - 1],
616
- // The top panel keeps its search params and hash: it isn't going
617
- // anywhere, and `go()` would otherwise default them away.
618
- search: A.peek(() => ({ ...route.current.search })),
619
- hash: A.peek(route.current, "hash"),
620
- state: { panels: target.slice(0, -1) },
621
- }));
622
- }
623
- /** Guarded close of the panel whose `.s-panel` element this is. */
624
- closePanelEl(el) {
625
- const entry = this.live.find((e) => e.el === el);
626
- return entry ? this.closePath(entry.path) : Promise.resolve(false);
820
+ return A.peek(() => {
821
+ const arr = this.intended();
822
+ const index = arr.stack.indexOf(normalizePath(path));
823
+ if (index < 0 || arr.stack.length < 2 || this.unsavedAt(arr.stack[index]))
824
+ return Promise.resolve(false);
825
+ const stack = arr.stack.filter((_, i) => i !== index);
826
+ // Closing the current panel hands the focus to the panel on its left (or,
827
+ // at the stack's start, to the one that was parked beside it); closing
828
+ // any other panel moves the focus not at all.
829
+ const focus = index === arr.focus ? Math.max(0, index - 1) : arr.focus - (index < arr.focus ? 1 : 0);
830
+ const target = { stack, focus };
831
+ if (index === arr.focus && index === arr.stack.length - 1) {
832
+ // When no history entry matches and the current one is replaced
833
+ // instead, the panel beneath gets its stashed query back through the
834
+ // fallback (a match restores the matched entry's own). Pins can't
835
+ // ride the same way — a `state` in the fallback would be shadowed by
836
+ // the match target's — so they are re-stamped onto whatever entry we
837
+ // land on, the way `togglePin` writes them.
838
+ const beneath = this.$state.live.find((e) => e.path === stack[focus]);
839
+ const fallback = {};
840
+ if (beneath?.search)
841
+ fallback.search = beneath.search;
842
+ if (beneath?.hash)
843
+ fallback.hash = beneath.hash;
844
+ const pinned = stack.filter((p) => this.$state.live.find((e) => e.path === p)?.$panel.pinned === true);
845
+ return this.issue(target, () => Promise.resolve(route.back({ path: stack[focus], state: { panels: stack.slice(0, focus), parked: [] } }, fallback)).then((ok) => {
846
+ if (ok)
847
+ route.current.state.pinned = pinned;
848
+ return ok;
849
+ }));
850
+ }
851
+ const current = stack[focus];
852
+ const moved = current !== arr.stack[arr.focus];
853
+ return this.issue(target, () => {
854
+ // The current panel keeps its search params and hash: it isn't going
855
+ // anywhere, and `go()` would otherwise default them away. When the
856
+ // close *did* move the focus, the newly current panel gets its own back.
857
+ const entry = moved ? this.$state.live.find((e) => e.path === current) : undefined;
858
+ return route.go({
859
+ path: current,
860
+ search: moved ? entry?.search : { ...route.current.search },
861
+ hash: moved ? entry?.hash : route.current.hash,
862
+ state: this.stateFor(target),
863
+ });
864
+ });
865
+ });
627
866
  }
628
867
  /**
629
868
  * Navigate to `href`. `origin` is the path of the panel the link lives in, or
@@ -631,105 +870,314 @@ export class PanelController {
631
870
  * the whole stack instead (see {@link deriveStack}). `replace` swaps the
632
871
  * originating panel rather than stacking on top of it, and `beneath` says what
633
872
  * the stack under the target is outright, for callers that know.
873
+ *
874
+ * Resolves the way every {@link PanelStack} method does: `true` once the
875
+ * navigation lands, `false` when it doesn't (already there counts as
876
+ * landed).
634
877
  */
635
878
  navigate(href, origin, replace = false, beneath) {
636
- let url;
637
- try {
638
- url = new URL(href, location.href);
639
- }
640
- catch {
641
- return;
642
- }
643
- const path = normalizePath(url.pathname);
644
- const search = Object.fromEntries(new URLSearchParams(url.search));
645
- const hash = url.hash;
646
- const paths = this.intended();
647
- // A link to a panel that is already open is a return, not a navigation —
648
- // so a stack can never hold the same path twice.
649
- const open = paths.indexOf(path);
650
- if (open >= 0 && open < paths.length - 1 && !beneath) {
651
- void this.closeDownTo(open);
652
- return;
653
- }
654
- if (open >= 0 && !beneath) {
655
- // The target is the panel we're already on. Going nowhere — but the link
656
- // may still carry a different search or hash, which belong to the top
657
- // panel: record that as a history entry, leaving the stack alone (the
658
- // panel reconciles by path, so it isn't even redrawn).
659
- if (url.search === location.search && (url.hash || "") === (location.hash || ""))
660
- return;
661
- void this.issue(paths, () => route.go({ path, search, hash, state: { panels: paths.slice(0, -1) } }));
662
- return;
663
- }
664
- // Without an originating panel there is no stack to build on, so derive
665
- // one — a nav click and a deep link to the same URL land identically.
666
- // The route guard (checkChange) asks every panel this removes — a set
667
- // defined by the target stack, wherever those panels happen to sit —
668
- // before the change is applied; a veto leaves everything untouched.
669
- const originIndex = origin == null ? -1 : paths.indexOf(origin);
670
- const under = beneath
671
- ? beneath.map(normalizePath).filter((p) => p !== path)
672
- : originIndex < 0
673
- ? this.deriveStack(path).slice(0, -1)
674
- : paths.slice(0, replace ? originIndex : originIndex + 1);
675
- void this.issue([...under, path], () => route.go({ path, search, hash, state: { panels: under } }));
879
+ return A.peek(() => {
880
+ let url;
881
+ try {
882
+ url = new URL(href, location.href);
883
+ }
884
+ catch {
885
+ return Promise.resolve(false);
886
+ }
887
+ const path = normalizePath(url.pathname);
888
+ const search = Object.fromEntries(new URLSearchParams(url.search));
889
+ const hash = url.hash;
890
+ const arr = this.intended();
891
+ // A link to a panel that is already open is a return, not a navigation —
892
+ // a stack never holds the same path twice. Returning just moves the
893
+ // focus: the panels right of the target stay open, parked past the right
894
+ // edge, and nothing closes. That is the whole behaviour of a breadcrumb,
895
+ // which is exactly such a link.
896
+ const open = beneath ? -1 : arr.stack.indexOf(path);
897
+ if (open >= 0 && open !== arr.focus) {
898
+ return this.focusAt(open, url.search ? search : undefined, hash || undefined);
899
+ }
900
+ if (open >= 0) {
901
+ // The target is the panel we're already on. Going nowhere — but the link
902
+ // may still carry a different search or hash, which belong to the
903
+ // current panel: record that as a history entry, leaving the stack alone
904
+ // (the panel reconciles by path, so it isn't even redrawn).
905
+ if (url.search === location.search && (url.hash || "") === (location.hash || ""))
906
+ return Promise.resolve(true);
907
+ return this.issue(arr, () => route.go({ path, search, hash, state: this.stateFor(arr) }));
908
+ }
909
+ // A new panel: it opens at the stack's end and becomes current. The
910
+ // panels after the origin close — except pinned ones, which ride along,
911
+ // keeping their order, beneath the new panel (and unsaved ones, which
912
+ // the commit itself keeps, parked — see `propose`). Without an
913
+ // originating panel there is no stack to build on, so derive one — a
914
+ // nav click and a deep link to the same URL land identically (bar the
915
+ // pins, which a fresh tab doesn't have).
916
+ const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
917
+ // A stack never holds the same path twice (rendering reconciles by
918
+ // path), so a caller-supplied `beneath` is deduplicated, not just
919
+ // filtered against the target.
920
+ const base = beneath
921
+ ? beneath.map(normalizePath).filter((p, i, all) => p !== path && all.indexOf(p) === i)
922
+ : originIndex < 0
923
+ ? this.deriveStack(path).slice(0, -1)
924
+ : arr.stack.slice(0, replace ? originIndex : originIndex + 1);
925
+ // A replaced origin closes, pin or no pin: replacing is the panel's own
926
+ // doing, not somewhere else navigating over it.
927
+ const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
928
+ const target = { stack: [...under, path], focus: under.length };
929
+ return this.issue(target, () => route.go({ path, search, hash, state: this.stateFor(target) }));
930
+ });
676
931
  }
677
- /** Programmatic push/replace, with the top panel as the implied origin. */
932
+ /** Programmatic push/replace, with the current panel as the implied origin. */
678
933
  pushPath(path, replace) {
679
- const paths = this.intended();
680
- this.navigate(path, paths[paths.length - 1] ?? null, replace);
681
- }
682
- /** Programmatic open-as-a-whole-stack: `beneath` as given, or derived. */
683
- openPath(path, beneath) {
684
- this.navigate(path, null, false, beneath);
934
+ return A.peek(() => {
935
+ const arr = this.intended();
936
+ return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
937
+ });
685
938
  }
686
939
  // ── Link interception ──────────────────────────────────────────────────
687
940
  /**
688
941
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
689
942
  * the anchor so we can decide what the click *means*: the originating
690
- * `.s-panel` (which decides what the click truncates), `data-panel=replace`,
691
- * and return-to-an-open-panel semantics. The exclusion rules (targets,
692
- * downloads, modified clicks, external URLs) live in Aberdeen; the close
693
- * guards run in `checkChange` when our navigation reaches the router.
943
+ * `.s-panel` (which decides what the click truncates), the `data-panel`
944
+ * attribute, and return-to-an-open-panel semantics. The exclusion rules
945
+ * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
946
+ * close guards run in `checkChange` when our navigation reaches the router.
947
+ *
948
+ * `data-panel` names which of the three {@link PanelStack} navigations the
949
+ * click is: `push` (the default), `replace`, or `open`, which drops the
950
+ * originating panel so the target arrives with its own stack beneath it,
951
+ * exactly as a nav item's link does. An unrecognised value is a `push`.
694
952
  */
695
953
  interceptLinks() {
696
954
  route.interceptLinks((url, anchor) => {
697
- const panel = anchor.closest(".s-panel");
698
- const origin = panel ? this.live.find((entry) => entry.el === panel) : undefined;
699
- this.navigate(url.href, origin?.path ?? null, anchor.getAttribute("data-panel") === "replace");
955
+ const mode = anchor.getAttribute("data-panel");
956
+ const panel = mode === "open" ? null : anchor.closest(".s-panel");
957
+ const origin = panel ? this.$state.live.find((entry) => entry.el === panel) : undefined;
958
+ void this.navigate(url.href, origin?.path ?? null, mode === "replace");
700
959
  return true;
701
960
  });
702
961
  }
962
+ // ── What the shell's top bar asks ──────────────────────────────────────
963
+ // The {@link PanelStack} face, where all the documentation lives. These are
964
+ // the *queries* of the "queries subscribe, commands peek" rule on `$state`:
965
+ // read one in a scope and that scope follows the stack's shape.
966
+ get currentPanel() {
967
+ return this.$state.live[this.$state.focus]?.$panel;
968
+ }
969
+ get panels() {
970
+ return this.$state.live.map((e) => e.$panel);
971
+ }
972
+ get currentPanelIndex() {
973
+ return this.$state.focus;
974
+ }
975
+ pushPanel(path) {
976
+ return this.pushPath(path, false);
977
+ }
978
+ replacePanel(path) {
979
+ return this.pushPath(path, true);
980
+ }
981
+ openPanelStack(path, beneath) {
982
+ return this.navigate(path, null, false, beneath);
983
+ }
984
+ closePanel(path) {
985
+ return A.peek(() => {
986
+ const arr = this.intended();
987
+ return this.closePath(path ?? arr.stack[arr.focus] ?? "");
988
+ });
989
+ }
990
+ /**
991
+ * The breadcrumb stack, drawn by `main()` into the top bar: every open
992
+ * panel, oldest first, the ones on screen right now in bold, pinned ones
993
+ * wearing their pin. Every crumb but the current panel's is a plain link to
994
+ * that panel, and a link to an open panel is a focus move (see `navigate`) —
995
+ * so clicking along the stack closes nothing, in either direction, and the
996
+ * panels right of the current one wait just past the viewport's edge.
997
+ * Right-click (or long-press) offers pinning, and closing just that one
998
+ * panel — the close that splices it out of the middle when it isn't last.
999
+ */
1000
+ drawCrumbs() {
1001
+ // The very same row `S.tabs` puts its tab strip in: it scrolls when the
1002
+ // stack outgrows the bar, and shows a ‹ / › over whichever end still has
1003
+ // crumbs to reach — which a mouse can use, unlike a bare scroll area.
1004
+ scrollStrip({
1005
+ attrs: ".s-crumbs role=navigation aria-label=Breadcrumbs",
1006
+ content: () => {
1007
+ A(() => {
1008
+ const paths = this.panels.map((p) => p.path);
1009
+ const focus = this.currentPanelIndex;
1010
+ let currentEl;
1011
+ for (let i = 0; i < paths.length; i++) {
1012
+ if (i)
1013
+ sepIcon({ size: "0.85em", attrs: ".s-crumb-sep" });
1014
+ const el = this.drawCrumb(paths[i], i, i === focus);
1015
+ if (i === focus)
1016
+ currentEl = el;
1017
+ }
1018
+ // Keep the panel you are on in view once this pass has been laid out.
1019
+ requestAnimationFrame(() => { if (currentEl)
1020
+ revealInStrip(currentEl); });
1021
+ });
1022
+ },
1023
+ });
1024
+ }
1025
+ drawCrumb(path, index, current) {
1026
+ // Safe to close over: the crumb list is rebuilt whenever the stack (or
1027
+ // its focus) changes, so this entry is `paths[index]`'s for the crumb's
1028
+ // whole life.
1029
+ const entry = this.$state.live[index];
1030
+ // A real link, for the panel it names — so it has an address to hover, to
1031
+ // middle-click, to copy. No click handling of its own: the shell's link
1032
+ // handling already makes any link to an open panel the focus move a crumb
1033
+ // should be (see `navigate`). The panel you are on is a span: nowhere to go.
1034
+ return A(current ? "span.s-crumb aria-current=page" : "a.s-crumb", () => {
1035
+ if (!current)
1036
+ A("href=", path);
1037
+ // Bold = on screen right now, so the stack also says which of its panels
1038
+ // are the visible columns — not just which one is current.
1039
+ A(() => { if (entry?.$panel.visible)
1040
+ A(".s-crumb-on"); });
1041
+ // The ● of unsaved work — the mark that nothing can close this panel —
1042
+ // and the pin of a panel that navigation elsewhere won't close.
1043
+ A(() => { if (entry?.$panel.unsaved)
1044
+ dotIcon({ size: "0.45em", attrs: ".s-crumb-unsaved" }); });
1045
+ A(() => { if (entry?.$panel.pinned)
1046
+ pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
1047
+ // `||`, not `??`: the root path's last segment is the empty string.
1048
+ A(() => { A("#", entry?.$panel.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
1049
+ // Taking over right-click means taking the browser's link menu away, so
1050
+ // the two entries anyone actually reaches for on a link come first,
1051
+ // where that menu would have had them, and the shell's own verbs sit
1052
+ // below the rule.
1053
+ addContextMenu({ items: [
1054
+ {
1055
+ // A real new tab, so it arrives cold and builds its own stack
1056
+ // from the path — exactly what the same link middle-clicked does.
1057
+ label: "Open in new tab",
1058
+ icon: newTabIcon,
1059
+ click: () => { window.open(path, "_blank", "noopener"); },
1060
+ },
1061
+ {
1062
+ label: "Copy link",
1063
+ icon: linkIcon,
1064
+ click: () => void copyLink(path),
1065
+ },
1066
+ { separator: true },
1067
+ {
1068
+ label: () => { A(() => { A("#", entry?.$panel.pinned ? "Unpin" : "Pin"); }); },
1069
+ icon: () => { A(() => { (entry?.$panel.pinned ? pinOffIcon : pinIcon)(); }); },
1070
+ click: () => { if (entry)
1071
+ this.togglePin(entry); },
1072
+ },
1073
+ {
1074
+ label: "Close",
1075
+ icon: closeIcon,
1076
+ // Greyed out while the panel holds unsaved work: nothing may
1077
+ // close it (`closePath` would refuse anyway). Read here, in the
1078
+ // crumb's own scope, so the flag flipping redraws the crumb —
1079
+ // the ● above and this menu entry stay one truth.
1080
+ disabled: entry?.$panel.unsaved === true,
1081
+ click: () => void this.closePath(path),
1082
+ },
1083
+ ] });
1084
+ });
1085
+ }
1086
+ /**
1087
+ * Flip a panel's pin (see {@link Panel.pinned}). The flag lives on the panel;
1088
+ * the current history entry's snapshot is rewritten too, so a reload keeps
1089
+ * the pin — a same-panel state tweak, which the router applies unguarded.
1090
+ */
1091
+ togglePin(entry) {
1092
+ entry.$panel.pinned = !entry.$panel.pinned || undefined;
1093
+ route.current.state.pinned = this.pinnedPaths();
1094
+ }
703
1095
  // ── document.title ─────────────────────────────────────────────────────
704
- /** `"<page title> · <app title>"`, kept in sync with the top panel. */
1096
+ /**
1097
+ * `"<panel title> · <app title>"`, kept in sync with the current panel — and
1098
+ * prefixed `"• "` while *any* open panel holds unsaved work, the way editors
1099
+ * mark a dirty document. Any panel, not just the current one: the risk of
1100
+ * losing the work is tab-wide, so the mark on the tab is too.
1101
+ */
705
1102
  watchTitle() {
706
1103
  const original = document.title;
707
1104
  A(() => {
708
- const entry = this.byId.get(this.$state.topId);
709
- const pageTitle = entry?.$page.title;
1105
+ const entry = this.$state.live[this.$state.focus];
1106
+ const panelTitle = entry?.$panel.title ?? entry?.$ui.fallback;
710
1107
  const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
711
- const title = pageTitle && appTitle ? `${pageTitle} · ${appTitle}` : pageTitle || appTitle;
1108
+ // Reading the stack re-runs this when its shape changes; each panel's
1109
+ // own `unsaved` (up to the first dirty one) does the rest.
1110
+ const dirty = this.$state.live.some((e) => e.$panel.unsaved);
1111
+ const title = panelTitle && appTitle ? `${panelTitle} · ${appTitle}` : panelTitle || appTitle;
712
1112
  if (title)
713
- document.title = title;
1113
+ document.title = (dirty ? "• " : "") + title;
714
1114
  });
715
1115
  A.clean(() => { document.title = original; });
716
1116
  }
1117
+ /**
1118
+ * While any open panel holds unsaved work, closing the tab — or navigating
1119
+ * the whole browser away — runs into the browser's own are-you-sure. When
1120
+ * the user stays, the unsaved panel is brought back on screen if it wasn't,
1121
+ * so what held the tab is in front of them rather than parked out of sight.
1122
+ */
1123
+ guardTabClose() {
1124
+ if (typeof window === "undefined")
1125
+ return;
1126
+ let leaving = false;
1127
+ const onHide = () => { leaving = true; };
1128
+ const onBeforeUnload = (e) => {
1129
+ // Being asked again means we weren't gone after all (a bfcache restore).
1130
+ leaving = false;
1131
+ const dirty = this.$state.live.find((entry) => entry.$panel.unsaved);
1132
+ if (!dirty)
1133
+ return;
1134
+ e.preventDefault();
1135
+ e.returnValue = true; // Chrome/Edge < 119
1136
+ // This task only ever amounts to anything if the user cancels: a
1137
+ // confirmed leave unloads the document (`pagehide`) first.
1138
+ const path = dirty.path;
1139
+ setTimeout(() => {
1140
+ if (leaving)
1141
+ return;
1142
+ const entry = this.$state.live.find((live) => live.path === path);
1143
+ if (entry && !entry.$panel.visible)
1144
+ void this.focusAt(this.intended().stack.indexOf(path));
1145
+ }, 0);
1146
+ };
1147
+ // Registered only while a panel actually holds unsaved work: a page with a
1148
+ // `beforeunload` listener is shut out of the browser's back/forward cache,
1149
+ // and that is a tax every navigation in the app would otherwise pay — for a
1150
+ // guard that almost never has anything to guard.
1151
+ A(() => {
1152
+ if (!this.$state.live.some((entry) => entry.$panel.unsaved))
1153
+ return;
1154
+ window.addEventListener("beforeunload", onBeforeUnload);
1155
+ window.addEventListener("pagehide", onHide);
1156
+ A.clean(() => {
1157
+ window.removeEventListener("beforeunload", onBeforeUnload);
1158
+ window.removeEventListener("pagehide", onHide);
1159
+ });
1160
+ });
1161
+ }
717
1162
  // ── Rendering ──────────────────────────────────────────────────────────
718
1163
  /**
719
- * Draw the panel viewport into the current element. Called by `main()`.
1164
+ * Draw the column viewport into the current element. Called by `main()`.
720
1165
  *
721
- * There is deliberately no close chrome here — no back rail, no ←: pages
722
- * provide their own way out (see {@link Page.close} and `S.box`'s `close`
723
- * option). The shell contributes Escape and the browser's own back button.
1166
+ * A column is the panel's own content, plus the one bit of chrome the shell
1167
+ * places for it: its {@link Panel.actions}, in a strip on wide shells and in
1168
+ * the top bar on narrow ones (see {@link drawActions}).
724
1169
  */
725
- drawStack() {
1170
+ drawColumns() {
726
1171
  const container = A("div.s-panels role=main", () => {
727
1172
  // Published before the first panel draws, rather than from the return
728
1173
  // value below: a panel sizes itself from the shell's measurements (see
729
1174
  // `measure`), and the first ones do that while this very call is still
730
1175
  // running. `A()` without arguments is "the element we're in".
731
1176
  this.containerEl = A();
732
- A.onEach(this.$ids, (_order, id) => this.drawPanel(Number(id)), (order, id) => [order, Number(id)]);
1177
+ // Mounted and unmounted by path (see `$open`); a panel's DOM position
1178
+ // among its siblings is its creation order, which is all the layering
1179
+ // needs — `layout()` places and stacks the columns itself.
1180
+ A.onEach(this.$open, (entry) => this.drawPanel(entry), (entry) => entry.order);
733
1181
  });
734
1182
  if (typeof ResizeObserver !== "undefined") {
735
1183
  const ro = new ResizeObserver(() => this.layout());
@@ -745,45 +1193,58 @@ export class PanelController {
745
1193
  this.containerEl = undefined; });
746
1194
  this.scheduleLayout();
747
1195
  }
748
- drawPanel(id) {
749
- const entry = this.byId.get(id);
750
- if (!entry)
751
- return;
1196
+ drawPanel(entry) {
752
1197
  let el;
753
1198
  // How much room the panel wants, resolved *before* its content is drawn: an
754
1199
  // element that arrives without a width has no box for its content to measure
755
1200
  // itself against until the next frame's layout pass, which is a frame too
756
1201
  // late for anything that sizes itself from its container. So the panel is
757
- // created at the width the window gives its layout — "medium" until the page
758
- // says otherwise. Reactively, too: a page that changes its mind later (when
1202
+ // created at the width the window gives it — the "full" width until the panel
1203
+ // says otherwise. Reactively, too: a panel that changes its mind later (when
759
1204
  // its data arrives, say) reflows in place rather than being redrawn, and the
760
1205
  // columns beside it slide over to make room.
761
1206
  A(() => {
762
- const asked = entry.$page.layout;
763
- entry.layout = asked === "small" || asked === "large" ? asked : "medium";
764
- const width = this.roomFor(entry.layout);
1207
+ const asked = entry.$panel.maxWidth;
1208
+ entry.maxWidth = asked === "half" || asked === "screen" ? asked : "full";
1209
+ const width = this.roomFor(entry.maxWidth);
765
1210
  if (!width)
766
1211
  return;
767
1212
  entry.width = width;
1213
+ // Published to the panel too, so a handler can read the box it is about
1214
+ // to draw into without measuring it.
1215
+ if (A.peek(entry.$panel, "width") !== width)
1216
+ entry.$panel.width = width;
768
1217
  // The first run has no element to put it on yet — it's created with this
769
- // width, just below. Later runs are the page changing its layout.
1218
+ // width, just below. Later runs are the panel changing its mind.
770
1219
  if (!el)
771
1220
  return;
772
1221
  el.style.width = `${width}px`;
773
1222
  this.scheduleLayout();
774
1223
  });
775
1224
  el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node) => this.playExit(entry, node), () => {
776
- const contentEl = A("div.s-content", () => {
777
- entry.draw(entry.$page);
1225
+ // The actions strip, in its own scope: chrome may redraw freely — when
1226
+ // the panel changes its actions, when the shell crosses the narrow
1227
+ // threshold — but the body below never may.
1228
+ A(() => this.drawActions(entry));
1229
+ A("div.s-content", () => {
1230
+ entry.draw(entry.$panel);
778
1231
  // After the content, so there is something to scroll when restoring.
779
1232
  route.persistScroll(entry.path);
1233
+ // A panel that named itself is done; one that didn't lends its
1234
+ // first line of text (the DOM is already built — Aberdeen draws
1235
+ // synchronously) to the crumbs and document.title, so neither
1236
+ // ever goes blank. Peeked: a rename must not redraw the panel.
1237
+ if (A.peek(entry.$panel, "title") == null) {
1238
+ const text = firstText(A());
1239
+ if (text && A.peek(entry.$ui, "fallback") !== text)
1240
+ entry.$ui.fallback = text;
1241
+ }
780
1242
  });
781
- watchVerticalOverflow(contentEl);
782
1243
  // The loading hint, in its own scope so flipping the flag doesn't
783
1244
  // redraw the panel's content. Held-back panels show nothing yet: they
784
1245
  // are still parked off screen, waiting to slide in with real content.
785
1246
  A(() => {
786
- if (!entry.$page.loading || entry.$ui.holding)
1247
+ if (!entry.$panel.loading || entry.$ui.holding)
787
1248
  return;
788
1249
  A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
789
1250
  });
@@ -800,11 +1261,23 @@ export class PanelController {
800
1261
  entry.el = undefined; });
801
1262
  // A held-back panel that finishes loading gets to play its enter animation.
802
1263
  A(() => {
803
- void entry.$page.loading;
1264
+ void entry.$panel.loading;
804
1265
  this.scheduleLayout();
805
1266
  });
806
1267
  this.scheduleLayout();
807
1268
  }
1269
+ /**
1270
+ * The one bit of column chrome the shell draws: the panel's actions, in a
1271
+ * quiet strip above the scroll area — and only while the shell is wide, the
1272
+ * top bar carrying them otherwise. Everything else in a column is the panel's
1273
+ * own content: a screen that wants a heading or a card draws them itself.
1274
+ * Going back isn't here either — that is the breadcrumbs' job, in the bar.
1275
+ */
1276
+ drawActions(entry) {
1277
+ if (this.opts.$shell.narrow || entry.$panel.actions == null)
1278
+ return;
1279
+ A("div.s-panel-actions", () => drawSlot(entry.$panel.actions));
1280
+ }
808
1281
  // ── Layout engine ──────────────────────────────────────────────────────
809
1282
  scheduleLayout() {
810
1283
  if (this.layoutQueued)
@@ -817,7 +1290,7 @@ export class PanelController {
817
1290
  }
818
1291
  /**
819
1292
  * Measure the shell, and with it the width the window gives a panel of each
820
- * layout. Measured on the *shell*, not on the panel region: the region's width
1293
+ * layout. Measured on the *shell*, not on the column region: the region's width
821
1294
  * is the layout engine's own output, so reading it back would nail the layout
822
1295
  * to whatever it happened to be a frame ago. Fractional widths throughout — a
823
1296
  * rounded column edge would drift a pixel away from the chrome above it.
@@ -841,24 +1314,24 @@ export class PanelController {
841
1314
  if (child !== container)
842
1315
  chrome += child.getBoundingClientRect().width;
843
1316
  }
844
- // The standard page is SHELL_PX wide, capped by the window; what it leaves
1317
+ // The standard panel is SHELL_PX wide, capped by the window; what it leaves
845
1318
  // beside the sidebar is the *standard* content area. Widths are a pure
846
1319
  // function of the window — never of what else is open — so a panel NEVER
847
1320
  // resizes because a neighbour came or went; only a window resize (the
848
1321
  // snap pass in `layout`) changes them:
849
- // - "medium" fills the standard content area exactly;
850
- // - "small" is half of it (minus the gutter) whenever that half is still
851
- // a usable column, and the whole of it on narrower screens;
852
- // - "large" ignores the standard width and takes everything the window
1322
+ // - "full" fills the standard content area exactly;
1323
+ // - "half" is half of it whenever that half is still a usable column, and
1324
+ // the whole of it on narrower screens;
1325
+ // - "screen" ignores the standard width and takes everything the window
853
1326
  // has — which also means nothing ever fits beside it.
854
- const medium = Math.max(0, Math.min(SHELL_PX, total) - chrome);
855
- const half = (medium - GUTTER_PX) / 2;
1327
+ const full = Math.max(0, Math.min(SHELL_PX, total) - chrome);
1328
+ const halved = full / 2;
856
1329
  return {
857
1330
  total,
858
1331
  chrome,
859
- small: half >= PAIR_MIN_PX ? half : medium,
860
- medium,
861
- large: Math.max(0, total - chrome),
1332
+ half: halved >= PAIR_MIN_PX ? halved : full,
1333
+ full,
1334
+ screen: Math.max(0, total - chrome),
862
1335
  };
863
1336
  }
864
1337
  /**
@@ -870,9 +1343,9 @@ export class PanelController {
870
1343
  geometry() {
871
1344
  return (this.geom ??= this.measure());
872
1345
  }
873
- /** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
874
- roomFor(layout) {
875
- return this.geometry()?.[layout] ?? 0;
1346
+ /** How wide a panel asking for this is, right now; 0 while the shell can't be measured. */
1347
+ roomFor(maxWidth) {
1348
+ return this.geometry()?.[maxWidth] ?? 0;
876
1349
  }
877
1350
  /**
878
1351
  * Size and position every panel, and publish the width of the whole ensemble
@@ -887,11 +1360,15 @@ export class PanelController {
887
1360
  const shell = container?.closest(".s-main");
888
1361
  if (!container || !shell)
889
1362
  return;
890
- const n = this.live.length;
1363
+ // This pass always runs from a fresh frame (rAF, a ResizeObserver) — no
1364
+ // reactive scope is active, so the stack and the panels are read plainly:
1365
+ // nothing here can subscribe to anything.
1366
+ const live = this.$state.live;
1367
+ const n = live.length;
891
1368
  // A panel that hasn't drawn yet has no width to contribute, which would make
892
1369
  // this pass's arithmetic (and any enter animation it triggers) meaningless.
893
1370
  // Every mount schedules another pass, so simply wait for it.
894
- if (!n || this.live.some((entry) => !entry.el))
1371
+ if (!n || live.some((entry) => !entry.el))
895
1372
  return;
896
1373
  // This pass measures afresh — it is the one thing that runs after a resize.
897
1374
  this.geom = undefined;
@@ -908,32 +1385,34 @@ export class PanelController {
908
1385
  this.lastBodyW = geom.total;
909
1386
  shell.classList.add("s-shell-snap");
910
1387
  }
911
- const width = (entry) => geom[entry.layout];
912
- // The visible run: as many top-of-stack panels as the window fits, at the
913
- // sizes the window gives them. The top panel always shows.
914
- let first = n - 1;
915
- let runSum = width(this.live[first]);
1388
+ const width = (entry) => geom[entry.maxWidth];
1389
+ // The visible run: as many columns as the window fits, at the sizes the
1390
+ // window gives them, ending at the current panel — which always shows.
1391
+ // Panels beyond it are parked past the right edge (see phase 1).
1392
+ const cur = Math.min(this.$state.focus, n - 1);
1393
+ let first = cur;
1394
+ let runSum = width(live[cur]);
916
1395
  if (stacking) {
917
- for (let i = n - 2; i >= 0; i--) {
918
- const sum = runSum + GUTTER_PX + width(this.live[i]);
919
- if (sum > geom.large)
1396
+ for (let i = cur - 1; i >= 0; i--) {
1397
+ const sum = runSum + width(live[i]);
1398
+ if (sum > geom.screen)
920
1399
  break;
921
1400
  runSum = sum;
922
1401
  first = i;
923
1402
  }
924
1403
  }
925
1404
  // The content area holds the run, but is never smaller than the standard
926
- // page (a lone small leaves its other half open — which is exactly where
1405
+ // panel (a lone small leaves its other half open — which is exactly where
927
1406
  // the next small lands, without anything on screen moving) and never
928
- // wider than the window. So the page is the familiar 1280px until extra
1407
+ // wider than the window. So the panel is the familiar 1280px until extra
929
1408
  // columns genuinely fit, and stretches — centred — to hold the ones that
930
- // do; with a "large" up that's the window's edges.
931
- const area = Math.min(geom.large, Math.max(geom.medium, runSum));
932
- for (let i = first; i < n; i++)
933
- this.live[i].width = width(this.live[i]);
1409
+ // do; with a "screen" up that's the window's edges.
1410
+ const area = Math.min(geom.screen, Math.max(geom.full, runSum));
1411
+ for (let i = first; i <= cur; i++)
1412
+ live[i].width = width(live[i]);
934
1413
  // Panels that have never been visible get their would-be width too, so a
935
1414
  // reveal doesn't start from nothing.
936
- for (const entry of this.live) {
1415
+ for (const entry of live) {
937
1416
  if (!entry.width)
938
1417
  entry.width = width(entry);
939
1418
  }
@@ -950,27 +1429,37 @@ export class PanelController {
950
1429
  const fresh = [];
951
1430
  let x = 0;
952
1431
  for (let i = 0; i < n; i++) {
953
- const entry = this.live[i];
1432
+ const entry = live[i];
954
1433
  const el = entry.el;
955
- const shown = i >= first;
956
- // Visible panels are left-aligned in the content area, a gutter apart;
957
- // hidden ones park at its left edge, keeping their last width. Deeper
958
- // panels layer over shallower ones, each on the odd layer for its depth
959
- // (see LAYER_STEP).
960
- place(el, shown ? x : 0, entry.width, LAYER_STEP * i + 1);
1434
+ const shown = i >= first && i <= cur;
1435
+ // Visible columns tile the content area, left to right. Panels crowded
1436
+ // out from under the run rest at its left edge; panels beyond the
1437
+ // current panel park just past its right edge — both keep their last
1438
+ // width. Deeper panels layer over shallower ones, each on the odd
1439
+ // layer for its depth (see LAYER_STEP).
1440
+ place(el, shown ? x : i > cur ? area : 0, entry.width, LAYER_STEP * i + 1);
1441
+ // What `$panel.visible` and `$panel.width` report: this pass is the one
1442
+ // thing that knows them, window resizes included. Written only on a
1443
+ // change, so per-panel UI hanging off them isn't rebuilt by every pass.
1444
+ if (entry.$panel.visible !== shown)
1445
+ entry.$panel.visible = shown;
1446
+ if (entry.$panel.width !== entry.width)
1447
+ entry.$panel.width = entry.width;
961
1448
  if (shown)
962
- x += entry.width + GUTTER_PX;
1449
+ x += entry.width;
963
1450
  el.classList.toggle("s-panel-sep", shown && i > first);
964
- // Hidden panels fade out over the left edge and, once faded, stop being
965
- // rendered at all — but they keep their DOM, and their scroll position.
966
- el.classList.toggle("s-panel-hidden", !shown);
1451
+ // Off-screen panels fade out over the edge they park at and, once
1452
+ // faded, stop being rendered at all — but they keep their DOM, and
1453
+ // their scroll position.
1454
+ el.classList.toggle("s-panel-hidden", i < first);
1455
+ el.classList.toggle("s-panel-parked", i > cur);
967
1456
  el.toggleAttribute("inert", !shown);
968
1457
  if (entry.placed)
969
1458
  continue;
970
1459
  fresh.push(entry);
971
1460
  // A panel that mounts while still fetching holds here for a moment, so
972
1461
  // it can enter with real content instead of an empty column.
973
- if (!A.peek(entry.$page, "loading") || entry.holdDone)
1462
+ if (!entry.$panel.loading || entry.holdDone)
974
1463
  entry.$ui.holding = false;
975
1464
  else if (!entry.$ui.holding) {
976
1465
  entry.$ui.holding = true;
@@ -1017,143 +1506,38 @@ function place(el, x, width, z) {
1017
1506
  el.style.width = `${width}px`;
1018
1507
  el.style.zIndex = String(z);
1019
1508
  }
1020
- // ─── Guards ──────────────────────────────────────────────────────────────────
1021
- /**
1022
- * Ask every panel being removed, deepest first, whether it may go. Returns a
1023
- * plain boolean when no guard needs awaiting, so the common case stays
1024
- * synchronous (and screenshots stay deterministic).
1025
- */
1026
- function runGuards(removed) {
1027
- const list = [...removed].reverse();
1028
- let i = 0;
1029
- const step = () => {
1030
- while (i < list.length) {
1031
- const guard = A.peek(list[i++].$page, "requestClose");
1032
- if (!guard)
1033
- continue;
1034
- let verdict;
1035
- try {
1036
- verdict = guard();
1037
- }
1038
- catch (e) {
1039
- console.error(e);
1040
- return false;
1041
- }
1042
- if (verdict === false)
1043
- return false;
1044
- if (verdict !== true)
1045
- return Promise.resolve(verdict).then((ok) => (ok === false ? false : step()));
1046
- }
1047
- return true;
1048
- };
1049
- return step();
1050
- }
1051
1509
  // ─── Helpers ─────────────────────────────────────────────────────────────────
1052
1510
  function sameStack(a, b) {
1053
1511
  return a.length === b.length && a.every((v, i) => v === b[i]);
1054
1512
  }
1055
- function drawDefaultNotFound($page) {
1056
- A("p fg:$s-muted", () => A("#", `No page at ${$page.path}`));
1057
- }
1058
1513
  /**
1059
- * Toggle `.s-scroll-y` on `el` whenever a vertical scrollbar is eating into its
1060
- * width, so CSS can inset the bar from the panel's edge. Same trick (and the
1061
- * same reasoning) as content mode's `watchVerticalOverflow` in main.ts.
1514
+ * The first non-empty text inside `el`, trimmed and capped at a name-like
1515
+ * length — the stand-in title for a panel that never set one.
1062
1516
  */
1063
- function watchVerticalOverflow(el) {
1064
- if (typeof ResizeObserver === "undefined")
1065
- return;
1066
- const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
1067
- const ro = new ResizeObserver(update);
1068
- ro.observe(el);
1069
- if (el.firstElementChild)
1070
- ro.observe(el.firstElementChild);
1071
- update();
1072
- A.clean(() => ro.disconnect());
1073
- }
1074
- // ─── Public helpers ──────────────────────────────────────────────────────────
1075
- /**
1076
- * Navigating the routed `S.main()` shell from code, for the times it isn't a
1077
- * link click, such as opening the screen for a record you just created.
1078
- *
1079
- * The same rules as a link click apply: pushing a path that is already open
1080
- * goes back to it rather than opening it twice, and anything that would close a
1081
- * panel asks its {@link Page.requestClose} first.
1082
- *
1083
- * @example
1084
- * ```ts
1085
- * S.button({ content: "New task", click: async () => {
1086
- * const task = await createTask();
1087
- * S.panels.push(`/tasks/${task.id}`);
1088
- * }});
1089
- * ```
1090
- */
1091
- export const panels = {
1092
- /** Opens `path` in a new panel on top of the top one. */
1093
- push(path) {
1094
- requireActive().pushPath(path, false);
1095
- },
1096
- /**
1097
- * Opens `path` in place of the top panel, which closes (asking its
1098
- * {@link Page.requestClose} first). The panels beneath it stay as they are.
1099
- */
1100
- replace(path) {
1101
- requireActive().pushPath(path, true);
1102
- },
1103
- /**
1104
- * Opens `path` as a whole arrangement rather than on top of what's there: the
1105
- * same thing a nav item or a fresh tab does. Without `beneath`, the stack under
1106
- * it is worked out the way a cold link's is (see `S.main()`'s `ancestors`);
1107
- * with it, the paths you give are opened underneath, shallowest first.
1108
- *
1109
- * That's the one for a screen whose URL doesn't say where it belongs — the
1110
- * thread a notification opens — and for seeding a stack from code in general.
1111
- * Panels the new arrangement also holds stay as they are, and any it drops are
1112
- * asked their {@link Page.requestClose} first.
1113
- *
1114
- * @example
1115
- * ```ts
1116
- * S.panels.open(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
1117
- * ```
1118
- */
1119
- open(path, beneath) {
1120
- requireActive().openPath(path, beneath);
1121
- },
1122
- /**
1123
- * Closes the top panel, or, given a `path`, whichever panel is open at it,
1124
- * asking {@link Page.requestClose} first. A panel that isn't on top is taken
1125
- * out on its own, leaving the columns to its right exactly as they are.
1126
- *
1127
- * Resolves `false` if the panel didn't close: `requestClose` said no, `path`
1128
- * isn't open, or another navigation got there first.
1129
- */
1130
- close(path) {
1131
- const ctl = requireActive();
1132
- return path == null ? ctl.closeTop() : ctl.closePath(path);
1133
- },
1134
- /** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
1135
- get stack() {
1136
- return active ? active.$state.paths : [];
1137
- },
1138
- };
1139
- function requireActive() {
1140
- if (!active)
1141
- throw new Error("Staffa: S.panels needs a routed S.main() (one with `routes`) to be mounted");
1142
- return active;
1517
+ function firstText(el) {
1518
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
1519
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
1520
+ const t = n.textContent.trim();
1521
+ if (t)
1522
+ return t.length > 48 ? `${t.slice(0, 47).trimEnd()}…` : t;
1523
+ }
1143
1524
  }
1144
1525
  /**
1145
- * Closes the panel `el` sits in, working out which one that is from the DOM.
1146
- * That is what lets a close button work without being handed a `$page`, from
1147
- * any column, whether or not it is on top. Used by `S.box`'s `close: true`.
1148
- *
1149
- * Outside a routed shell (or outside any panel, such as a box in a dialog) there is
1150
- * nothing to close: it warns and resolves `false`.
1526
+ * Put a panel's address on the clipboard, as the absolute URL someone can paste
1527
+ * anywhere — which is what the browser's own "Copy link" would have given them.
1528
+ * Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
1529
+ * needs a secure context, so a failure says so rather than lying.
1151
1530
  */
1152
- export function closeContainingPanel(el) {
1153
- const panelEl = el?.closest(".s-panel");
1154
- if (!active || !panelEl) {
1155
- console.warn("Staffa: `close: true` needs to be drawn inside a panel of a routed S.main()");
1156
- return Promise.resolve(false);
1531
+ async function copyLink(path) {
1532
+ const url = new URL(path, location.href).href;
1533
+ try {
1534
+ await navigator.clipboard.writeText(url);
1535
+ toast({ message: "Link copied." });
1536
+ }
1537
+ catch {
1538
+ toast({ message: "Couldn't copy the link.", type: "danger" });
1157
1539
  }
1158
- return active.closePanelEl(panelEl);
1540
+ }
1541
+ function drawDefaultNotFound($panel) {
1542
+ A("p fg:$s-muted", () => A("#", `No panel at ${$panel.path}`));
1159
1543
  }