staffa 0.13.0 → 0.15.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.
@@ -2,32 +2,35 @@ import A, { OPAQUE } from "aberdeen";
2
2
  import * as route from "aberdeen/route";
3
3
  import { type Slot, drawSlot } from "../core.js";
4
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,
5
+ circle as dotIcon, pin as pinIcon, pinOff as pinOffIcon,
6
+ slash as sepIcon, x as closeIcon,
7
7
  } from "../icons.js";
8
- import { SURFACE_SHEEN } from "../theme.js";
8
+ import { PANEL_SHEEN } from "../theme.js";
9
9
  import { addContextMenu } from "./menu.js";
10
10
  import { scrollStrip, revealInStrip } from "./tabs.js";
11
- import { toast } from "./toast.js";
12
11
 
13
12
  /**
14
13
  * Routed, multi-column panel navigation for {@link main}.
15
14
  *
16
15
  * 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
16
+ * form a **stack**, whose last panel is the **current** one: the panel the URL
18
17
  * names, and the rightmost column on screen. As many panels as fit are shown,
19
18
  * ending at the current one — on a phone that is one at a time, on a wider
20
19
  * screen the panels that would have covered each other sit side by side
21
20
  * instead. The app's own code is the same either way.
22
21
  *
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.
22
+ * Whatever a navigation lands on becomes the top of that stack, and the same
23
+ * path is never in it twice. Opening a new panel closes everything after the
24
+ * panel it came from; a plain link to a panel that is already open — a
25
+ * breadcrumb, a nav item for the section you are in — returns to its own
26
+ * place, closing whatever was stacked on top, while a `replace` or `open`
27
+ * applies its usual shape, the open panel moving into it alive.
28
+ * Two kinds of panel survive that: the ones the user pinned, which ride along,
29
+ * and the ones holding unsaved work, which no navigation ever tears down.
30
+ * Those wait parked out of sight past the rightmost column, which is the only
31
+ * way a panel ever sits *after* the current one. Escape closes the current
32
+ * panel — or just steps left, when it holds unsaved work or panels sit parked
33
+ * beyond it.
31
34
  *
32
35
  * Navigation runs through `aberdeen/route`: the URL holds the current panel,
33
36
  * and the rest of the arrangement — the panels before it, the ones parked
@@ -101,6 +104,15 @@ export type AncestorTable<R> = {
101
104
 
102
105
  // ─── The Panel object ─────────────────────────────────────────────────────────
103
106
 
107
+ /**
108
+ * How wide a panel asks to be — a ceiling the shell never exceeds; see
109
+ * {@link Panel.maxWidth}. `"small"` is the column the content area is divided
110
+ * into; `"medium"` and `"large"` are two and three of those, and `"none"` is
111
+ * the whole area. Each is capped at the content area, so on a narrow window
112
+ * they all come to the same thing.
113
+ */
114
+ export type PanelSize = "small" | "medium" | "large" | "none";
115
+
104
116
  /**
105
117
  * What a route handler gets: the params from its route, plus everything the
106
118
  * shell needs to know about the panel it is drawing. It's an Aberdeen proxy, so
@@ -171,35 +183,39 @@ export interface Panel<P = Record<string, string | number | string[]>> {
171
183
  */
172
184
  readonly visible: boolean;
173
185
  /**
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.
186
+ * The widest this panel can usefully be — a ceiling the shell never
187
+ * exceeds, so the draw function never has to look right past it. It is
188
+ * counted in the shell's *columns*: the content area divides into the
189
+ * narrowest whole number of columns of at least 360px each (three columns
190
+ * of 360 in a 1080px area, four of 380 in 1520px), and a column is never
191
+ * wider than 540 — where the area holds just one, a small centres in it
192
+ * rather than stretching. So an ask never exceeds its column count × 540:
177
193
  *
178
- * - `"half"` — nothing more. Half the content area (360px up to half of
179
- * {@link MainOptions.fullWidth}), so a second column fits beside it. For
180
- * lists and detail forms.
181
- * - `"full"` (the default) — the whole content area, which is exactly
182
- * {@link MainOptions.fullWidth}: 1080px unless the app says otherwise.
183
- * - `"screen"` — the whole window, unbounded: boards, wide tables, dense
184
- * dashboards. While one is open the columns stretch to the screen edges
185
- * instead of stopping at the standard page; the top bar and footer hold
186
- * the standard width throughout.
194
+ * - `"small"` — one column, never above 540px: lists, detail forms.
195
+ * - `"medium"` (the default) — two columns, never above 1080px.
196
+ * - `"large"` — three columns, never above 1620px: wide tables.
197
+ * - `"none"` — the whole content area, unbounded: boards, dashboards.
198
+ * Bound it with the shell's own `maxWidth` where that matters.
187
199
  *
188
- * Below the width two columns need, everything takes the content area
189
- * whatever it asked for. Widths depend only on the window, never on what
190
- * else is open, so opening or closing a panel never resizes another.
200
+ * Every size is capped at the content area, so on a phone they all come to
201
+ * the same thing: one screen at a time. And a width depends only on the
202
+ * window, never on what else is open, so opening or closing a panel never
203
+ * resizes another — the run of columns just recentres in the area.
191
204
  *
192
- * This is a *layout regime*, not a width guarantee: handle whatever width
193
- * the bucket yields, and ask only for what your content can actually use —
194
- * a screen that would cap its own content narrower than its ask is holding
195
- * room that would have let another column fit beside it.
205
+ * The ask is a ceiling only; there is no matching floor, since the window can
206
+ * be any width. Aim your layout at 360px — about the narrowest phone still in
207
+ * common use — and let it degrade gracefully below that.
208
+ *
209
+ * Ask only for what your content can actually use: a panel that would cap
210
+ * its own content narrower than its ask is holding room that would have
211
+ * let another column fit beside it.
196
212
  *
197
213
  * Set it at the top of your handler and the panel is already that wide when
198
214
  * you draw (see {@link Panel.width}); set it later — when your data tells you
199
215
  * — and the panel reflows without being redrawn, keeping its state, while
200
216
  * the columns beside it move over.
201
217
  */
