staffa 0.7.4 → 0.8.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.
@@ -0,0 +1,1167 @@
1
+ import A from "aberdeen";
2
+ import * as route from "aberdeen/route";
3
+ import { NARROW_PX } from "../core.js";
4
+
5
+ /**
6
+ * Routed, multi-column panel navigation for {@link main}.
7
+ *
8
+ * Each route draws one screen of the app, called a panel, and as many panels as
9
+ * fit are shown at a time. On a phone that is one, so a link opens a new panel
10
+ * on top and closing it brings the previous one back. On a wider screen the
11
+ * panels that would have covered each other sit side by side instead, oldest on
12
+ * the left. The app's own code is the same either way.
13
+ *
14
+ * Navigation runs through `aberdeen/route`: the URL holds the top panel, and
15
+ * the ones beneath it are stored beside it in the history entry. So back and
16
+ * forward step through whole arrangements of columns, and a reload (or a shared
17
+ * link) brings the same columns back.
18
+ */
19
+
20
+ // ─── Route table typing ──────────────────────────────────────────────────────
21
+
22
+ /** Flattens an intersection into a single object type, so hovers read nicely. */
23
+ type Prettify<T> = { [K in keyof T]: T[K] } & {};
24
+
25
+ /**
26
+ * What a `[name=matcher]` matcher name yields. An unrecognised name resolves to
27
+ * `never`, which shows up as an unusable param at the handler rather than
28
+ * quietly typing as `string` (the route key itself throws at mount time).
29
+ */
30
+ export type MatcherType<M extends string> = M extends "integer" ? number : never;
31
+
32
+ /**
33
+ * The params contributed by a single path-template segment: `[x]` a string,
34
+ * `[x=integer]` a number, `[...x]` the rest of the path as one raw string.
35
+ */
36
+ export type SegParams<S extends string> =
37
+ S extends `[...${infer Name}]` ? { [K in Name]: string } :
38
+ S extends `[${infer Name}=${infer Matcher}]` ? { [K in Name]: MatcherType<Matcher> } :
39
+ S extends `[${infer Name}]` ? { [K in Name]: string } : {};
40
+
41
+ /**
42
+ * The params object described by a path template, e.g.
43
+ * `PathParams<"/projects/[id]/tasks/[taskId=integer]">` is
44
+ * `{ id: string; taskId: number }`.
45
+ */
46
+ export type PathParams<P extends string> =
47
+ P extends `${infer Head}/${infer Rest}` ? SegParams<Head> & PathParams<Rest> : SegParams<P>;
48
+
49
+ /** A panel draw function: it receives the panel's {@link Page} and draws into the current scope. */
50
+ export type RouteHandler<P = any> = (page: Page<P>) => void;
51
+
52
+ /**
53
+ * A route table: path templates mapped to panel draw functions. Used as the
54
+ * loose (non-inferred) type; `S.main()` infers a more precise type from the
55
+ * literal you pass, so each handler's `$page.params` is typed per its key.
56
+ */
57
+ export type Routes = Record<string, RouteHandler>;
58
+
59
+ /**
60
+ * The shape `S.main()`'s `routes` option is checked against: every key types its
61
+ * own handler's `params`. Used as a self-referential generic constraint, which
62
+ * is what makes `$page.params` infer from the route key.
63
+ */
64
+ export type RouteTable<R> = { [K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void };
65
+
66
+ // ─── The Page object ─────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * What a route handler gets: the params from its route, plus everything the
70
+ * shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
71
+ * you can set things later, such as a `title` that arrives with your data or
72
+ * `loading` going back to `false`, and the shell keeps up.
73
+ *
74
+ * Search params and the `#hash` belong to the top panel only. A panel with
75
+ * another one on top of it keeps just its path, so anything a panel needs in
76
+ * order to redraw itself has to live in that path.
77
+ */
78
+ export interface Page<P = Record<string, string | number | string[]>> {
79
+ /**
80
+ * The params matched from this panel's path, typed per its route key:
81
+ * `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
82
+ * Read-only.
83
+ */
84
+ readonly params: P;
85
+ /** This panel's path, e.g. `"/projects/7"`. Read-only. */
86
+ readonly path: string;
87
+ /** Shown in `document.title` while this panel is top-most. */
88
+ title?: string;
89
+ /**
90
+ * How much room this panel takes. The content area is the page, at most
91
+ * 1280px wide, minus the nav sidebar; the widths below assume a sidebar of
92
+ * around 170px, so without one add that back.
93
+ *
94
+ * - `"small"` is 360 to 540px once two panels fit side by side, which is
95
+ * what makes it right for lists, detail forms, and anything else that
96
+ * reads well at phone width. Below that it takes the whole content area
97
+ * (so up to ~730px), like a medium does. A lone small leaves its other
98
+ * half empty, and that is exactly where the next small lands, without
99
+ * anything on screen moving.
100
+ * - `"medium"` (the default) takes the whole content area: up to ~1100px,
101
+ * and the screen width on a phone. The safe default for ordinary screens.
102
+ * Nothing fits beside a medium on a standard 1280px page, though on a wide
103
+ * enough window a small still can.
104
+ * - `"large"` takes the whole window, with no upper limit (~1750px on a
105
+ * 1920px screen): for boards, wide tables and dense dashboards. While it's
106
+ * open the whole shell (top bar, content and footer) stretches to the
107
+ * screen edges rather than stopping at 1280px.
108
+ *
109
+ * When more columns fit than the standard page holds (three smalls, or a
110
+ * medium and a small) the page itself grows, staying centred, to hold them.
111
+ *
112
+ * A panel's width depends only on the size of the window, never on what else
113
+ * is open, so opening or closing a panel never resizes the ones already on
114
+ * screen. This is read **once**, right after your handler runs, so set it
115
+ * there; later changes are ignored.
116
+ */
117
+ layout?: "small" | "medium" | "large";
118
+ /**
119
+ * Set this while you're fetching what the panel needs, and back to `false`
120
+ * when you're done. A new panel waits a moment before sliding in, so it can
121
+ * arrive with real content instead of empty; if the wait drags on it slides
122
+ * in anyway and shows a loading indicator until the flag clears. It only
123
+ * affects the animation; the stack, the URL and `requestClose` never wait
124
+ * for it.
125
+ */
126
+ loading?: boolean;
127
+ /**
128
+ * Your chance to say no. Everything that would close this panel waits for
129
+ * it: Escape, the panel's own ✕ or Cancel button ({@link Page.close}, or a
130
+ * box with `close: true`), the browser's back button, a link that would
131
+ * close it, and {@link panels}.`close()`. Return `false` to keep the panel
132
+ * open, usually after a dirty check and a {@link confirm}.
133
+ */
134
+ requestClose?: () => boolean | Promise<boolean>;
135
+ /**
136
+ * Closes **this** panel, wherever it sits in the stack. The top panel goes
137
+ * back to whatever was underneath it; any other panel is taken out on its
138
+ * own, leaving the columns to its right where they are, with their state,
139
+ * and the URL alone, since the top panel didn't move. Either way it
140
+ * becomes a history entry, so the browser's back button brings it back.
141
+ *
142
+ * Resolves `false` if the panel didn't close: {@link Page.requestClose} said
143
+ * no, it was the only panel on the stack (so there's nothing to go back to),
144
+ * or another navigation got there first. The shell draws no back arrows or
145
+ * ✕ of its own, so this (or `S.box`'s `close` option) is how a panel gives
146
+ * the user a way out.
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
151
+ * ```
152
+ */
153
+ close(): Promise<boolean>;
154
+ }
155
+
156
+ // ─── Path matching ───────────────────────────────────────────────────────────
157
+
158
+ type Seg =
159
+ | { kind: "lit"; value: string }
160
+ | { kind: "param"; name: string; matcher?: string }
161
+ | { kind: "rest"; name: string };
162
+
163
+ /**
164
+ * The matchers a `[name=matcher]` segment can use. A matcher returns the param's
165
+ * value, or `undefined` to fail the match, in which case the path falls through
166
+ * to a later route (or to `notFound`) instead of reaching a handler.
167
+ *
168
+ * `integer` deliberately refuses anything that wouldn't survive a round trip
169
+ * back to the same URL: no leading zeroes ("007"), no "-0", no "1.5", "1e3" or
170
+ * "0x10", and nothing past `Number.MAX_SAFE_INTEGER` (where the number would no
171
+ * longer hold the id it came from). Two spellings of one id would otherwise be
172
+ * two different paths, so the same record could sit open in two panels at once.
173
+ * Use a plain `[id]` for ids that aren't safe integers, such as snowflakes.
174
+ */
175
+ const MATCHERS: Record<string, (segment: string) => unknown> = {
176
+ integer(segment) {
177
+ if (!/^(0|-?[1-9]\d*)$/.test(segment)) return undefined;
178
+ const n = Number(segment);
179
+ return Number.isSafeInteger(n) ? n : undefined;
180
+ },
181
+ };
182
+
183
+ interface CompiledRoute {
184
+ key: string;
185
+ segs: Seg[];
186
+ draw: RouteHandler;
187
+ }
188
+
189
+ /** Leading slash, no trailing slash (except for the root itself) — as `route.current.path` is. */
190
+ function normalizePath(path: string): string {
191
+ let p = String(path).replace(/\/+$/, "");
192
+ if (!p.startsWith("/")) p = `/${p}`;
193
+ return p;
194
+ }
195
+
196
+ function splitPath(path: string): string[] {
197
+ const p = normalizePath(path);
198
+ return p === "/" ? [] : p.slice(1).split("/");
199
+ }
200
+
201
+ /**
202
+ * Turn a route key into segment tokens, throwing on malformed templates. A
203
+ * segment is a param only when it is *entirely* a bracket group, so a literal
204
+ * segment that merely contains brackets (`/v[1]beta`) stays literal.
205
+ */
206
+ function compileRoute(key: string, draw: RouteHandler): CompiledRoute {
207
+ const parts = splitPath(key);
208
+ const segs = parts.map((part, i): Seg => {
209
+ if (!part.startsWith("[") || !part.endsWith("]")) return { kind: "lit", value: part };
210
+
211
+ const rest = /^\[\.\.\.([A-Za-z_$][\w$]*)\]$/.exec(part);
212
+ if (rest) {
213
+ if (i !== parts.length - 1) throw new Error(`Staffa: "${part}" must be the last segment of route "${key}"`);
214
+ return { kind: "rest", name: rest[1] };
215
+ }
216
+ const param = /^\[([A-Za-z_$][\w$]*)(?:=([A-Za-z_$][\w$]*))?\]$/.exec(part);
217
+ if (!param) throw new Error(`Staffa: malformed param "${part}" in route "${key}"`);
218
+ const [, name, matcher] = param;
219
+ if (matcher && !(matcher in MATCHERS)) {
220
+ throw new Error(`Staffa: unknown matcher "${matcher}" in route "${key}" (known: ${Object.keys(MATCHERS).join(", ")})`);
221
+ }
222
+ return { kind: "param", name, matcher };
223
+ });
224
+ return { key, segs, draw };
225
+ }
226
+
227
+ /** Percent-decode a path segment, leaving it alone when it isn't valid encoding. */
228
+ function decodeSeg(value: string): string {
229
+ try { return decodeURIComponent(value); } catch { return value; }
230
+ }
231
+
232
+ function matchRoute(r: CompiledRoute, segments: string[]): Record<string, any> | null {
233
+ const params: Record<string, any> = {};
234
+ for (let i = 0; i < r.segs.length; i++) {
235
+ const seg = r.segs[i];
236
+ if (seg.kind === "rest") {
237
+ // One-or-more remaining segments, handed over exactly as they appear in
238
+ // the URL. Decoding first and joining would be lossy: an encoded slash
239
+ // inside a segment would come back indistinguishable from a separator.
240
+ if (i >= segments.length) return null;
241
+ params[seg.name] = segments.slice(i).join("/");
242
+ return params;
243
+ }
244
+ if (i >= segments.length) return null;
245
+ const value = segments[i];
246
+ if (seg.kind === "lit") {
247
+ if (value !== seg.value) return null;
248
+ } else if (seg.matcher) {
249
+ // A segment the matcher rejects fails the match, so junk falls through
250
+ // to later routes (or notFound) instead of reaching a handler.
251
+ const matched = MATCHERS[seg.matcher](value);
252
+ if (matched === undefined) return null;
253
+ params[seg.name] = matched;
254
+ } else {
255
+ params[seg.name] = decodeSeg(value);
256
+ }
257
+ }
258
+ return r.segs.length === segments.length ? params : null;
259
+ }
260
+
261
+ // ─── Constants ───────────────────────────────────────────────────────────────
262
+
263
+ /**
264
+ * The one duration every bit of panel motion shares: the enter/exit fades, the
265
+ * `left` moves of columns shifting sideways, and the ensemble-width transition
266
+ * the chrome follows (see `--s-shell-w` in main.ts). Published as the
267
+ * `--s-panel-ms` custom property below, so CSS and JS can't drift apart.
268
+ */
269
+ const PANEL_MS = 450;
270
+ /** How long a freshly pushed `loading` panel holds its enter animation. */
271
+ const LOADING_HOLD_MS = 300;
272
+ /**
273
+ * The standard page width: sidebar plus content area, capped by the window.
274
+ * `"medium"` fills the content-area part of this exactly; only a `"large"`
275
+ * panel makes the shell grow past it.
276
+ */
277
+ const SHELL_PX = 1280;
278
+ /** The gap between two `"small"` panels sitting two-up. */
279
+ const GUTTER_PX = 24;
280
+ /** Don't pair smalls when half the content area would be narrower than this. */
281
+ const PAIR_MIN_PX = 360;
282
+
283
+ // ─── Module-level styling ────────────────────────────────────────────────────
284
+
285
+ A.insertGlobalCss({
286
+ ":root": `--s-panel-ms:${PANEL_MS}ms`,
287
+ // The clipping viewport that the columns slide through. Panels are absolutely
288
+ // positioned inside it, with their width and x offset set from JS (see
289
+ // `layout()`), so they can animate between arrangements.
290
+ ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden",
291
+ ".s-panel": {
292
+ // A panel rests at a plain `left` offset and carries no transform: a
293
+ // transformed element is composited, which costs it subpixel text
294
+ // antialiasing. `transform` is used only to play the enter/exit slides,
295
+ // where the compositing is what makes them cheap. There is deliberately
296
+ // no `width` transition: widths depend only on the window, change only in
297
+ // `.s-shell-snap` passes, and a column's content never reflows while the
298
+ // arrangement moves.
299
+ // Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
300
+ // and the chrome recentering around them all run as one motion. The drift
301
+ // eases out (it should read as a slow settle) while the fade runs *linear*
302
+ // across the whole duration — an eased opacity spends its last stretch near
303
+ // zero, which looks like the panel vanishing rather than fading.
304
+ // No `overflow:hidden` here: the scroll container below clips the content
305
+ // itself, and the pair hairline sits in the gutter *outside* the panel.
306
+ "&":
307
+ "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
308
+ "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
309
+ // The scroll container. Mirrors content mode's `main > .s-content`: same
310
+ // padding, and the same scrollbar inset (see `.s-scroll-y` in main.ts) so a
311
+ // single-column shell is pixel-identical to a non-routed one.
312
+ "> .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
313
+ "> .s-content.s-scroll-y": "margin-right:$3",
314
+ // A vertical hairline centred in the gutter between two paired smalls,
315
+ // fading out at both ends — the same treatment as the sidebar's `.s-nav-sep`.
316
+ "&.s-panel-sep::before":
317
+ `content:'' position:absolute left:-${GUTTER_PX / 2}px top:0.6rem bottom:0.6rem width:1px ` +
318
+ "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
319
+ // One vocabulary for every arrival and departure: a gradual fade over a short,
320
+ // slow drift — 8cqw (`cqw`: `.s-main` is the container). Panels appear and
321
+ // leave at the right edge; being crowded out at the left edge is its mirror.
322
+ //
323
+ // The start state of an enter, adopted with transitions off and then
324
+ // dropped, which is what makes the panel settle instead of jumping.
325
+ "&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
326
+ // On its way out: fading where it stands, drifting the same short distance,
327
+ // and out of reach while it does. It is removed from the DOM when the fade
328
+ // itself ends (see `beginClose`), never part-way through it.
329
+ "&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
330
+ // Crowded out from under the visible run. It keeps its DOM (and thus its
331
+ // scroll position and half-typed forms), so `display:none` is out —
332
+ // `visibility` takes it out of the rendering instead, but only once the fade
333
+ // has played: a transitioned `visibility` counts as *visible* for the whole
334
+ // duration and flips at the very end. Revealing it again uses the rule above
335
+ // (`visibility 0s`), so it comes back instantly.
336
+ "&.s-panel-hidden":
337
+ "opacity:0 visibility:hidden transform: translateX(-8cqw); " +
338
+ "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);",
339
+ },
340
+ // A window resize (and the very first pass) must track the window instantly,
341
+ // not rubber-band 450ms behind it: the layout engine raises this class on the
342
+ // shell for exactly those passes, applies the new geometry, and drops it
343
+ // after a forced reflow. Beats the standing transitions on specificity.
344
+ ".s-main.s-shell-snap .s-panel": "transition:none",
345
+ // On a narrow shell the single column is edge-to-edge, so there is no inset
346
+ // chrome for the scrollbar to line up with — cancel the `.s-scroll-y` margin
347
+ // (the twin of content mode's rule in main.ts).
348
+ [`@container (max-width: ${NARROW_PX}px)`]: {
349
+ ".s-panel > .s-content.s-scroll-y": "margin-right:0",
350
+ },
351
+ // A minimal "still fetching" hint, centred over the panel's content (which
352
+ // stays mounted underneath, so it can fill in reactively).
353
+ ".s-panel-loading": {
354
+ "&": "position:absolute inset:0 display:flex align-items:center justify-content:center gap:$1 pointer-events:none",
355
+ "i": "width:0.5rem height:0.5rem r:50% background:$s-muted opacity:0.45 animation: s-panel-pulse 1s ease-in-out infinite;",
356
+ "i:nth-child(2)": "animation-delay:0.15s",
357
+ "i:nth-child(3)": "animation-delay:0.3s",
358
+ },
359
+ "@keyframes s-panel-pulse": {
360
+ "0%, 100%": "opacity:0.25 transform:scale(0.8)",
361
+ "50%": "opacity:0.7 transform:scale(1)",
362
+ },
363
+ });
364
+
365
+ // ─── Panel entries ───────────────────────────────────────────────────────────
366
+
367
+ interface PanelEntry {
368
+ /** Unique and stable; the key `$ids` (and thus the DOM) is keyed by. */
369
+ id: number;
370
+ /**
371
+ * Depth in the stack at creation time, and the DOM sort key. Deliberately never
372
+ * updated: rewriting it would make Aberdeen redraw the panel, throwing away the
373
+ * scroll position and half-typed forms rule 5 promises to keep. So after a
374
+ * panel is spliced out of the middle of the stack (see `closePanelAt`) the DOM
375
+ * order goes slightly stale — invisible, since panels are absolutely
376
+ * positioned, beyond a small drift in tab order.
377
+ */
378
+ order: number;
379
+ path: string;
380
+ $page: Page<any>;
381
+ draw: RouteHandler;
382
+ /** Extra per-panel UI state that the panel's own render scope observes. */
383
+ $ui: { holding: boolean };
384
+ el?: HTMLElement;
385
+ /** Set once the panel is on its way out, playing its exit animation. */
386
+ closing?: boolean;
387
+ /** Set while an enter animation is still to be played. */
388
+ enter?: boolean;
389
+ /** Whether the panel has been through a full layout pass (and so may animate). */
390
+ placed?: boolean;
391
+ /** Whether its `loading` hold has already expired, so it can't hold again. */
392
+ holdDone?: boolean;
393
+ /** What the panel asked for, read once — right after its handler ran. */
394
+ layout: "small" | "medium" | "large";
395
+ /**
396
+ * The width it was last laid out at. Visible panels get a fresh value every
397
+ * pass (widths are a pure function of the content area and small-pairing);
398
+ * hidden and closing panels keep this, so nothing invisible ever reflows.
399
+ */
400
+ width: number;
401
+ }
402
+
403
+ // ─── Controller ──────────────────────────────────────────────────────────────
404
+
405
+ /** Options the panel stack needs from its shell. */
406
+ export interface PanelStackOptions {
407
+ routes: Routes;
408
+ notFound?: RouteHandler<{}>;
409
+ /** Set `false` to show only the top panel, however much room there is. */
410
+ stacking?: boolean;
411
+ /** The shell's own title, used as the suffix of `document.title`. */
412
+ title?: unknown;
413
+ }
414
+
415
+ /** At most one routed shell per app — that's what `S.panels` is bound to. */
416
+ let active: PanelController | null = null;
417
+
418
+ export class PanelController {
419
+ private compiled: CompiledRoute[];
420
+ private opts: PanelStackOptions;
421
+ /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
422
+ private live: PanelEntry[] = [];
423
+ private byId = new Map<number, PanelEntry>();
424
+ private nextId = 1;
425
+ /** Drives rendering: panel id → its `order` (used only as the sort key). */
426
+ $ids = A.proxy<Record<string, number>>({});
427
+ /**
428
+ * The live stack's paths and its top panel, for reactive readers: the
429
+ * `document.title` watcher, `main()`'s Escape handling and `S.panels.stack`.
430
+ */
431
+ $state = A.proxy({ paths: [] as string[], topId: 0 });
432
+ private containerEl?: HTMLElement;
433
+ /** The body width at the last layout; a change means a window resize → snap. */
434
+ private lastBodyW = -1;
435
+ private layoutQueued = false;
436
+ private timers = new Set<ReturnType<typeof setTimeout>>();
437
+
438
+ constructor(opts: PanelStackOptions) {
439
+ if (active) {
440
+ throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
441
+ }
442
+ active = this;
443
+ this.opts = opts;
444
+ this.compiled = Object.entries(opts.routes).map(([key, draw]) => compileRoute(key, draw));
445
+
446
+ // The router consults this guard before any navigation is applied — ours,
447
+ // a link's, browser back/forward, even a direct route.go() by app code —
448
+ // so every panel the change would remove gets its requestClose asked,
449
+ // exactly once, and a veto leaves the URL and the stack untouched (the
450
+ // router holds the route steady while an async guard is pending, and
451
+ // knows the exact history depth to restore on a vetoed popstate). A
452
+ // guard the app registered before mounting keeps working: it is chained
453
+ // in front of ours — an app veto (or redirect) wins without the panels
454
+ // being asked — and handed back when the shell unmounts.
455
+ const appGuard = route.setGuard((to, from) => {
456
+ const outer = appGuard ? appGuard(to, from) : true;
457
+ if (outer === false) return false;
458
+ if (outer === true) return this.checkChange(to);
459
+ return outer.then((ok) => (ok === false ? false : this.checkChange(to)));
460
+ });
461
+
462
+ // Commit the stack whenever the URL or its snapshot changes — the initial
463
+ // load, our own navigations, and browser back/forward. Anything that
464
+ // reaches this point has already passed the guard above.
465
+ A(() => {
466
+ const target = this.computeTarget();
467
+ A.peek(() => this.propose(target));
468
+ });
469
+
470
+ this.interceptLinks();
471
+ this.watchTitle();
472
+
473
+ A.clean(() => {
474
+ for (const t of this.timers) clearTimeout(t);
475
+ this.timers.clear();
476
+ route.setGuard(appGuard);
477
+ if (active === this) active = null;
478
+ });
479
+ }
480
+
481
+ // ── Stack derivation ───────────────────────────────────────────────────
482
+
483
+ /** Resolve a path to its route handler + params, falling back to `notFound`. */
484
+ private resolve(path: string): { draw: RouteHandler; params: Record<string, any> } {
485
+ const segments = splitPath(path);
486
+ for (const r of this.compiled) {
487
+ const params = matchRoute(r, segments);
488
+ if (params) return { draw: r.draw, params };
489
+ }
490
+ return { draw: this.opts.notFound ?? drawDefaultNotFound, params: {} };
491
+ }
492
+
493
+ private matches(path: string): boolean {
494
+ const segments = splitPath(path);
495
+ return this.compiled.some((r) => matchRoute(r, segments) != null);
496
+ }
497
+
498
+ /**
499
+ * The one derivation rule for origin-less navigation (§2.8): probe every
500
+ * prefix of the path against the route table; the matching prefixes become
501
+ * the stack. Prefixes without a route are simply skipped, so an app that
502
+ * doesn't want one screen stacked under another just doesn't route that
503
+ * prefix. The path itself is always the top panel, matched or not.
504
+ */
505
+ deriveStack(path: string): string[] {
506
+ const segments = splitPath(path);
507
+ const stack: string[] = [];
508
+ for (let i = 1; i < segments.length; i++) {
509
+ const prefix = "/" + segments.slice(0, i).join("/");
510
+ if (this.matches(prefix)) stack.push(prefix);
511
+ }
512
+ stack.push(normalizePath(path));
513
+ return stack;
514
+ }
515
+
516
+ /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
517
+ private targetFor(path: string, snapshot: unknown): string[] {
518
+ if (Array.isArray(snapshot)) return snapshot.map(String).concat(normalizePath(path));
519
+ return this.deriveStack(path);
520
+ }
521
+
522
+ /** The stack the current history entry asks for. Subscribes to path + snapshot. */
523
+ private computeTarget(): string[] {
524
+ return this.targetFor(route.current.path, route.current.state.panels);
525
+ }
526
+
527
+ /**
528
+ * The route guard (see `route.setGuard` in the constructor): asked before any
529
+ * route change lands, wherever it came from. Runs the {@link Page.requestClose}
530
+ * guard of every panel the new route's stack would remove — a set defined by
531
+ * the target (the commit reconciles by path), so a derived stack that shares
532
+ * nothing with the live one still asks exactly the panels that are closing.
533
+ */
534
+ private checkChange(to: route.Route): boolean | Promise<boolean> {
535
+ const removed = this.removedBy(this.targetFor(to.path, to.state.panels));
536
+ return removed.length ? runGuards(removed) : true;
537
+ }
538
+
539
+ // ── Commit pipeline ────────────────────────────────────────────────────
540
+
541
+ private paths(): string[] {
542
+ return this.live.map((e) => e.path);
543
+ }
544
+
545
+ /** The live panels a target stack drops — by path, so a splice removes only its own column. */
546
+ private removedBy(target: string[]): PanelEntry[] {
547
+ return this.live.filter((entry) => !target.includes(entry.path));
548
+ }
549
+
550
+ /**
551
+ * Adopt a stack proposed by the URL. Close guards have already been run (and
552
+ * have passed) by the time a route change is visible here — `checkChange` is
553
+ * consulted by the router itself, before anything is applied.
554
+ */
555
+ private propose(target: string[]): void {
556
+ if (sameStack(this.paths(), target)) return;
557
+ this.commit(target, A.peek(route.current, "nav"));
558
+ }
559
+
560
+ /**
561
+ * Apply a target stack: unmount what's gone, mount what's new, animate the
562
+ * difference.
563
+ *
564
+ * Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
565
+ * well-defined): a panel present in both stacks stays mounted *even if its
566
+ * index shifted*, which is what lets a panel be spliced out of the middle
567
+ * (§7) without disturbing the columns above it. A common-prefix diff would
568
+ * remount every one of them, throwing away exactly the scroll and form state
569
+ * rule 5 promises to keep.
570
+ */
571
+ private commit(target: string[], nav: string): void {
572
+ const existing = new Map(this.live.map((entry) => [entry.path, entry]));
573
+ const next: PanelEntry[] = [];
574
+ for (const path of target) {
575
+ const kept = existing.get(path);
576
+ if (kept) {
577
+ // Retained: it just takes its new place in the stack. Its `order` (the
578
+ // DOM sort key) deliberately stays put — see PanelEntry.order.
579
+ existing.delete(path);
580
+ next.push(kept);
581
+ continue;
582
+ }
583
+ const entry = this.createEntry(path, next.length);
584
+ // An initial load just appears, and so do panels *revealed* by a back —
585
+ // they belong underneath the ones sliding away. Everything else enters at
586
+ // the right edge, a replacement exactly like a push.
587
+ if (nav !== "load" && nav !== "back") entry.enter = true;
588
+ next.push(entry);
589
+ this.byId.set(entry.id, entry);
590
+ }
591
+ // Whatever the target no longer holds leaves the same way: fading out over
592
+ // the right edge, which is also where its replacement (if any) comes in from.
593
+ for (const entry of existing.values()) this.beginClose(entry);
594
+ this.live = next;
595
+
596
+ A.merge(this.$state, { paths: this.paths(), topId: this.live.length ? this.live[this.live.length - 1].id : 0 });
597
+ for (const entry of this.live) this.$ids[String(entry.id)] = entry.order;
598
+ this.scheduleLayout();
599
+ }
600
+
601
+ private createEntry(path: string, order: number): PanelEntry {
602
+ const { draw, params } = this.resolve(path);
603
+ const entry = {
604
+ id: this.nextId++,
605
+ order,
606
+ path,
607
+ draw,
608
+ $ui: A.proxy({ holding: false }),
609
+ layout: "medium" as const,
610
+ width: 0,
611
+ } as PanelEntry;
612
+ // `close` closes *this* panel, top of the stack or not. It resolves the
613
+ // panel's current depth at call time, so it keeps working after a splice has
614
+ // moved it — and quietly resolves false once the panel is gone.
615
+ entry.$page = A.proxy({
616
+ params,
617
+ path,
618
+ close: () => this.closePanelAt(this.live.indexOf(entry)),
619
+ }) as Page<any>;
620
+ return entry;
621
+ }
622
+
623
+ /**
624
+ * Start a panel's exit: it lingers in the DOM, inert and fading, and is dropped
625
+ * only when the fade itself ends. Removing it on a fixed timer instead would
626
+ * race the transition — pull the element a frame early and the panel appears to
627
+ * fade half-way and then vanish. The timeout is just a fallback for when no
628
+ * `transitionend` is coming at all (transitions off, or an element that never
629
+ * got placed).
630
+ */
631
+ private beginClose(entry: PanelEntry): void {
632
+ entry.closing = true;
633
+ const el = entry.el;
634
+ const drop = () => {
635
+ if (!this.byId.has(entry.id)) return;
636
+ this.byId.delete(entry.id);
637
+ delete this.$ids[String(entry.id)];
638
+ };
639
+ if (el) {
640
+ el.classList.add("s-panel-closing");
641
+ el.setAttribute("inert", "");
642
+ el.addEventListener("transitionend", (e: TransitionEvent) => {
643
+ if (e.target === el && e.propertyName === "opacity") drop();
644
+ });
645
+ }
646
+ const timer = setTimeout(() => {
647
+ this.timers.delete(timer);
648
+ drop();
649
+ }, PANEL_MS + 80);
650
+ this.timers.add(timer);
651
+ }
652
+
653
+ // ── Navigation ─────────────────────────────────────────────────────────
654
+
655
+ /**
656
+ * Navigate back to a stack that is a truncation of the current one — the shared
657
+ * implementation of Escape, a page closing itself, return-links and
658
+ * `S.panels.close()`. `route.back()` prefers the history entry where that
659
+ * panel was on top (with its scroll state intact); when there is no such entry
660
+ * it replaces the current one, carrying the snapshot passed as the fallback.
661
+ * Either way the route guard asks the closing panels first, and the returned
662
+ * promise reports its verdict.
663
+ */
664
+ private goBackTo(target: string[]): Promise<boolean> {
665
+ return route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } });
666
+ }
667
+
668
+ /** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
669
+ closeDownTo(index: number): Promise<boolean> {
670
+ if (index < 0 || index >= this.live.length - 1) return Promise.resolve(false);
671
+ return this.goBackTo(this.paths().slice(0, index + 1));
672
+ }
673
+
674
+ /** Guarded close of the top panel. */
675
+ closeTop(): Promise<boolean> {
676
+ return this.closeDownTo(this.live.length - 2);
677
+ }
678
+
679
+ /**
680
+ * Guarded close of the panel at `index`, top of the stack or not — what a
681
+ * page's own close affordances ({@link Page.close}, a box's ✕) come down to.
682
+ *
683
+ * The top panel pops back to the snapshot beneath it. Any other panel is
684
+ * *spliced* out: its guard runs, the columns above it keep their place and
685
+ * state (the commit reconciles by path), and the URL doesn't change, since the
686
+ * top panel didn't. That still gets its own history entry, so the browser's
687
+ * back button restores the closed column like any other snapshot — which is
688
+ * why it goes through `route.go` here rather than through `navigate()`, whose
689
+ * "link to the panel we're already on" check would see a no-op.
690
+ */
691
+ closePanelAt(index: number): Promise<boolean> {
692
+ if (index < 0 || index >= this.live.length) return Promise.resolve(false);
693
+ if (index === this.live.length - 1) return this.closeTop();
694
+ const target = this.paths().filter((_, i) => i !== index);
695
+ return Promise.resolve(route.go({
696
+ path: target[target.length - 1],
697
+ // The top panel keeps its search params and hash: it isn't going
698
+ // anywhere, and `go()` would otherwise default them away.
699
+ search: A.peek(() => ({ ...route.current.search })),
700
+ hash: A.peek(route.current, "hash"),
701
+ state: { panels: target.slice(0, -1) },
702
+ }));
703
+ }
704
+
705
+ /** Guarded close of whichever panel `path` is open as. False when it isn't open. */
706
+ closeByPath(path: string): Promise<boolean> {
707
+ const wanted = normalizePath(path);
708
+ return this.closePanelAt(this.live.findIndex((entry) => entry.path === wanted));
709
+ }
710
+
711
+ /** Guarded close of the panel whose `.s-panel` element this is. */
712
+ closePanelEl(el: HTMLElement): Promise<boolean> {
713
+ return this.closePanelAt(this.live.findIndex((entry) => entry.el === el));
714
+ }
715
+
716
+ /**
717
+ * Navigate to `href`. `originIndex` is the depth of the panel the link lives
718
+ * in (−1 when it has none — a nav item or a programmatic call, which derives
719
+ * the whole stack instead). `replace` swaps the originating panel rather than
720
+ * stacking on top of it.
721
+ */
722
+ navigate(href: string, originIndex: number, replace = false): void {
723
+ let url: URL;
724
+ try { url = new URL(href, location.href); } catch { return; }
725
+ const path = normalizePath(url.pathname);
726
+ const search = Object.fromEntries(new URLSearchParams(url.search));
727
+ const hash = url.hash;
728
+
729
+ // A link to a panel that is already open is a return, not a navigation —
730
+ // so a stack can never hold the same path twice.
731
+ const open = this.live.findIndex((e) => e.path === path);
732
+ if (open >= 0 && open < this.live.length - 1) { void this.closeDownTo(open); return; }
733
+ if (open >= 0) {
734
+ // The target is the panel we're already on. Going nowhere — but the link
735
+ // may still carry a different search or hash, which belong to the top
736
+ // panel: record that as a history entry, leaving the stack alone (the
737
+ // panel reconciles by path, so it isn't even redrawn).
738
+ if (url.search === location.search && (url.hash || "") === (location.hash || "")) return;
739
+ route.go({ path, search, hash, state: { panels: this.paths().slice(0, -1) } });
740
+ return;
741
+ }
742
+
743
+ // Without an originating panel there is no stack to build on, so derive
744
+ // one — a nav click and a deep link to the same URL land identically.
745
+ // The route guard (checkChange) asks every panel this removes — a set
746
+ // defined by the target stack, wherever those panels happen to sit —
747
+ // before the change is applied; a veto leaves everything untouched.
748
+ const beneath = originIndex < 0
749
+ ? this.deriveStack(path).slice(0, -1)
750
+ : this.paths().slice(0, replace ? originIndex : originIndex + 1);
751
+ route.go({ path, search, hash, state: { panels: beneath } });
752
+ }
753
+
754
+ /** Programmatic push/replace, with the top panel as the implied origin. */
755
+ pushPath(path: string, replace: boolean): void {
756
+ this.navigate(path, this.live.length - 1, replace);
757
+ }
758
+
759
+ // ── Link interception ──────────────────────────────────────────────────
760
+
761
+ /**
762
+ * Link handling through `route.interceptLinks()`, whose handler hook hands us
763
+ * the anchor so we can decide what the click *means*: the originating
764
+ * `.s-panel` (which decides what the click truncates), `data-panel=replace`,
765
+ * and return-to-an-open-panel semantics. The exclusion rules (targets,
766
+ * downloads, modified clicks, external URLs) live in Aberdeen; the close
767
+ * guards run in `checkChange` when our navigation reaches the router.
768
+ */
769
+ private interceptLinks(): void {
770
+ route.interceptLinks((url, anchor) => {
771
+ const panel = anchor.closest<HTMLElement>(".s-panel");
772
+ const originIndex = panel ? this.live.findIndex((entry) => entry.el === panel) : -1;
773
+ this.navigate(url.href, originIndex, anchor.getAttribute("data-panel") === "replace");
774
+ return true;
775
+ });
776
+ }
777
+
778
+ // ── document.title ─────────────────────────────────────────────────────
779
+
780
+ /** `"<page title> · <app title>"`, kept in sync with the top panel. */
781
+ private watchTitle(): void {
782
+ const original = document.title;
783
+ A(() => {
784
+ const entry = this.byId.get(this.$state.topId);
785
+ const pageTitle = entry?.$page.title;
786
+ const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
787
+ const title = pageTitle && appTitle ? `${pageTitle} · ${appTitle}` : pageTitle || appTitle;
788
+ if (title) document.title = title;
789
+ });
790
+ A.clean(() => { document.title = original; });
791
+ }
792
+
793
+ // ── Rendering ──────────────────────────────────────────────────────────
794
+
795
+ /**
796
+ * Draw the panel viewport into the current element. Called by `main()`.
797
+ *
798
+ * There is deliberately no close chrome here — no back rail, no ←: pages
799
+ * provide their own way out (see {@link Page.close} and `S.box`'s `close`
800
+ * option). The shell contributes Escape and the browser's own back button.
801
+ */
802
+ drawStack(): void {
803
+ const container = A("div.s-panels role=main", () => {
804
+ A.onEach(
805
+ this.$ids,
806
+ (_order, id) => this.drawPanel(Number(id)),
807
+ (order, id) => [order, Number(id)],
808
+ );
809
+ }) as HTMLElement;
810
+
811
+ this.containerEl = container;
812
+ if (typeof ResizeObserver !== "undefined") {
813
+ const ro = new ResizeObserver(() => this.layout());
814
+ // The region *and* the body it sits in: the region alone misses a shell
815
+ // resize that the columns happen to absorb, which still re-resolves widths.
816
+ ro.observe(container);
817
+ const body = container.parentElement?.parentElement;
818
+ if (body) ro.observe(body);
819
+ A.clean(() => ro.disconnect());
820
+ }
821
+ A.clean(() => { if (this.containerEl === container) this.containerEl = undefined; });
822
+ this.scheduleLayout();
823
+ }
824
+
825
+ private drawPanel(id: number): void {
826
+ const entry = this.byId.get(id);
827
+ if (!entry) return;
828
+
829
+ const el = A("section.s-panel", () => {
830
+ const contentEl = A("div.s-content", () => {
831
+ entry.draw(entry.$page);
832
+ // After the content, so there is something to scroll when restoring.
833
+ route.persistScroll(entry.path);
834
+ }) as HTMLElement;
835
+ watchVerticalOverflow(contentEl);
836
+
837
+ // The loading hint, in its own scope so flipping the flag doesn't
838
+ // redraw the panel's content. Held-back panels show nothing yet: they
839
+ // are still parked off screen, waiting to slide in with real content.
840
+ A(() => {
841
+ if (!entry.$page.loading || entry.$ui.holding) return;
842
+ A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
843
+ });
844
+ }) as HTMLElement;
845
+
846
+ // How much room the panel wants, settled right after its handler's
847
+ // synchronous run — deliberately once: a column that changed its mind
848
+ // later would reflow itself and shove its neighbours around.
849
+ const asked = A.peek(entry.$page, "layout");
850
+ entry.layout = asked === "small" || asked === "large" ? asked : "medium";
851
+
852
+ entry.el = el;
853
+ // Nothing animates from the arbitrary initial position; `layout()` gives
854
+ // the panel its real geometry (and turns transitions back on) in the
855
+ // upcoming frame, before anything is painted. A redraw (a reactive
856
+ // dependency inside the handler) lands here too, with a brand-new element
857
+ // that has to be placed again before it may animate.
858
+ entry.placed = false;
859
+ el.style.transition = "none";
860
+ A.clean(() => { if (entry.el === el) entry.el = undefined; });
861
+
862
+ // A held-back panel that finishes loading gets to play its enter animation.
863
+ A(() => {
864
+ void entry.$page.loading;
865
+ this.scheduleLayout();
866
+ });
867
+
868
+ this.scheduleLayout();
869
+ }
870
+
871
+ // ── Layout engine ──────────────────────────────────────────────────────
872
+
873
+ scheduleLayout(): void {
874
+ if (this.layoutQueued) return;
875
+ this.layoutQueued = true;
876
+ requestAnimationFrame(() => {
877
+ this.layoutQueued = false;
878
+ this.layout();
879
+ });
880
+ }
881
+
882
+ /**
883
+ * Size and position every panel, and publish the width of the whole ensemble
884
+ * (sidebar + separator + columns) for the shell to centre itself on.
885
+ *
886
+ * This is everything CSS can't work out for itself: which panels exist, which
887
+ * of them are visible, how wide each one is and where it sits. All the motion
888
+ * between two of these arrangements is CSS's job.
889
+ */
890
+ private layout(): void {
891
+ const container = this.containerEl;
892
+ const inner = container?.parentElement;
893
+ const body = inner?.parentElement;
894
+ const shell = container?.closest<HTMLElement>(".s-main");
895
+ if (!container || !inner || !body || !shell) return;
896
+ const n = this.live.length;
897
+ // A panel that hasn't drawn yet has no width to contribute, which would make
898
+ // this pass's arithmetic (and any enter animation it triggers) meaningless.
899
+ // Every mount schedules another pass, so simply wait for it.
900
+ if (!n || this.live.some((entry) => !entry.el)) return;
901
+
902
+ // Measured on the *shell*, not on the region: the region's width is this
903
+ // function's own output, so reading it back would nail the layout to
904
+ // whatever it happened to be a frame ago. Fractional widths throughout — a
905
+ // rounded column edge would drift a pixel away from the chrome above it.
906
+ const total = body.getBoundingClientRect().width;
907
+ if (!total) return;
908
+
909
+ const stacking = this.opts.stacking !== false;
910
+
911
+ // A window resize (or the very first pass) must be adopted instantly —
912
+ // geometry tracking the window through a 450ms transition reads as lag,
913
+ // and a shell animating itself into place on load reads as a glitch.
914
+ // `.s-shell-snap` suppresses every standing transition for this one pass.
915
+ const snap = this.lastBodyW !== total;
916
+ if (snap) {
917
+ this.lastBodyW = total;
918
+ shell.classList.add("s-shell-snap");
919
+ }
920
+
921
+ // Everything that sits beside the columns: the sidebar and its hairline,
922
+ // either of which may be display:none on a narrow shell.
923
+ let chrome = 0;
924
+ for (const child of inner.children) {
925
+ if (child !== container) chrome += child.getBoundingClientRect().width;
926
+ }
927
+
928
+ // The standard page is SHELL_PX wide, capped by the window; what it leaves
929
+ // beside the sidebar is the *standard* content area. Widths are a pure
930
+ // function of the window — never of what else is open — so a panel NEVER
931
+ // resizes because a neighbour came or went; only a window resize (the
932
+ // snap pass above) changes them:
933
+ // - "medium" fills the standard content area exactly;
934
+ // - "small" is half of it (minus the gutter) whenever that half is still
935
+ // a usable column, and the whole of it on narrower screens;
936
+ // - "large" ignores the standard width and takes everything the window
937
+ // has — which also means nothing ever fits beside it.
938
+ const stdRoom = Math.max(0, Math.min(SHELL_PX, total) - chrome);
939
+ const fullRoom = Math.max(0, total - chrome);
940
+ const halfW = (stdRoom - GUTTER_PX) / 2;
941
+ const smallW = halfW >= PAIR_MIN_PX ? halfW : stdRoom;
942
+ const width = (entry: PanelEntry) =>
943
+ entry.layout === "small" ? smallW : entry.layout === "large" ? fullRoom : stdRoom;
944
+
945
+ // The visible run: as many top-of-stack panels as the window fits, at the
946
+ // sizes the window gives them. The top panel always shows.
947
+ let first = n - 1;
948
+ let runSum = width(this.live[first]);
949
+ if (stacking) {
950
+ for (let i = n - 2; i >= 0; i--) {
951
+ const sum = runSum + GUTTER_PX + width(this.live[i]);
952
+ if (sum > fullRoom) break;
953
+ runSum = sum;
954
+ first = i;
955
+ }
956
+ }
957
+
958
+ // The content area holds the run, but is never smaller than the standard
959
+ // page (a lone small leaves its other half open — which is exactly where
960
+ // the next small lands, without anything on screen moving) and never
961
+ // wider than the window. So the page is the familiar 1280px until extra
962
+ // columns genuinely fit, and stretches — centred — to hold the ones that
963
+ // do; with a "large" up that's the window's edges.
964
+ const area = Math.min(fullRoom, Math.max(stdRoom, runSum));
965
+
966
+ for (let i = first; i < n; i++) this.live[i].width = width(this.live[i]);
967
+ // Panels that have never been visible get their would-be width too, so a
968
+ // reveal doesn't start from nothing.
969
+ for (const entry of this.live) {
970
+ if (!entry.width) entry.width = width(entry);
971
+ }
972
+
973
+ // The chrome above and below the body caps itself to the ensemble width,
974
+ // keeping everything centred and aligned however far the area stretches.
975
+ // The consumers transition their max-width (see main.ts), so the
976
+ // recentring plays along with the panel that caused it instead of
977
+ // snapping.
978
+ shell.style.setProperty("--s-shell-w", `${chrome + area}px`);
979
+
980
+ // Phase 1 — every panel's *start* state for this frame. Panels already on
981
+ // screen simply move (their standing transition animates it); freshly
982
+ // mounted ones still have transitions switched off, so what we set here is
983
+ // adopted instantly and becomes the "before" of their enter animation.
984
+ const fresh: PanelEntry[] = [];
985
+ let x = 0;
986
+ for (let i = 0; i < n; i++) {
987
+ const entry = this.live[i];
988
+ const el = entry.el!;
989
+ const shown = i >= first;
990
+ // Visible panels are left-aligned in the content area, a gutter apart;
991
+ // hidden ones park at its left edge, keeping their last width.
992
+ place(el, shown ? x : 0, entry.width);
993
+ if (shown) x += entry.width + GUTTER_PX;
994
+ el.classList.toggle("s-panel-sep", shown && i > first);
995
+ // Hidden panels fade out over the left edge and, once faded, stop being
996
+ // rendered at all — but they keep their DOM, and their scroll position.
997
+ el.classList.toggle("s-panel-hidden", !shown);
998
+ el.toggleAttribute("inert", !shown);
999
+ if (entry.placed) continue;
1000
+ fresh.push(entry);
1001
+ // A panel that mounts while still fetching holds here for a moment, so
1002
+ // it can enter with real content instead of an empty column.
1003
+ if (!A.peek(entry.$page, "loading") || entry.holdDone) entry.$ui.holding = false;
1004
+ else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
1005
+ // Already at its resting place; the enter animation is the offset (and
1006
+ // the transparency) it starts from, one edge to the right.
1007
+ if (entry.enter && shown) el.classList.add("s-panel-enter");
1008
+ }
1009
+
1010
+ // Phase 2 — force the browser to adopt those start states (and, on a snap
1011
+ // pass, the transition-free geometry) as the ones to animate *from*.
1012
+ // (Reading a layout property is what does it.)
1013
+ if (fresh.length || snap) void container.offsetWidth;
1014
+ if (snap) shell.classList.remove("s-shell-snap");
1015
+ // Phase 3 — transitions back on, start state dropped, and off they go.
1016
+ for (const entry of fresh) {
1017
+ if (entry.$ui.holding) continue;
1018
+ entry.el!.style.transition = "";
1019
+ entry.el!.classList.remove("s-panel-enter");
1020
+ entry.enter = false;
1021
+ entry.placed = true;
1022
+ }
1023
+ }
1024
+
1025
+ /** Let a `loading` panel's enter animation wait — but not indefinitely. */
1026
+ private holdEnter(entry: PanelEntry): void {
1027
+ const timer = setTimeout(() => {
1028
+ this.timers.delete(timer);
1029
+ entry.holdDone = true;
1030
+ if (entry.$ui.holding) {
1031
+ entry.$ui.holding = false;
1032
+ this.scheduleLayout();
1033
+ }
1034
+ }, LOADING_HOLD_MS);
1035
+ this.timers.add(timer);
1036
+ }
1037
+ }
1038
+
1039
+ /** Put a panel at rest: `x` from the region's left edge, `width` pixels wide. */
1040
+ function place(el: HTMLElement, x: number, width: number): void {
1041
+ el.style.left = `${x}px`;
1042
+ el.style.width = `${width}px`;
1043
+ }
1044
+
1045
+ // ─── Guards ──────────────────────────────────────────────────────────────────
1046
+
1047
+ /**
1048
+ * Ask every panel being removed, deepest first, whether it may go. Returns a
1049
+ * plain boolean when no guard needs awaiting, so the common case stays
1050
+ * synchronous (and screenshots stay deterministic).
1051
+ */
1052
+ function runGuards(removed: PanelEntry[]): boolean | Promise<boolean> {
1053
+ const list = [...removed].reverse();
1054
+ let i = 0;
1055
+ const step = (): boolean | Promise<boolean> => {
1056
+ while (i < list.length) {
1057
+ const guard = A.peek(list[i++].$page, "requestClose");
1058
+ if (!guard) continue;
1059
+ let verdict: boolean | Promise<boolean>;
1060
+ try {
1061
+ verdict = guard();
1062
+ } catch (e) {
1063
+ console.error(e);
1064
+ return false;
1065
+ }
1066
+ if (verdict === false) return false;
1067
+ if (verdict !== true) return Promise.resolve(verdict).then((ok) => (ok === false ? false : step()));
1068
+ }
1069
+ return true;
1070
+ };
1071
+ return step();
1072
+ }
1073
+
1074
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
1075
+
1076
+ function sameStack(a: string[], b: string[]): boolean {
1077
+ return a.length === b.length && a.every((v, i) => v === b[i]);
1078
+ }
1079
+
1080
+ function drawDefaultNotFound($page: Page<{}>): void {
1081
+ A("p fg:$s-muted", () => A("#", `No page at ${$page.path}`));
1082
+ }
1083
+
1084
+ /**
1085
+ * Toggle `.s-scroll-y` on `el` whenever a vertical scrollbar is eating into its
1086
+ * width, so CSS can inset the bar from the panel's edge. Same trick (and the
1087
+ * same reasoning) as content mode's `watchVerticalOverflow` in main.ts.
1088
+ */
1089
+ function watchVerticalOverflow(el: HTMLElement): void {
1090
+ if (typeof ResizeObserver === "undefined") return;
1091
+ const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
1092
+ const ro = new ResizeObserver(update);
1093
+ ro.observe(el);
1094
+ if (el.firstElementChild) ro.observe(el.firstElementChild);
1095
+ update();
1096
+ A.clean(() => ro.disconnect());
1097
+ }
1098
+
1099
+ // ─── Public helpers ──────────────────────────────────────────────────────────
1100
+
1101
+ /**
1102
+ * Navigating the routed `S.main()` shell from code, for the times it isn't a
1103
+ * link click, such as opening the screen for a record you just created.
1104
+ *
1105
+ * The same rules as a link click apply: pushing a path that is already open
1106
+ * goes back to it rather than opening it twice, and anything that would close a
1107
+ * panel asks its {@link Page.requestClose} first.
1108
+ *
1109
+ * @example
1110
+ * ```ts
1111
+ * S.button({ content: "New task", click: async () => {
1112
+ * const task = await createTask();
1113
+ * S.panels.push(`/tasks/${task.id}`);
1114
+ * }});
1115
+ * ```
1116
+ */
1117
+ export const panels = {
1118
+ /** Opens `path` in a new panel on top of the top one. */
1119
+ push(path: string): void {
1120
+ requireActive().pushPath(path, false);
1121
+ },
1122
+ /**
1123
+ * Opens `path` in place of the top panel, which closes (asking its
1124
+ * {@link Page.requestClose} first). The panels beneath it stay as they are.
1125
+ */
1126
+ replace(path: string): void {
1127
+ requireActive().pushPath(path, true);
1128
+ },
1129
+ /**
1130
+ * Closes the top panel, or, given a `path`, whichever panel is open at it,
1131
+ * asking {@link Page.requestClose} first. A panel that isn't on top is taken
1132
+ * out on its own, leaving the columns to its right exactly as they are.
1133
+ *
1134
+ * Resolves `false` if the panel didn't close: `requestClose` said no, `path`
1135
+ * isn't open, or another navigation got there first.
1136
+ */
1137
+ close(path?: string): Promise<boolean> {
1138
+ const ctl = requireActive();
1139
+ return path == null ? ctl.closeTop() : ctl.closeByPath(path);
1140
+ },
1141
+ /** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
1142
+ get stack(): readonly string[] {
1143
+ return active ? active.$state.paths : [];
1144
+ },
1145
+ };
1146
+
1147
+ function requireActive(): PanelController {
1148
+ if (!active) throw new Error("Staffa: S.panels needs a routed S.main() (one with `routes`) to be mounted");
1149
+ return active;
1150
+ }
1151
+
1152
+ /**
1153
+ * Closes the panel `el` sits in, working out which one that is from the DOM.
1154
+ * That is what lets a close button work without being handed a `$page`, from
1155
+ * any column, whether or not it is on top. Used by `S.box`'s `close: true`.
1156
+ *
1157
+ * Outside a routed shell (or outside any panel, such as a box in a dialog) there is
1158
+ * nothing to close: it warns and resolves `false`.
1159
+ */
1160
+ export function closeContainingPanel(el: Element | null | undefined): Promise<boolean> {
1161
+ const panelEl = el?.closest<HTMLElement>(".s-panel");
1162
+ if (!active || !panelEl) {
1163
+ console.warn("Staffa: `close: true` needs to be drawn inside a panel of a routed S.main()");
1164
+ return Promise.resolve(false);
1165
+ }
1166
+ return active.closePanelEl(panelEl);
1167
+ }