staffa 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,14 @@
1
1
  import A from "aberdeen";
2
- import { current as currentRoute, matchCurrent } from "aberdeen/route";
2
+ import { current as currentRoute } from "aberdeen/route";
3
3
  import { type Slot, type Attributes, drawSlot, focusFirst, NARROW_PX } from "../core.js";
4
- import { type MenuOptions, type MenuEntry, drawMenu, isFloatingMenuOpen, consumeBranchNav } from "./menu.js";
4
+ import { type MenuOptions, drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent } from "./menu.js";
5
5
  // The shell's own chrome glyphs, from the same Lucide set an app draws with —
6
6
  // so a nav trigger sits beside app icons as an equal. Named imports, so a
7
7
  // bundler keeps these two and tree-shakes the other ~1950 away.
8
8
  import { menu as menuIcon, x as closeIcon } from "../icons.js";
9
9
  import { iconButton } from "./button.js";
10
10
  import { isDialogOpen } from "./dialog.js";
11
- import { PanelStackController, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
11
+ import { PanelStackController, SHELL_PX, type PanelStack, type AncestorTable, type Panel, type RouteHandler, type RouteTable, type Routes } from "./panels.js";
12
12
 
13
13
  /** Options for {@link main}. */
14
14
  export interface MainOptions<R = Routes> {
@@ -195,6 +195,10 @@ export interface MainOptions<R = Routes> {
195
195
  * — the phone experience at every size (the nav sidebar still sits beside
196
196
  * it). Only the display differs: the stack, the breadcrumbs, the URL,
197
197
  * Escape and the back button behave identically in both. Routed mode only.
198
+ *
199
+ * Live: pass a proxied options object (or make this field a getter) and a
200
+ * change is adopted in place — one layout pass, every panel keeping its
201
+ * state.
198
202
  */
199
203
  columns?: "auto" | "single";
200
204
  /**
@@ -205,6 +209,9 @@ export interface MainOptions<R = Routes> {
205
209
  * every click replaces the content as a whole — which, with flat routes,
206
210
  * is the conventional sidebar-and-content app: one pane, swapped on every
207
211
  * click, the crumb line simply naming it. Routed mode only.
212
+ *
213
+ * Live, like {@link MainOptions.columns}: change it and the next click
214
+ * uses the new default.
208
215
  */
209
216
  linkNavigation?: "push" | "replace" | "open";
210
217
  /** Footer content, pinned below the scroll area. */
@@ -271,8 +278,10 @@ A.insertGlobalCss({
271
278
  // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
272
279
  // trailing slot's own growth: it takes the free space and right-aligns
273
280
  // itself in it, which is what lets a search box live there. When the two
274
- // compete, the title truncates first — but only down to a floor, past
275
- // which the trailing slot shrinks instead: a wide search box must not
281
+ // compete, the titles give way first: the trailing slot's near-zero
282
+ // shrink factor keeps a row of actions at its natural width while the
283
+ // crumbs absorb the squeeze — but only down to the titles' floor, past
284
+ // which the trailing slot shrinks after all: a wide search box must not
276
285
  // starve the titles to nothing (the crumb strip's overlay buttons would
277
286
  // escape their zero-width strip, over the ☰ beside it).
278
287
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
@@ -293,13 +302,16 @@ A.insertGlobalCss({
293
302
  // which their classes then provide. (`filter:none` keeps the global
294
303
  // `a:hover` brighten off the gradient text.)
295
304
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
296
- "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
305
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
297
306
  // Body always wraps <main> (with or without a sidebar) so max-width centering
298
307
  // and scrollbar alignment work identically in both cases.
299
308
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
300
309
  // It's also the positioning + clipping context for the narrow-screen nav panel,
301
- // which slides in and out across its left edge.
302
- ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
310
+ // which slides in and out across its left edge. `overflow:clip` rather than
311
+ // `hidden` for the same reason as `.s-panels`: a hidden box can still be
312
+ // scrolled (find-in-page, an anchor, an extension), and a stray scroll here
313
+ // would shove the whole row — sidebar and columns — out of place for good.
314
+ ".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
303
315
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
304
316
  // Put the sidebar on the right (content fills the left) for right-hand navs.
305
317
  "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
@@ -331,22 +343,25 @@ A.insertGlobalCss({
331
343
  // Routed mode takes its width from the stack instead of from
332
344
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
333
345
  // separator + content area) as --s-shell-w — the standard 1280px page
334
- // normally, the window's edges while a "screen" page is up — and the body
335
- // row and the bars cap themselves to it. So the chrome lines up with the
336
- // columns and the lot stays centred in the shell.
337
- "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
338
- "&.s-routed > header > .s-bar": "max-width: var(--s-shell-w, 100%);",
339
- "&.s-routed > footer > .s-bar": "max-width: var(--s-shell-w, 100%);",
340
- // Changing the custom property animates the max-widths consuming it, with no
341
- // JS in the loop: the chrome recentres in step with the panel whose arrival
342
- // or departure moved it, over the same --s-panel-ms (see panels.ts). During
343
- // a window resize (and the very first pass) the layout engine raises
344
- // `.s-shell-snap` so the new width is adopted instantly instead of chasing
345
- // the window through a transition.
346
- "&.s-routed > .s-body > .s-body-inner, &.s-routed > header > .s-bar, &.s-routed > footer > .s-bar":
347
- "transition: max-width var(--s-panel-ms) ease;",
348
- "&.s-routed.s-shell-snap > .s-body > .s-body-inner, &.s-routed.s-shell-snap > header > .s-bar, &.s-routed.s-shell-snap > footer > .s-bar":
349
- "transition:none",
346
+ // normally, wider while the columns outgrow it (a "screen" page, or
347
+ // extra columns fitting a wide window) — and the body row caps itself
348
+ // to it, staying centred around the columns. Changing the custom
349
+ // property animates the max-width consuming it, with no JS in the loop:
350
+ // the body recentres in step with the panel whose arrival or departure
351
+ // moved it, over the same --s-panel-ms (see panels.ts). During a window
352
+ // resize (and the very first pass) the layout engine raises
353
+ // `.s-shell-snap` so the new width is adopted instantly instead of
354
+ // chasing the window through a transition.
355
+ "&.s-routed > .s-body > .s-body-inner":
356
+ "max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
357
+ "&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
358
+ // The bars don't follow the ensemble past the standard page: a header
359
+ // stretching to the window's edges and back with every "screen" panel
360
+ // reads as the whole app flexing, so the chrome holds still and only
361
+ // the columns grow. (Below the standard width the ensemble is simply
362
+ // the window, which only a resize changes — so the bars never animate,
363
+ // and take no part in the transition above.)
364
+ [`&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar`]: `max-width:${SHELL_PX}px`,
350
365
  },
351
366
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
352
367
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
@@ -372,10 +387,12 @@ A.insertGlobalCss({
372
387
  // body starts below the bar), but the bar should still win if they ever do.
373
388
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
374
389
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
375
- "transition: transform var(--s-panel-ms) ease;",
390
+ "transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
376
391
  // Parked one screen to the left: the state the `create=`/`destroy=` hooks
377
- // transition out of and back into.
378
- "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
392
+ // transition out of and back into. `visibility` flips at the slide's end
393
+ // (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
394
+ // until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
395
+ "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
379
396
  // Roomier rows than the dropdown's: this is the whole screen, and every row
380
397
  // is a thumb target.
381
398
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
@@ -483,12 +500,19 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
483
500
  routes,
484
501
  notFound: opts.notFound,
485
502
  ancestors: opts.ancestors,
486
- columns: opts.columns,
487
- linkNavigation: opts.linkNavigation,
488
503
  title: opts.title,
489
504
  $shell,
490
505
  })
491
506
  : null;
507
+ if (ctl) {
508
+ // `columns` and `linkNavigation` are live: each is read in a scope of
509
+ // its own, so when the options object is a proxy (or the field a
510
+ // getter), a change re-runs just that scope — the columns relayout in
511
+ // place with every panel's state intact, and the next click picks up
512
+ // the new link default. Nothing else of the shell is touched.
513
+ A(() => ctl.setColumns(opts.columns));
514
+ A(() => ctl.setLinkNavigation(opts.linkNavigation));
515
+ }
492
516
  // Where the brand mark and the app's name link — or nowhere, when the app
493
517
  // said `home: null` (a title slot holding a control of its own, say).
494
518
  const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
@@ -743,23 +767,18 @@ function drawSecondLine(
743
767
 
744
768
  /**
745
769
  * Whether the stack would only be saying what the sidebar already says: a
746
- * single panel open, the sidebar on screen, and that panel being one of the nav's
747
- * own rows.
770
+ * single panel open, the sidebar on screen, and that panel being one of the
771
+ * nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
772
+ * highlighted (inside its unfolded branch) all the same.
748
773
  *
749
- * The row test is {@link matchCurrent} — the very thing that marks a row
750
- * `aria-current=page` — so "the crumb is redundant" and "the sidebar has it
751
- * highlighted" can never come apart. It compares whole paths, so it is true
752
- * only for a nav item's own screen, never for one opened beneath it.
774
+ * The row test is the menu's own {@link anyCurrent} — the very thing that
775
+ * marks a row `aria-current=page` — so "the crumb is redundant" and "the
776
+ * sidebar has it highlighted" can never come apart.
753
777
  */
754
778
  function taglineFits(ctl: PanelStackController, nav: MenuOptions | undefined, $shell: { narrow: boolean }): boolean {
755
779
  if ($shell.narrow || nav == null) return false;
756
780
  if (ctl.panels.length > 1) return false;
757
- return nav.items.some((entry: MenuEntry) =>
758
- typeof entry !== "string" &&
759
- typeof entry !== "function" &&
760
- !("separator" in entry) &&
761
- entry.href != null &&
762
- matchCurrent(entry.href));
781
+ return anyCurrent(nav.items);
763
782
  }
764
783
 
765
784
  /**
@@ -30,6 +30,17 @@ export interface MenuItem {
30
30
  * `attrs: "data-panel=push"` for a row that should stack instead.
31
31
  */
32
32
  href?: string;
33
+ /**
34
+ * Pages this item claims *beyond* its own `href`: a string claims that path
35
+ * and everything under it (`"/mail"` claims `/mail/…`, not `/mailbox`), a
36
+ * function is asked with the current path. While a claimed page is current,
37
+ * the item is highlighted and the branches above it stay unfolded — for the
38
+ * detail screens a menu has no row of their own: the `/thread/[id]` a
39
+ * notification lands on, an icon's page under the gallery's row. Claims
40
+ * work from the very first paint, so they also cover cold deep links,
41
+ * which no amount of fold-state keeping can.
42
+ */
43
+ match?: string | ((path: string) => boolean);
33
44
  /** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
34
45
  target?: string;
35
46
  /** Disables the item. */
@@ -133,12 +144,17 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
133
144
  A.insertGlobalCss({
134
145
  // Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
135
146
  // (lg) and opts into elevation via `.shadow` (added on the element below).
147
+ // `visibility` rides the same transition as the fade (flipping only at its
148
+ // end, per CSS visibility interpolation): a dismissed menu lingers in the
149
+ // DOM for a while — Aberdeen's `destroy=` removes it on a timer, not at
150
+ // the transition's end — and without this it would spend that time
151
+ // invisible yet still hittable by tests and read by assistive tech.
136
152
  ".s-menu-list":
137
153
  "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
138
154
  "r:$s-radius-lg " +
139
155
  "overflow-y:auto max-height:min(80vh,28rem) " +
140
- "transition: opacity 0.15s, transform 0.15s;",
141
- ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px)",
156
+ "transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
157
+ ".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden",
142
158
  // One class for both the `<a>` (link) and `<button>` forms — they look
143
159
  // identical; the element only differs where link semantics matter (see below).
144
160
  // The scroll-margin keeps a revealed row (see the scrollIntoView in
@@ -282,7 +298,7 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
282
298
  A(() => {
283
299
  const first = !drawn;
284
300
  drawn = true;
285
- if (!matchCurrent(entry.href!)) return;
301
+ if (!isCurrent(entry)) return;
286
302
  A("aria-current=page");
287
303
  // A list taller than its scrollport (a long sidebar nav, mostly)
288
304
  // highlights nothing when the current row is scrolled out of it,
@@ -307,6 +323,20 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
307
323
  });
308
324
  }
309
325
 
326
+ /**
327
+ * The last route-derived fold state of every linked branch, keyed by the
328
+ * branch's selection href. Module-level on purpose: the phone's full-page nav
329
+ * (and any dropdown) mounts a fresh menu every time it opens, and per-mount
330
+ * state would hand it three folded sections in the middle of the user's work.
331
+ * Bounded by the number of distinct branch hrefs an app ever shows.
332
+ */
333
+ const foldMemory = new Map<string, boolean>();
334
+
335
+ function setFold(href: string, open: boolean): boolean {
336
+ foldMemory.set(href, open);
337
+ return open;
338
+ }
339
+
310
340
  /**
311
341
  * A branch: a native `<details>` folding a sub-list of entries in and out. The
312
342
  * children stay mounted whether folded or not — closing hides them, it doesn't
@@ -327,12 +357,17 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
327
357
  // nowhere in the menu, nothing has an opinion, and the fold simply keeps
328
358
  // its last state — folding everything up would answer a question nobody
329
359
  // asked with a menu that forgot where the user was.
330
- let last = false;
360
+ //
361
+ // "Last state" lives in `foldMemory`, not in this closure: menus remount —
362
+ // the phone's full-page nav exists only while it is open — and a remount
363
+ // must find the state where the previous mount left it. It is keyed on the
364
+ // branch's selection href, so the sidebar and the phone nav (two renderings
365
+ // of the same items) share one truth, however often either is rebuilt.
331
366
  const $open = href != null
332
367
  ? A.derive(() => {
333
- if (containsCurrent(entry)) return (last = true);
334
- if ($menuHasCurrent == null || $menuHasCurrent.value) return (last = false);
335
- return last;
368
+ if (containsCurrent(entry)) return setFold(href, true);
369
+ if ($menuHasCurrent == null || $menuHasCurrent.value) return setFold(href, false);
370
+ return foldMemory.get(href) ?? false;
336
371
  })
337
372
  : null;
338
373
 
@@ -344,9 +379,10 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
344
379
  A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
345
380
  if (entry.disabled) A("aria-disabled=true");
346
381
  A(() => {
347
- // Current only on its *own* page: when a descendant is current, that
382
+ // Current only on its *own* page (its `href`, or a `match` claim —
383
+ // pages with no row of their own): when a descendant is current, that
348
384
  // row carries the highlight, and two highlights would read as two pages.
349
- if (entry.href != null && matchCurrent(entry.href)) A("aria-current=page");
385
+ if (isCurrent(entry)) A("aria-current=page");
350
386
  });
351
387
  A("click=", (e: Event) => {
352
388
  if (entry.disabled) { e.preventDefault(); return; }
@@ -384,15 +420,35 @@ function foldedAway(el: HTMLElement): boolean {
384
420
  return false;
385
421
  }
386
422
 
387
- /** Whether any page linked anywhere in `items` is the current one. */
388
- function anyCurrent(items: MenuEntry[]): boolean {
423
+ /**
424
+ * Whether any item anywhere in `items` — branches, their leaves, `match`
425
+ * claims — is the current page. The one question both the fold logic and the
426
+ * shell's tagline rule (see `taglineFits` in main.ts) ask of a menu, exported
427
+ * so the two can never disagree with the highlighting.
428
+ */
429
+ export function anyCurrent(items: MenuEntry[]): boolean {
389
430
  return items.some((entry) =>
390
431
  typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
391
432
  }
392
433
 
393
- /** Whether `entry`'s own page, or any page linked below it, is the current one. */
394
- function containsCurrent(entry: MenuItem): boolean {
434
+ /**
435
+ * Whether this item is the current page: its own `href` matches, or its
436
+ * `match` claims the current path. The single test behind `aria-current`,
437
+ * branch unfolding, and {@link anyCurrent} — one truth, three consumers.
438
+ */
439
+ function isCurrent(entry: MenuItem): boolean {
395
440
  if (entry.href != null && matchCurrent(entry.href)) return true;
441
+ const m = entry.match;
442
+ if (m == null) return false;
443
+ const path = currentRoute.path;
444
+ if (typeof m === "function") return m(path);
445
+ const claim = m.replace(/\/+$/, "") || "/";
446
+ return path === claim || path.startsWith(claim === "/" ? "/" : claim + "/");
447
+ }
448
+
449
+ /** Whether `entry` is the current page itself, or holds it anywhere below. */
450
+ function containsCurrent(entry: MenuItem): boolean {
451
+ if (isCurrent(entry)) return true;
396
452
  for (const child of entry.items ?? []) {
397
453
  if (typeof child === "string" || typeof child === "function" || "separator" in child) continue;
398
454
  if (containsCurrent(child)) return true;
@@ -179,13 +179,19 @@ export interface Panel<P = Record<string, string | number | string[]>> {
179
179
  * column fits beside it. For lists and detail forms.
180
180
  * - `"full"` (the default) — the whole content area, up to ~1100px.
181
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.
182
+ * dashboards. While one is open the columns stretch to the screen edges
183
+ * instead of stopping at the standard 1280px page; the top bar and
184
+ * footer hold the standard width throughout.
184
185
  *
185
186
  * Below the width two columns need, everything takes the content area
186
187
  * whatever it asked for. Widths depend only on the window, never on what
187
188
  * else is open, so opening or closing a panel never resizes another.
188
189
  *
190
+ * This is a *layout regime*, not a width guarantee: handle whatever width
191
+ * the bucket yields, and ask only for what your content can actually use —
192
+ * a screen that would cap its own content narrower than its ask is holding
193
+ * room that would have let another column fit beside it.
194
+ *
189
195
  * Set it at the top of your handler and the panel is already that wide when
190
196
  * you draw (see {@link Panel.width}); set it later — when your data tells you
191
197
  * — and the panel reflows without being redrawn, keeping its state, while
@@ -261,6 +267,31 @@ export interface Panel<P = Record<string, string | number | string[]>> {
261
267
  * ```
262
268
  */
263
269
  close(): Promise<boolean>;
270
+ /**
271
+ * Opens `href` exactly as a click on a link inside this panel does — the
272
+ * shell's own link handling runs through this very call, so the two can't
273
+ * drift apart. By default that is a push: the target opens on top of this
274
+ * panel, closing the panels after it first (pinned ones ride along
275
+ * beneath the new panel, unsaved ones park), and a path that is already
276
+ * open is returned to rather than opened twice. `how` plays the part of a
277
+ * link's `data-panel` attribute: `"replace"` puts the target in this
278
+ * panel's place, `"open"` leaves the panel behind and gives the target
279
+ * its own stack, and omitting it follows the shell's
280
+ * {@link MainOptions.linkNavigation}, like a link without the attribute.
281
+ *
282
+ * This is the one for navigation that can't be a link: a row's click
283
+ * handler, a keyboard shortcut acting on this screen. The stack's
284
+ * {@link PanelStack.pushPanel} builds on the *current* panel instead — a
285
+ * different panel exactly when the interaction happened in a column
286
+ * beside it, where it would pile the new panel on top of the open detail
287
+ * rather than pruning back to this one.
288
+ *
289
+ * @example
290
+ * ```ts
291
+ * A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);
292
+ * ```
293
+ */
294
+ open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
264
295
  }
265
296
 
266
297
  // ─── Path matching ───────────────────────────────────────────────────────────
@@ -374,7 +405,7 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
374
405
  /**
375
406
  * The one duration every bit of shell motion shares: the enter/exit fades, the
376
407
  * `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
408
+ * body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
378
409
  * panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
379
410
  * and JS can't drift apart.
380
411
  *
@@ -388,9 +419,11 @@ const LOADING_HOLD_MS = 300;
388
419
  /**
389
420
  * The standard page width: sidebar plus content area, capped by the window.
390
421
  * `"full"` fills the content-area part of this exactly; only a `"screen"`
391
- * page makes the shell grow past it.
422
+ * page makes the shell grow past it. The top bar and footer keep to this
423
+ * width even then (see main.ts), so the chrome holds still while the
424
+ * columns stretch.
392
425
  */
393
- const SHELL_PX = 1280;
426
+ export const SHELL_PX = 1280;
394
427
  /** Don't pair smalls when half the content area would be narrower than this. */
395
428
  const PAIR_MIN_PX = 360;
396
429
  /**
@@ -415,8 +448,13 @@ A.insertGlobalCss({
415
448
  // The region paints the panel's sheen over its own box, and every panel shows
416
449
  // a slice of that same gradient (see `.s-panel` below), so the columns and
417
450
  // the ground beside them are one continuous surface.
451
+ // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
452
+ // and anything that ever scrolls it — find-in-page reaching for text in a
453
+ // parked column, an in-page anchor, an extension — shifts every column
454
+ // sideways, permanently, because nothing here would ever scroll it back.
455
+ // `clip` clips without being scrollable at all, closing the whole class.
418
456
  ".s-panels":
419
- "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
457
+ "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
420
458
  SURFACE_SHEEN,
421
459
  ".s-panel": {
422
460
  // A panel rests at a plain `left` offset and carries no transform: a
@@ -513,14 +551,19 @@ A.insertGlobalCss({
513
551
  // the weight change alone is ambiguous in a short crumb, the colour alone
514
552
  // too subtle. No padding of its own — the first crumb has to start on the
515
553
  // 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.
554
+ // The flex is how a tight row is shared out. Every crumb grows from the
555
+ // same 4rem basis in equal shares, freezing at its own text
556
+ // (`max-width:max-content`) — so with room to spare every title shows in
557
+ // full, and under pressure it is the *longest* crumbs that give way
558
+ // first, equalising downward while short ones keep every character. No
559
+ // crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
560
+ // that point the row overflows and the strip scrolls — which is what
561
+ // keeps a deep stack on a phone readable. (Crumbs allowed to shrink
562
+ // would ellipsise to a row of stubs instead, and the stack would never
563
+ // scroll.)
521
564
  "&":
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 " +
565
+ "flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
566
+ "white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
524
567
  "transition: color 0.12s;",
525
568
  "&.s-crumb-on": "font-weight:600 fg:$s-text",
526
569
  // The same hover treatment as a menu item. The panel you are on is a plain
@@ -739,6 +782,11 @@ export interface PanelStack {
739
782
  * than opening it twice, and a panel holding {@link Panel.unsaved} work is
740
783
  * never closed, only parked. That's what a plain link does, and what
741
784
  * `data-panel=push` says outright.
785
+ *
786
+ * Note that a link builds on the panel it is *drawn in*, which is the
787
+ * current panel only while no column beside it has the focus. Code
788
+ * navigating on behalf of a particular screen — a row's click handler —
789
+ * wants that panel's own {@link Panel.open} instead.
742
790
  */
743
791
  pushPanel(path: string): Promise<boolean>;
744
792
  /**
@@ -1117,9 +1165,12 @@ export class PanelStackController implements PanelStack {
1117
1165
  maxWidth: "full" as const,
1118
1166
  width: 0,
1119
1167
  } as PanelEntry;
1120
- // `close` closes *this* panel, current or not. It resolves the panel's
1121
- // place in the stack at call time, so it keeps working after a splice has
1122
- // moved it — and quietly resolves false once the panel is gone.
1168
+ // `close` closes *this* panel, current or not, and `open` navigates
1169
+ // *from* it, through the very implementation a link click uses (see
1170
+ // `navigate`). Both resolve the panel's place in the stack at call
1171
+ // time, so they keep working after a splice has moved it; an `open`
1172
+ // from a panel that has since closed falls back to a derived stack,
1173
+ // like a link from nowhere.
1123
1174
  //
1124
1175
  // `visible` starts at what the panel's place implies: shown when it sits
1125
1176
  // at or before the current panel (a pushed panel always does), hidden when
@@ -1133,6 +1184,7 @@ export class PanelStackController implements PanelStack {
1133
1184
  visible,
1134
1185
  pinned: pinned || undefined,
1135
1186
  close: () => this.closePath(entry.path),
1187
+ open: (href: string, how?: "push" | "replace" | "open") => this.navigate(href, { from: entry.path, how }),
1136
1188
  }) as PanelState;
1137
1189
  return entry;
1138
1190
  }
@@ -1365,17 +1417,31 @@ export class PanelStackController implements PanelStack {
1365
1417
  }
1366
1418
 
1367
1419
  /**
1368
- * Navigate to `href`. `origin` is the path of the panel the link lives in, or
1369
- * `null` when it has none — a nav item, or a programmatic call, which builds
1370
- * the whole stack instead (see {@link deriveStack}). `replace` swaps the
1371
- * originating panel rather than stacking on top of it, and `beneath` says what
1372
- * the stack under the target is outright, for callers that know.
1420
+ * Navigate to `href` — the one implementation behind a link click,
1421
+ * {@link Panel.open} and the stack's own methods, so none of them can
1422
+ * behave differently.
1423
+ *
1424
+ * `from` is the path of the panel the navigation starts from — the one
1425
+ * the link lives in — or absent when it has none: a nav item, or a call
1426
+ * that means the whole stack, which is then built instead (see
1427
+ * {@link deriveStack}), or taken outright from `beneath`, for callers
1428
+ * that know it.
1429
+ *
1430
+ * `how` is the link's `data-panel` attribute (or the caller's word for
1431
+ * it): absent — like a link without the attribute — it is the shell's
1432
+ * `linkNavigation` default, an unrecognised value is a push on top of
1433
+ * `from`, `"replace"` swaps `from` out rather than stacking on it, and
1434
+ * `"open"` drops `from` altogether so the target arrives with its own
1435
+ * stack, the way a nav item's link does.
1373
1436
  *
1374
1437
  * Resolves the way every {@link PanelStack} method does: `true` once the
1375
1438
  * navigation lands, `false` when it doesn't (already there counts as
1376
1439
  * landed).
1377
1440
  */
1378
- private navigate(href: string, origin: string | null, replace = false, beneath?: readonly string[]): Promise<boolean> {
1441
+ private navigate(href: string, { from, how, beneath }: { from?: string; how?: string; beneath?: readonly string[] } = {}): Promise<boolean> {
1442
+ const mode = how ?? this.opts.linkNavigation;
1443
+ const origin = mode === "open" ? null : from ?? null;
1444
+ const replace = mode === "replace";
1379
1445
  return A.peek(() => {
1380
1446
  let url: URL;
1381
1447
  try { url = new URL(href, location.href); } catch { return Promise.resolve(false); }
@@ -1430,7 +1496,7 @@ export class PanelStackController implements PanelStack {
1430
1496
  private pushPath(path: string, replace: boolean): Promise<boolean> {
1431
1497
  return A.peek(() => {
1432
1498
  const arr = this.intended();
1433
- return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
1499
+ return this.navigate(path, { from: arr.stack[arr.focus], how: replace ? "replace" : "push" });
1434
1500
  });
1435
1501
  }
1436
1502
 
@@ -1438,36 +1504,30 @@ export class PanelStackController implements PanelStack {
1438
1504
 
1439
1505
  /**
1440
1506
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
1441
- * the anchor so we can decide what the click *means*: the originating
1442
- * `.s-panel` (which decides what the click truncates), the `data-panel`
1443
- * attribute, and return-to-an-open-panel semantics. The exclusion rules
1507
+ * the anchor so we can decide what the click *means*. The exclusion rules
1444
1508
  * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
1445
1509
  * close guards run in `checkChange` when our navigation reaches the router.
1446
1510
  *
1447
- * `data-panel` names which of the three {@link PanelStack} navigations the
1448
- * click is: `push`, `replace`, or `open`, which drops the
1449
- * originating panel so the target arrives with its own stack beneath it,
1450
- * exactly as a nav item's link does. A link that doesn't say gets the
1451
- * shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
1452
- * an unrecognised value is a `push`.
1511
+ * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
1512
+ * attribute as its `how` (see {@link navigate}, the shared implementation).
1513
+ * A link that isn't inside any panel — a nav item, one in a dialog — has no
1514
+ * panel to build on, so it replaces the stack as a whole, exactly as a cold
1515
+ * link to the same URL would open it.
1453
1516
  */
1454
1517
  private interceptLinks(): void {
1455
1518
  route.interceptLinks((url, anchor) => {
1456
- const mode = anchor.getAttribute("data-panel") ?? this.opts.linkNavigation;
1457
- let origin: string | null = null;
1458
- if (mode !== "open") {
1459
- const panel = anchor.closest<HTMLElement>(".s-panel");
1460
- if (panel) {
1461
- origin = this.$state.live.find((entry) => entry.el === panel)?.path ?? null;
1462
- } else if (anchor.closest(".s-panel-origin")) {
1463
- // The current panel's actions, promoted into the top bar on a
1464
- // narrow shell (see main.ts), sit outside every `.s-panel` — but
1465
- // they are still the current panel's own chrome, so a link among
1466
- // them builds on that panel, exactly as it does at full width.
1467
- origin = this.$state.live[this.$state.focus]?.path ?? null;
1468
- }
1469
- }
1470
- void this.navigate(url.href, origin, mode === "replace");
1519
+ const how = anchor.getAttribute("data-panel") ?? undefined;
1520
+ // The panel the link lives in: the enclosing `.s-panel`, or — for the
1521
+ // current panel's actions, promoted into the top bar on a narrow shell
1522
+ // (see main.ts), outside every `.s-panel` — the current panel, whose
1523
+ // own chrome they remain at every width.
1524
+ const panelEl = anchor.closest<HTMLElement>(".s-panel");
1525
+ const entry = panelEl
1526
+ ? this.$state.live.find((e) => e.el === panelEl)
1527
+ : anchor.closest(".s-panel-origin")
1528
+ ? this.$state.live[this.$state.focus]
1529
+ : undefined;
1530
+ void this.navigate(url.href, { from: entry?.path, how });
1471
1531
  return true;
1472
1532
  });
1473
1533
  }
@@ -1499,7 +1559,7 @@ export class PanelStackController implements PanelStack {
1499
1559
  }
1500
1560
 
1501
1561
  openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean> {
1502
- return this.navigate(path, null, false, beneath);
1562
+ return this.navigate(path, { how: "open", beneath });
1503
1563
  }
1504
1564
 
1505
1565
  closePanel(path?: string): Promise<boolean> {
@@ -1509,6 +1569,25 @@ export class PanelStackController implements PanelStack {
1509
1569
  });
1510
1570
  }
1511
1571
 
1572
+ // ── Live settings ──────────────────────────────────────────────────────
1573
+ // `main()` keeps these fed from small reactive scopes of their own, so an
1574
+ // app that reads them off a proxy (or through a getter) can change them at
1575
+ // runtime and the shell adapts in place — nothing is redrawn, no panel
1576
+ // loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
1577
+ // options; these are how `main()` talks to the stack.
1578
+
1579
+ /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
1580
+ setColumns(columns: "auto" | "single" | undefined): void {
1581
+ if (this.opts.columns === columns) return;
1582
+ this.opts.columns = columns;
1583
+ this.scheduleLayout();
1584
+ }
1585
+
1586
+ /** Adopt a changed `linkNavigation` default; the next click reads it. */
1587
+ setLinkNavigation(mode: "push" | "replace" | "open" | undefined): void {
1588
+ this.opts.linkNavigation = mode;
1589
+ }
1590
+
1512
1591
  /**
1513
1592
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
1514
1593
  * panel, oldest first, the ones on screen right now in bold, pinned ones
@@ -1942,11 +2021,11 @@ export class PanelStackController implements PanelStack {
1942
2021
  if (!entry.width) entry.width = width(entry);
1943
2022
  }
1944
2023
 
1945
- // The chrome above and below the body caps itself to the ensemble width,
1946
- // keeping everything centred and aligned however far the area stretches.
1947
- // The consumers transition their max-width (see main.ts), so the
1948
- // recentring plays along with the panel that caused it instead of
1949
- // snapping.
2024
+ // The body row caps itself to the ensemble width, keeping the columns
2025
+ // centred however far the area stretches, and transitions its max-width
2026
+ // (see main.ts) so the recentring plays along with the panel that caused
2027
+ // it. The bars above and below don't follow — they hold at the standard
2028
+ // page width (also main.ts).
1950
2029
  shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
1951
2030
 
1952
2031
  // Phase 1 — every panel's *start* state for this frame. Panels already on