202
- maxWidth?: "half" | "full" | "screen";
218
+ maxWidth?: PanelSize;
203
219
  /**
204
220
  * Set this while you're fetching what the panel needs, and back to `false`
205
221
  * when you're done. A new panel waits a moment before sliding in, so it can
@@ -210,10 +226,10 @@ export interface Panel<P = Record<string, string | number | string[]>> {
210
226
  loading?: boolean;
211
227
  /**
212
228
  * Keeps this panel from being closed by navigation happening *elsewhere*.
213
- * Opening a new panel normally closes everything after the panel it came
214
- * from; a pinned panel survives that, staying in the stack — parked past the
215
- * right edge of the viewport — slotted in beneath the new panel, one crumb
216
- * click away. The user toggles it from the crumb's context menu
229
+ * A navigation normally closes everything after the panel it came from (or
230
+ * returned to); a pinned panel survives that, staying in the stack — slotted
231
+ * in beneath the new panel, or parked out of sight when the new panel was
232
+ * already beneath it. Either way it is one crumb click away. The user toggles it from the crumb's context menu
217
233
  * (right-click or long-press), which is also where the pin shows; setting
218
234
  * it from code does the same thing.
219
235
  *
@@ -225,12 +241,11 @@ export interface Panel<P = Record<string, string | number | string[]>> {
225
241
  /**
226
242
  * Set this while the panel holds work that must not be lost — a dirty form,
227
243
  * an upload in flight. An unsaved panel cannot be closed, by anything:
228
- * navigation that would prune it parks it instead, past the viewport's
229
- * right edge, wearing a ● in its crumb — even the browser's back button
230
- * only parks it. {@link Panel.close} and the crumb menu's Close refuse,
244
+ * navigation that would prune it parks it out of sight instead, wearing a
245
+ * ● in its crumb — even the browser's back button only parks it. {@link Panel.close} and the crumb menu's Close refuse,
231
246
  * Escape on it steps left along the stack rather than closing, and closing
232
- * the browser tab runs into the browser's own are-you-sure (after which the
233
- * shell brings the unsaved panel back on screen).
247
+ * the browser tab runs into the browser's own are-you-sure, the unsaved
248
+ * panel brought on screen as the question is raised.
234
249
  *
235
250
  * Only the app clears it; the user has no toggle. A Save or Discard button
236
251
  * clears it and then closes:
@@ -406,10 +421,9 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
406
421
 
407
422
  /**
408
423
  * The one duration every bit of shell motion shares: the enter/exit fades, the
409
- * `left` moves of columns shifting sideways, the ensemble-width transition the
410
- * body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
411
- * panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
412
- * and JS can't drift apart.
424
+ * `left` moves of columns shifting sideways, and the narrow-screen nav panel's
425
+ * slide. Published as the `--s-panel-ms` custom property below, so CSS and JS
426
+ * can't drift apart.
413
427
  *
414
428
  * Short enough to read as *the screen responded*, rather than as an animation
415
429
  * being played at you: a panel arriving is navigation, and navigation should
@@ -418,8 +432,17 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
418
432
  const PAGE_MS = 250;
419
433
  /** How long a freshly pushed `loading` panel holds its enter animation. */
420
434
  const LOADING_HOLD_MS = 300;
421
- /** Don't pair smalls when half the content area would be narrower than this. */
422
- const PAIR_MIN_PX = 360;
435
+ /**
436
+ * The bounds of a column. At least 360px — about the narrowest phone still in
437
+ * common use, so it is where a panel's layout is aimed. And at most 540: that
438
+ * is the width the multi-column regime never reaches (`area / floor(area /
439
+ * 360)` stays under it), so capping the lone column of a 540–720px area to it
440
+ * too — centred, rather than stretched — makes an ask's ceiling uniform: never
441
+ * wider than its column count × 540. A content area narrower than 360 still
442
+ * gets its single column, just narrower than the minimum.
443
+ */
444
+ const SMALL_MIN_PX = 360;
445
+ export const SMALL_MAX_PX = 540;
423
446
  /**
424
447
  * Panels are layered by their depth in the stack, two `z-index` steps per panel:
425
448
  * a panel sits on the odd layer for its depth, and a *closing* one drops to the
@@ -439,9 +462,11 @@ A.insertGlobalCss({
439
462
  // layers they stack themselves in (see LAYER_STEP) to themselves: the region
440
463
  // as a whole still sits under the shell's own chrome — the sticky top bar, and
441
464
  // the nav panel that slides across the body — however deep the stack gets.
442
- // The region paints the panel's sheen over its own box, and every panel shows
443
- // a slice of that same gradient (see `.s-panel` below), so the columns and
444
- // the ground beside them are one continuous surface.
465
+ // The region paints the columns' sheen over its own box, and every panel
466
+ // paints the very same one (see `.s-panel` below) — which, being a straight
467
+ // vertical wash over boxes of one height, comes out identical whatever a
468
+ // column's width, so the columns and the ground beside them are one
469
+ // continuous surface.
445
470
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
446
471
  // and anything that ever scrolls it — find-in-page reaching for text in a
447
472
  // parked column, an in-page anchor, an extension — shifts every column
@@ -449,7 +474,7 @@ A.insertGlobalCss({
449
474
  // `clip` clips without being scrollable at all, closing the whole class.
450
475
  ".s-panels":
451
476
  "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
452
- SURFACE_SHEEN,
477
+ PANEL_SHEEN,
453
478
  ".s-panel": {
454
479
  // A panel rests at a plain `left` offset and carries no transform: a
455
480
  // transformed element is composited, which costs it subpixel text
@@ -472,18 +497,17 @@ A.insertGlobalCss({
472
497
  //
473
498
  // Every panel paints an opaque ground, because panels animate over one
474
499
  // another — entering, leaving, being crowded out — and two transparent ones
475
- // mean text sliding over text. It takes the panel's own sheen, the one
476
- // `.s-s, body` paints in theme.ts, resolved here against the inherited
477
- // `--s-bg` (a panel is not a surface, so it has to paint it itself).
500
+ // mean text sliding over text. It takes {@link PANEL_SHEEN}, resolved here
501
+ // against the inherited `--s-bg` (a panel is not a surface, so it has to
502
+ // paint it itself).
478
503
  //
479
- // Painted per panel, over the panel's own box, which is as good as it
480
- // needs to be: the sheen is a 9%-either-way wash over a whole column, so
481
- // two columns' worth of it meeting at a hairline is not something the eye
482
- // picks out. The region (`.s-panels` above) paints the same wash, so the
483
- // ground beside a lone column matches it just as closely.
504
+ // Painted per panel, over the panel's own box — and yet seamless with its
505
+ // neighbours and with the ground beside them, because that wash runs
506
+ // straight down: it takes its extent from the height these boxes all share,
507
+ // never from their differing widths. See PANEL_SHEEN for why that matters.
484
508
  "&":
485
509
  "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
486
- SURFACE_SHEEN + " " +
510
+ PANEL_SHEEN + " " +
487
511
  "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
488
512
  // The hairline between two columns, fading out at both ends — the same
489
513
  // treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
@@ -657,39 +681,34 @@ interface PanelEntry {
657
681
  /** Whether its `loading` hold has already expired, so it can't hold again. */
