staffa 0.9.0 → 0.10.1

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