staffa 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +130 -64
  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 +187 -64
  11. package/dist/components/main.js +284 -158
  12. package/dist/components/menu.d.ts +72 -14
  13. package/dist/components/menu.js +235 -31
  14. package/dist/components/pages.d.ts +638 -0
  15. package/dist/components/pages.js +1510 -0
  16. package/dist/components/panels.d.ts +512 -206
  17. package/dist/components/panels.js +926 -414
  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 +5 -5
  25. package/dist/index.js +4 -5
  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/AncestorTable.md +10 -0
  31. package/skill/BoxOptions.md +7 -12
  32. package/skill/IconButtonOptions.md +41 -0
  33. package/skill/MainOptions.md +143 -54
  34. package/skill/MenuItem.md +16 -1
  35. package/skill/MenuListOptions.md +24 -0
  36. package/skill/MenuOptions.md +3 -2
  37. package/skill/Panel.md +190 -0
  38. package/skill/PanelStack.md +106 -0
  39. package/skill/SKILL.md +214 -77
  40. package/skill/ScrollStripOptions.md +21 -0
  41. package/skill/box.md +1 -4
  42. package/skill/closeNav.md +23 -0
  43. package/skill/iconButton.md +27 -0
  44. package/skill/main.md +13 -9
  45. package/skill/menu.md +29 -0
  46. package/skill/scrollStrip.md +28 -0
  47. package/src/components/autocomplete.ts +1 -1
  48. package/src/components/box.ts +29 -39
  49. package/src/components/button.ts +109 -8
  50. package/src/components/buttonChooser.ts +1 -1
  51. package/src/components/checkbox.ts +3 -3
  52. package/src/components/field.ts +3 -3
  53. package/src/components/main.ts +459 -167
  54. package/src/components/menu.ts +268 -34
  55. package/src/components/panels.ts +1254 -497
  56. package/src/components/tabs.ts +134 -68
  57. package/src/core.ts +1 -1
  58. package/src/index.ts +5 -5
  59. package/src/theme.ts +14 -3
  60. package/skill/Page.md +0 -119
  61. package/skill/panels.md +0 -10
@@ -1,20 +1,39 @@
1
- import A from "aberdeen";
1
+ import A, { OPAQUE } from "aberdeen";
2
2
  import * as route from "aberdeen/route";
3
- import { NARROW_PX } from "../core.js";
3
+ import { type Slot, drawSlot } from "../core.js";
4
+ import {
5
+ circle as dotIcon, externalLink as newTabIcon, link as linkIcon,
6
+ pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon,
7
+ } from "../icons.js";
8
+ import { SURFACE_SHEEN } from "../theme.js";
9
+ import { addContextMenu } from "./menu.js";
10
+ import { scrollStrip, revealInStrip } from "./tabs.js";
11
+ import { toast } from "./toast.js";
4
12
 
5
13
  /**
6
14
  * Routed, multi-column panel navigation for {@link main}.
7
15
  *
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.
16
+ * Each route draws one screen of the app, called a *panel*. The open panels
17
+ * form a **stack**, and one of them is the **current** panel: the one the URL
18
+ * names, and the rightmost column on screen. As many panels as fit are shown,
19
+ * ending at the current one — on a phone that is one at a time, on a wider
20
+ * screen the panels that would have covered each other sit side by side
21
+ * instead. The app's own code is the same either way.
13
22
  *
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.
23
+ * Going to a panel that is already open — a breadcrumb, or any link to it —
24
+ * just moves the current-panel cursor along the stack: panels right of it stay
25
+ * open, parked past the right edge of the viewport, and nothing closes.
26
+ * Opening a *new* panel is what prunes: everything after the panel it came
27
+ * from closes, except panels the user pinned — which ride along beneath the
28
+ * new panel — and panels holding unsaved work, which no navigation ever tears
29
+ * down. Escape steps one panel left, closing the panel it leaves only when
30
+ * that panel is the stack's discardable end.
31
+ *
32
+ * Navigation runs through `aberdeen/route`: the URL holds the current panel,
33
+ * and the rest of the arrangement — the panels before it, the ones parked
34
+ * after it, and which are pinned — is stored beside it in the history entry.
35
+ * So back and forward step through whole arrangements of columns, and a reload
36
+ * (or a shared link) brings the same columns back.
18
37
  */
19
38
 
20
39
  // ─── Route table typing ──────────────────────────────────────────────────────
@@ -46,24 +65,41 @@ export type SegParams<S extends string> =
46
65
  export type PathParams<P extends string> =
47
66
  P extends `${infer Head}/${infer Rest}` ? SegParams<Head> & PathParams<Rest> : SegParams<P>;
48
67
 
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;
68
+ /** A panel draw function: it receives the panel's {@link Panel} and draws into the current scope. */
69
+ export type RouteHandler<P = any> = (panel: Panel<P>) => void;
51
70
 
52
71
  /**
53
72
  * A route table: path templates mapped to panel draw functions. Used as the
54
73
  * 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.
74
+ * literal you pass, so each handler's `$panel.params` is typed per its key.
56
75
  */
57
76
  export type Routes = Record<string, RouteHandler>;
58
77
 
59
78
  /**
60
79
  * The shape `S.main()`'s `routes` option is checked against: every key types its
61
80
  * own handler's `params`. Used as a self-referential generic constraint, which
62
- * is what makes `$page.params` infer from the route key.
81
+ * is what makes `$panel.params` infer from the route key.
82
+ */
83
+ export type RouteTable<R> = { [K in keyof R & string]: (panel: Panel<Prettify<PathParams<K>>>) => void };
84
+
85
+ /**
86
+ * What belongs beneath a path that arrives cold, worked out from the params of
87
+ * the path itself. Return the paths shallowest first, or nothing to leave this
88
+ * one to the parent-path derivation.
63
89
  */
64
- export type RouteTable<R> = { [K in keyof R & string]: (page: Page<Prettify<PathParams<K>>>) => void };
90
+ export type AncestorsHandler<P = any> = (params: P, path: string) => readonly string[] | undefined | void;
91
+
92
+ /**
93
+ * A table of {@link AncestorsHandler}s keyed by path template, the same way
94
+ * `routes` is — so each one's `params` are matched and typed from its own key
95
+ * rather than parsed out of the path a second time. The keys are checked
96
+ * against the route table, so a stale one is a type error.
97
+ */
98
+ export type AncestorTable<R> = {
99
+ [K in keyof R & string]?: (params: Prettify<PathParams<K>>, path: string) => readonly string[] | undefined | void;
100
+ };
65
101
 
66
- // ─── The Page object ─────────────────────────────────────────────────────────
102
+ // ─── The Panel object ─────────────────────────────────────────────────────────
67
103
 
68
104
  /**
69
105
  * What a route handler gets: the params from its route, plus everything the
@@ -71,94 +107,157 @@ export type RouteTable<R> = { [K in keyof R & string]: (page: Page<Prettify<Path
71
107
  * you can set things later, such as a `title` that arrives with your data or
72
108
  * `loading` going back to `false`, and the shell keeps up.
73
109
  *
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.
110
+ * Search params and the `#hash` belong to the current panel only. Any other
111
+ * panel keeps just its path, so anything a panel needs in order to redraw
112
+ * itself has to live in that path. (A panel browsed away from does get its
113
+ * search and hash back when a crumb makes it current again.)
77
114
  */