658
682
  holdDone?: boolean;
659
683
  /** What the panel asks for, kept in step with its `$panel.maxWidth`. */
660
- maxWidth: "half" | "full" | "screen";
684
+ maxWidth: PanelSize;
661
685
  /**
662
686
  * The width it was last laid out at. Set before the panel's content is first
663
687
  * drawn, so that content has a real box to measure itself against. Visible
664
- * panels get a fresh value every pass (widths are a pure function of the
665
- * content area and small-pairing); hidden and closing panels keep this, so
666
- * nothing invisible ever reflows.
688
+ * panels get a fresh value every pass (a width is a pure function of the
689
+ * content area); hidden and closing panels keep this, so nothing invisible
690
+ * ever reflows.
667
691
  */
668
692
  width: number;
669
693
  }
670
694
 
671
695
  /**
672
696
  * What the shell measures out to, and with it the width every panel size gets.
673
- * A pure function of the window, so it is the same for every panel in a pass.
697
+ * A pure function of the content area, so it is the same for every panel in a
698
+ * pass — and a panel never resizes because a neighbour came or went.
674
699
  */
675
700
  interface Geometry {
676
- /** The body row: everything the columns and the sidebar share. */
677
- total: number;
678
- /** What sits beside the columns — the sidebar and its hairline, if shown. */
679
- chrome: number;
680
- /** Half the standard content area, or all of it when a half would be too narrow. */
681
- half: number;
682
- /** The standard content area: what the app asked a `"full"` panel to be. */
683
- full: number;
684
- /** Everything the window has beside the chrome, with no upper limit. */
685
- screen: number;
701
+ /** The content area: all the room the columns have between them. */
702
+ area: number;
703
+ /** What each {@link PanelSize} comes to in that area. */
704
+ size: Record<PanelSize, number>;
686
705
  }
687
706
 
688
707
  /**
689
708
  * One state of the stack: the open paths, oldest first, and which of them is
690
709
  * the current panel. The panels before `focus` sit (or are crowded out) to the
691
- * current panel's left; the ones after it are parked past the right edge of the
692
- * viewport. What a history entry describes, and what every navigation is
710
+ * current panel's left; the ones after it are parked out of sight — panels
711
+ * that refused to be closed, which is the only way anything ends up there. What a history entry describes, and what every navigation is
693
712
  * expressed as a change to.
694
713
  */
