staffa 0.10.1 → 0.12.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.
@@ -1,7 +1,7 @@
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.
@@ -50,9 +50,11 @@ export interface MainOptions<R = Routes> {
50
50
  * when your home screen lives elsewhere. It's an ordinary link, so the
51
51
  * usual rules apply: a home that is already open in the stack — its first
52
52
  * panel, usually — is returned to, closing nothing, and one that isn't is
53
- * opened the way a nav item would be. Routed mode only.
53
+ * opened the way a nav item would be. Pass `null` to link neither — for a
54
+ * `title` or `logo` slot holding interactive content of its own, which
55
+ * can't sit inside a link. Routed mode only.
54
56
  */
55
- home?: string;
57
+ home?: string | null;
56
58
  /**
57
59
  * The app's own chrome, at the trailing end of the top bar: an account
58
60
  * button, a global search box, a settings menu. It may grow into the bar's
@@ -187,12 +189,31 @@ export interface MainOptions<R = Routes> {
187
189
  // `$panel` would quietly degrade to `any` (see the note on `main` below).
188
190
  ancestors?: AncestorTable<NoInfer<R>>;
189
191
  /**
190
- * Set `false` to show only the current panel, however wide the screen (the
191
- * nav sidebar still sits beside it). Everything else behaves the same: the
192
- * URL, the back button, unsaved panels, and the panels' own close buttons.
193
- * This only changes how many you see. Defaults to `true`.
192
+ * How many panels are *shown* at a time. `"auto"` (the default) shows as
193
+ * many columns, side by side, as comfortably fit, ending at the current
194
+ * panel; `"single"` shows only the current panel, however wide the screen
195
+ * — the phone experience at every size (the nav sidebar still sits beside
196
+ * it). Only the display differs: the stack, the breadcrumbs, the URL,
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.
202
+ */
203
+ columns?: "auto" | "single";
204
+ /**
205
+ * What a link *without* a `data-panel` attribute does — the per-link
206
+ * attribute always wins. `"push"` (the default) opens the target on top of
207
+ * the panel the link sits in; `"replace"` opens it in that panel's place;
208
+ * `"open"` gives it its own stack, the way a nav item does. With `"open"`
209
+ * every click replaces the content as a whole — which, with flat routes,
210
+ * is the conventional sidebar-and-content app: one pane, swapped on every
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.
194
215
  */
195
- stacking?: boolean;
216
+ linkNavigation?: "push" | "replace" | "open";
196
217
  /** Footer content, pinned below the scroll area. */
197
218
  footer?: Slot;
198
219
  /**
@@ -256,9 +277,11 @@ A.insertGlobalCss({
256
277
  "> footer": "border-top: 1px solid $s-faint; fg:$s-muted",
257
278
  // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
258
279
  // trailing slot's own growth: it takes the free space and right-aligns
259
- // itself in it, which is what lets a search box live there. It doesn't
260
- // shrink, and the title does — so the title is what truncates when the two
261
- // compete, and the app's chrome stays usable.
280
+ // itself in it, which is what lets a search box live there. When the two
281
+ // compete, the title truncates first — but only down to a floor, past
282
+ // which the trailing slot shrinks instead: a wide search box must not
283
+ // starve the titles to nothing (the crumb strip's overlay buttons would
284
+ // escape their zero-width strip, over the ☰ beside it).
262
285
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
263
286
  "> header .s-logo, > header .s-nav-trigger": "display:flex align-items:center flex-shrink:0",
264
287
  // The ☰ is a glyph in a 2rem hit area, so it carries ~6px of its own
@@ -266,7 +289,7 @@ A.insertGlobalCss({
266
289
  // up with the bar's edge and with the stack below.
267
290
  "> header .s-nav-trigger": "margin-left:-0.375rem",
268
291
  "> header .s-logo": "font-size:1.4em background: $s-gradient; -webkit-background-clip:text; background-clip:text; color:transparent;",
269
- "> header .s-titles": "display:flex flex-direction:column min-width:0 flex: 0 1 auto;",
292
+ "> header .s-titles": "display:flex flex-direction:column min-width:5rem flex: 0 1 auto;",
270
293
  // Same font-size and line-height as `.s-crumb`, because in routed mode the
271
294
  // two take turns on this line (see `drawSecondLine`): a different height
272
295
  // would jog the whole bar as they swap.
@@ -277,7 +300,7 @@ A.insertGlobalCss({
277
300
  // which their classes then provide. (`filter:none` keeps the global
278
301
  // `a:hover` brighten off the gradient text.)
279
302
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
280
- "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0 auto;",
303
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
281
304
  // Body always wraps <main> (with or without a sidebar) so max-width centering
282
305
  // and scrollbar alignment work identically in both cases.
283
306
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
@@ -356,10 +379,12 @@ A.insertGlobalCss({
356
379
  // body starts below the bar), but the bar should still win if they ever do.
357
380
  "position:absolute inset:0 z-index:5 display:flex flex-direction:column " +
358
381
  "overflow-y:auto overscroll-behavior:contain border:0 r:0 padding:$2 gap:$1 " +
359
- "transition: transform var(--s-panel-ms) ease;",
382
+ "transition: transform var(--s-panel-ms) ease, visibility var(--s-panel-ms);",
360
383
  // Parked one screen to the left: the state the `create=`/`destroy=` hooks
361
- // transition out of and back into.
362
- "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none",
384
+ // transition out of and back into. `visibility` flips at the slide's end
385
+ // (see `.s-menu-list` in menu.ts): the dismissed page lingers off screen
386
+ // until Aberdeen's removal timer, and mustn't stay reachable meanwhile.
387
+ "&.s-nav-page-off": "transform:translateX(-100%) pointer-events:none visibility:hidden",
363
388
  // Roomier rows than the dropdown's: this is the whole screen, and every row
364
389
  // is a thumb target.
365
390
  ".s-menu-item": "padding: $2 $3; min-height:3rem font-size:1.05em gap:$3",
@@ -467,11 +492,22 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
467
492
  routes,
468
493
  notFound: opts.notFound,
469
494
  ancestors: opts.ancestors,
470
- stacking: opts.stacking,
471
495
  title: opts.title,
472
496
  $shell,
473
497
  })
474
498
  : null;
499
+ if (ctl) {
500
+ // `columns` and `linkNavigation` are live: each is read in a scope of
501
+ // its own, so when the options object is a proxy (or the field a
502
+ // getter), a change re-runs just that scope — the columns relayout in
503
+ // place with every panel's state intact, and the next click picks up
504
+ // the new link default. Nothing else of the shell is touched.
505
+ A(() => ctl.setColumns(opts.columns));
506
+ A(() => ctl.setLinkNavigation(opts.linkNavigation));
507
+ }
508
+ // Where the brand mark and the app's name link — or nowhere, when the app
509
+ // said `home: null` (a title slot holding a control of its own, say).
510
+ const homeHref = ctl && opts.home !== null ? opts.home ?? "/" : null;
475
511
  // Routed mode caps the shell to the ensemble width the layout engine publishes,
476
512
  // rather than to `maxWidth`.
477
513
  const capWidth = ctl ? null : opts.maxWidth;
@@ -527,8 +563,8 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
527
563
  // twinned with the app's name beside it — a real link, so it
528
564
  // has an address to hover, middle-click and copy, and a click
529
565
  // runs the shell's usual link rules.
530
- A(ctl ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
531
- if (ctl) A("href=", opts.home ?? "/");
566
+ A(homeHref != null ? "a.s-logo aria-label=Home" : "div.s-logo", () => {
567
+ if (homeHref != null) A("href=", homeHref);
532
568
  drawSlot(opts.logo);
533
569
  });
534
570
  });
@@ -541,8 +577,8 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
541
577
  A("div.s-titles", () => {
542
578
  A(() => {
543
579
  if (opts.title == null) return;
544
- A(ctl ? "a.s-title" : "div.s-title", () => {
545
- if (ctl) A("href=", opts.home ?? "/");
580
+ A(homeHref != null ? "a.s-title" : "div.s-title", () => {
581
+ if (homeHref != null) A("href=", homeHref);
546
582
  drawSlot(opts.title);
547
583
  });
548
584
  });
@@ -551,10 +587,13 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
551
587
 
552
588
  // Trailing: on a narrow shell the screen's own verbs win the space,
553
589
  // and a screen with none of its own leaves the app's chrome up.
590
+ // Promoted actions are marked as the current panel's own chrome
591
+ // (`.s-panel-origin`), so a link among them still builds on that
592
+ // panel — see `interceptLinks` in panels.ts.
554
593
  A(() => {
555
594
  const actions = $shell.narrow ? ctl?.currentPanel?.actions : undefined;
556
595
  const slot = actions ?? opts.menu;
557
- if (slot != null) A("div.s-menu", () => drawSlot(slot));
596
+ if (slot != null) A(`div.s-menu${actions != null ? ".s-panel-origin" : ""}`, () => drawSlot(slot));
558
597
  });
559
598
  });
560
599
  });
@@ -720,23 +759,18 @@ function drawSecondLine(
720
759
 
721
760
  /**
722
761
  * Whether the stack would only be saying what the sidebar already says: a
723
- * single panel open, the sidebar on screen, and that panel being one of the nav's
724
- * own rows.
762
+ * single panel open, the sidebar on screen, and that panel being one of the
763
+ * nav's own rows — a leaf inside a submenu counts, since the sidebar shows it
764
+ * highlighted (inside its unfolded branch) all the same.
725
765
  *
726
- * The row test is {@link matchCurrent} — the very thing that marks a row
727
- * `aria-current=page` — so "the crumb is redundant" and "the sidebar has it
728
- * highlighted" can never come apart. It compares whole paths, so it is true
729
- * only for a nav item's own screen, never for one opened beneath it.
766
+ * The row test is the menu's own {@link anyCurrent} — the very thing that
767
+ * marks a row `aria-current=page` — so "the crumb is redundant" and "the
768
+ * sidebar has it highlighted" can never come apart.
730
769
  */
731
770
  function taglineFits(ctl: PanelStackController, nav: MenuOptions | undefined, $shell: { narrow: boolean }): boolean {
732
771
  if ($shell.narrow || nav == null) return false;
733
772
  if (ctl.panels.length > 1) return false;
734
- return nav.items.some((entry: MenuEntry) =>
735
- typeof entry !== "string" &&
736
- typeof entry !== "function" &&
737
- !("separator" in entry) &&
738
- entry.href != null &&
739
- matchCurrent(entry.href));
773
+ return anyCurrent(nav.items);
740
774
  }
741
775
 
742
776
  /**
@@ -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. */
@@ -38,11 +49,13 @@ export interface MenuItem {
38
49
  attrs?: Attributes;
39
50
  /**
40
51
  * Child entries, which turn the item into a collapsible **branch** of a
41
- * tree. Only the branch holding the current page is expanded; navigate away
42
- * and it folds back up. Clicking a branch *selects* rather than toggles: it
43
- * follows the item's own `href`, or failing that the first linked leaf
44
- * below it — which is what expands it. A branch with no link anywhere below
45
- * it falls back to plain open/close toggling.
52
+ * tree. Only the branch holding the current page is expanded; navigate to
53
+ * another page in the menu and it folds back up. (Navigating to a page the
54
+ * menu doesn't hold *anywhere* leaves every fold as it was: there is no
55
+ * better answer to fold up to.) Clicking a branch *selects* rather than
56
+ * toggles: it follows the item's own `href`, or failing that the first
57
+ * linked leaf below it — which is what expands it. A branch with no link
58
+ * anywhere below it falls back to plain open/close toggling.
46
59
  *
47
60
  * Expanding is not selecting: a branch click never counts as picking an
48
61
  * item (see `onLeafSelect` on {@link menu}), so on a phone the nav stays up
@@ -131,12 +144,17 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
131
144
  A.insertGlobalCss({
132
145
  // Border comes from the `.s-s.neutral` surface; the panel only overrides the radius
133
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.
134
152
  ".s-menu-list":
135
153
  "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
136
154
  "r:$s-radius-lg " +
137
155
  "overflow-y:auto max-height:min(80vh,28rem) " +
138
- "transition: opacity 0.15s, transform 0.15s;",
139
- ".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",
140
158
  // One class for both the `<a>` (link) and `<button>` forms — they look
141
159
  // identical; the element only differs where link semantics matter (see below).
142
160
  // The scroll-margin keeps a revealed row (see the scrollIntoView in
@@ -243,14 +261,20 @@ export function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void {
243
261
  els[next].focus();
244
262
  });
245
263
 
246
- drawEntries(items, onLeafSelect);
264
+ // Whether the current page is in this menu *at all*, shared by every branch
265
+ // below: a navigation to a page the menu doesn't hold must leave the folds
266
+ // alone (see `drawBranch`), and that is a fact about the whole menu, which
267
+ // no branch can tell on its own. Derived, so the branches re-run only when
268
+ // the answer flips — not on every navigation between two held pages.
269
+ const $menuHasCurrent = A.derive(() => anyCurrent(items));
270
+ drawEntries(items, onLeafSelect, $menuHasCurrent);
247
271
  }
248
272
 
249
- function drawEntries(items: MenuEntry[], onLeafSelect?: () => void): void {
273
+ function drawEntries(items: MenuEntry[], onLeafSelect?: () => void, $menuHasCurrent?: { value: boolean }): void {
250
274
  for (const entry of items) {
251
275
  if (typeof entry === "string" || typeof entry === "function") { drawSlot(entry); continue; }
252
276
  if ("separator" in entry) { A("hr.s-menu-sep"); continue; }
253
- if (entry.items) drawBranch(entry, onLeafSelect);
277
+ if (entry.items) drawBranch(entry, onLeafSelect, $menuHasCurrent);
254
278
  else drawLeaf(entry, onLeafSelect);
255
279
  }
256
280
  }
@@ -274,7 +298,7 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
274
298
  A(() => {
275
299
  const first = !drawn;
276
300
  drawn = true;
277
- if (!matchCurrent(entry.href!)) return;
301
+ if (!isCurrent(entry)) return;
278
302
  A("aria-current=page");
279
303
  // A list taller than its scrollport (a long sidebar nav, mostly)
280
304
  // highlights nothing when the current row is scrolled out of it,
@@ -299,6 +323,20 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
299
323
  });
300
324
  }
301
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
+
302
340
  /**
303
341
  * A branch: a native `<details>` folding a sub-list of entries in and out. The
304
342
  * children stay mounted whether folded or not — closing hides them, it doesn't
@@ -311,12 +349,27 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
311
349
  * linked branch is open exactly while it holds the current page. Only a branch
312
350
  * with no link anywhere below it keeps the native open/close toggle.
313
351
  */
314
- function drawBranch(entry: MenuItem, onLeafSelect?: () => void): void {
352
+ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?: { value: boolean }): void {
315
353
  const href = entry.href ?? firstLeafHref(entry.items!);
316
354
  // The route-derived fold state, as a derived boolean so the attribute scope
317
355
  // below re-runs only when the answer flips — not on every navigation that
318
- // merely moves *between* pages inside the branch.
319
- const $open = href != null ? A.derive(() => containsCurrent(entry)) : null;
356
+ // merely moves *between* pages inside the branch. When the current page is
357
+ // nowhere in the menu, nothing has an opinion, and the fold simply keeps
358
+ // its last state — folding everything up would answer a question nobody
359
+ // asked with a menu that forgot where the user was.
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.
366
+ const $open = href != null
367
+ ? A.derive(() => {
368
+ if (containsCurrent(entry)) return setFold(href, true);
369
+ if ($menuHasCurrent == null || $menuHasCurrent.value) return setFold(href, false);
370
+ return foldMemory.get(href) ?? false;
371
+ })
372
+ : null;
320
373
 
321
374
  A("details.s-menu-details", () => {
322
375
  // For a no-link branch this scope has no subscriptions and never re-runs,
@@ -326,9 +379,10 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void): void {
326
379
  A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
327
380
  if (entry.disabled) A("aria-disabled=true");
328
381
  A(() => {
329
- // 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
330
384
  // row carries the highlight, and two highlights would read as two pages.
331
- if (entry.href != null && matchCurrent(entry.href)) A("aria-current=page");
385
+ if (isCurrent(entry)) A("aria-current=page");
332
386
  });
333
387
  A("click=", (e: Event) => {
334
388
  if (entry.disabled) { e.preventDefault(); return; }
@@ -346,7 +400,7 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void): void {
346
400
  A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
347
401
  });
348
402
 
349
- A("div.s-menu-sub", () => drawEntries(entry.items!, onLeafSelect));
403
+ A("div.s-menu-sub", () => drawEntries(entry.items!, onLeafSelect, $menuHasCurrent));
350
404
  });
351
405
  }
352
406
 
@@ -366,9 +420,35 @@ function foldedAway(el: HTMLElement): boolean {
366
420
  return false;
367
421
  }
368
422
 
369
- /** Whether `entry`'s own page, or any page linked below it, is the current one. */
370
- function containsCurrent(entry: MenuItem): 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 {
430
+ return items.some((entry) =>
431
+ typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
432
+ }
433
+
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 {
371
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;
372
452
  for (const child of entry.items ?? []) {
373
453
  if (typeof child === "string" || typeof child === "function" || "separator" in child) continue;
374
454
  if (containsCurrent(child)) return true;
@@ -668,8 +668,10 @@ export interface PanelStackOptions {
668
668
  notFound?: RouteHandler<{}>;
669
669
  /** What to open beneath a path that arrives cold. See {@link MainOptions.ancestors}. */
670
670
  ancestors?: Record<string, AncestorsHandler | undefined>;
671
- /** Set `false` to show only the current panel, however much room there is. */
672
- stacking?: boolean;
671
+ /** How many panels are shown at a time. See {@link MainOptions.columns}. */
672
+ columns?: "auto" | "single";
673
+ /** What a bare link does. See {@link MainOptions.linkNavigation}. */
674
+ linkNavigation?: "push" | "replace" | "open";
673
675
  /** The shell's own title, used as the suffix of `document.title`. */
674
676
  title?: unknown;
675
677
  /**
@@ -1443,16 +1445,29 @@ export class PanelStackController implements PanelStack {
1443
1445
  * close guards run in `checkChange` when our navigation reaches the router.
1444
1446
  *
1445
1447
  * `data-panel` names which of the three {@link PanelStack} navigations the
1446
- * click is: `push` (the default), `replace`, or `open`, which drops the
1448
+ * click is: `push`, `replace`, or `open`, which drops the
1447
1449
  * 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`.
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`.
1449
1453
  */
1450
1454
  private interceptLinks(): void {
1451
1455
  route.interceptLinks((url, anchor) => {
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");
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");
1456
1471
  return true;
1457
1472
  });
1458
1473
  }
@@ -1494,6 +1509,25 @@ export class PanelStackController implements PanelStack {
1494
1509
  });
1495
1510
  }
1496
1511
 
1512
+ // ── Live settings ──────────────────────────────────────────────────────
1513
+ // `main()` keeps these fed from small reactive scopes of their own, so an
1514
+ // app that reads them off a proxy (or through a getter) can change them at
1515
+ // runtime and the shell adapts in place — nothing is redrawn, no panel
1516
+ // loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
1517
+ // options; these are how `main()` talks to the stack.
1518
+
1519
+ /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
1520
+ setColumns(columns: "auto" | "single" | undefined): void {
1521
+ if (this.opts.columns === columns) return;
1522
+ this.opts.columns = columns;
1523
+ this.scheduleLayout();
1524
+ }
1525
+
1526
+ /** Adopt a changed `linkNavigation` default; the next click reads it. */
1527
+ setLinkNavigation(mode: "push" | "replace" | "open" | undefined): void {
1528
+ this.opts.linkNavigation = mode;
1529
+ }
1530
+
1497
1531
  /**
1498
1532
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
1499
1533
  * panel, oldest first, the ones on screen right now in bold, pinned ones
@@ -1883,7 +1917,7 @@ export class PanelStackController implements PanelStack {
1883
1917
  const geom = this.geometry();
1884
1918
  if (!geom) return;
1885
1919
 
1886
- const stacking = this.opts.stacking !== false;
1920
+ const stacking = this.opts.columns !== "single";
1887
1921
 
1888
1922
  // A window resize (or the very first pass) must be adopted instantly —
1889
1923
  // geometry tracking the window through a 450ms transition reads as lag,