78
- export interface Page<P = Record<string, string | number | string[]>> {
115
+ export interface Panel<P = Record<string, string | number | string[]>> {
79
116
  /**
80
117
  * The params matched from this panel's path, typed per its route key:
81
118
  * `[x]` is a `string`, `[x=integer]` a `number`, `[...x]` a `string`.
82
119
  * Read-only.
83
120
  */
84
121
  readonly params: P;
122
+ /**
123
+ * The stack this panel is in — the very object `S.main()` hands back. It is
124
+ * here as well because a route handler runs *while* that call is still
125
+ * going, so its return value isn't available to it yet; this always is.
126
+ */
127
+ readonly stack: PanelStack;
85
128
  /** This panel's path, e.g. `"/projects/7"`. Read-only. */
86
129
  readonly path: string;
87
- /** Shown in `document.title` while this panel is top-most. */
130
+ /**
131
+ * Names this screen, in the top bar's breadcrumb stack and in
132
+ * `document.title` while the panel is current. A panel that doesn't set one
133
+ * borrows the first line of text in its own body, so the stack never shows a
134
+ * blank — but a borrowed paragraph makes a poor name, so say it yourself.
135
+ *
136
+ * It does **not** conjure a heading: naming a screen and heading its content
137
+ * are different jobs, and a screen that wants its name in its own body
138
+ * writes it there, where it owns the typography.
139
+ */
88
140
  title?: string;
89
141
  /**
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.
142
+ * This screen's own actions: a couple of buttons, a menu. The shell draws
143
+ * them — never the panel — and *where* depends on facts only the shell has:
144
+ * a quiet strip at the top of this panel's column while several columns are
145
+ * up, and the top bar (where they take the app's own `menu` slot) once the
146
+ * shell is narrow and this panel is the screen.
93
147
  *
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.
148
+ * They are drawn in exactly one of those places at a time, so crossing the
149
+ * threshold redraws them; anything stateful inside (the focus in a search
150
+ * box) is lost. Buttons and menus are fine.
151
+ */
152
+ actions?: Slot;
153
+ /**
154
+ * How wide this panel's column actually is, in pixels — what
155
+ * {@link Panel.maxWidth} asked for, resolved against the window. Reactive and
156
+ * read-only, and correct *before* your handler draws, so content that sizes
157
+ * itself can read it instead of measuring.
108
158
  *
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.
159
+ * You rarely need it: the shell places the chrome for you. It's for content
160
+ * that genuinely differs by width, such as a table that becomes a list.
161
+ */
162
+ readonly width: number;
163
+ /**
164
+ * Whether this panel is on screen right now: not crowded out from under the
165
+ * visible run, not parked past its right end, and not on its way out.
166
+ * Reactive and read-only.
111
167
  *
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.
168
+ * The one to hang per-panel floating UI on (a FAB, a "3 selected" bar), for
169
+ * which "am I the current panel?" is the wrong question — two columns can be
170
+ * visible at once, and both of them are really there.
171
+ */
172
+ readonly visible: boolean;
173
+ /**
174
+ * The widest this panel can usefully be. Every panel must work at 360–540px,
175
+ * because that is what it gets when two columns fit; this says how much
176
+ * *more* it can take.
115
177
  *
116
- * The panel is sized from this **before** your handler runs, so anything that
117
- * measures its own box has a real one from the first frame. What it is sized
118
- * at is whatever this says at that moment, which for a brand-new panel is the
119
- * default: a handler that *assigns* `layout` is drawn at the medium width and
120
- * reflowed immediately after — in time for the frame, but not for a
121
- * measurement taken in the same breath.
178
+ * - `"half"` — nothing more. Half the content area (360–540px), so a second
179
+ * column fits beside it. For lists and detail forms.
180
+ * - `"full"` (the default) — the whole content area, up to ~1100px.
181
+ * - `"screen"` — the whole window, unbounded: boards, wide tables, dense
182
+ * dashboards. While one is open the shell itself stretches to the screen
183
+ * edges instead of stopping at the standard 1280px page.
122
184
  *
123
- * Assigning it later works just as well. When your data arrives and you find
124
- * you want the wide one, the panel reflows to its new width without being
125
- * redrawn — so nothing in it is rebuilt or loses its state — and the columns
126
- * beside it move over.
185
+ * Below the width two columns need, everything takes the content area
186
+ * whatever it asked for. Widths depend only on the window, never on what
187
+ * else is open, so opening or closing a panel never resizes another.
188
+ *
189
+ * Set it at the top of your handler and the panel is already that wide when
190
+ * you draw (see {@link Panel.width}); set it later — when your data tells you
191
+ * — and the panel reflows without being redrawn, keeping its state, while
192
+ * the columns beside it move over.
127
193
  */
128
- layout?: "small" | "medium" | "large";
194
+ maxWidth?: "half" | "full" | "screen";
129
195
  /**
130
196
  * Set this while you're fetching what the panel needs, and back to `false`
131
197
  * when you're done. A new panel waits a moment before sliding in, so it can
132
198
  * arrive with real content instead of empty; if the wait drags on it slides
133
199
  * in anyway and shows a loading indicator until the flag clears. It only
134
- * affects the animation; the stack, the URL and `requestClose` never wait
135
- * for it.
200
+ * affects the animation; the stack and the URL never wait for it.
136
201
  */
137
202
  loading?: boolean;
138
203
  /**
139
- * Your chance to say no. Everything that would close this panel waits for
140
- * it: Escape, the panel's own ✕ or Cancel button ({@link Page.close}, or a
141
- * box with `close: true`), the browser's back button, a link that would
142
- * close it, and {@link panels}.`close()`. Return `false` to keep the panel
143
- * open, usually after a dirty check and a {@link confirm}.
204
+ * Keeps this panel from being closed by navigation happening *elsewhere*.
205
+ * Opening a new panel normally closes everything after the panel it came
206
+ * from; a pinned panel survives that, staying in the stack — parked past the
207
+ * right edge of the viewport — slotted in beneath the new panel, one crumb
208
+ * click away. The user toggles it from the crumb's context menu
209
+ * (right-click or long-press), which is also where the pin shows; setting
210
+ * it from code does the same thing.
211
+ *
212
+ * A pin never blocks an *explicit* close: Escape at the stack's end,
213
+ * {@link Panel.close}, the crumb menu's Close and `data-panel=replace` all
214
+ * still close the panel.
215
+ */
216
+ pinned?: boolean;
217
+ /**
218
+ * Set this while the panel holds work that must not be lost — a dirty form,
219
+ * an upload in flight. An unsaved panel cannot be closed, by anything:
220
+ * navigation that would prune it parks it instead, past the viewport's
221
+ * right edge, wearing a ● in its crumb — even the browser's back button
222
+ * only parks it. {@link Panel.close} and the crumb menu's Close refuse,
223
+ * Escape on it steps left along the stack rather than closing, and closing
224
+ * the browser tab runs into the browser's own are-you-sure (after which the
225
+ * shell brings the unsaved panel back on screen).
226
+ *
227
+ * Only the app clears it; the user has no toggle. A Save or Discard button
228
+ * clears it and then closes:
229
+ *
230
+ * ```ts
231
+ * A(() => { $panel.unsaved = $form.dirty || undefined; });
232
+ * S.button({ content: "Discard", attrs: ".neutral", click: () => {
233
+ * $panel.unsaved = false; // explicitly — see below
234
+ * void $panel.close();
235
+ * }});
236
+ * ```
237
+ *
238
+ * The explicit `unsaved = false` before `close()` matters when the flag is
239
+ * kept by a reactive scope, as above: resetting the form marks that scope
240
+ * dirty, but it reruns *after* the running handler — after `close()` has
241
+ * already been refused.
144
242
  */
145
- requestClose?: () => boolean | Promise<boolean>;
243
+ unsaved?: boolean;
146
244
  /**
147
- * Closes **this** panel, wherever it sits in the stack. The top panel goes
148
- * back to whatever was underneath it; any other panel is taken out on its
149
- * own, leaving the columns to its right where they are, with their state,
150
- * and the URL alone, since the top panel didn't move. Either way it
151
- * becomes a history entry, so the browser's back button brings it back.
245
+ * Closes **this** panel, wherever it sits in the stack. Closing the current
246
+ * panel hands the focus to the panel on its left; closing any other panel
247
+ * takes just it away, leaving the columns around it where they are, with
248
+ * their state. Either way it becomes a history entry, so the browser's
249
+ * back button brings the panel back.
152
250
  *
153
- * Resolves `false` if the panel didn't close: {@link Page.requestClose} said
154
- * no, it was the only panel on the stack (so there's nothing to go back to),
155
- * or another navigation got there first. The shell draws no back arrows or
156
- * ✕ of its own, so this (or `S.box`'s `close` option) is how a panel gives
157
- * the user a way out.
251
+ * Resolves `false` if the panel didn't close: it holds
252
+ * {@link Panel.unsaved} work, it was the only panel on the stack (so
253
+ * there's nothing to show instead), or another navigation got there first.
254
+ * The shell's breadcrumbs already travel back, so reach for this when a
255
+ * screen wants a more explicit way out: a Cancel button, or a Save that
256
+ * closes.
158
257
  *
159
258
  * @example
160
259
  * ```ts
161
- * S.button({ content: "Cancel", attrs: ".neutral", click: () => void $page.close() });
260
+ * S.button({ content: "Cancel", attrs: ".neutral", click: () => void $panel.close() });
162
261
  * ```
163
262
  */
164
263
  close(): Promise<boolean>;
@@ -180,7 +279,7 @@ type Seg =
180
279
  * back to the same URL: no leading zeroes ("007"), no "-0", no "1.5", "1e3" or
181
280
  * "0x10", and nothing past `Number.MAX_SAFE_INTEGER` (where the number would no
182
281
  * longer hold the id it came from). Two spellings of one id would otherwise be
183
- * two different paths, so the same record could sit open in two panels at once.
282
+ * two different paths, so the same record could sit open in two columns at once.
184
283
  * Use a plain `[id]` for ids that aren't safe integers, such as snowflakes.
185
284
  */
186
285
  const MATCHERS: Record<string, (segment: string) => unknown> = {
@@ -210,11 +309,12 @@ function splitPath(path: string): string[] {
210
309
  }
211
310
 
212
311
  /**
213
- * Turn a route key into segment tokens, throwing on malformed templates. A
312
+ * Turn a path template into segment tokens, throwing on malformed ones. A
214
313
  * segment is a param only when it is *entirely* a bracket group, so a literal
215
- * segment that merely contains brackets (`/v[1]beta`) stays literal.
314
+ * segment that merely contains brackets (`/v[1]beta`) stays literal. Used for
315
+ * both tables keyed by a path template: `routes` and `ancestors`.
216
316
  */
217
- function compileRoute(key: string, draw: RouteHandler): CompiledRoute {
317
+ function compileKey(key: string): { key: string; segs: Seg[] } {
218
318
  const parts = splitPath(key);
219
319
  const segs = parts.map((part, i): Seg => {
220
320
  if (!part.startsWith("[") || !part.endsWith("]")) return { kind: "lit", value: part };
@@ -232,7 +332,7 @@ function compileRoute(key: string, draw: RouteHandler): CompiledRoute {
232
332
  }
233
333
  return { kind: "param", name, matcher };
234
334
  });
235
- return { key, segs, draw };
335
+ return { key, segs };
236
336
  }
237
337
 
238
338
  /** Percent-decode a path segment, leaving it alone when it isn't valid encoding. */
@@ -240,7 +340,7 @@ function decodeSeg(value: string): string {
240
340
  try { return decodeURIComponent(value); } catch { return value; }
241
341
  }
242
342
 
243
- function matchRoute(r: CompiledRoute, segments: string[]): Record<string, any> | null {
343
+ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any> | null {
244
344
  const params: Record<string, any> = {};
245
345
  for (let i = 0; i < r.segs.length; i++) {
246
346
  const seg = r.segs[i];
@@ -272,22 +372,25 @@ function matchRoute(r: CompiledRoute, segments: string[]): Record<string, any> |
272
372
  // ─── Constants ───────────────────────────────────────────────────────────────
273
373
 
274
374
  /**
275
- * The one duration every bit of panel motion shares: the enter/exit fades, the
276
- * `left` moves of columns shifting sideways, and the ensemble-width transition
277
- * the chrome follows (see `--s-shell-w` in main.ts). Published as the
278
- * `--s-panel-ms` custom property below, so CSS and JS can't drift apart.
375
+ * The one duration every bit of shell motion shares: the enter/exit fades, the
376
+ * `left` moves of columns shifting sideways, the ensemble-width transition the
377
+ * chrome follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
378
+ * panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
379
+ * and JS can't drift apart.
380
+ *
381
+ * Short enough to read as *the screen responded*, rather than as an animation
382
+ * being played at you: a panel arriving is navigation, and navigation should
383
+ * feel instant even when it moves.
279
384
  */
280
- const PANEL_MS = 450;
385
+ const PAGE_MS = 250;
281
386
  /** How long a freshly pushed `loading` panel holds its enter animation. */
282
387
  const LOADING_HOLD_MS = 300;
283
388
  /**
284
389
  * The standard page width: sidebar plus content area, capped by the window.
285
- * `"medium"` fills the content-area part of this exactly; only a `"large"`
286
- * panel makes the shell grow past it.
390
+ * `"full"` fills the content-area part of this exactly; only a `"screen"`
391
+ * page makes the shell grow past it.
287
392
  */
288
393
  const SHELL_PX = 1280;
289
- /** The gap between two `"small"` panels sitting two-up. */
290
- const GUTTER_PX = 24;
291
394
  /** Don't pair smalls when half the content area would be narrower than this. */
292
395
  const PAIR_MIN_PX = 360;
293
396
  /**
@@ -302,21 +405,26 @@ const LAYER_STEP = 2;
302
405
  // ─── Module-level styling ────────────────────────────────────────────────────
303
406
 
304
407
  A.insertGlobalCss({
305
- ":root": `--s-panel-ms:${PANEL_MS}ms`,
408
+ ":root": `--s-panel-ms:${PAGE_MS}ms`,
306
409
  // The clipping viewport that the columns slide through. Panels are absolutely
307
410
  // positioned inside it, with their width and x offset set from JS (see
308
411
  // `layout()`), so they can animate between arrangements. `isolation` keeps the
309
412
  // layers they stack themselves in (see LAYER_STEP) to themselves: the region
310
413
  // as a whole still sits under the shell's own chrome — the sticky top bar, and
311
- // the nav page that slides across the body — however deep the stack gets.
312
- ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate",
414
+ // the nav panel that slides across the body — however deep the stack gets.
415
+ // The region paints the panel's sheen over its own box, and every panel shows
416
+ // a slice of that same gradient (see `.s-panel` below), so the columns and
417
+ // the ground beside them are one continuous surface.
418
+ ".s-panels":
419
+ "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
420
+ SURFACE_SHEEN,
313
421
  ".s-panel": {
314
422
  // A panel rests at a plain `left` offset and carries no transform: a
315
423
  // transformed element is composited, which costs it subpixel text
316
424
  // antialiasing. `transform` is used only to play the enter/exit slides,
317
425
  // where the compositing is what makes them cheap. There is deliberately no
318
426
  // `width` transition: a width changes only when the window resizes or when
319
- // the page itself asks for another layout, and animating one would reflow
427
+ // the panel itself asks for another layout, and animating one would reflow
320
428
  // the column's content on every frame of it.
321
429
  // Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
322
430
  // and the chrome recentering around them all run as one motion. The drift
@@ -324,23 +432,36 @@ A.insertGlobalCss({
324
432
  // across the whole duration — an eased opacity spends its last stretch near
325
433
  // zero, which looks like the panel vanishing rather than fading.
326
434
  // No `overflow:hidden` here: the scroll container below clips the content
327
- // itself, and the pair hairline sits in the gutter *outside* the panel.
435
+ // itself.
328
436
  // Layering is set from JS (`layout()` and `beginClose`) rather than left to
329
437
  // DOM order: a closing panel is no longer part of the reactive list, so
330
438
  // where its element sits among the live ones is Aberdeen's business, not a
331
439
  // thing to depend on. `LAYER_*` says what the numbers mean.
440
+ //
441
+ // Every panel paints an opaque ground, because panels animate over one
442
+ // another — entering, leaving, being crowded out — and two transparent ones
443
+ // mean text sliding over text. It takes the panel's own sheen, the one
444
+ // `.s-s, body` paints in theme.ts, resolved here against the inherited
445
+ // `--s-bg` (a panel is not a surface, so it has to paint it itself).
446
+ //
447
+ // Painted per panel, over the panel's own box, which is as good as it
448
+ // needs to be: the sheen is a 9%-either-way wash over a whole column, so
449
+ // two columns' worth of it meeting at a hairline is not something the eye
450
+ // picks out. The region (`.s-panels` above) paints the same wash, so the
451
+ // ground beside a lone column matches it just as closely.
332
452
  "&":
333
453
  "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
454
+ SURFACE_SHEEN + " " +
334
455
  "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
335
- // The scroll container. Mirrors content mode's `main > .s-content`: same
336
- // padding, and the same scrollbar inset (see `.s-scroll-y` in main.ts) so a
337
- // single-column shell is pixel-identical to a non-routed one.
338
- "> .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
339
- "> .s-content.s-scroll-y": "margin-right:$3",
340
- // A vertical hairline centred in the gutter between two paired smalls,
341
- // fading out at both ends — the same treatment as the sidebar's `.s-nav-sep`.
456
+ // The hairline between two columns, fading out at both ends — the same
457
+ // treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
458
+ // gutter between them: each already brings its own `$3` of padding, which
459
+ // keeps two columns' *contents* comfortably apart, while a gutter on top
460
+ // of that only opened a strip of the panel's own ground between two columns
461
+ // painting theirs. So this sits exactly on the boundary — and an
462
+ // edge-to-edge column (`A("p:0")`) really does reach the line bounding it.
342
463
  "&.s-panel-sep::before":
343
- `content:'' position:absolute left:-${GUTTER_PX / 2}px top:0.6rem bottom:0.6rem width:1px ` +
464
+ "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
344
465
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
345
466
  // One vocabulary for every arrival and departure: a gradual fade over a short,
346
467
  // slow drift — 8cqw (`cqw`: `.s-main` is the container). Panels appear and
@@ -353,27 +474,76 @@ A.insertGlobalCss({
353
474
  // and out of reach while it does. It leaves the DOM when the fade itself
354
475
  // ends (see `playExit`), never part-way through it.
355
476
  "&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
356
- // Crowded out from under the visible run. It keeps its DOM (and thus its
357
- // scroll position and half-typed forms), so `display:none` is out —
358
- // `visibility` takes it out of the rendering instead, but only once the fade
359
- // has played: a transitioned `visibility` counts as *visible* for the whole
360
- // duration and flips at the very end. Revealing it again uses the rule above
361
- // (`visibility 0s`), so it comes back instantly.
362
- "&.s-panel-hidden":
363
- "opacity:0 visibility:hidden transform: translateX(-8cqw); " +
477
+ // Off screen but open: crowded out from under the visible run at the left
478
+ // edge (`hidden`), or right of the current panel, parked past the right
479
+ // edge (`parked`) — the two are mirror images. Either keeps its DOM (and
480
+ // thus its scroll position and half-typed forms), so `display:none` is out
481
+ // — `visibility` takes it out of the rendering instead, but only once the
482
+ // fade has played: a transitioned `visibility` counts as *visible* for the
483
+ // whole duration and flips at the very end. Revealing it again uses the
484
+ // rule above (`visibility 0s`), so it comes back instantly.
485
+ "&.s-panel-hidden, &.s-panel-parked":
486
+ "opacity:0 visibility:hidden " +
364
487
  "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);",
488
+ "&.s-panel-hidden": "transform: translateX(-8cqw);",
489
+ "&.s-panel-parked": "transform: translateX(8cqw);",
490
+ },
491
+ // The scroll container, with the column's own padding. A scrollbar here is
492
+ // left flush against the column's right edge — unlike content mode's, which
493
+ // insets one from the shell edge to line up with the bar above it. A column
494
+ // has something better to line up with: the hairline the next column starts
495
+ // at, or the edge of the content area. Both want the bar hard against them,
496
+ // and an inset would leave a strip of nothing between the two.
497
+ ".s-panel > .s-content": "flex:1 min-height:0 overflow-y:auto overflow-x:hidden p:$3",
498
+ // A panel's actions on a wide shell: a quiet strip at the column's top-right,
499
+ // above the scroll area (never sticky inside it). On a narrow shell the top
500
+ // bar carries them instead, and no strip is drawn at all.
501
+ ".s-panel-actions": "display:flex align-items:center justify-content:flex-end gap:$1 flex-shrink:0 padding: $3 $3 0;",
502
+ // The breadcrumb stack the top bar shows: every open panel, oldest first,
503
+ // the ones on screen right now in bold ink (see `drawCrumbs`). One row that
504
+ // scrolls sideways when the bar is tight — scrollbarless, with a fade at
505
+ // whichever edge has more stack behind it, so the cut-off reads as "keep
506
+ // going" rather than as the stack simply ending.
507
+ // The stack is a `.s-strip` (see tabs.ts), so the scrolling, the hidden
508
+ // scrollbar and the ‹ / › come with it; all that is left to say is the gap
509
+ // between a crumb and its chevron.
510
+ ".s-crumbs > .s-strip-row": "gap:$m1",
511
+ ".s-crumb": {
512
+ // Quiet ink for the stack, full ink and weight for the panels on screen:
513
+ // the weight change alone is ambiguous in a short crumb, the colour alone
514
+ // too subtle. No padding of its own — the first crumb has to start on the
515
+ // same pixel as the app's name above it, and the gap below spaces the row.
516
+ // `flex-shrink:0` because the crumbs are the strip's flex items and carry
517
+ // `overflow:hidden`, which resolves their automatic minimum size to zero:
518
+ // left to shrink they would ellipsise themselves down to stubs rather than
519
+ // overflow, and the stack would never scroll. One long title still caps at
520
+ // 14rem — that is this crumb's own business, not the row running out.
521
+ "&":
522
+ "flex-shrink:0 font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
523
+ "white-space:nowrap max-width:14rem overflow:hidden text-overflow:ellipsis " +
524
+ "transition: color 0.12s;",
525
+ "&.s-crumb-on": "font-weight:600 fg:$s-text",
526
+ // The same hover treatment as a menu item. The panel you are on is a plain
527
+ // span rather than a link, so it needs no `:not()` guard here.
528
+ "a&:hover": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
529
+ // The pin of a pinned panel, sitting inline just before its title. Filled:
530
+ // at crumb size the icon's hairline strokes alias into a wobble, while the
531
+ // body path filled solid reads as the classic pinned-tab pushpin.
532
+ "svg.s-crumb-pin": "vertical-align:-0.12em margin-right:0.3em opacity:0.8 fill:currentColor",
533
+ // The ● of a panel holding unsaved work — the editors' dirty mark, filled
534
+ // solid for the same reason as the pin.
535
+ "svg.s-crumb-unsaved": "vertical-align:0.08em margin-right:0.3em fill:currentColor",
365
536
  },
537
+ // A slash, not a chevron: the stack is a path, and a path's separator is what
538
+ // the URL itself uses. It also has to stay clearly *unlike* the ‹ / › the
539
+ // strip grows when the stack overflows (see `scrollStrip` in tabs.ts) — two
540
+ // near-identical chevrons, one meaningful and one a button, read as a bug.
541
+ "svg.s-crumb-sep": "flex-shrink:0 opacity:0.4",
366
542
  // A window resize (and the very first pass) must track the window instantly,
367
543
  // not rubber-band 450ms behind it: the layout engine raises this class on the
368
544
  // shell for exactly those passes, applies the new geometry, and drops it
369
545
  // after a forced reflow. Beats the standing transitions on specificity.
370
546
  ".s-main.s-shell-snap .s-panel": "transition:none",
371
- // On a narrow shell the single column is edge-to-edge, so there is no inset
372
- // chrome for the scrollbar to line up with — cancel the `.s-scroll-y` margin
373
- // (the twin of content mode's rule in main.ts).
374
- [`@container (max-width: ${NARROW_PX}px)`]: {
375
- ".s-panel > .s-content.s-scroll-y": "margin-right:0",
376
- },
377
547
  // A minimal "still fetching" hint, centred over the panel's content (which
378
548
  // stays mounted underneath, so it can fill in reactively).
379
549
  ".s-panel-loading": {
@@ -390,24 +560,57 @@ A.insertGlobalCss({
390
560
 
391
561
  // ─── Panel entries ───────────────────────────────────────────────────────────
392
562
 
563
+ /**
564
+ * A {@link Panel} as the controller holds it: the same object the handler gets,
565
+ * minus the `readonly`s. `width` and `visible` are read-only *to the app* —
566
+ * they are facts about the panel, not requests — but the shell keeps them up to
567
+ * date by writing them, which is what makes reading them reactive.
568
+ */
569
+ type PanelState = { -readonly [K in keyof Panel<any>]: Panel<any>[K] };
570
+
393
571
  interface PanelEntry {
394
- /** Unique and stable; the key `$ids` (and thus the DOM) is keyed by. */
395
- id: number;
396
572
  /**
397
- * Depth in the stack at creation time, and the DOM sort key. Deliberately never
398
- * updated: rewriting it would make Aberdeen redraw the panel, throwing away the
399
- * scroll position and half-typed forms rule 5 promises to keep. So after a
400
- * panel is spliced out of the middle of the stack (see `closePanelAt`) the DOM
401
- * order goes slightly stale — invisible, since panels are absolutely
402
- * positioned, beyond a small drift in tab order.
573
+ * Opaque to Aberdeen: an entry rides inside the reactive `$state` and
574
+ * `$open` collections, but is itself the controller's plain state — its
575
+ * reactive faces are `$panel` (the app's) and `$ui` (the shell's own).
576
+ * Without this, reading an entry back out of either collection would wrap
577
+ * it in a proxy, DOM element, draw function and all.
578
+ */
579
+ readonly [OPAQUE]: true;
580
+ /**
581
+ * The DOM sort key: one shared counter, so panels sit in creation order.
582
+ * Deliberately never updated: rewriting it would make Aberdeen redraw the
583
+ * panel, throwing away the scroll position and half-typed forms rule 5
584
+ * promises to keep. So the DOM order drifts from the stack order once
585
+ * panels are spliced or restored out of sequence — invisible, since panels
586
+ * are absolutely positioned, beyond a small drift in tab order.
403
587
  */
404
588
  order: number;
405
589
  path: string;
406
- $page: Page<any>;
590
+ $panel: PanelState;
407
591
  draw: RouteHandler;
408
- /** Extra per-panel UI state that the panel's own render scope observes. */
409
- $ui: { holding: boolean };
592
+ /** Extra per-panel UI state, split off `$panel` so the app can't see it. */
593
+ $ui: {
594
+ holding: boolean;
595
+ /**
596
+ * The title borrowed from the panel's first line of text, for a panel that
597
+ * never set one. Kept beside `$panel.title` rather than written into it,
598
+ * so the app's own field only ever holds what the app wrote — and so a
599
+ * body redraw can refresh the borrowed text, which a write into `title`
600
+ * would have frozen.
601
+ */
602
+ fallback?: string;
603
+ };
410
604
  el?: HTMLElement;
605
+ /**
606
+ * The search params and hash the URL held while this panel was last the
607
+ * current panel, stashed when the focus moves off it and restored when a
608
+ * crumb (or any link) makes it current again. Search and hash belong to
609
+ * the current panel only, so this is the one place they survive a visit
610
+ * elsewhere along the stack.
611
+ */
612
+ search?: Record<string, string>;
613
+ hash?: string;
411
614
  /** Set once the panel is on its way out, playing its exit animation. */
412
615
  closing?: boolean;
413
616
  /** Set while an enter animation is still to be played. */
@@ -416,8 +619,8 @@ interface PanelEntry {
416
619
  placed?: boolean;
417
620
  /** Whether its `loading` hold has already expired, so it can't hold again. */
418
621
  holdDone?: boolean;
419
- /** What the panel asks for, kept in step with its `$page.layout`. */
420
- layout: "small" | "medium" | "large";
622
+ /** What the panel asks for, kept in step with its `$panel.maxWidth`. */
623
+ maxWidth: "half" | "full" | "screen";
421
624
  /**
422
625
  * The width it was last laid out at. Set before the panel's content is first
423
626
  * drawn, so that content has a real box to measure itself against. Visible
@@ -438,42 +641,196 @@ interface Geometry {
438
641
  /** What sits beside the columns — the sidebar and its hairline, if shown. */
439
642
  chrome: number;
440
643
  /** Half the standard content area, or all of it when a half would be too narrow. */
441
- small: number;
644
+ half: number;
442
645
  /** The standard content area: the 1280px page minus the chrome. */
443
- medium: number;
646
+ full: number;
444
647
  /** Everything the window has beside the chrome, with no upper limit. */
445
- large: number;
648
+ screen: number;
649
+ }
650
+
651
+ /**
652
+ * One state of the stack: the open paths, oldest first, and which of them is
653
+ * the current panel. The panels before `focus` sit (or are crowded out) to the
654
+ * current panel's left; the ones after it are parked past the right edge of the
655
+ * viewport. What a history entry describes, and what every navigation is
656
+ * expressed as a change to.
657
+ */
658
+ interface Arrangement {
659
+ stack: string[];
660
+ focus: number;
446
661
  }
447
662
 
448
663
  // ─── Controller ──────────────────────────────────────────────────────────────
449
664
 
450
- /** Options the panel stack needs from its shell. */
665
+ /** Options the stack needs from its shell. */
451
666
  export interface PanelStackOptions {
452
667
  routes: Routes;
453
668
  notFound?: RouteHandler<{}>;
454
- /** Set `false` to show only the top panel, however much room there is. */
669
+ /** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
670
+ ancestors?: Record<string, AncestorsHandler | undefined>;
671
+ /** Set `false` to show only the current panel, however much room there is. */
455
672
  stacking?: boolean;
456
673
  /** The shell's own title, used as the suffix of `document.title`. */
457
674
  title?: unknown;
675
+ /**
676
+ * The shell's live narrow flag (see `main()`), which decides where a panel's
677
+ * chrome goes: in its own column, or promoted into the top bar. Shared rather
678
+ * than measured again here, so the bar and the columns can't disagree about
679
+ * which regime they are in.
680
+ */
681
+ $shell: { narrow: boolean };
458
682
  }
459
683
 
460
- /** At most one routed shell per app — that's what `S.panels` is bound to. */
461
- let active: PanelController | null = null;
684
+ /**
685
+ * The URL is global, so two routed shells would fight over it. This is only a
686
+ * guard against that — the stack is reached through the object `main()` hands
687
+ * back, never through a module-level singleton.
688
+ */
689
+ let mounted = false;
690
+
691
+ /**
692
+ * The panel stack behind a routed `S.main()`, and what that call hands back:
693
+ * the open {@link Panel}s, which of them is current, and the four ways to
694
+ * change that. Everything on it is scoped to its own shell.
695
+ *
696
+ * Every navigation settles asynchronously (closes travel through the
697
+ * browser's history), so the methods resolve once it has: `true` when it
698
+ * landed, `false` when it didn't — an unsaved panel refused to close, an
699
+ * app-registered route guard said no, or another navigation superseded it.
700
+ * Ignore the promise unless you care.
701
+ *
702
+ * @example
703
+ * ```ts
704
+ * const shell = S.main({ title: "Trackle", routes: { ... } });
705
+ *
706
+ * S.button({ content: "New task", click: async () => {
707
+ * const task = await createTask();
708
+ * shell.pushPanel(`/tasks/${task.id}`);
709
+ * }});
710
+ * ```
711
+ */
712
+ export interface PanelStack {
713
+ /**
714
+ * The open panels, oldest first — the stack itself, as live objects rather
715
+ * than a copy of it. Writing through one is how you pin a panel, or rename
716
+ * it, from outside its own handler. Reactive on the stack's shape; don't
717
+ * hold a {@link Panel} across a navigation, since a closed one is dropped
718
+ * here while its element plays out its exit.
719
+ */
720
+ readonly panels: readonly Panel[];
721
+ /** Index into {@link PanelStack.panels} of the current panel. Reactive. */
722
+ readonly currentPanelIndex: number;
723
+ /**
724
+ * The current panel — shorthand for `panels[currentPanelIndex]`, and
725
+ * `undefined` only while the stack is still empty. Reactive on *which*
726
+ * panel is current; the fields you then read (`title`, `actions`, …) are
727
+ * reactive in their own right.
728
+ */
729
+ readonly currentPanel: Panel | undefined;
730
+ /**
731
+ * Opens `path` in a new panel on top of the current one, closing the
732
+ * unpinned panels that were after it (pinned ones stay, sliding in beneath
733
+ * the new panel).
734
+ *
735
+ * The same rules as a link click apply: pushing a path that is already open
736
+ * goes back to it — a focus move along the stack, closing nothing — rather
737
+ * than opening it twice, and a panel holding {@link Panel.unsaved} work is
738
+ * never closed, only parked. That's what a plain link does, and what
739
+ * `data-panel=push` says outright.
740
+ */
741
+ pushPanel(path: string): Promise<boolean>;
742
+ /**
743
+ * Opens `path` in place of the current panel, which closes. The panels
744
+ * beneath it stay as they are. That's what a `data-panel=replace` link does.
745
+ */
746
+ replacePanel(path: string): Promise<boolean>;
747
+ /**
748
+ * Opens `path` as a whole stack rather than on top of what's there: the same
749
+ * thing a nav item or a fresh tab does. Without `beneath`, the stack under it
750
+ * is worked out the way a cold link's is (see {@link MainOptions.ancestors});
751
+ * with it, the paths you give are opened underneath, shallowest first.
752
+ *
753
+ * That's the one for a screen whose URL doesn't say where it belongs — the
754
+ * thread a notification opens — and for seeding a stack from code in general.
755
+ * Panels the new stack also holds stay as they are; ones it drops close,
756
+ * except panels with {@link Panel.unsaved} work, which stay, parked.
757
+ *
758
+ * A `data-panel=open` link does the same thing (without a `beneath`): it
759
+ * leaves the panel it sits in behind rather than stacking on it, which is
760
+ * what a link to somewhere else in the app wants — a search hit, a mention.
761
+ *
762
+ * @example
763
+ * ```ts
764
+ * shell.openPanelStack(`/thread/${id}`, [`/mailbox/${mailboxId}`]);
765
+ * ```
766
+ */
767
+ openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean>;
768
+ /**
769
+ * Closes the current panel, or, given a `path`, whichever panel is open at
770
+ * it. Closing the current panel hands the focus to the panel on its left;
771
+ * closing any other panel takes just it away, leaving the columns around it
772
+ * exactly as they are, with their state. Either way it becomes a history
773
+ * entry, so the browser's back button brings the panel back.
774
+ *
775
+ * Resolves `false` if the panel didn't close: it holds {@link Panel.unsaved}
776
+ * work, `path` isn't open, it was the stack's only panel, or another
777
+ * navigation got there first.
778
+ */
779
+ closePanel(path?: string): Promise<boolean>;
780
+ }
462
781
 
463
- export class PanelController {
782
+ /**
783
+ * The controller behind a routed shell. It implements {@link PanelStack} —
784
+ * the app-facing face, and the only part of it that is Staffa API — and on
785
+ * top of that draws the columns and the breadcrumbs for `main()`, which
786
+ * constructs it.
787
+ */
788
+ export class PanelStackController implements PanelStack {
789
+ /**
790
+ * Kept out of Aberdeen's proxy wrapping: this is a class instance holding
791
+ * DOM nodes, timers and route handlers, and it rides inside every
792
+ * {@link Panel.stack}. Its reactivity doesn't need the wrapper — it comes
793
+ * from `$state` and the panels, which are proxies in their own right.
794
+ */
795
+ readonly [OPAQUE] = true;
464
796
  private compiled: CompiledRoute[];
797
+ /** The `ancestors` table, compiled like the routes it is keyed by. */
798
+ private ancestors: { key: string; segs: Seg[]; fn: AncestorsHandler }[];
465
799
  private opts: PanelStackOptions;
466
- /** The live stack, shallow-to-deep. Closing panels are no longer part of it. */
467
- private live: PanelEntry[] = [];
468
- private byId = new Map<number, PanelEntry>();
469
- private nextId = 1;
470
- /** Drives rendering: panel id → its `order` (used only as the sort key). */
471
- $ids = A.proxy<Record<string, number>>({});
472
800
  /**
473
- * The live stack's paths and its top panel, for reactive readers: the
474
- * `document.title` watcher, `main()`'s Escape handling and `S.panels.stack`.
801
+ * The live stack, oldest first (closing panels are no longer part of it),
802
+ * and which of its panels is current — the one reactive fact about the
803
+ * stack's *shape*. The getters, the crumbs and `document.title` subscribe
804
+ * to it simply by reading it; each commit publishes the next shape by
805
+ * assigning a fresh `live` array. The entries themselves are opaque (see
806
+ * {@link PanelEntry}), so the array carries their comings, goings and
807
+ * order — nothing deeper; a panel's own facts stay separately reactive on
808
+ * its `$panel`, which is what lets a panel rename itself without the
809
+ * stack redrawing.
810
+ *
811
+ * One rule makes this safe to touch from anywhere: **queries subscribe,
812
+ * commands peek**. The getters below are the queries. Every navigation
813
+ * entry point (`navigate`, `closePath`, `back`, …) wraps itself in
814
+ * `A.peek`, so an app calling one from inside a reactive scope (a
815
+ * redirect in a route handler, say) can't subscribe that scope to the
816
+ * very stack it is changing — and everything those commands call through
817
+ * to, `propose` and `commit` included, inherits the same guarantee and
818
+ * reads the stack plainly.
819
+ */
820
+ private $state = A.proxy({ live: [] as PanelEntry[], focus: 0 });
821
+ /**
822
+ * The open panels again, keyed by path — the shape as the DOM consumes it.
823
+ * `drawColumns`' `onEach` mounts and unmounts panels by key, so a panel
824
+ * spliced out of the middle of the stack touches exactly one key, and the
825
+ * DOM of the retained columns — scroll positions, half-typed forms — is
826
+ * left alone. (Iterating `live` itself would key panels by array index,
827
+ * and a splice renumbers every index after it, redrawing them all.) A
828
+ * key's value is its entry, by reference, and is never reassigned, so a
829
+ * panel only ever redraws wholesale when its path closes.
475
830
  */
476
- $state = A.proxy({ paths: [] as string[], topId: 0 });
831
+ private $open = A.proxy<Record<string, PanelEntry>>({});
832
+ /** Feeds {@link PanelEntry.order}: one shared counter, so keys never tie. */
833
+ private nextOrder = 0;
477
834
  private containerEl?: HTMLElement;
478
835
  /** The shell's measurements, shared by everything drawn since they were taken. */
479
836
  private geom?: Geometry;
@@ -481,47 +838,78 @@ export class PanelController {
481
838
  private lastBodyW = -1;
482
839
  private layoutQueued = false;
483
840
  private timers = new Set<ReturnType<typeof setTimeout>>();
841
+ /** The arrangement the navigation in flight is heading for; see {@link intended}. */
842
+ private intent: Arrangement | null = null;
843
+ /** The navigation the router hasn't settled yet, if any. */
844
+ private settling: Promise<boolean> | null = null;
845
+ /** The route last seen by the observer, for stashing a left panel's query. */
846
+ private lastSeen: { path: string; search: Record<string, string>; hash: string } | null = null;
847
+ /** The one navigation waiting behind it; see {@link issue}. */
848
+ private queued: { run: () => boolean | Promise<boolean>; settle: (ok: boolean) => void } | null = null;
484
849
 
485
850
  constructor(opts: PanelStackOptions) {
486
- if (active) {
851
+ if (mounted) {
487
852
  throw new Error("Staffa: only one routed S.main() (one with `routes`) can be active at a time");
488
853
  }
489
- active = this;
854
+ mounted = true;
490
855
  this.opts = opts;
491
- this.compiled = Object.entries(opts.routes).map(([key, draw]) => compileRoute(key, draw));
492
-
493
- // The router consults this guard before any navigation is applied — ours,
494
- // a link's, browser back/forward, even a direct route.go() by app code —
495
- // so every panel the change would remove gets its requestClose asked,
496
- // exactly once, and a veto leaves the URL and the stack untouched (the
497
- // router holds the route steady while an async guard is pending, and
498
- // knows the exact history depth to restore on a vetoed popstate). A
499
- // guard the app registered before mounting keeps working: it is chained
500
- // in front of ours — an app veto (or redirect) wins without the panels
501
- // being asked — and handed back when the shell unmounts.
502
- const appGuard = route.setGuard((to, from) => {
503
- const outer = appGuard ? appGuard(to, from) : true;
504
- if (outer === false) return false;
505
- if (outer === true) return this.checkChange(to);
506
- return outer.then((ok) => (ok === false ? false : this.checkChange(to)));
507
- });
856
+ this.compiled = Object.entries(opts.routes).map(([key, draw]) => ({ ...compileKey(key), draw }));
857
+ this.ancestors = Object.entries(opts.ancestors ?? {})
858
+ .filter((entry): entry is [string, AncestorsHandler] => entry[1] != null)
859
+ .map(([key, fn]) => ({ ...compileKey(key), fn }));
508
860
 
509
861
  // Commit the stack whenever the URL or its snapshot changes — the initial
510
- // load, our own navigations, and browser back/forward. Anything that
511
- // reaches this point has already passed the guard above.
862
+ // load, our own navigations, and browser back/forward. There is no route
863
+ // guard of the shell's own to pass first: closes are refused up front
864
+ // (`closePath` on an unsaved panel) or repaired at the commit (`propose`
865
+ // keeps unsaved panels a navigation would drop), so a guard the app
866
+ // itself registered with `route.setGuard` — an auth redirect, say — is
867
+ // left exactly where it is and keeps working untouched.
512
868
  A(() => {
513
869
  const target = this.computeTarget();
514
- A.peek(() => this.propose(target));
870
+ // Subscribed (not peeked) deliberately: a search/hash change with the
871
+ // path staying put must refresh `lastSeen` too, or the next stash would
872
+ // restore stale ones.
873
+ const search = { ...route.current.search };
874
+ const hash = route.current.hash;
875
+ A.peek(() => {
876
+ // Stash the query of the panel the URL just left on that panel, for
877
+ // when a crumb brings it back (see PanelEntry.search). Done here, on
878
+ // the shared pipeline, so every origin is covered alike: the stack's
879
+ // own navigations, an app's `route.go()`, and browser back/forward.
880
+ const prev = this.lastSeen;
881
+ if (prev && prev.path !== route.current.path) {
882
+ const entry = this.$state.live.find((e) => e.path === prev.path);
883
+ if (entry) {
884
+ entry.search = prev.search;
885
+ entry.hash = prev.hash;
886
+ }
887
+ }
888
+ this.lastSeen = { path: route.current.path, search, hash };
889
+ this.propose(target);
890
+ // A history entry that doesn't describe an arrangement — the initial
891
+ // load, or an app's own `route.go()` — is stamped with the one it
892
+ // just produced: a reload restores the same columns, and a later
893
+ // `route.back()` can recognize the entry (its matching wants the
894
+ // `panels`/`parked` keys present, not merely compatible).
895
+ if (!Array.isArray(route.current.state.panels)) {
896
+ Object.assign(route.current.state, this.stateFor({ stack: this.paths(), focus: this.$state.focus }));
897
+ }
898
+ });
515
899
  });
516
900
 
517
901
  this.interceptLinks();
518
902
  this.watchTitle();
903
+ this.guardTabClose();
519
904
 
520
905
  A.clean(() => {
521
906
  for (const t of this.timers) clearTimeout(t);
522
907
  this.timers.clear();
523
- route.setGuard(appGuard);
524
- if (active === this) active = null;
908
+ // Nothing is going to navigate a shell that isn't there: whatever was
909
+ // waiting its turn is answered rather than left hanging.
910
+ this.queued?.settle(false);
911
+ this.queued = null;
912
+ mounted = false;
525
913
  });
526
914
  }
527
915
 
@@ -543,70 +931,135 @@ export class PanelController {
543
931
  }
544
932
 
545
933
  /**
546
- * The one derivation rule for origin-less navigation (§2.8): probe every
547
- * prefix of the path against the route table; the matching prefixes become
548
- * the stack. Prefixes without a route are simply skipped, so an app that
549
- * doesn't want one screen stacked under another just doesn't route that
550
- * prefix. The path itself is always the top panel, matched or not.
934
+ * The stack for origin-less navigation: a cold deep link, a nav item, a
935
+ * `route.go()` — anything arriving without a panel to build on and without a
936
+ * snapshot to restore.
937
+ *
938
+ * The app's {@link PanelStackOptions.ancestors} gets first say, since only it
939
+ * can know what belongs under a path that doesn't spell its own context out
940
+ * (a `/thread/[id]` reached from a notification). Failing that — or when it
941
+ * has no opinion — every prefix of the path is probed against the route table
942
+ * and the matching ones become the stack. Either way, a path with no route is
943
+ * skipped rather than opened as a "not found" column, so an app that doesn't
944
+ * want one screen stacked under another simply doesn't route it. The path
945
+ * itself always ends the derived stack, matched or not.
551
946
  */
552
- deriveStack(path: string): string[] {
553
- const segments = splitPath(path);
947
+ private deriveStack(path: string): string[] {
948
+ const top = normalizePath(path);
949
+ const asked = this.askAncestors(top);
950
+ const beneath = asked ? asked.map(normalizePath) : this.prefixesOf(top);
554
951
  const stack: string[] = [];
555
- for (let i = 1; i < segments.length; i++) {
556
- const prefix = "/" + segments.slice(0, i).join("/");
557
- if (this.matches(prefix)) stack.push(prefix);
952
+ for (const ancestor of beneath) {
953
+ if (ancestor !== top && !stack.includes(ancestor) && this.matches(ancestor)) stack.push(ancestor);
558
954
  }
559
- stack.push(normalizePath(path));
955
+ stack.push(top);
560
956
  return stack;
561
957
  }
562
958
 
563
- /** The stack a route implies: its snapshot topped by its path, or — without a snapshot — derived. */
564
- private targetFor(path: string, snapshot: unknown): string[] {
565
- if (Array.isArray(snapshot)) return snapshot.map(String).concat(normalizePath(path));
566
- return this.deriveStack(path);
959
+ /**
960
+ * Ask the `ancestors` table what belongs beneath `path`. The first key that
961
+ * matches answers — with its own matched params, so it never has to take the
962
+ * path apart itself — and `undefined` from it means "no opinion", leaving the
963
+ * path to the prefix derivation just as an unlisted one is.
964
+ */
965
+ private askAncestors(path: string): readonly string[] | undefined {
966
+ const segments = splitPath(path);
967
+ for (const entry of this.ancestors) {
968
+ const params = matchRoute(entry, segments);
969
+ if (params) return entry.fn(params, path) ?? undefined;
970
+ }
971
+ return undefined;
972
+ }
973
+
974
+ /** Every prefix of `path` that has a route, shallowest first. */
975
+ private prefixesOf(path: string): string[] {
976
+ const segments = splitPath(path);
977
+ const found: string[] = [];
978
+ for (let i = 1; i < segments.length; i++) found.push("/" + segments.slice(0, i).join("/"));
979
+ return found;
567
980
  }
568
981
 
569
- /** The stack the current history entry asks for. Subscribes to path + snapshot. */
570
- private computeTarget(): string[] {
571
- return this.targetFor(route.current.path, route.current.state.panels);
982
+ /**
983
+ * The pinned panels among `stack`, in order, minus `omit` — the ones a
984
+ * navigation must carry along rather than close. Pin flags live on the
985
+ * panels themselves, so a path without a live panel can't be pinned.
986
+ */
987
+ private pinnedIn(stack: readonly string[], omit: (string | null | undefined)[]): string[] {
988
+ return stack.filter((path) => {
989
+ if (omit.includes(path)) return false;
990
+ return this.$state.live.find((e) => e.path === path)?.$panel.pinned === true;
991
+ });
992
+ }
993
+
994
+ /** Whether the panel open at `path` (if any) holds unsaved work. */
995
+ private unsavedAt(path: string | undefined): boolean {
996
+ return this.$state.live.find((e) => e.path === path)?.$panel.unsaved === true;
572
997
  }
573
998
 
574
999
  /**
575
- * The route guard (see `route.setGuard` in the constructor): asked before any
576
- * route change lands, wherever it came from. Runs the {@link Page.requestClose}
577
- * guard of every panel the new route's stack would remove — a set defined by
578
- * the target (the commit reconciles by path), so a derived stack that shares
579
- * nothing with the live one still asks exactly the panels that are closing.
1000
+ * The arrangement a route implies: its snapshot around its path, or —
1001
+ * without a snapshot — derived, with the new panel current at the end and
1002
+ * any pinned panels carried along beneath it.
1003
+ *
1004
+ * The snapshot reads subscribe — they are the URL's, exactly what the
1005
+ * route observer is for. The derivation is peeked instead: it reads the
1006
+ * live stack for its pins, which is the very thing that observer rewrites,
1007
+ * and subscribing to it would re-run the observer once per commit.
580
1008
  */
581
- private checkChange(to: route.Route): boolean | Promise<boolean> {
582
- const removed = this.removedBy(this.targetFor(to.path, to.state.panels));
583
- return removed.length ? runGuards(removed) : true;
1009
+ private targetFor(path: string, state: Record<string, any>): Arrangement {
1010
+ const before = Array.isArray(state?.panels) ? state.panels.map(String) : null;
1011
+ if (before) {
1012
+ const after = Array.isArray(state.parked) ? state.parked.map(String) : [];
1013
+ // A stack never holds the same path twice: rendering reconciles by path,
1014
+ // so a duplicate would leave a permanently element-less entry that stalls
1015
+ // the layout. Our own states are clean, but `route.go` accepts
1016
+ // hand-written ones — drop duplicates rather than wedge.
1017
+ const cur = normalizePath(path);
1018
+ const seen = new Set([cur]);
1019
+ const uniq = (paths: string[]) =>
1020
+ paths.map(normalizePath).filter((p) => !seen.has(p) && !!seen.add(p));
1021
+ const beforeUnique = uniq(before);
1022
+ return { stack: [...beforeUnique, cur, ...uniq(after)], focus: beforeUnique.length };
1023
+ }
1024
+ return A.peek(() => {
1025
+ const base = this.deriveStack(path).slice(0, -1);
1026
+ const stack = [...base, ...this.pinnedIn(this.paths(), [...base, normalizePath(path)]), normalizePath(path)];
1027
+ return { stack, focus: stack.length - 1 };
1028
+ });
1029
+ }
1030
+
1031
+ /** The arrangement the current history entry asks for. Subscribes to path + snapshot. */
1032
+ private computeTarget(): Arrangement {
1033
+ return this.targetFor(route.current.path, route.current.state);
584
1034
  }
585
1035
 
586
1036
  // ── Commit pipeline ────────────────────────────────────────────────────
587
1037
 
588
1038
  private paths(): string[] {
589
- return this.live.map((e) => e.path);
590
- }
591
-
592
- /** The live panels a target stack drops — by path, so a splice removes only its own column. */
593
- private removedBy(target: string[]): PanelEntry[] {
594
- return this.live.filter((entry) => !target.includes(entry.path));
1039
+ return this.$state.live.map((e) => e.path);
595
1040
  }
596
1041
 
597
1042
  /**
598
- * Adopt a stack proposed by the URL. Close guards have already been run (and
599
- * have passed) by the time a route change is visible here — `checkChange` is
600
- * consulted by the router itself, before anything is applied.
1043
+ * Adopt an arrangement proposed by the URL — after repairing it: panels
1044
+ * holding unsaved work are never torn down by a navigation, wherever it
1045
+ * came from — a link, a nav item, even a browser back to an entry from
1046
+ * before the panel existed. Whatever the target drops, they stay, parked
1047
+ * after the current panel and wearing the ● that says why. (They are
1048
+ * deliberately not written into history entries: the work they protect
1049
+ * lives in the page's DOM, which a reload clears anyway.)
601
1050
  */
602
- private propose(target: string[]): void {
603
- if (sameStack(this.paths(), target)) return;
604
- this.commit(target, A.peek(route.current, "nav"));
1051
+ private propose(target: Arrangement): void {
1052
+ const kept = this.$state.live
1053
+ .filter((e) => !target.stack.includes(e.path) && e.$panel.unsaved)
1054
+ .map((e) => e.path);
1055
+ if (kept.length) target = { stack: [...target.stack, ...kept], focus: target.focus };
1056
+ if (sameStack(this.paths(), target.stack) && target.focus === this.$state.focus) return;
1057
+ this.commit(target, route.current.nav);
605
1058
  }
606
1059
 
607
1060
  /**
608
- * Apply a target stack: unmount what's gone, mount what's new, animate the
609
- * difference.
1061
+ * Apply a target arrangement: unmount what's gone, mount what's new, animate
1062
+ * the difference.
610
1063
  *
611
1064
  * Reconciliation is BY PATH (a stack can't hold the same path twice, so that's
612
1065
  * well-defined): a panel present in both stacks stays mounted *even if its
@@ -615,13 +1068,18 @@ export class PanelController {
615
1068
  * remount every one of them, throwing away exactly the scroll and form state
616
1069
  * rule 5 promises to keep.
617
1070
  */
618
- private commit(target: string[], nav: string): void {
1071
+ private commit(target: Arrangement, nav: string): void {
619
1072
  // The panels this commit mounts size themselves as they draw, so make them
620
1073
  // measure the shell as it is now rather than trusting the last pass's numbers.
621
1074
  this.geom = undefined;
622
- const existing = new Map(this.live.map((entry) => [entry.path, entry]));
1075
+ // Pin flags for panels this commit *creates* — a reload, or a cold
1076
+ // restore. Live panels keep their own flag: a pin is the user's mark on
1077
+ // the panel, not part of where back/forward travel.
1078
+ const pinned = route.current.state.pinned;
1079
+ const seedPins = new Set<string>(Array.isArray(pinned) ? pinned.map(String) : []);
1080
+ const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
623
1081
  const next: PanelEntry[] = [];
624
- for (const path of target) {
1082
+ for (const path of target.stack) {
625
1083
  const kept = existing.get(path);
626
1084
  if (kept) {
627
1085
  // Retained: it just takes its new place in the stack. Its `order` (the
@@ -630,43 +1088,50 @@ export class PanelController {
630
1088
  next.push(kept);
631
1089
  continue;
632
1090
  }
633
- const entry = this.createEntry(path, next.length);
1091
+ const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
634
1092
  // An initial load just appears, and so do panels *revealed* by a back —
635
1093
  // they belong underneath the ones sliding away. Everything else enters at
636
1094
  // the right edge, a replacement exactly like a push.
637
1095
  if (nav !== "load" && nav !== "back") entry.enter = true;
638
1096
  next.push(entry);
639
- this.byId.set(entry.id, entry);
1097
+ this.$open[path] = entry;
640
1098
  }
641
1099
  // Whatever the target no longer holds leaves the same way: fading out over
642
1100
  // the right edge, which is also where its replacement (if any) comes in from.
643
1101
  for (const entry of existing.values()) this.beginClose(entry);
644
- this.live = next;
645
-
646
- A.merge(this.$state, { paths: this.paths(), topId: this.live.length ? this.live[this.live.length - 1].id : 0 });
647
- for (const entry of this.live) this.$ids[String(entry.id)] = entry.order;
1102
+ this.$state.live = next;
1103
+ this.$state.focus = Math.min(target.focus, next.length - 1);
648
1104
  this.scheduleLayout();
649
1105
  }
650
1106
 
651
- private createEntry(path: string, order: number): PanelEntry {
1107
+ private createEntry(path: string, visible: boolean, pinned: boolean): PanelEntry {
652
1108
  const { draw, params } = this.resolve(path);
653
1109
  const entry = {
654
- id: this.nextId++,
655
- order,
1110
+ [OPAQUE]: true,
1111
+ order: this.nextOrder++,
656
1112
  path,
657
1113
  draw,
658
1114
  $ui: A.proxy({ holding: false }),
659
- layout: "medium" as const,
1115
+ maxWidth: "full" as const,
660
1116
  width: 0,
661
1117
  } as PanelEntry;
662
- // `close` closes *this* panel, top of the stack or not. It resolves the
663
- // panel's current depth at call time, so it keeps working after a splice has
1118
+ // `close` closes *this* panel, current or not. It resolves the panel's
1119
+ // place in the stack at call time, so it keeps working after a splice has
664
1120
  // moved it — and quietly resolves false once the panel is gone.
665
- entry.$page = A.proxy({
1121
+ //
1122
+ // `visible` starts at what the panel's place implies: shown when it sits
1123
+ // at or before the current panel (a pushed panel always does), hidden when
1124
+ // it is restored already parked. `width` is filled in by the sizing scope
1125
+ // in `drawPanel` before the handler draws.
1126
+ entry.$panel = A.proxy({
1127
+ stack: this,
666
1128
  params,
667
1129
  path,
668
- close: () => this.closePanelAt(this.live.indexOf(entry)),
669
- }) as Page<any>;
1130
+ width: 0,
1131
+ visible,
1132
+ pinned: pinned || undefined,
1133
+ close: () => this.closePath(entry.path),
1134
+ }) as PanelState;
670
1135
  return entry;
671
1136
  }
672
1137
 
@@ -680,13 +1145,16 @@ export class PanelController {
680
1145
  */
681
1146
  private beginClose(entry: PanelEntry): void {
682
1147
  entry.closing = true;
1148
+ // It is on its way out, so it is no longer "on screen" as far as anything
1149
+ // hanging off `$panel.visible` is concerned — even though its element lingers
1150
+ // to play the fade.
1151
+ entry.$panel.visible = false;
683
1152
  // Frozen one layer below where it was, which is still above everything it
684
1153
  // was covering: it fades out over the panel it uncovers, and under the one
685
1154
  // that takes its place (see LAYER_STEP). Set here, while the element is
686
1155
  // still ours — a moment later the scope, and with it `entry.el`, is gone.
687
- if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
688
- this.byId.delete(entry.id);
689
- delete this.$ids[String(entry.id)];
1156
+ if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
1157
+ delete this.$open[entry.path];
690
1158
  }
691
1159
 
692
1160
  /**
@@ -713,114 +1181,255 @@ export class PanelController {
713
1181
  el.addEventListener("transitionend", (e: TransitionEvent) => {
714
1182
  if (e.target === el && e.propertyName === "opacity") drop();
715
1183
  });
716
- const timer = setTimeout(drop, PANEL_MS + 80);
1184
+ const timer = setTimeout(drop, PAGE_MS + 80);
717
1185
  this.timers.add(timer);
718
1186
  }
719
1187
 
720
1188
  // ── Navigation ─────────────────────────────────────────────────────────
721
1189
 
722
1190
  /**
723
- * Navigate back to a stack that is a truncation of the current one — the shared
724
- * implementation of Escape, a page closing itself, return-links and
725
- * `S.panels.close()`. `route.back()` prefers the history entry where that
726
- * panel was on top (with its scroll state intact); when there is no such entry
727
- * it replaces the current one, carrying the snapshot passed as the fallback.
728
- * Either way the route guard asks the closing panels first, and the returned
729
- * promise reports its verdict.
1191
+ * The arrangement navigation works from: the one we're on the way to while a
1192
+ * change is still settling, and the one on screen otherwise.
1193
+ *
1194
+ * Settling takes a moment more often than it looks: every `route.back()`
1195
+ * travels through the browser's history and lands on a `popstate`, and an
1196
+ * app-registered route guard may be async. Working from the committed
1197
+ * arrangement in that window would make a second Escape aim at the panel the
1198
+ * first one is already taking away — so two quick Escapes would peel one panel.
730
1199
  */
731
- private goBackTo(target: string[]): Promise<boolean> {
732
- return route.back({ path: target[target.length - 1] }, { state: { panels: target.slice(0, -1) } });
1200
+ private intended(): Arrangement {
1201
+ return this.intent ?? { stack: this.paths(), focus: this.$state.focus };
733
1202
  }
734
1203
 
735
- /** Close every panel above `index` (guarded). Resolves `false` when vetoed. */
736
- closeDownTo(index: number): Promise<boolean> {
737
- if (index < 0 || index >= this.live.length - 1) return Promise.resolve(false);
738
- return this.goBackTo(this.paths().slice(0, index + 1));
1204
+ /** The history `state` describing `arr` — exactly what `targetFor` reads back. */
1205
+ private stateFor(arr: Arrangement): Record<string, any> {
1206
+ return {
1207
+ panels: arr.stack.slice(0, arr.focus),
1208
+ parked: arr.stack.slice(arr.focus + 1),
1209
+ pinned: this.pinnedPaths(),
1210
+ };
739
1211
  }
740
1212
 
741
- /** Guarded close of the top panel. */
742
- closeTop(): Promise<boolean> {
743
- return this.closeDownTo(this.live.length - 2);
1213
+ /** The paths of the pinned panels — the pin list history entries persist. */
1214
+ private pinnedPaths(): string[] {
1215
+ return this.$state.live.filter((e) => e.$panel.pinned).map((e) => e.path);
744
1216
  }
745
1217
 
746
1218
  /**
747
- * Guarded close of the panel at `index`, top of the stack or not — what a
748
- * page's own close affordances ({@link Page.close}, a box's ✕) come down to.
1219
+ * Put a navigation to the router, or — while one is still settling — behind
1220
+ * the one that is. Only the newest waits: each was worked out against
1221
+ * {@link intended}, so the newest is the one that means what the user last
1222
+ * asked for, and the one it displaces resolves `false`.
749
1223
  *
750
- * The top panel pops back to the snapshot beneath it. Any other panel is
751
- * *spliced* out: its guard runs, the columns above it keep their place and
752
- * state (the commit reconciles by path), and the URL doesn't change, since the
753
- * top panel didn't. That still gets its own history entry, so the browser's
754
- * back button restores the closed column like any other snapshot — which is
755
- * why it goes through `route.go` here rather than through `navigate()`, whose
756
- * "link to the panel we're already on" check would see a no-op.
1224
+ * A refusal empties the queue instead of running it: a navigation can still
1225
+ * fail to land — an app-registered route guard vetoes it, or another one
1226
+ * supersedes it — and what was queued behind it was worked out against the
1227
+ * arrangement it would have produced.
757
1228
  */
758
- closePanelAt(index: number): Promise<boolean> {
759
- if (index < 0 || index >= this.live.length) return Promise.resolve(false);
760
- if (index === this.live.length - 1) return this.closeTop();
761
- const target = this.paths().filter((_, i) => i !== index);
762
- return Promise.resolve(route.go({
763
- path: target[target.length - 1],
764
- // The top panel keeps its search params and hash: it isn't going
765
- // anywhere, and `go()` would otherwise default them away.
766
- search: A.peek(() => ({ ...route.current.search })),
767
- hash: A.peek(route.current, "hash"),
768
- state: { panels: target.slice(0, -1) },
769
- }));
1229
+ private issue(target: Arrangement, run: () => boolean | Promise<boolean>): Promise<boolean> {
1230
+ this.intent = target;
1231
+ if (this.settling) {
1232
+ this.queued?.settle(false);
1233
+ return new Promise<boolean>((settle) => { this.queued = { run, settle }; });
1234
+ }
1235
+ return this.start(run);
770
1236
  }
771
1237
 
772
- /** Guarded close of whichever panel `path` is open as. False when it isn't open. */
773
- closeByPath(path: string): Promise<boolean> {
774
- const wanted = normalizePath(path);
775
- return this.closePanelAt(this.live.findIndex((entry) => entry.path === wanted));
1238
+ private start(run: () => boolean | Promise<boolean>): Promise<boolean> {
1239
+ const done = (ok: boolean): boolean => {
1240
+ this.settling = null;
1241
+ const next = this.queued;
1242
+ this.queued = null;
1243
+ // The router applies a change (and runs Aberdeen's queue, so our own
1244
+ // commit has happened) before it settles us, which is what lets the next
1245
+ // one go straight out: it asks the guards of the panels it removes from
1246
+ // the stack as it stands now, not the one it was queued against.
1247
+ if (ok && next) this.start(next.run).then(next.settle, () => next.settle(false));
1248
+ else { this.intent = null; next?.settle(false); }
1249
+ return ok;
1250
+ };
1251
+ const settling = Promise.resolve(run()).then(done, (e) => { console.error(e); return done(false); });
1252
+ this.settling = settling;
1253
+ return settling;
776
1254
  }
777
1255
 
778
- /** Guarded close of the panel whose `.s-panel` element this is. */
779
- closePanelEl(el: HTMLElement): Promise<boolean> {
780
- return this.closePanelAt(this.live.findIndex((entry) => entry.el === el));
1256
+ /**
1257
+ * Make the stack's `index`th panel current: the URL and the visible run move
1258
+ * to it, while the panels right of it stay open, parked past the right edge
1259
+ * of the viewport. Nothing closes; it is a history entry, so the browser's
1260
+ * back button returns the focus to where it was. What a click on a
1261
+ * breadcrumb — any link to an open panel — comes down to.
1262
+ */
1263
+ private focusAt(index: number, search?: Record<string, string>, hash?: string): Promise<boolean> {
1264
+ const arr = this.intended();
1265
+ if (index < 0 || index >= arr.stack.length || index === arr.focus) return Promise.resolve(false);
1266
+ const target = { stack: arr.stack, focus: index };
1267
+ const path = arr.stack[index];
1268
+ return this.issue(target, () => {
1269
+ // The panel gets its own last search and hash back, unless the link
1270
+ // that brought us here carries its own.
1271
+ const entry = this.$state.live.find((e) => e.path === path);
1272
+ return route.go({ path, search: search ?? entry?.search, hash: hash ?? entry?.hash, state: this.stateFor(target) });
1273
+ });
781
1274
  }
782
1275
 
783
1276
  /**
784
- * Navigate to `href`. `originIndex` is the depth of the panel the link lives
785
- * in (−1 when it has none — a nav item or a programmatic call, which derives
786
- * the whole stack instead). `replace` swaps the originating panel rather than
787
- * stacking on top of it.
1277
+ * One step back along the stack — what Escape does (`main()` calls this;
1278
+ * it is not {@link PanelStack} API). At the stack's end this closes the
1279
+ * current panel; mid-stack — with panels parked to the right — or when the
1280
+ * panel holds {@link Panel.unsaved} work, the panel stays open and the
1281
+ * focus just moves to the panel on its left, parking the one it leaves.
1282
+ * Resolves `false` at the stack's start, where there is no left to go.
788
1283
  */
789
- navigate(href: string, originIndex: number, replace = false): void {
790
- let url: URL;
791
- try { url = new URL(href, location.href); } catch { return; }
792
- const path = normalizePath(url.pathname);
793
- const search = Object.fromEntries(new URLSearchParams(url.search));
794
- const hash = url.hash;
795
-
796
- // A link to a panel that is already open is a return, not a navigation —
797
- // so a stack can never hold the same path twice.
798
- const open = this.live.findIndex((e) => e.path === path);
799
- if (open >= 0 && open < this.live.length - 1) { void this.closeDownTo(open); return; }
800
- if (open >= 0) {
801
- // The target is the panel we're already on. Going nowhere — but the link
802
- // may still carry a different search or hash, which belong to the top
803
- // panel: record that as a history entry, leaving the stack alone (the
804
- // panel reconciles by path, so it isn't even redrawn).
805
- if (url.search === location.search && (url.hash || "") === (location.hash || "")) return;
806
- route.go({ path, search, hash, state: { panels: this.paths().slice(0, -1) } });
807
- return;
808
- }
1284
+ back(): Promise<boolean> {
1285
+ return A.peek(() => {
1286
+ const arr = this.intended();
1287
+ if (arr.focus === arr.stack.length - 1 && !this.unsavedAt(arr.stack[arr.focus])) {
1288
+ return this.closePath(arr.stack[arr.focus] ?? "");
1289
+ }
1290
+ if (arr.focus === 0) return Promise.resolve(false);
1291
+ return this.focusAt(arr.focus - 1);
1292
+ });
1293
+ }
809
1294
 
810
- // Without an originating panel there is no stack to build on, so derive
811
- // one — a nav click and a deep link to the same URL land identically.
812
- // The route guard (checkChange) asks every panel this removes — a set
813
- // defined by the target stack, wherever those panels happen to sit —
814
- // before the change is applied; a veto leaves everything untouched.
815
- const beneath = originIndex < 0
816
- ? this.deriveStack(path).slice(0, -1)
817
- : this.paths().slice(0, replace ? originIndex : originIndex + 1);
818
- route.go({ path, search, hash, state: { panels: beneath } });
1295
+ /**
1296
+ * Close whichever panel is open at `path`, current or not — what
1297
+ * {@link Panel.close}, {@link PanelStack.closePanel} and the crumb menu's
1298
+ * Close come down to. `false` when that path isn't open, is the stack's
1299
+ * only panel, or holds {@link Panel.unsaved} work — nothing may close an
1300
+ * unsaved panel; the app clears the flag first, which is its explicit
1301
+ * "this is now discardable".
1302
+ *
1303
+ * Closing the current panel at the stack's very end pops back through the
1304
+ * browser's history to the entry beneath it, when it is there (restoring its
1305
+ * scroll and search state); the arrangement is part of the match, so an
1306
+ * entry where the closing panel was merely parked won't do. Every other
1307
+ * close is a *splice*: the columns around the closed one keep their place
1308
+ * and state (the commit reconciles by path). That still gets its own
1309
+ * history entry, so the browser's back button restores the closed column
1310
+ * like any other arrangement — which is why it goes through `route.go` here
1311
+ * rather than through `navigate()`, whose "link to an open panel" check
1312
+ * would turn it into a focus move.
1313
+ */
1314
+ private closePath(path: string): Promise<boolean> {
1315
+ return A.peek(() => {
1316
+ const arr = this.intended();
1317
+ const index = arr.stack.indexOf(normalizePath(path));
1318
+ if (index < 0 || arr.stack.length < 2 || this.unsavedAt(arr.stack[index])) return Promise.resolve(false);
1319
+ const stack = arr.stack.filter((_, i) => i !== index);
1320
+ // Closing the current panel hands the focus to the panel on its left (or,
1321
+ // at the stack's start, to the one that was parked beside it); closing
1322
+ // any other panel moves the focus not at all.
1323
+ const focus = index === arr.focus ? Math.max(0, index - 1) : arr.focus - (index < arr.focus ? 1 : 0);
1324
+ const target = { stack, focus };
1325
+
1326
+ if (index === arr.focus && index === arr.stack.length - 1) {
1327
+ // When no history entry matches and the current one is replaced
1328
+ // instead, the panel beneath gets its stashed query back through the
1329
+ // fallback (a match restores the matched entry's own). Pins can't
1330
+ // ride the same way — a `state` in the fallback would be shadowed by
1331
+ // the match target's — so they are re-stamped onto whatever entry we
1332
+ // land on, the way `togglePin` writes them.
1333
+ const beneath = this.$state.live.find((e) => e.path === stack[focus]);
1334
+ const fallback: { search?: Record<string, string>; hash?: string } = {};
1335
+ if (beneath?.search) fallback.search = beneath.search;
1336
+ if (beneath?.hash) fallback.hash = beneath.hash;
1337
+ const pinned = stack.filter((p) => this.$state.live.find((e) => e.path === p)?.$panel.pinned === true);
1338
+ return this.issue(target, () =>
1339
+ Promise.resolve(route.back(
1340
+ { path: stack[focus], state: { panels: stack.slice(0, focus), parked: [] } },
1341
+ fallback,
1342
+ )).then((ok) => {
1343
+ if (ok) route.current.state.pinned = pinned;
1344
+ return ok;
1345
+ }));
1346
+ }
1347
+
1348
+ const current = stack[focus];
1349
+ const moved = current !== arr.stack[arr.focus];
1350
+ return this.issue(target, () => {
1351
+ // The current panel keeps its search params and hash: it isn't going
1352
+ // anywhere, and `go()` would otherwise default them away. When the
1353
+ // close *did* move the focus, the newly current panel gets its own back.
1354
+ const entry = moved ? this.$state.live.find((e) => e.path === current) : undefined;
1355
+ return route.go({
1356
+ path: current,
1357
+ search: moved ? entry?.search : { ...route.current.search },
1358
+ hash: moved ? entry?.hash : route.current.hash,
1359
+ state: this.stateFor(target),
1360
+ });
1361
+ });
1362
+ });
819
1363
  }
820
1364
 
821
- /** Programmatic push/replace, with the top panel as the implied origin. */
822
- pushPath(path: string, replace: boolean): void {
823
- this.navigate(path, this.live.length - 1, replace);
1365
+ /**
1366
+ * Navigate to `href`. `origin` is the path of the panel the link lives in, or
1367
+ * `null` when it has none — a nav item, or a programmatic call, which builds
1368
+ * the whole stack instead (see {@link deriveStack}). `replace` swaps the
1369
+ * originating panel rather than stacking on top of it, and `beneath` says what
1370
+ * the stack under the target is outright, for callers that know.
1371
+ *
1372
+ * Resolves the way every {@link PanelStack} method does: `true` once the
1373
+ * navigation lands, `false` when it doesn't (already there counts as
1374
+ * landed).
1375
+ */
1376
+ private navigate(href: string, origin: string | null, replace = false, beneath?: readonly string[]): Promise<boolean> {
1377
+ return A.peek(() => {
1378
+ let url: URL;
1379
+ try { url = new URL(href, location.href); } catch { return Promise.resolve(false); }
1380
+ const path = normalizePath(url.pathname);
1381
+ const search = Object.fromEntries(new URLSearchParams(url.search));
1382
+ const hash = url.hash;
1383
+ const arr = this.intended();
1384
+
1385
+ // A link to a panel that is already open is a return, not a navigation —
1386
+ // a stack never holds the same path twice. Returning just moves the
1387
+ // focus: the panels right of the target stay open, parked past the right
1388
+ // edge, and nothing closes. That is the whole behaviour of a breadcrumb,
1389
+ // which is exactly such a link.
1390
+ const open = beneath ? -1 : arr.stack.indexOf(path);
1391
+ if (open >= 0 && open !== arr.focus) {
1392
+ return this.focusAt(open, url.search ? search : undefined, hash || undefined);
1393
+ }
1394
+ if (open >= 0) {
1395
+ // The target is the panel we're already on. Going nowhere — but the link
1396
+ // may still carry a different search or hash, which belong to the
1397
+ // current panel: record that as a history entry, leaving the stack alone
1398
+ // (the panel reconciles by path, so it isn't even redrawn).
1399
+ if (url.search === location.search && (url.hash || "") === (location.hash || "")) return Promise.resolve(true);
1400
+ return this.issue(arr, () => route.go({ path, search, hash, state: this.stateFor(arr) }));
1401
+ }
1402
+
1403
+ // A new panel: it opens at the stack's end and becomes current. The
1404
+ // panels after the origin close — except pinned ones, which ride along,
1405
+ // keeping their order, beneath the new panel (and unsaved ones, which
1406
+ // the commit itself keeps, parked — see `propose`). Without an
1407
+ // originating panel there is no stack to build on, so derive one — a
1408
+ // nav click and a deep link to the same URL land identically (bar the
1409
+ // pins, which a fresh tab doesn't have).
1410
+ const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
1411
+ // A stack never holds the same path twice (rendering reconciles by
1412
+ // path), so a caller-supplied `beneath` is deduplicated, not just
1413
+ // filtered against the target.
1414
+ const base = beneath
1415
+ ? beneath.map(normalizePath).filter((p, i, all) => p !== path && all.indexOf(p) === i)
1416
+ : originIndex < 0
1417
+ ? this.deriveStack(path).slice(0, -1)
1418
+ : arr.stack.slice(0, replace ? originIndex : originIndex + 1);
1419
+ // A replaced origin closes, pin or no pin: replacing is the panel's own
1420
+ // doing, not somewhere else navigating over it.
1421
+ const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
1422
+ const target = { stack: [...under, path], focus: under.length };
1423
+ return this.issue(target, () => route.go({ path, search, hash, state: this.stateFor(target) }));
1424
+ });
1425
+ }
1426
+
1427
+ /** Programmatic push/replace, with the current panel as the implied origin. */
1428
+ private pushPath(path: string, replace: boolean): Promise<boolean> {
1429
+ return A.peek(() => {
1430
+ const arr = this.intended();
1431
+ return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
1432
+ });
824
1433
  }
825
1434
 
826
1435
  // ── Link interception ──────────────────────────────────────────────────
@@ -828,55 +1437,250 @@ export class PanelController {
828
1437
  /**
829
1438
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
830
1439
  * the anchor so we can decide what the click *means*: the originating
831
- * `.s-panel` (which decides what the click truncates), `data-panel=replace`,
832
- * and return-to-an-open-panel semantics. The exclusion rules (targets,
833
- * downloads, modified clicks, external URLs) live in Aberdeen; the close
834
- * guards run in `checkChange` when our navigation reaches the router.
1440
+ * `.s-panel` (which decides what the click truncates), the `data-panel`
1441
+ * attribute, and return-to-an-open-panel semantics. The exclusion rules
1442
+ * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
1443
+ * close guards run in `checkChange` when our navigation reaches the router.
1444
+ *
1445
+ * `data-panel` names which of the three {@link PanelStack} navigations the
1446
+ * click is: `push` (the default), `replace`, or `open`, which drops the
1447
+ * originating panel so the target arrives with its own stack beneath it,
1448
+ * exactly as a nav item's link does. An unrecognised value is a `push`.
835
1449
  */
836
1450
  private interceptLinks(): void {
837
1451
  route.interceptLinks((url, anchor) => {
838
- const panel = anchor.closest<HTMLElement>(".s-panel");
839
- const originIndex = panel ? this.live.findIndex((entry) => entry.el === panel) : -1;
840
- this.navigate(url.href, originIndex, anchor.getAttribute("data-panel") === "replace");
1452
+ const mode = anchor.getAttribute("data-panel");
1453
+ const panel = mode === "open" ? null : anchor.closest<HTMLElement>(".s-panel");
1454
+ const origin = panel ? this.$state.live.find((entry) => entry.el === panel) : undefined;
1455
+ void this.navigate(url.href, origin?.path ?? null, mode === "replace");
841
1456
  return true;
842
1457
  });
843
1458
  }
844
1459
 
1460
+ // ── What the shell's top bar asks ──────────────────────────────────────
1461
+
1462
+ // The {@link PanelStack} face, where all the documentation lives. These are
1463
+ // the *queries* of the "queries subscribe, commands peek" rule on `$state`:
1464
+ // read one in a scope and that scope follows the stack's shape.
1465
+
1466
+ get currentPanel(): Panel | undefined {
1467
+ return this.$state.live[this.$state.focus]?.$panel;
1468
+ }
1469
+
1470
+ get panels(): readonly Panel[] {
1471
+ return this.$state.live.map((e) => e.$panel);
1472
+ }
1473
+
1474
+ get currentPanelIndex(): number {
1475
+ return this.$state.focus;
1476
+ }
1477
+
1478
+ pushPanel(path: string): Promise<boolean> {
1479
+ return this.pushPath(path, false);
1480
+ }
1481
+
1482
+ replacePanel(path: string): Promise<boolean> {
1483
+ return this.pushPath(path, true);
1484
+ }
1485
+
1486
+ openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean> {
1487
+ return this.navigate(path, null, false, beneath);
1488
+ }
1489
+
1490
+ closePanel(path?: string): Promise<boolean> {
1491
+ return A.peek(() => {
1492
+ const arr = this.intended();
1493
+ return this.closePath(path ?? arr.stack[arr.focus] ?? "");
1494
+ });
1495
+ }
1496
+
1497
+ /**
1498
+ * The breadcrumb stack, drawn by `main()` into the top bar: every open
1499
+ * panel, oldest first, the ones on screen right now in bold, pinned ones
1500
+ * wearing their pin. Every crumb but the current panel's is a plain link to
1501
+ * that panel, and a link to an open panel is a focus move (see `navigate`) —
1502
+ * so clicking along the stack closes nothing, in either direction, and the
1503
+ * panels right of the current one wait just past the viewport's edge.
1504
+ * Right-click (or long-press) offers pinning, and closing just that one
1505
+ * panel — the close that splices it out of the middle when it isn't last.
1506
+ */
1507
+ drawCrumbs(): void {
1508
+ // The very same row `S.tabs` puts its tab strip in: it scrolls when the
1509
+ // stack outgrows the bar, and shows a ‹ / › over whichever end still has
1510
+ // crumbs to reach — which a mouse can use, unlike a bare scroll area.
1511
+ scrollStrip({
1512
+ attrs: ".s-crumbs role=navigation aria-label=Breadcrumbs",
1513
+ content: () => {
1514
+ A(() => {
1515
+ const paths = this.panels.map((p) => p.path);
1516
+ const focus = this.currentPanelIndex;
1517
+ let currentEl: HTMLElement | undefined;
1518
+ for (let i = 0; i < paths.length; i++) {
1519
+ if (i) sepIcon({ size: "0.85em", attrs: ".s-crumb-sep" });
1520
+ const el = this.drawCrumb(paths[i], i, i === focus);
1521
+ if (i === focus) currentEl = el;
1522
+ }
1523
+ // Keep the panel you are on in view once this pass has been laid out.
1524
+ requestAnimationFrame(() => { if (currentEl) revealInStrip(currentEl); });
1525
+ });
1526
+ },
1527
+ });
1528
+ }
1529
+
1530
+ private drawCrumb(path: string, index: number, current: boolean): HTMLElement {
1531
+ // Safe to close over: the crumb list is rebuilt whenever the stack (or
1532
+ // its focus) changes, so this entry is `paths[index]`'s for the crumb's
1533
+ // whole life.
1534
+ const entry = this.$state.live[index];
1535
+ // A real link, for the panel it names — so it has an address to hover, to
1536
+ // middle-click, to copy. No click handling of its own: the shell's link
1537
+ // handling already makes any link to an open panel the focus move a crumb
1538
+ // should be (see `navigate`). The panel you are on is a span: nowhere to go.
1539
+ return A(current ? "span.s-crumb aria-current=page" : "a.s-crumb", () => {
1540
+ if (!current) A("href=", path);
1541
+ // Bold = on screen right now, so the stack also says which of its panels
1542
+ // are the visible columns — not just which one is current.
1543
+ A(() => { if (entry?.$panel.visible) A(".s-crumb-on"); });
1544
+ // The ● of unsaved work — the mark that nothing can close this panel —
1545
+ // and the pin of a panel that navigation elsewhere won't close.
1546
+ A(() => { if (entry?.$panel.unsaved) dotIcon({ size: "0.45em", attrs: ".s-crumb-unsaved" }); });
1547
+ A(() => { if (entry?.$panel.pinned) pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
1548
+ // `||`, not `??`: the root path's last segment is the empty string.
1549
+ A(() => { A("#", entry?.$panel.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
1550
+ // Taking over right-click means taking the browser's link menu away, so
1551
+ // the two entries anyone actually reaches for on a link come first,
1552
+ // where that menu would have had them, and the shell's own verbs sit
1553
+ // below the rule.
1554
+ addContextMenu({ items: [
1555
+ {
1556
+ // A real new tab, so it arrives cold and builds its own stack
1557
+ // from the path — exactly what the same link middle-clicked does.
1558
+ label: "Open in new tab",
1559
+ icon: newTabIcon,
1560
+ click: () => { window.open(path, "_blank", "noopener"); },
1561
+ },
1562
+ {
1563
+ label: "Copy link",
1564
+ icon: linkIcon,
1565
+ click: () => void copyLink(path),
1566
+ },
1567
+ { separator: true },
1568
+ {
1569
+ label: () => { A(() => { A("#", entry?.$panel.pinned ? "Unpin" : "Pin"); }); },
1570
+ icon: () => { A(() => { (entry?.$panel.pinned ? pinOffIcon : pinIcon)(); }); },
1571
+ click: () => { if (entry) this.togglePin(entry); },
1572
+ },
1573
+ {
1574
+ label: "Close",
1575
+ icon: closeIcon,
1576
+ // Greyed out while the panel holds unsaved work: nothing may
1577
+ // close it (`closePath` would refuse anyway). Read here, in the
1578
+ // crumb's own scope, so the flag flipping redraws the crumb —
1579
+ // the ● above and this menu entry stay one truth.
1580
+ disabled: entry?.$panel.unsaved === true,
1581
+ click: () => void this.closePath(path),
1582
+ },
1583
+ ] });
1584
+ }) as HTMLElement;
1585
+ }
1586
+
1587
+ /**
1588
+ * Flip a panel's pin (see {@link Panel.pinned}). The flag lives on the panel;
1589
+ * the current history entry's snapshot is rewritten too, so a reload keeps
1590
+ * the pin — a same-panel state tweak, which the router applies unguarded.
1591
+ */
1592
+ private togglePin(entry: PanelEntry): void {
1593
+ entry.$panel.pinned = !entry.$panel.pinned || undefined;
1594
+ route.current.state.pinned = this.pinnedPaths();
1595
+ }
1596
+
845
1597
  // ── document.title ─────────────────────────────────────────────────────
846
1598
 
847
- /** `"<page title> · <app title>"`, kept in sync with the top panel. */
1599
+ /**
1600
+ * `"<panel title> · <app title>"`, kept in sync with the current panel — and
1601
+ * prefixed `"• "` while *any* open panel holds unsaved work, the way editors
1602
+ * mark a dirty document. Any panel, not just the current one: the risk of
1603
+ * losing the work is tab-wide, so the mark on the tab is too.
1604
+ */
848
1605
  private watchTitle(): void {
849
1606
  const original = document.title;
850
1607
  A(() => {
851
- const entry = this.byId.get(this.$state.topId);
852
- const pageTitle = entry?.$page.title;
1608
+ const entry = this.$state.live[this.$state.focus];
1609
+ const panelTitle = entry?.$panel.title ?? entry?.$ui.fallback;
853
1610
  const appTitle = typeof this.opts.title === "string" ? this.opts.title : undefined;
854
- const title = pageTitle && appTitle ? `${pageTitle} · ${appTitle}` : pageTitle || appTitle;
855
- if (title) document.title = title;
1611
+ // Reading the stack re-runs this when its shape changes; each panel's
1612
+ // own `unsaved` (up to the first dirty one) does the rest.
1613
+ const dirty = this.$state.live.some((e) => e.$panel.unsaved);
1614
+ const title = panelTitle && appTitle ? `${panelTitle} · ${appTitle}` : panelTitle || appTitle;
1615
+ if (title) document.title = (dirty ? "• " : "") + title;
856
1616
  });
857
1617
  A.clean(() => { document.title = original; });
858
1618
  }
859
1619
 
1620
+ /**
1621
+ * While any open panel holds unsaved work, closing the tab — or navigating
1622
+ * the whole browser away — runs into the browser's own are-you-sure. When
1623
+ * the user stays, the unsaved panel is brought back on screen if it wasn't,
1624
+ * so what held the tab is in front of them rather than parked out of sight.
1625
+ */
1626
+ private guardTabClose(): void {
1627
+ if (typeof window === "undefined") return;
1628
+ let leaving = false;
1629
+ const onHide = () => { leaving = true; };
1630
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
1631
+ // Being asked again means we weren't gone after all (a bfcache restore).
1632
+ leaving = false;
1633
+ const dirty = this.$state.live.find((entry) => entry.$panel.unsaved);
1634
+ if (!dirty) return;
1635
+ e.preventDefault();
1636
+ e.returnValue = true; // Chrome/Edge < 119
1637
+ // This task only ever amounts to anything if the user cancels: a
1638
+ // confirmed leave unloads the document (`pagehide`) first.
1639
+ const path = dirty.path;
1640
+ setTimeout(() => {
1641
+ if (leaving) return;
1642
+ const entry = this.$state.live.find((live) => live.path === path);
1643
+ if (entry && !entry.$panel.visible) void this.focusAt(this.intended().stack.indexOf(path));
1644
+ }, 0);
1645
+ };
1646
+ // Registered only while a panel actually holds unsaved work: a page with a
1647
+ // `beforeunload` listener is shut out of the browser's back/forward cache,
1648
+ // and that is a tax every navigation in the app would otherwise pay — for a
1649
+ // guard that almost never has anything to guard.
1650
+ A(() => {
1651
+ if (!this.$state.live.some((entry) => entry.$panel.unsaved)) return;
1652
+ window.addEventListener("beforeunload", onBeforeUnload);
1653
+ window.addEventListener("pagehide", onHide);
1654
+ A.clean(() => {
1655
+ window.removeEventListener("beforeunload", onBeforeUnload);
1656
+ window.removeEventListener("pagehide", onHide);
1657
+ });
1658
+ });
1659
+ }
1660
+
860
1661
  // ── Rendering ──────────────────────────────────────────────────────────
861
1662
 
862
1663
  /**
863
- * Draw the panel viewport into the current element. Called by `main()`.
1664
+ * Draw the column viewport into the current element. Called by `main()`.
864
1665
  *
865
- * There is deliberately no close chrome here — no back rail, no ←: pages
866
- * provide their own way out (see {@link Page.close} and `S.box`'s `close`
867
- * option). The shell contributes Escape and the browser's own back button.
1666
+ * A column is the panel's own content, plus the one bit of chrome the shell
1667
+ * places for it: its {@link Panel.actions}, in a strip on wide shells and in
1668
+ * the top bar on narrow ones (see {@link drawActions}).
868
1669
  */
869
- drawStack(): void {
1670
+ drawColumns(): void {
870
1671
  const container = A("div.s-panels role=main", () => {
871
1672
  // Published before the first panel draws, rather than from the return
872
1673
  // value below: a panel sizes itself from the shell's measurements (see
873
1674
  // `measure`), and the first ones do that while this very call is still
874
1675
  // running. `A()` without arguments is "the element we're in".
875
1676
  this.containerEl = A() as HTMLElement;
1677
+ // Mounted and unmounted by path (see `$open`); a panel's DOM position
1678
+ // among its siblings is its creation order, which is all the layering
1679
+ // needs — `layout()` places and stacks the columns itself.
876
1680
  A.onEach(
877
- this.$ids,
878
- (_order, id) => this.drawPanel(Number(id)),
879
- (order, id) => [order, Number(id)],
1681
+ this.$open,
1682
+ (entry) => this.drawPanel(entry),
1683
+ (entry) => entry.order,
880
1684
  );
881
1685
  }) as HTMLElement;
882
1686
 
@@ -893,45 +1697,58 @@ export class PanelController {
893
1697
  this.scheduleLayout();
894
1698
  }
895
1699
 
896
- private drawPanel(id: number): void {
897
- const entry = this.byId.get(id);
898
- if (!entry) return;
1700
+ private drawPanel(entry: PanelEntry): void {
899
1701
  let el: HTMLElement | undefined;
900
1702
 
901
1703
  // How much room the panel wants, resolved *before* its content is drawn: an
902
1704
  // element that arrives without a width has no box for its content to measure
903
1705
  // itself against until the next frame's layout pass, which is a frame too
904
1706
  // late for anything that sizes itself from its container. So the panel is
905
- // created at the width the window gives its layout — "medium" until the page
906
- // says otherwise. Reactively, too: a page that changes its mind later (when
1707
+ // created at the width the window gives it — the "full" width until the panel
1708
+ // says otherwise. Reactively, too: a panel that changes its mind later (when
907
1709
  // its data arrives, say) reflows in place rather than being redrawn, and the
908
1710
  // columns beside it slide over to make room.
909
1711
  A(() => {
910
- const asked = entry.$page.layout;
911
- entry.layout = asked === "small" || asked === "large" ? asked : "medium";
912
- const width = this.roomFor(entry.layout);
1712
+ const asked = entry.$panel.maxWidth;
1713
+ entry.maxWidth = asked === "half" || asked === "screen" ? asked : "full";
1714
+ const width = this.roomFor(entry.maxWidth);
913
1715
  if (!width) return;
914
1716
  entry.width = width;
1717
+ // Published to the panel too, so a handler can read the box it is about
1718
+ // to draw into without measuring it.
1719
+ if (A.peek(entry.$panel, "width") !== width) entry.$panel.width = width;
915
1720
  // The first run has no element to put it on yet — it's created with this
916
- // width, just below. Later runs are the page changing its layout.
1721
+ // width, just below. Later runs are the panel changing its mind.
917
1722
  if (!el) return;
918
1723
  el.style.width = `${width}px`;
919
1724
  this.scheduleLayout();
920
1725
  });
921
1726
 
922
1727
  el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node: HTMLElement) => this.playExit(entry, node), () => {
923
- const contentEl = A("div.s-content", () => {
924
- entry.draw(entry.$page);
1728
+ // The actions strip, in its own scope: chrome may redraw freely — when
1729
+ // the panel changes its actions, when the shell crosses the narrow
1730
+ // threshold — but the body below never may.
1731
+ A(() => this.drawActions(entry));
1732
+
1733
+ A("div.s-content", () => {
1734
+ entry.draw(entry.$panel);
925
1735
  // After the content, so there is something to scroll when restoring.
926
1736
  route.persistScroll(entry.path);
927
- }) as HTMLElement;
928
- watchVerticalOverflow(contentEl);
1737
+ // A panel that named itself is done; one that didn't lends its
1738
+ // first line of text (the DOM is already built — Aberdeen draws
1739
+ // synchronously) to the crumbs and document.title, so neither
1740
+ // ever goes blank. Peeked: a rename must not redraw the panel.
1741
+ if (A.peek(entry.$panel, "title") == null) {
1742
+ const text = firstText(A() as HTMLElement);
1743
+ if (text && A.peek(entry.$ui, "fallback") !== text) entry.$ui.fallback = text;
1744
+ }
1745
+ });
929
1746
 
930
1747
  // The loading hint, in its own scope so flipping the flag doesn't
931
1748
  // redraw the panel's content. Held-back panels show nothing yet: they
932
1749
  // are still parked off screen, waiting to slide in with real content.
933
1750
  A(() => {
934
- if (!entry.$page.loading || entry.$ui.holding) return;
1751
+ if (!entry.$panel.loading || entry.$ui.holding) return;
935
1752
  A("div.s-panel-loading aria-hidden=true", () => { A("i"); A("i"); A("i"); });
936
1753
  });
937
1754
  }) as HTMLElement;
@@ -948,13 +1765,25 @@ export class PanelController {
948
1765
 
949
1766
  // A held-back panel that finishes loading gets to play its enter animation.
950
1767
  A(() => {
951
- void entry.$page.loading;
1768
+ void entry.$panel.loading;
952
1769
  this.scheduleLayout();
953
1770
  });
954
1771
 
955
1772
  this.scheduleLayout();
956
1773
  }
957
1774
 
1775
+ /**
1776
+ * The one bit of column chrome the shell draws: the panel's actions, in a
1777
+ * quiet strip above the scroll area — and only while the shell is wide, the
1778
+ * top bar carrying them otherwise. Everything else in a column is the panel's
1779
+ * own content: a screen that wants a heading or a card draws them itself.
1780
+ * Going back isn't here either — that is the breadcrumbs' job, in the bar.
1781
+ */
1782
+ private drawActions(entry: PanelEntry): void {
1783
+ if (this.opts.$shell.narrow || entry.$panel.actions == null) return;
1784
+ A("div.s-panel-actions", () => drawSlot(entry.$panel.actions));
1785
+ }
1786
+
958
1787
  // ── Layout engine ──────────────────────────────────────────────────────
959
1788
 
960
1789
  scheduleLayout(): void {
@@ -968,7 +1797,7 @@ export class PanelController {
968
1797
 
969
1798
  /**
970
1799
  * Measure the shell, and with it the width the window gives a panel of each
971
- * layout. Measured on the *shell*, not on the panel region: the region's width
1800
+ * layout. Measured on the *shell*, not on the column region: the region's width
972
1801
  * is the layout engine's own output, so reading it back would nail the layout
973
1802
  * to whatever it happened to be a frame ago. Fractional widths throughout — a
974
1803
  * rounded column edge would drift a pixel away from the chrome above it.
@@ -991,24 +1820,24 @@ export class PanelController {
991
1820
  if (child !== container) chrome += child.getBoundingClientRect().width;
992
1821
  }
993
1822
 
994
- // The standard page is SHELL_PX wide, capped by the window; what it leaves
1823
+ // The standard panel is SHELL_PX wide, capped by the window; what it leaves
995
1824
  // beside the sidebar is the *standard* content area. Widths are a pure
996
1825
  // function of the window — never of what else is open — so a panel NEVER
997
1826
  // resizes because a neighbour came or went; only a window resize (the
998
1827
  // snap pass in `layout`) changes them:
999
- // - "medium" fills the standard content area exactly;
1000
- // - "small" is half of it (minus the gutter) whenever that half is still
1001
- // a usable column, and the whole of it on narrower screens;
1002
- // - "large" ignores the standard width and takes everything the window
1828
+ // - "full" fills the standard content area exactly;
1829
+ // - "half" is half of it whenever that half is still a usable column, and
1830
+ // the whole of it on narrower screens;
1831
+ // - "screen" ignores the standard width and takes everything the window
1003
1832
  // has — which also means nothing ever fits beside it.
1004
- const medium = Math.max(0, Math.min(SHELL_PX, total) - chrome);
1005
- const half = (medium - GUTTER_PX) / 2;
1833
+ const full = Math.max(0, Math.min(SHELL_PX, total) - chrome);
1834
+ const halved = full / 2;
1006
1835
  return {
1007
1836
  total,
1008
1837
  chrome,
1009
- small: half >= PAIR_MIN_PX ? half : medium,
1010
- medium,
1011
- large: Math.max(0, total - chrome),
1838
+ half: halved >= PAIR_MIN_PX ? halved : full,
1839
+ full,
1840
+ screen: Math.max(0, total - chrome),
1012
1841
  };
1013
1842
  }
1014
1843
 
@@ -1022,9 +1851,9 @@ export class PanelController {
1022
1851
  return (this.geom ??= this.measure());
1023
1852
  }
1024
1853
 
1025
- /** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
1026
- private roomFor(layout: PanelEntry["layout"]): number {
1027
- return this.geometry()?.[layout] ?? 0;
1854
+ /** How wide a panel asking for this is, right now; 0 while the shell can't be measured. */
1855
+ private roomFor(maxWidth: PanelEntry["maxWidth"]): number {
1856
+ return this.geometry()?.[maxWidth] ?? 0;
1028
1857
  }
1029
1858
 
1030
1859
  /**
@@ -1039,11 +1868,15 @@ export class PanelController {
1039
1868
  const container = this.containerEl;
1040
1869
  const shell = container?.closest<HTMLElement>(".s-main");
1041
1870
  if (!container || !shell) return;
1042
- const n = this.live.length;
1871
+ // This pass always runs from a fresh frame (rAF, a ResizeObserver) — no
1872
+ // reactive scope is active, so the stack and the panels are read plainly:
1873
+ // nothing here can subscribe to anything.
1874
+ const live = this.$state.live;
1875
+ const n = live.length;
1043
1876
  // A panel that hasn't drawn yet has no width to contribute, which would make
1044
1877
  // this pass's arithmetic (and any enter animation it triggers) meaningless.
1045
1878
  // Every mount schedules another pass, so simply wait for it.
1046
- if (!n || this.live.some((entry) => !entry.el)) return;
1879
+ if (!n || live.some((entry) => !entry.el)) return;
1047
1880
 
1048
1881
  // This pass measures afresh — it is the one thing that runs after a resize.
1049
1882
  this.geom = undefined;
@@ -1062,33 +1895,35 @@ export class PanelController {
1062
1895
  shell.classList.add("s-shell-snap");
1063
1896
  }
1064
1897
 
1065
- const width = (entry: PanelEntry) => geom[entry.layout];
1898
+ const width = (entry: PanelEntry) => geom[entry.maxWidth];
1066
1899
 
1067
- // The visible run: as many top-of-stack panels as the window fits, at the
1068
- // sizes the window gives them. The top panel always shows.
1069
- let first = n - 1;
1070
- let runSum = width(this.live[first]);
1900
+ // The visible run: as many columns as the window fits, at the sizes the
1901
+ // window gives them, ending at the current panel — which always shows.
1902
+ // Panels beyond it are parked past the right edge (see phase 1).
1903
+ const cur = Math.min(this.$state.focus, n - 1);
1904
+ let first = cur;
1905
+ let runSum = width(live[cur]);
1071
1906
  if (stacking) {
1072
- for (let i = n - 2; i >= 0; i--) {
1073
- const sum = runSum + GUTTER_PX + width(this.live[i]);
1074
- if (sum > geom.large) break;
1907
+ for (let i = cur - 1; i >= 0; i--) {
1908
+ const sum = runSum + width(live[i]);
1909
+ if (sum > geom.screen) break;
1075
1910
  runSum = sum;
1076
1911
  first = i;
1077
1912
  }
1078
1913
  }
1079
1914
 
1080
1915
  // The content area holds the run, but is never smaller than the standard
1081
- // page (a lone small leaves its other half open — which is exactly where
1916
+ // panel (a lone small leaves its other half open — which is exactly where
1082
1917
  // the next small lands, without anything on screen moving) and never
1083
- // wider than the window. So the page is the familiar 1280px until extra
1918
+ // wider than the window. So the panel is the familiar 1280px until extra
1084
1919
  // columns genuinely fit, and stretches — centred — to hold the ones that
1085
- // do; with a "large" up that's the window's edges.
1086
- const area = Math.min(geom.large, Math.max(geom.medium, runSum));
1920
+ // do; with a "screen" up that's the window's edges.
1921
+ const area = Math.min(geom.screen, Math.max(geom.full, runSum));
1087
1922
 
1088
- for (let i = first; i < n; i++) this.live[i].width = width(this.live[i]);
1923
+ for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
1089
1924
  // Panels that have never been visible get their would-be width too, so a
1090
1925
  // reveal doesn't start from nothing.
1091
- for (const entry of this.live) {
1926
+ for (const entry of live) {
1092
1927
  if (!entry.width) entry.width = width(entry);
1093
1928
  }
1094
1929
 
@@ -1106,25 +1941,33 @@ export class PanelController {
1106
1941
  const fresh: PanelEntry[] = [];
1107
1942
  let x = 0;
1108
1943
  for (let i = 0; i < n; i++) {
1109
- const entry = this.live[i];
1944
+ const entry = live[i];
1110
1945
  const el = entry.el!;
1111
- const shown = i >= first;
1112
- // Visible panels are left-aligned in the content area, a gutter apart;
1113
- // hidden ones park at its left edge, keeping their last width. Deeper
1114
- // panels layer over shallower ones, each on the odd layer for its depth
1115
- // (see LAYER_STEP).
1116
- place(el, shown ? x : 0, entry.width, LAYER_STEP * i + 1);
1117
- if (shown) x += entry.width + GUTTER_PX;
1946
+ const shown = i >= first && i <= cur;
1947
+ // Visible columns tile the content area, left to right. Panels crowded
1948
+ // out from under the run rest at its left edge; panels beyond the
1949
+ // current panel park just past its right edge — both keep their last
1950
+ // width. Deeper panels layer over shallower ones, each on the odd
1951
+ // layer for its depth (see LAYER_STEP).
1952
+ place(el, shown ? x : i > cur ? area : 0, entry.width, LAYER_STEP * i + 1);
1953
+ // What `$panel.visible` and `$panel.width` report: this pass is the one
1954
+ // thing that knows them, window resizes included. Written only on a
1955
+ // change, so per-panel UI hanging off them isn't rebuilt by every pass.
1956
+ if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
1957
+ if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
1958
+ if (shown) x += entry.width;
1118
1959
  el.classList.toggle("s-panel-sep", shown && i > first);
1119
- // Hidden panels fade out over the left edge and, once faded, stop being
1120
- // rendered at all — but they keep their DOM, and their scroll position.
1121
- el.classList.toggle("s-panel-hidden", !shown);
1960
+ // Off-screen panels fade out over the edge they park at and, once
1961
+ // faded, stop being rendered at all — but they keep their DOM, and
1962
+ // their scroll position.
1963
+ el.classList.toggle("s-panel-hidden", i < first);
1964
+ el.classList.toggle("s-panel-parked", i > cur);
1122
1965
  el.toggleAttribute("inert", !shown);
1123
1966
  if (entry.placed) continue;
1124
1967
  fresh.push(entry);
1125
1968
  // A panel that mounts while still fetching holds here for a moment, so
1126
1969
  // it can enter with real content instead of an empty column.
1127
- if (!A.peek(entry.$page, "loading") || entry.holdDone) entry.$ui.holding = false;
1970
+ if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
1128
1971
  else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
1129
1972
  // Already at its resting place; the enter animation is the offset (and
1130
1973
  // the transparency) it starts from, one edge to the right.
@@ -1167,126 +2010,40 @@ function place(el: HTMLElement, x: number, width: number, z: number): void {
1167
2010
  el.style.zIndex = String(z);
1168
2011
  }
1169
2012
 
1170
- // ─── Guards ──────────────────────────────────────────────────────────────────
1171
-
1172
- /**
1173
- * Ask every panel being removed, deepest first, whether it may go. Returns a
1174
- * plain boolean when no guard needs awaiting, so the common case stays
1175
- * synchronous (and screenshots stay deterministic).
1176
- */
1177
- function runGuards(removed: PanelEntry[]): boolean | Promise<boolean> {
1178
- const list = [...removed].reverse();
1179
- let i = 0;
1180
- const step = (): boolean | Promise<boolean> => {
1181
- while (i < list.length) {
1182
- const guard = A.peek(list[i++].$page, "requestClose");
1183
- if (!guard) continue;
1184
- let verdict: boolean | Promise<boolean>;
1185
- try {
1186
- verdict = guard();
1187
- } catch (e) {
1188
- console.error(e);
1189
- return false;
1190
- }
1191
- if (verdict === false) return false;
1192
- if (verdict !== true) return Promise.resolve(verdict).then((ok) => (ok === false ? false : step()));
1193
- }
1194
- return true;
1195
- };
1196
- return step();
1197
- }
1198
-
1199
2013
  // ─── Helpers ─────────────────────────────────────────────────────────────────
1200
2014
 
1201
2015
  function sameStack(a: string[], b: string[]): boolean {
1202
2016
  return a.length === b.length && a.every((v, i) => v === b[i]);
1203
2017
  }
1204
2018
 
1205
- function drawDefaultNotFound($page: Page<{}>): void {
1206
- A("p fg:$s-muted", () => A("#", `No page at ${$page.path}`));
1207
- }
1208
-
1209
2019
  /**
1210
- * Toggle `.s-scroll-y` on `el` whenever a vertical scrollbar is eating into its
1211
- * width, so CSS can inset the bar from the panel's edge. Same trick (and the
1212
- * same reasoning) as content mode's `watchVerticalOverflow` in main.ts.
2020
+ * The first non-empty text inside `el`, trimmed and capped at a name-like
2021
+ * length — the stand-in title for a panel that never set one.
1213
2022
  */
1214
- function watchVerticalOverflow(el: HTMLElement): void {
1215
- if (typeof ResizeObserver === "undefined") return;
1216
- const update = () => el.classList.toggle("s-scroll-y", el.offsetWidth > el.clientWidth);
1217
- const ro = new ResizeObserver(update);
1218
- ro.observe(el);
1219
- if (el.firstElementChild) ro.observe(el.firstElementChild);
1220
- update();
1221
- A.clean(() => ro.disconnect());
2023
+ function firstText(el: HTMLElement): string | undefined {
2024
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
2025
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
2026
+ const t = n.textContent!.trim();
2027
+ if (t) return t.length > 48 ? `${t.slice(0, 47).trimEnd()}…` : t;
2028
+ }
1222
2029
  }
1223
2030
 
1224
- // ─── Public helpers ──────────────────────────────────────────────────────────
1225
-
1226
2031
  /**
1227
- * Navigating the routed `S.main()` shell from code, for the times it isn't a
1228
- * link click, such as opening the screen for a record you just created.
1229
- *
1230
- * The same rules as a link click apply: pushing a path that is already open
1231
- * goes back to it rather than opening it twice, and anything that would close a
1232
- * panel asks its {@link Page.requestClose} first.
1233
- *
1234
- * @example
1235
- * ```ts
1236
- * S.button({ content: "New task", click: async () => {
1237
- * const task = await createTask();
1238
- * S.panels.push(`/tasks/${task.id}`);
1239
- * }});
1240
- * ```
2032
+ * Put a panel's address on the clipboard, as the absolute URL someone can paste
2033
+ * anywhere — which is what the browser's own "Copy link" would have given them.
2034
+ * Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
2035
+ * needs a secure context, so a failure says so rather than lying.
1241
2036
  */
1242
- export const panels = {
1243
- /** Opens `path` in a new panel on top of the top one. */
1244
- push(path: string): void {
1245
- requireActive().pushPath(path, false);
1246
- },
1247
- /**
1248
- * Opens `path` in place of the top panel, which closes (asking its
1249
- * {@link Page.requestClose} first). The panels beneath it stay as they are.
1250
- */
1251
- replace(path: string): void {
1252
- requireActive().pushPath(path, true);
1253
- },
1254
- /**
1255
- * Closes the top panel, or, given a `path`, whichever panel is open at it,
1256
- * asking {@link Page.requestClose} first. A panel that isn't on top is taken
1257
- * out on its own, leaving the columns to its right exactly as they are.
1258
- *
1259
- * Resolves `false` if the panel didn't close: `requestClose` said no, `path`
1260
- * isn't open, or another navigation got there first.
1261
- */
1262
- close(path?: string): Promise<boolean> {
1263
- const ctl = requireActive();
1264
- return path == null ? ctl.closeTop() : ctl.closeByPath(path);
1265
- },
1266
- /** The paths of the open panels, oldest first. Reactive: safe to read in a scope. */
1267
- get stack(): readonly string[] {
1268
- return active ? active.$state.paths : [];
1269
- },
1270
- };
1271
-
1272
- function requireActive(): PanelController {
1273
- if (!active) throw new Error("Staffa: S.panels needs a routed S.main() (one with `routes`) to be mounted");
1274
- return active;
2037
+ async function copyLink(path: string): Promise<void> {
2038
+ const url = new URL(path, location.href).href;
2039
+ try {
2040
+ await navigator.clipboard.writeText(url);
2041
+ toast({ message: "Link copied." });
2042
+ } catch {
2043
+ toast({ message: "Couldn't copy the link.", type: "danger" });
2044
+ }
1275
2045
  }
1276
2046
 
1277
- /**
1278
- * Closes the panel `el` sits in, working out which one that is from the DOM.
1279
- * That is what lets a close button work without being handed a `$page`, from
1280
- * any column, whether or not it is on top. Used by `S.box`'s `close: true`.
1281
- *
1282
- * Outside a routed shell (or outside any panel, such as a box in a dialog) there is
1283
- * nothing to close: it warns and resolves `false`.
1284
- */
1285
- export function closeContainingPanel(el: Element | null | undefined): Promise<boolean> {
1286
- const panelEl = el?.closest<HTMLElement>(".s-panel");
1287
- if (!active || !panelEl) {
1288
- console.warn("Staffa: `close: true` needs to be drawn inside a panel of a routed S.main()");
1289
- return Promise.resolve(false);
1290
- }
1291
- return active.closePanelEl(panelEl);
2047
+ function drawDefaultNotFound($panel: Panel<{}>): void {
2048
+ A("p fg:$s-muted", () => A("#", `No panel at ${$panel.path}`));
1292
2049
  }