695
714
  interface Arrangement {
@@ -709,8 +728,6 @@ export interface PanelStackOptions {
709
728
  columns?: "auto" | "single";
710
729
  /** What a bare link does. See {@link MainOptions.linkNavigation}. */
711
730
  linkNavigation?: "push" | "replace" | "open";
712
- /** How wide a `"full"` panel gets, in px. See {@link MainOptions.fullWidth}. */
713
- fullWidth: number;
714
731
  /** The shell's own title, used as the suffix of `document.title`. */
715
732
  title?: unknown;
716
733
  /**
@@ -774,10 +791,10 @@ export interface PanelStack {
774
791
  * the new panel).
775
792
  *
776
793
  * The same rules as a link click apply: pushing a path that is already open
777
- * goes back to it — a focus move along the stack, closing nothing — rather
778
- * than opening it twice, and a panel holding {@link Panel.unsaved} work is
779
- * never closed, only parked. That's what a plain link does, and what
780
- * `data-panel=push` says outright.
794
+ * returns to it — closing whatever was stacked on top — rather than opening
795
+ * it twice, and a panel holding {@link Panel.unsaved} work is never closed,
796
+ * only parked. That's what a plain link does, and what `data-panel=push`
797
+ * says outright.
781
798
  *
782
799
  * Note that a link builds on the panel it is *drawn in*, which is the
783
800
  * current panel only while no column beside it has the focus. Code
@@ -1158,7 +1175,7 @@ export class PanelStackController implements PanelStack {
1158
1175
  path,
1159
1176
  draw,
1160
1177
  $ui: A.proxy({ holding: false }),
1161
- maxWidth: "full" as const,
1178
+ maxWidth: "medium" as const,
1162
1179
  width: 0,
1163
1180
  } as PanelEntry;
1164
1181
  // `close` closes *this* panel, current or not, and `open` navigates
@@ -1304,31 +1321,32 @@ export class PanelStackController implements PanelStack {
1304
1321
  }
1305
1322
 
1306
1323
  /**
1307
- * Make the stack's `index`th panel current: the URL and the visible run move
1308
- * to it, while the panels right of it stay open, parked past the right edge
1309
- * of the viewport. Nothing closes; it is a history entry, so the browser's
1310
- * back button returns the focus to where it was. What a click on a
1311
- * breadcrumb — any link to an open panel — comes down to.
1324
+ * Make the stack's `index`th panel current without closing anything, leaving
1325
+ * the panels right of it parked out of sight.
1326
+ *
1327
+ * Only ever a step around a panel that refuses to close — nothing else is
1328
+ * left sitting after the current one — so this is Escape's way past an
1329
+ * unsaved panel, and the way back to one. It is a history entry, so the
1330
+ * browser's back button returns the focus to where it was.
1312
1331
  */
1313
- private focusAt(index: number, search?: Record<string, string>, hash?: string): Promise<boolean> {
1332
+ private focusAt(index: number): Promise<boolean> {
1314
1333
  const arr = this.intended();
1315
1334
  if (index < 0 || index >= arr.stack.length || index === arr.focus) return Promise.resolve(false);
1316
1335
  const target = { stack: arr.stack, focus: index };
1317
1336
  const path = arr.stack[index];
1318
1337
  return this.issue(target, () => {
1319
- // The panel gets its own last search and hash back, unless the link
1320
- // that brought us here carries its own.
1338
+ // The panel gets its own last search and hash back (see PanelEntry.search).
1321
1339
  const entry = this.$state.live.find((e) => e.path === path);
1322
- return route.go({ path, search: search ?? entry?.search, hash: hash ?? entry?.hash, state: this.stateFor(target) });
1340
+ return route.go({ path, search: entry?.search, hash: entry?.hash, state: this.stateFor(target) });
1323
1341
  });
1324
1342
  }
1325
1343
 
1326
1344
  /**
1327
1345
  * One step back along the stack — what Escape does (`main()` calls this;
1328
- * it is not {@link PanelStack} API). At the stack's end this closes the
1329
- * current panel; mid-stack — with panels parked to the right — or when the
1330
- * panel holds {@link Panel.unsaved} work, the panel stays open and the
1331
- * focus just moves to the panel on its left, parking the one it leaves.
1346
+ * it is not {@link PanelStack} API). Normally that closes the current panel,
1347
+ * which is the stack's end. When it holds {@link Panel.unsaved} work — or
1348
+ * panels sit parked beyond it — it stays open instead, and the focus
1349
+ * simply moves to the panel on its left.
1332
1350
  * Resolves `false` at the stack's start, where there is no left to go.
1333
1351
  */
1334
1352
  back(): Promise<boolean> {
@@ -1424,11 +1442,14 @@ export class PanelStackController implements PanelStack {
1424
1442
  * that know it.
1425
1443
  *
1426
1444
  * `how` is the link's `data-panel` attribute (or the caller's word for
1427
- * it): absent — like a link without the attribute — it is the shell's
1428
- * `linkNavigation` default, an unrecognised value is a push on top of
1429
- * `from`, `"replace"` swaps `from` out rather than stacking on it, and
1430
- * `"open"` drops `from` altogether so the target arrives with its own
1431
- * stack, the way a nav item's link does.
1445
+ * it), picking how much of `from`'s context the target keeps: a push (the
1446
+ * default, and what unrecognised values fall back to) keeps `from` and
1447
+ * builds on it, `"replace"` keeps only what is beneath `from`, and
1448
+ * `"open"` keeps nothing — the target arrives with its own stack, the way
1449
+ * a nav item's link does. Absent, it is the shell's `linkNavigation`
1450
+ * default, like a link without the attribute. A target that is already
1451
+ * open is returned to by a push, and *moved* — alive, state intact — by
1452
+ * the other two: the stack never holds a path twice.
1432
1453
  *
1433
1454
  * Resolves the way every {@link PanelStack} method does: `true` once the
1434
1455
  * navigation lands, `false` when it doesn't (already there counts as
@@ -1442,48 +1463,61 @@ export class PanelStackController implements PanelStack {
1442
1463
  let url: URL;
1443
1464
  try { url = new URL(href, location.href); } catch { return Promise.resolve(false); }
1444
1465
  const path = normalizePath(url.pathname);
1445
- const search = Object.fromEntries(new URLSearchParams(url.search));
1446
- const hash = url.hash;
1447
1466
  const arr = this.intended();
1448
1467
 
1449
- // A link to a panel that is already open is a return, not a navigation —
1450
- // a stack never holds the same path twice. Returning just moves the
1451
- // focus: the panels right of the target stay open, parked past the right
1452
- // edge, and nothing closes. That is the whole behaviour of a breadcrumb,
1453
- // which is exactly such a link.
1468
+ // Whatever we navigate to ends up on top of the stack; all that differs
1469
+ // is what it lands on.
1454
1470
  const open = beneath ? -1 : arr.stack.indexOf(path);
1455
- if (open >= 0 && open !== arr.focus) {
1456
- return this.focusAt(open, url.search ? search : undefined, hash || undefined);
1457
- }
1458
- if (open >= 0) {
1459
- // The target is the panel we're already on. Going nowhere — but the link
1460
- // may still carry a different search or hash, which belong to the
1461
- // current panel: record that as a history entry, leaving the stack alone
1462
- // (the panel reconciles by path, so it isn't even redrawn).
1463
- if (url.search === location.search && (url.hash || "") === (location.hash || "")) return Promise.resolve(true);
1464
- return this.issue(arr, () => route.go({ path, search, hash, state: this.stateFor(arr) }));
1471
+ let target: Arrangement;
1472
+ if (open >= 0 && mode !== "replace" && mode !== "open") {
1473
+ // A push to a path that is already open is a return: the panel takes
1474
+ // back its own place, and whatever was stacked on top of it closes.
1475
+ // (A stack never holds the same path twice, so there is no second
1476
+ // copy to open — and a breadcrumb is exactly such a link.) Pinned
1477
+ // panels above it are the exception, as ever: they stay in their
1478
+ // order, parked past the panel we return to, one crumb click away.
1479
+ const above = this.pinnedIn(arr.stack.slice(open + 1), []);
1480
+ target = { stack: [...arr.stack.slice(0, open + 1), ...above], focus: open };
1481
+ } else {
1482
+ // The target opens on top of the panel the link sits in, or in its
1483
+ // place for a `replace`, closing the panels after it. Without an
1484
+ // originating panel there is no stack to build on, so it is the
1485
+ // caller's own `beneath` or one derived from the path — which is what
1486
+ // makes a nav click and a deep link to the same URL land identically
1487
+ // (bar the pins, which a fresh tab doesn't have).
1488
+ const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
1489
+ const raw = beneath
1490
+ ? beneath.map(normalizePath)
1491
+ : originIndex < 0
1492
+ ? this.deriveStack(path).slice(0, -1)
1493
+ : arr.stack.slice(0, replace ? originIndex : originIndex + 1);
1494
+ // A stack never holds the same path twice (rendering reconciles by
1495
+ // path), so the target is dropped from the base — a `replace` or
1496
+ // `open` may well aim at a path that is open mid-stack, whose panel
1497
+ // then simply *moves* to the top, alive — and a caller-supplied
1498
+ // `beneath` is deduplicated for the same reason.
1499
+ const base = raw.filter((p, i, all) => p !== path && all.indexOf(p) === i);
1500
+ // Pinned panels ride along, keeping their order, beneath the new one
1501
+ // (unsaved ones the commit itself keeps, parked — see `propose`). A
1502
+ // replaced origin closes, pin or no pin: replacing is the panel's own
1503
+ // doing, not somewhere else navigating over it.
1504
+ const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
1505
+ target = { stack: [...under, path], focus: under.length };
1465
1506
  }
1466
1507
 
1467
- // A new panel: it opens at the stack's end and becomes current. The
1468
- // panels after the origin close — except pinned ones, which ride along,
1469
- // keeping their order, beneath the new panel (and unsaved ones, which
1470
- // the commit itself keeps, parked — see `propose`). Without an
1471
- // originating panel there is no stack to build on, so derive one — a
1472
- // nav click and a deep link to the same URL land identically (bar the
1473
- // pins, which a fresh tab doesn't have).
1474
- const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
1475
- // A stack never holds the same path twice (rendering reconciles by
1476
- // path), so a caller-supplied `beneath` is deduplicated, not just
1477
- // filtered against the target.
1478
- const base = beneath
1479
- ? beneath.map(normalizePath).filter((p, i, all) => p !== path && all.indexOf(p) === i)
1480
- : originIndex < 0
1481
- ? this.deriveStack(path).slice(0, -1)
1482
- : arr.stack.slice(0, replace ? originIndex : originIndex + 1);
1483
- // A replaced origin closes, pin or no pin: replacing is the panel's own
1484
- // doing, not somewhere else navigating over it.
1485
- const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
1486
- const target = { stack: [...under, path], focus: under.length };
1508
+ // Search and hash belong to the current panel only, so a panel we return
1509
+ // to gets its own back (see PanelEntry.search) — unless the link carries
1510
+ // its own, which win.
1511
+ const returning = open >= 0 ? this.$state.live.find((e) => e.path === path) : undefined;
1512
+ const search = url.search ? Object.fromEntries(new URLSearchParams(url.search)) : returning?.search ?? {};
1513
+ const hash = url.hash || returning?.hash || "";
1514
+
1515
+ // Going nowhere at all: this panel, on this arrangement, with the query
1516
+ // the link asks for. Not even a history entry.
1517
+ if (target.focus === arr.focus && sameStack(target.stack, arr.stack)
1518
+ && url.search === location.search && (url.hash || "") === (location.hash || "")) {
1519
+ return Promise.resolve(true);
1520
+ }
1487
1521
  return this.issue(target, () => route.go({ path, search, hash, state: this.stateFor(target) }));
1488
1522
  });
1489
1523
  }
@@ -1570,7 +1604,9 @@ export class PanelStackController implements PanelStack {
1570
1604
  // app that reads them off a proxy (or through a getter) can change them at
1571
1605
  // runtime and the shell adapts in place — nothing is redrawn, no panel
1572
1606
  // loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
1573
- // options; these are how `main()` talks to the stack.
1607
+ // options; these are how `main()` talks to the stack. The shell's *widths*
1608
+ // need no counterpart here: `navWidth` and `maxWidth` both resize the
1609
+ // column region, which the layout engine is already observing.
1574
1610
 
1575
1611
  /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
1576
1612
  setColumns(columns: "auto" | "single" | undefined): void {
@@ -1584,26 +1620,15 @@ export class PanelStackController implements PanelStack {
1584
1620
  this.opts.linkNavigation = mode;
1585
1621
  }
1586
1622
 
1587
- /**
1588
- * Adopt a changed `fullWidth`: one layout pass, nothing redrawn. A changed
1589
- * `navWidth` needs no counterpart — resizing the sidebar resizes the column
1590
- * region, which the layout engine is already observing.
1591
- */
1592
- setFullWidth(px: number): void {
1593
- if (this.opts.fullWidth === px) return;
1594
- this.opts.fullWidth = px;
1595
- this.scheduleLayout();
1596
- }
1597
-
1598
1623
  /**
1599
1624
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
1600
1625
  * panel, oldest first, the ones on screen right now in bold, pinned ones
1601
1626
  * wearing their pin. Every crumb but the current panel's is a plain link to
1602
- * that panel, and a link to an open panel is a focus move (see `navigate`) —
1603
- * so clicking along the stack closes nothing, in either direction, and the
1604
- * panels right of the current one wait just past the viewport's edge.
1605
- * Right-click (or long-press) offers pinning, and closing just that one
1606
- * panel — the close that splices it out of the middle when it isn't last.
1627
+ * that panel, and a link to an open panel returns to it (see `navigate`) —
1628
+ * so clicking a crumb goes back to that panel and closes what was stacked on
1629
+ * top of it, pinned and unsaved panels excepted. Right-click (or long-press)
1630
+ * offers pinning, and closing just that one panel — the close that splices
1631
+ * it out of the middle when it isn't last.
1607
1632
  */
1608
1633
  drawCrumbs(): void {
1609
1634
  // The very same row `S.tabs` puts its tab strip in: it scrolls when the
@@ -1648,24 +1673,9 @@ export class PanelStackController implements PanelStack {
1648
1673
  A(() => { if (entry?.$panel.pinned) pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
1649
1674
  // `||`, not `??`: the root path's last segment is the empty string.
1650
1675
  A(() => { A("#", entry?.$panel.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
1651
- // Taking over right-click means taking the browser's link menu away, so
1652
- // the two entries anyone actually reaches for on a link come first,
1653
- // where that menu would have had them, and the shell's own verbs sit
1654
- // below the rule.
1655
- addContextMenu({ items: [
1656
- {
1657
- // A real new tab, so it arrives cold and builds its own stack
1658
- // from the path — exactly what the same link middle-clicked does.
1659
- label: "Open in new tab",
1660
- icon: newTabIcon,
1661
- click: () => { window.open(path, "_blank", "noopener"); },
1662
- },
1663
- {
1664
- label: "Copy link",
1665
- icon: linkIcon,
1666
- click: () => void copyLink(path),
1667
- },
1668
- { separator: true },
1676
+ // `link` puts the browser's own link entries — Open in new tab, Copy
1677
+ // link — above the rule; the shell's own verbs sit below it.
1678
+ addContextMenu({ link: path, items: [
1669
1679
  {
1670
1680
  label: () => { A(() => { A("#", entry?.$panel.pinned ? "Unpin" : "Pin"); }); },
1671
1681
  icon: () => { A(() => { (entry?.$panel.pinned ? pinOffIcon : pinIcon)(); }); },
@@ -1720,29 +1730,31 @@ export class PanelStackController implements PanelStack {
1720
1730
 
1721
1731
  /**
1722
1732
  * While any open panel holds unsaved work, closing the tab — or navigating
1723
- * the whole browser away — runs into the browser's own are-you-sure. When
1724
- * the user stays, the unsaved panel is brought back on screen if it wasn't,
1725
- * so what held the tab is in front of them rather than parked out of sight.
1733
+ * the whole browser away — runs into the browser's own are-you-sure, with
1734
+ * the unsaved panel brought on screen as the question is raised, so what is
1735
+ * holding the tab is in front of the user rather than parked out of sight.
1726
1736
  */
1727
1737
  private guardTabClose(): void {
1728
1738
  if (typeof window === "undefined") return;
1729
- let leaving = false;
1730
- const onHide = () => { leaving = true; };
1731
1739
  const onBeforeUnload = (e: BeforeUnloadEvent) => {
1732
- // Being asked again means we weren't gone after all (a bfcache restore).
1733
- leaving = false;
1734
1740
  const dirty = this.$state.live.find((entry) => entry.$panel.unsaved);
1735
1741
  if (!dirty) return;
1736
1742
  e.preventDefault();
1737
1743
  e.returnValue = true; // Chrome/Edge < 119
1738
- // This task only ever amounts to anything if the user cancels: a
1739
- // confirmed leave unloads the document (`pagehide`) first.
1740
- const path = dirty.path;
1741
- setTimeout(() => {
1742
- if (leaving) return;
1743
- const entry = this.$state.live.find((live) => live.path === path);
1744
- if (entry && !entry.$panel.visible) void this.focusAt(this.intended().stack.indexOf(path));
1745
- }, 0);
1744
+ // Bring the unsaved panel on screen right here, so what is holding the
1745
+ // tab is in front of the user — behind the browser's dialog where the
1746
+ // browser paints that early, and the moment they choose to stay
1747
+ // otherwise. A confirmed leave unloads the document before any of it
1748
+ // is seen; the history entry the move makes is then where a back
1749
+ // navigation returns to, which is right: the panel that held the tab.
1750
+ // `visible` is written by the layout pass, which waits for an animation
1751
+ // frame — and the frame owed to the navigation that parked this panel
1752
+ // may not have landed, all the more so in a tab on its way out, which
1753
+ // may never paint again. Settle it first, or a panel parked a moment
1754
+ // ago still reads as on screen and the guard skips the very move it
1755
+ // exists to make.
1756
+ this.flushLayout();
1757
+ if (!dirty.$panel.visible) void this.focusAt(this.intended().stack.indexOf(dirty.path));
1746
1758
  };
1747
1759
  // Registered only while a panel actually holds unsaved work: a page with a
1748
1760
  // `beforeunload` listener is shut out of the browser's back/forward cache,
@@ -1751,11 +1763,7 @@ export class PanelStackController implements PanelStack {
1751
1763
  A(() => {
1752
1764
  if (!this.$state.live.some((entry) => entry.$panel.unsaved)) return;
1753
1765
  window.addEventListener("beforeunload", onBeforeUnload);
1754
- window.addEventListener("pagehide", onHide);
1755
- A.clean(() => {
1756
- window.removeEventListener("beforeunload", onBeforeUnload);
1757
- window.removeEventListener("pagehide", onHide);
1758
- });
1766
+ A.clean(() => window.removeEventListener("beforeunload", onBeforeUnload));
1759
1767
  });
1760
1768
  }
1761
1769
 
@@ -1786,12 +1794,11 @@ export class PanelStackController implements PanelStack {
1786
1794
  }) as HTMLElement;
1787
1795
 
1788
1796
  if (typeof ResizeObserver !== "undefined") {
1797
+ // The region *is* the content area every width is measured from (see
1798
+ // `measure`), so watching it catches the lot: a window resize, the
1799
+ // sidebar coming or going, the shell's own `maxWidth` changing.
1789
1800
  const ro = new ResizeObserver(() => this.layout());
1790
- // The region *and* the body it sits in: the region alone misses a shell
1791
- // resize that the columns happen to absorb, which still re-resolves widths.
1792
1801
  ro.observe(container);
1793
- const body = container.parentElement?.parentElement;
1794
- if (body) ro.observe(body);
1795
1802
  A.clean(() => ro.disconnect());
1796
1803
  }
1797
1804
  A.clean(() => { if (this.containerEl === container) this.containerEl = undefined; });
@@ -1805,13 +1812,13 @@ export class PanelStackController implements PanelStack {
1805
1812
  // element that arrives without a width has no box for its content to measure
1806
1813
  // itself against until the next frame's layout pass, which is a frame too
1807
1814
  // late for anything that sizes itself from its container. So the panel is
1808
- // created at the width the window gives it — the "full" width until the panel
1809
- // says otherwise. Reactively, too: a panel that changes its mind later (when
1810
- // its data arrives, say) reflows in place rather than being redrawn, and the
1811
- // columns beside it slide over to make room.
1815
+ // created at the width the window gives it — the "medium" width until the
1816
+ // panel says otherwise. Reactively, too: a panel that changes its mind later
1817
+ // (when its data arrives, say) reflows in place rather than being redrawn,
1818
+ // and the columns beside it slide over to make room.
1812
1819
  A(() => {
1813
1820
  const asked = entry.$panel.maxWidth;
1814
- entry.maxWidth = asked === "half" || asked === "screen" ? asked : "full";
1821
+ entry.maxWidth = asked === "small" || asked === "large" || asked === "none" ? asked : "medium";
1815
1822
  const width = this.roomFor(entry.maxWidth);
1816
1823
  if (!width) return;
1817
1824
  entry.width = width;
@@ -1890,52 +1897,51 @@ export class PanelStackController implements PanelStack {
1890
1897
  scheduleLayout(): void {
1891
1898
  if (this.layoutQueued) return;
1892
1899
  this.layoutQueued = true;
1893
- requestAnimationFrame(() => {
1894
- this.layoutQueued = false;
1895
- this.layout();
1896
- });
1900
+ requestAnimationFrame(() => this.flushLayout());
1897
1901
  }
1898
1902
 
1899
1903
  /**
1900
- * Measure the shell, and with it the width the window gives a panel of each
1901
- * layout. Measured on the *shell*, not on the column region: the region's width
1902
- * is the layout engine's own output, so reading it back would nail the layout
1903
- * to whatever it happened to be a frame ago. Fractional widths throughout — a
1904
- * rounded column edge would drift a pixel away from the chrome above it.
1904
+ * Run the pending layout pass now, rather than on the frame it is waiting
1905
+ * for. For the callers that have to *read* what only the pass knows —
1906
+ * `$panel.visible`, `$panel.width` — at a moment when waiting isn't an
1907
+ * option. Does nothing when no pass is owed.
1908
+ */
1909
+ private flushLayout(): void {
1910
+ if (!this.layoutQueued) return;
1911
+ this.layoutQueued = false;
1912
+ this.layout();
1913
+ }
1914
+
1915
+ /**
1916
+ * Measure the content area, and with it the width a panel of each size gets.
1917
+ *
1918
+ * The column region *is* the content area: it takes whatever the shell has
1919
+ * left beside the sidebar, capped by the shell's own `maxWidth` — all of it
1920
+ * CSS's doing, so there is nothing to add up here and nothing that could
1921
+ * drift from the width the bars above and below line up with. Fractional
1922
+ * widths throughout: a rounded column edge would drift a pixel away from that
1923
+ * chrome.
1924
+ *
1925
+ * The area divides into the narrowest whole number of columns that keeps each
1926
+ * at least {@link SMALL_MIN_PX} wide — the `"small"` unit every other size is
1927
+ * a multiple of, capped at the area itself. So 1080px is three columns of 360
1928
+ * and 1520px four of 380. An area too narrow for two is a single column,
1929
+ * itself capped at {@link SMALL_MAX_PX}: a small centres there instead of
1930
+ * stretching toward 720, so its ceiling holds, while the larger sizes still
1931
+ * take the whole area. A width is thus a pure function of the window: a panel
1932
+ * NEVER resizes because a neighbour came or went, and only a window resize
1933
+ * (the snap pass in `layout`) changes one.
1905
1934
  *
1906
1935
  * `undefined` while the shell has no width to speak of (it isn't in a document
1907
1936
  * yet, or it's `display:none`); the next pass tries again.
1908
1937
  */
1909
1938
  private measure(): Geometry | undefined {
1910
- const container = this.containerEl;
1911
- const inner = container?.parentElement;
1912
- const body = inner?.parentElement;
1913
- if (!container || !inner || !body) return undefined;
1914
- const total = body.getBoundingClientRect().width;
1915
- if (!total) return undefined;
1916
-
1917
- // Everything that sits beside the columns: the sidebar and its hairline,
1918
- // either of which may be display:none on a narrow shell.
1919
- let chrome = 0;
1920
- for (const child of inner.children) {
1921
- if (child !== container) chrome += child.getBoundingClientRect().width;
1922
- }
1923
-
1924
- // What the window has beside the sidebar, and within that the *standard*
1925
- // content area: the width the app gave a "full" panel, or all there is
1926
- // when the window has less. Widths are a pure function of the window —
1927
- // never of what else is open — so a panel NEVER resizes because a
1928
- // neighbour came or went; only a window resize (the snap pass in
1929
- // `layout`) changes them:
1930
- // - "full" fills the standard content area exactly;
1931
- // - "half" is half of it whenever that half is still a usable column, and
1932
- // the whole of it on narrower screens;
1933
- // - "screen" ignores the standard width and takes everything the window
1934
- // has — which also means nothing ever fits beside it.
1935
- const screen = Math.max(0, total - chrome);
1936
- const full = Math.min(this.opts.fullWidth, screen);
1937
- const halved = full / 2;
1938
- return { total, chrome, half: halved >= PAIR_MIN_PX ? halved : full, full, screen };
1939
+ const el = this.containerEl;
1940
+ const area = el ? el.getBoundingClientRect().width : 0;
1941
+ if (!area) return undefined;
1942
+ const small = Math.min(area / Math.max(1, Math.floor(area / SMALL_MIN_PX)), SMALL_MAX_PX);
1943
+ const units = (n: number) => Math.min(n * small, area);
1944
+ return { area, size: { small, medium: units(2), large: units(3), none: area } };
1939
1945
  }
1940
1946
 
1941
1947
  /**
@@ -1949,13 +1955,12 @@ export class PanelStackController implements PanelStack {
1949
1955
  }
1950
1956
 
1951
1957
  /** How wide a panel asking for this is, right now; 0 while the shell can't be measured. */
1952
- private roomFor(maxWidth: PanelEntry["maxWidth"]): number {
1953
- return this.geometry()?.[maxWidth] ?? 0;
1958
+ private roomFor(size: PanelSize): number {
1959
+ return this.geometry()?.size[size] ?? 0;
1954
1960
  }
1955
1961
 
1956
1962
  /**
1957
- * Size and position every panel, and publish the width of the whole ensemble
1958
- * (sidebar + separator + columns) for the shell to centre itself on.
1963
+ * Size and position every panel.
1959
1964
  *
1960
1965
  * This is everything CSS can't work out for itself: which panels exist, which
1961
1966
  * of them are visible, how wide each one is and where it sits. All the motion
@@ -1980,22 +1985,21 @@ export class PanelStackController implements PanelStack {
1980
1985
  const geom = this.geometry();
1981
1986
  if (!geom) return;
1982
1987
 
1983
- const stacking = this.opts.columns !== "single";
1988
+ const single = this.opts.columns === "single";
1984
1989
 
1985
1990
  // A window resize — or the app resizing the shell itself, by changing
1986
- // `navWidth` or `fullWidth` — must be adopted instantly: geometry tracking
1991
+ // `navWidth` or `maxWidth` — must be adopted instantly: geometry tracking
1987
1992
  // the window through a 450ms transition reads as lag, and a shell
1988
1993
  // animating itself into place on its first pass reads as a glitch. Only
1989
1994
  // what a *panel* did is worth animating, and none of those three are.
1990
1995
  // `.s-shell-snap` suppresses every standing transition for this one pass.
1991
- const was = this.lastGeom;
1992
- const snap = was == null || was.total !== geom.total || was.chrome !== geom.chrome || was.full !== geom.full;
1996
+ const snap = this.lastGeom?.area !== geom.area;
1993
1997
  if (snap) {
1994
1998
  this.lastGeom = geom;
1995
1999
  shell.classList.add("s-shell-snap");
1996
2000
  }
1997
2001
 
1998
- const width = (entry: PanelEntry) => geom[entry.maxWidth];
2002
+ const width = (entry: PanelEntry) => geom.size[entry.maxWidth];
1999
2003
 
2000
2004
  // The visible run: as many columns as the window fits, at the sizes the
2001
2005
  // window gives them, ending at the current panel — which always shows.
@@ -2003,22 +2007,20 @@ export class PanelStackController implements PanelStack {
2003
2007
  const cur = Math.min(this.$state.focus, n - 1);
2004
2008
  let first = cur;
2005
2009
  let runSum = width(live[cur]);
2006
- if (stacking) {
2010
+ if (!single) {
2007
2011
  for (let i = cur - 1; i >= 0; i--) {
2008
2012
  const sum = runSum + width(live[i]);
2009
- if (sum > geom.screen) break;
2013
+ if (sum > geom.area) break;
2010
2014
  runSum = sum;
2011
2015
  first = i;
2012
2016
  }
2013
2017
  }
2014
2018
 
2015
- // The content area holds the run, but is never smaller than the standard
2016
- // panel (a lone small leaves its other half open — which is exactly where
2017
- // the next small lands, without anything on screen moving) and never
2018
- // wider than the window. So the page holds its standard width until extra
2019
- // columns genuinely fit, and stretches — centred — to hold the ones that
2020
- // do; with a "screen" up that's the window's edges.
2021
- const area = Math.min(geom.screen, Math.max(geom.full, runSum));
2019
+ // The content area is a fixed width, so a run that doesn't fill it sits
2020
+ // centred in it rather than hanging off its left edge. Everything around
2021
+ // the columns holds still meanwhile: the sidebar, the top bar and the
2022
+ // footer never move, however many columns come and go.
2023
+ const left = (geom.area - runSum) / 2;
2022
2024
 
2023
2025
  for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
2024
2026
  // Panels that have never been visible get their would-be width too, so a
@@ -2027,29 +2029,22 @@ export class PanelStackController implements PanelStack {
2027
2029
  if (!entry.width) entry.width = width(entry);
2028
2030
  }
2029
2031
 
2030
- // The body row caps itself to the ensemble width, keeping the columns
2031
- // centred however far the area stretches, and transitions its max-width
2032
- // (see main.ts) so the recentring plays along with the panel that caused
2033
- // it. The bars above and below don't follow — they hold at the standard
2034
- // page width (also main.ts).
2035
- shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
2036
-
2037
2032
  // Phase 1 — every panel's *start* state for this frame. Panels already on
2038
2033
  // screen simply move (their standing transition animates it); freshly
2039
2034
  // mounted ones still have transitions switched off, so what we set here is
2040
2035
  // adopted instantly and becomes the "before" of their enter animation.
2041
2036
  const fresh: PanelEntry[] = [];
2042
- let x = 0;
2037
+ let x = left;
2043
2038
  for (let i = 0; i < n; i++) {
2044
2039
  const entry = live[i];
2045
2040
  const el = entry.el!;
2046
2041
  const shown = i >= first && i <= cur;
2047
- // Visible columns tile the content area, left to right. Panels crowded
2048
- // out from under the run rest at its left edge; panels beyond the
2049
- // current panel park just past its right edge — both keep their last
2050
- // width. Deeper panels layer over shallower ones, each on the odd
2051
- // layer for its depth (see LAYER_STEP).
2052
- place(el, shown ? x : i > cur ? area : 0, entry.width, LAYER_STEP * i + 1);
2042
+ // Visible columns tile the run, left to right. Panels crowded out from
2043
+ // under it rest at its left edge; panels beyond the current panel park
2044
+ // just past its right edge — both keep their last width. Deeper panels
2045
+ // layer over shallower ones, each on the odd layer for its depth (see
2046
+ // LAYER_STEP).
2047
+ place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
2053
2048
  // What `$panel.visible` and `$panel.width` report: this pass is the one
2054
2049
  // thing that knows them, window resizes included. Written only on a
2055
2050
  // change, so per-panel UI hanging off them isn't rebuilt by every pass.
@@ -2128,22 +2123,6 @@ function firstText(el: HTMLElement): string | undefined {
2128
2123
  }
2129
2124
  }
2130
2125
 
2131
- /**
2132
- * Put a panel's address on the clipboard, as the absolute URL someone can paste
2133
- * anywhere — which is what the browser's own "Copy link" would have given them.
2134
- * Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
2135
- * needs a secure context, so a failure says so rather than lying.
2136
- */
2137
- async function copyLink(path: string): Promise<void> {
2138
- const url = new URL(path, location.href).href;
2139
- try {
2140
- await navigator.clipboard.writeText(url);
2141
- toast({ message: "Link copied." });
2142
- } catch {
2143
- toast({ message: "Couldn't copy the link.", type: "danger" });
2144
- }
2145
- }
2146
-
2147
2126
  function drawDefaultNotFound($panel: Panel<{}>): void {
2148
2127
  A("p fg:$s-muted", () => A("#", `No panel at ${$panel.path}`));
2149
2128
  }