staffa 0.12.0 → 0.13.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.
@@ -229,6 +229,20 @@ export interface MainOptions<R = Routes> {
229
229
  * themselves up with them.
230
230
  */
231
231
  maxWidth?: string;
232
+ /**
233
+ * How wide a `"full"` panel gets, in pixels — and with it the whole content
234
+ * area, since a `"full"` fills it exactly. A `"half"` gets half of this, and
235
+ * a `"screen"` ignores it and takes the window. Defaults to 1080; the window
236
+ * caps it when there is less room than that. Routed mode only.
237
+ *
238
+ * This plus {@link MainOptions.navWidth} is the app's standard page — see
239
+ * there.
240
+ *
241
+ * Live, like {@link MainOptions.columns}: pass a proxied options object (or
242
+ * make this field a getter) and a change is adopted in one layout pass,
243
+ * every panel keeping its state.
244
+ */
245
+ fullWidth?: number;
232
246
  /** Aberdeen attr/style string applied to the content area. */
233
247
  contentAttrs?: Attributes;
234
248
  /** Aberdeen attr/style string applied to the top bar. */
@@ -255,12 +269,33 @@ export interface MainOptions<R = Routes> {
255
269
  * chrome goes assumes they are one.
256
270
  */
257
271
  navPosition?: "left" | "right";
272
+ /**
273
+ * How wide the nav sidebar column is, in pixels — its hairline included.
274
+ * Defaults to 200.
275
+ *
276
+ * Together with {@link MainOptions.fullWidth} this is the app's *standard
277
+ * page*: the width the top bar and footer keep to, and the width the
278
+ * columns settle back to. The defaults come to the familiar 1280px.
279
+ *
280
+ * Live, like {@link MainOptions.fullWidth}.
281
+ */
282
+ navWidth?: number;
258
283
  /** Aberdeen attr/style string applied to the sidebar nav panel. */
259
284
  navAttrs?: Attributes;
260
285
  /** Aberdeen attr/style string applied to the narrow-screen full-page nav. */
261
286
  navPageAttrs?: Attributes;
262
287
  }
263
288
 
289
+ /**
290
+ * The default nav column (hairline included) and the default width of a
291
+ * `"full"` panel — see {@link MainOptions.navWidth} and
292
+ * {@link MainOptions.fullWidth}. Side by side they come to the 1280px page the
293
+ * shell is usually seen as, but that figure lives nowhere: the browser adds
294
+ * these two up, and an app that changes either simply gets a different page.
295
+ */
296
+ const NAV_W = 200;
297
+ const FULL_W = 1080;
298
+
264
299
  A.insertGlobalCss({
265
300
  ".s-main": {
266
301
  // container-type so @container queries below can respond to shell width.
@@ -278,8 +313,10 @@ A.insertGlobalCss({
278
313
  // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
279
314
  // trailing slot's own growth: it takes the free space and right-aligns
280
315
  // 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
316
+ // compete, the titles give way first: the trailing slot's near-zero
317
+ // shrink factor keeps a row of actions at its natural width while the
318
+ // crumbs absorb the squeeze — but only down to the titles' floor, past
319
+ // which the trailing slot shrinks after all: a wide search box must not
283
320
  // starve the titles to nothing (the crumb strip's overlay buttons would
284
321
  // escape their zero-width strip, over the ☰ beside it).
285
322
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
@@ -300,13 +337,16 @@ A.insertGlobalCss({
300
337
  // which their classes then provide. (`filter:none` keeps the global
301
338
  // `a:hover` brighten off the gradient text.)
302
339
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
303
- "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
340
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
304
341
  // Body always wraps <main> (with or without a sidebar) so max-width centering
305
342
  // and scrollbar alignment work identically in both cases.
306
343
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
307
344
  // It's also the positioning + clipping context for the narrow-screen nav panel,
308
- // which slides in and out across its left edge.
309
- ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
345
+ // which slides in and out across its left edge. `overflow:clip` rather than
346
+ // `hidden` for the same reason as `.s-panels`: a hidden box can still be
347
+ // scrolled (find-in-page, an anchor, an extension), and a stray scroll here
348
+ // would shove the whole row — sidebar and columns — out of place for good.
349
+ ".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
310
350
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
311
351
  // Put the sidebar on the right (content fills the left) for right-hand navs.
312
352
  "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
@@ -337,23 +377,26 @@ A.insertGlobalCss({
337
377
  ".s-body main.s-scroll-y": "margin-right:$3",
338
378
  // Routed mode takes its width from the stack instead of from
339
379
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
340
- // separator + content area) as --s-shell-w — the standard 1280px page
341
- // normally, the window's edges while a "screen" page is up — and the body
342
- // row and the bars cap themselves to it. So the chrome lines up with the
343
- // columns and the lot stays centred in the shell.
344
- "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
345
- "&.s-routed > header > .s-bar": "max-width: var(--s-shell-w, 100%);",
346
- "&.s-routed > footer > .s-bar": "max-width: var(--s-shell-w, 100%);",
347
- // Changing the custom property animates the max-widths consuming it, with no
348
- // JS in the loop: the chrome recentres in step with the panel whose arrival
349
- // or departure moved it, over the same --s-panel-ms (see panels.ts). During
350
- // a window resize (and the very first pass) the layout engine raises
351
- // `.s-shell-snap` so the new width is adopted instantly instead of chasing
352
- // the window through a transition.
353
- "&.s-routed > .s-body > .s-body-inner, &.s-routed > header > .s-bar, &.s-routed > footer > .s-bar":
354
- "transition: max-width var(--s-panel-ms) ease;",
355
- "&.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":
356
- "transition:none",
380
+ // separator + content area) as --s-shell-w — the standard page
381
+ // normally, wider while the columns outgrow it (a "screen" page, or
382
+ // extra columns fitting a wide window) — and the body row caps itself
383
+ // to it, staying centred around the columns. Changing the custom
384
+ // property animates the max-width consuming it, with no JS in the loop:
385
+ // the body recentres in step with the panel whose arrival or departure
386
+ // moved it, over the same --s-panel-ms (see panels.ts). During a window
387
+ // resize (and the very first pass) the layout engine raises
388
+ // `.s-shell-snap` so the new width is adopted instantly instead of
389
+ // chasing the window through a transition.
390
+ "&.s-routed > .s-body > .s-body-inner":
391
+ "max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
392
+ "&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
393
+ // The bars don't follow the ensemble past the standard page: a header
394
+ // stretching to the window's edges and back with every "screen" panel
395
+ // reads as the whole app flexing, so the chrome holds still and only
396
+ // the columns grow. (Below the standard width the ensemble is simply
397
+ // the window, which only a resize changes — so the bars never animate,
398
+ // and take no part in the transition above.)
399
+ "&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "max-width: calc(var(--s-nav-w) + var(--s-full-w))",
357
400
  },
358
401
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
359
402
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
@@ -364,7 +407,10 @@ A.insertGlobalCss({
364
407
  // The generous horizontal padding is what keeps the rows clear of the content
365
408
  // separator on one side and the shell edge on the other; the vertical scroll
366
409
  // (overflow-y:auto, which also clips overflow-x) leaves no room to bleed past it.
367
- "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 max-width:228px padding:$3 gap:$1",
410
+ // `--s-nav-w` measures the whole column, hairline included, so the panel
411
+ // itself gives that 1px back — and the app's two widths then add up to
412
+ // exactly the page the bars above and below keep to.
413
+ "&": "display:flex flex-direction:column overflow-y:auto flex-shrink:0 width: calc(var(--s-nav-w) - 1px); padding:$3 gap:$1",
368
414
  },
369
415
  // The narrow-screen nav: a full "panel" that slides in over the content from the
370
416
  // left, rather than a dropdown — on a phone a nav is a screenful of UI, not a
@@ -493,6 +539,9 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
493
539
  notFound: opts.notFound,
494
540
  ancestors: opts.ancestors,
495
541
  title: opts.title,
542
+ // Corrected below, and on every change, from the app's own option:
543
+ // read here it would subscribe the whole shell to it.
544
+ fullWidth: FULL_W,
496
545
  $shell,
497
546
  })
498
547
  : null;
@@ -504,6 +553,7 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
504
553
  // the new link default. Nothing else of the shell is touched.
505
554
  A(() => ctl.setColumns(opts.columns));
506
555
  A(() => ctl.setLinkNavigation(opts.linkNavigation));
556
+ A(() => ctl.setFullWidth(opts.fullWidth ?? FULL_W));
507
557
  }
508
558
  // Where the brand mark and the app's name link — or nowhere, when the app
509
559
  // said `home: null` (a title slot holding a control of its own, say).
@@ -513,12 +563,20 @@ export function main<R extends RouteTable<R>>(opts: MainOptions<R> = {}): PanelS
513
563
  const capWidth = ctl ? null : opts.maxWidth;
514
564
 
515
565
  const root = A(`div.s-main${ctl ? ".s-routed" : ""}`, opts.attrs, () => {
516
- // Which side the sidebar is on, as a class on the shell for the CSS above to
517
- // hang off. Its own scope (see `nav` above), so a nav appearing or emptying
518
- // out only retags the shell rather than redrawing it.
566
+ // The two widths the CSS above works from, and with them the standard page
567
+ // the bars keep to. Each sits in a scope of its own — one that draws
568
+ // nothing, so re-running it is a single style write: an app that changes
569
+ // either on a proxied options object resizes the shell in place, panels
570
+ // and their state untouched.
571
+ A(() => A(`--s-full-w: ${opts.fullWidth ?? FULL_W}px`));
572
+ // `--s-nav-w` is the sidebar's whole column, and nothing at all when there
573
+ // is no sidebar to give it to — a shell without one lines its bars up with
574
+ // the content. This scope also tags the shell with the side the sidebar is
575
+ // on, for the CSS above to hang off (see `nav` above: reading `nav.items`
576
+ // here subscribes this scope alone, never the shell entire).
519
577
  A(() => {
520
- if (nav == null || !nav.items.length) return;
521
- A(`.s-nav-${navPos}`);
578
+ if (nav == null || !nav.items.length) A("--s-nav-w: 0px");
579
+ else A(`.s-nav-${navPos}`, `--s-nav-w: ${opts.navWidth ?? NAV_W}px`);
522
580
  });
523
581
 
524
582
  // Top bar: `[leading] [identity] …spacer… [trailing]`, where each slot's
@@ -175,17 +175,25 @@ export interface Panel<P = Record<string, string | number | string[]>> {
175
175
  * because that is what it gets when two columns fit; this says how much
176
176
  * *more* it can take.
177
177
  *
178
- * - `"half"` — nothing more. Half the content area (360–540px), so a second
179
- * column fits beside it. For lists and detail forms.
180
- * - `"full"` (the default) — the whole content area, up to ~1100px.
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.
181
183
  * - `"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.
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.
184
187
  *
185
188
  * Below the width two columns need, everything takes the content area
186
189
  * whatever it asked for. Widths depend only on the window, never on what
187
190
  * else is open, so opening or closing a panel never resizes another.
188
191
  *
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.
196
+ *
189
197
  * Set it at the top of your handler and the panel is already that wide when
190
198
  * you draw (see {@link Panel.width}); set it later — when your data tells you
191
199
  * — and the panel reflows without being redrawn, keeping its state, while
@@ -261,6 +269,31 @@ export interface Panel<P = Record<string, string | number | string[]>> {
261
269
  * ```
262
270
  */
263
271
  close(): Promise<boolean>;
272
+ /**
273
+ * Opens `href` exactly as a click on a link inside this panel does — the
274
+ * shell's own link handling runs through this very call, so the two can't
275
+ * drift apart. By default that is a push: the target opens on top of this
276
+ * panel, closing the panels after it first (pinned ones ride along
277
+ * beneath the new panel, unsaved ones park), and a path that is already
278
+ * open is returned to rather than opened twice. `how` plays the part of a
279
+ * link's `data-panel` attribute: `"replace"` puts the target in this
280
+ * panel's place, `"open"` leaves the panel behind and gives the target
281
+ * its own stack, and omitting it follows the shell's
282
+ * {@link MainOptions.linkNavigation}, like a link without the attribute.
283
+ *
284
+ * This is the one for navigation that can't be a link: a row's click
285
+ * handler, a keyboard shortcut acting on this screen. The stack's
286
+ * {@link PanelStack.pushPanel} builds on the *current* panel instead — a
287
+ * different panel exactly when the interaction happened in a column
288
+ * beside it, where it would pile the new panel on top of the open detail
289
+ * rather than pruning back to this one.
290
+ *
291
+ * @example
292
+ * ```ts
293
+ * A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);
294
+ * ```
295
+ */
296
+ open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
264
297
  }
265
298
 
266
299
  // ─── Path matching ───────────────────────────────────────────────────────────
@@ -374,7 +407,7 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
374
407
  /**
375
408
  * The one duration every bit of shell motion shares: the enter/exit fades, the
376
409
  * `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
410
+ * body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
378
411
  * panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
379
412
  * and JS can't drift apart.
380
413
  *
@@ -385,12 +418,6 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
385
418
  const PAGE_MS = 250;
386
419
  /** How long a freshly pushed `loading` panel holds its enter animation. */
387
420
  const LOADING_HOLD_MS = 300;
388
- /**
389
- * The standard page width: sidebar plus content area, capped by the window.
390
- * `"full"` fills the content-area part of this exactly; only a `"screen"`
391
- * page makes the shell grow past it.
392
- */
393
- const SHELL_PX = 1280;
394
421
  /** Don't pair smalls when half the content area would be narrower than this. */
395
422
  const PAIR_MIN_PX = 360;
396
423
  /**
@@ -415,8 +442,13 @@ A.insertGlobalCss({
415
442
  // The region paints the panel's sheen over its own box, and every panel shows
416
443
  // a slice of that same gradient (see `.s-panel` below), so the columns and
417
444
  // the ground beside them are one continuous surface.
445
+ // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
446
+ // and anything that ever scrolls it — find-in-page reaching for text in a
447
+ // parked column, an in-page anchor, an extension — shifts every column
448
+ // sideways, permanently, because nothing here would ever scroll it back.
449
+ // `clip` clips without being scrollable at all, closing the whole class.
418
450
  ".s-panels":
419
- "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
451
+ "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
420
452
  SURFACE_SHEEN,
421
453
  ".s-panel": {
422
454
  // A panel rests at a plain `left` offset and carries no transform: a
@@ -513,14 +545,19 @@ A.insertGlobalCss({
513
545
  // the weight change alone is ambiguous in a short crumb, the colour alone
514
546
  // too subtle. No padding of its own — the first crumb has to start on the
515
547
  // 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.
548
+ // The flex is how a tight row is shared out. Every crumb grows from the
549
+ // same 4rem basis in equal shares, freezing at its own text
550
+ // (`max-width:max-content`) — so with room to spare every title shows in
551
+ // full, and under pressure it is the *longest* crumbs that give way
552
+ // first, equalising downward while short ones keep every character. No
553
+ // crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
554
+ // that point the row overflows and the strip scrolls — which is what
555
+ // keeps a deep stack on a phone readable. (Crumbs allowed to shrink
556
+ // would ellipsise to a row of stubs instead, and the stack would never
557
+ // scroll.)
521
558
  "&":
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 " +
559
+ "flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
560
+ "white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
524
561
  "transition: color 0.12s;",
525
562
  "&.s-crumb-on": "font-weight:600 fg:$s-text",
526
563
  // The same hover treatment as a menu item. The panel you are on is a plain
@@ -642,7 +679,7 @@ interface Geometry {
642
679
  chrome: number;
643
680
  /** Half the standard content area, or all of it when a half would be too narrow. */
644
681
  half: number;
645
- /** The standard content area: the 1280px page minus the chrome. */
682
+ /** The standard content area: what the app asked a `"full"` panel to be. */
646
683
  full: number;
647
684
  /** Everything the window has beside the chrome, with no upper limit. */
648
685
  screen: number;
@@ -672,6 +709,8 @@ export interface PanelStackOptions {
672
709
  columns?: "auto" | "single";
673
710
  /** What a bare link does. See {@link MainOptions.linkNavigation}. */
674
711
  linkNavigation?: "push" | "replace" | "open";
712
+ /** How wide a `"full"` panel gets, in px. See {@link MainOptions.fullWidth}. */
713
+ fullWidth: number;
675
714
  /** The shell's own title, used as the suffix of `document.title`. */
676
715
  title?: unknown;
677
716
  /**
@@ -739,6 +778,11 @@ export interface PanelStack {
739
778
  * than opening it twice, and a panel holding {@link Panel.unsaved} work is
740
779
  * never closed, only parked. That's what a plain link does, and what
741
780
  * `data-panel=push` says outright.
781
+ *
782
+ * Note that a link builds on the panel it is *drawn in*, which is the
783
+ * current panel only while no column beside it has the focus. Code
784
+ * navigating on behalf of a particular screen — a row's click handler —
785
+ * wants that panel's own {@link Panel.open} instead.
742
786
  */
743
787
  pushPanel(path: string): Promise<boolean>;
744
788
  /**
@@ -836,8 +880,8 @@ export class PanelStackController implements PanelStack {
836
880
  private containerEl?: HTMLElement;
837
881
  /** The shell's measurements, shared by everything drawn since they were taken. */
838
882
  private geom?: Geometry;
839
- /** The body width at the last layout; a change means a window resize → snap. */
840
- private lastBodyW = -1;
883
+ /** The measurements the last layout ran on; a change in them → snap. */
884
+ private lastGeom?: Geometry;
841
885
  private layoutQueued = false;
842
886
  private timers = new Set<ReturnType<typeof setTimeout>>();
843
887
  /** The arrangement the navigation in flight is heading for; see {@link intended}. */
@@ -1117,9 +1161,12 @@ export class PanelStackController implements PanelStack {
1117
1161
  maxWidth: "full" as const,
1118
1162
  width: 0,
1119
1163
  } 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.
1164
+ // `close` closes *this* panel, current or not, and `open` navigates
1165
+ // *from* it, through the very implementation a link click uses (see
1166
+ // `navigate`). Both resolve the panel's place in the stack at call
1167
+ // time, so they keep working after a splice has moved it; an `open`
1168
+ // from a panel that has since closed falls back to a derived stack,
1169
+ // like a link from nowhere.
1123
1170
  //
1124
1171
  // `visible` starts at what the panel's place implies: shown when it sits
1125
1172
  // at or before the current panel (a pushed panel always does), hidden when
@@ -1133,6 +1180,7 @@ export class PanelStackController implements PanelStack {
1133
1180
  visible,
1134
1181
  pinned: pinned || undefined,
1135
1182
  close: () => this.closePath(entry.path),
1183
+ open: (href: string, how?: "push" | "replace" | "open") => this.navigate(href, { from: entry.path, how }),
1136
1184
  }) as PanelState;
1137
1185
  return entry;
1138
1186
  }
@@ -1365,17 +1413,31 @@ export class PanelStackController implements PanelStack {
1365
1413
  }
1366
1414
 
1367
1415
  /**
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.
1416
+ * Navigate to `href` — the one implementation behind a link click,
1417
+ * {@link Panel.open} and the stack's own methods, so none of them can
1418
+ * behave differently.
1419
+ *
1420
+ * `from` is the path of the panel the navigation starts from — the one
1421
+ * the link lives in — or absent when it has none: a nav item, or a call
1422
+ * that means the whole stack, which is then built instead (see
1423
+ * {@link deriveStack}), or taken outright from `beneath`, for callers
1424
+ * that know it.
1425
+ *
1426
+ * `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.
1373
1432
  *
1374
1433
  * Resolves the way every {@link PanelStack} method does: `true` once the
1375
1434
  * navigation lands, `false` when it doesn't (already there counts as
1376
1435
  * landed).
1377
1436
  */
1378
- private navigate(href: string, origin: string | null, replace = false, beneath?: readonly string[]): Promise<boolean> {
1437
+ private navigate(href: string, { from, how, beneath }: { from?: string; how?: string; beneath?: readonly string[] } = {}): Promise<boolean> {
1438
+ const mode = how ?? this.opts.linkNavigation;
1439
+ const origin = mode === "open" ? null : from ?? null;
1440
+ const replace = mode === "replace";
1379
1441
  return A.peek(() => {
1380
1442
  let url: URL;
1381
1443
  try { url = new URL(href, location.href); } catch { return Promise.resolve(false); }
@@ -1430,7 +1492,7 @@ export class PanelStackController implements PanelStack {
1430
1492
  private pushPath(path: string, replace: boolean): Promise<boolean> {
1431
1493
  return A.peek(() => {
1432
1494
  const arr = this.intended();
1433
- return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
1495
+ return this.navigate(path, { from: arr.stack[arr.focus], how: replace ? "replace" : "push" });
1434
1496
  });
1435
1497
  }
1436
1498
 
@@ -1438,36 +1500,30 @@ export class PanelStackController implements PanelStack {
1438
1500
 
1439
1501
  /**
1440
1502
  * 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
1503
+ * the anchor so we can decide what the click *means*. The exclusion rules
1444
1504
  * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
1445
1505
  * close guards run in `checkChange` when our navigation reaches the router.
1446
1506
  *
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`.
1507
+ * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
1508
+ * attribute as its `how` (see {@link navigate}, the shared implementation).
1509
+ * A link that isn't inside any panel — a nav item, one in a dialog — has no
1510
+ * panel to build on, so it replaces the stack as a whole, exactly as a cold
1511
+ * link to the same URL would open it.
1453
1512
  */
1454
1513
  private interceptLinks(): void {
1455
1514
  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");
1515
+ const how = anchor.getAttribute("data-panel") ?? undefined;
1516
+ // The panel the link lives in: the enclosing `.s-panel`, or — for the
1517
+ // current panel's actions, promoted into the top bar on a narrow shell
1518
+ // (see main.ts), outside every `.s-panel` — the current panel, whose
1519
+ // own chrome they remain at every width.
1520
+ const panelEl = anchor.closest<HTMLElement>(".s-panel");
1521
+ const entry = panelEl
1522
+ ? this.$state.live.find((e) => e.el === panelEl)
1523
+ : anchor.closest(".s-panel-origin")
1524
+ ? this.$state.live[this.$state.focus]
1525
+ : undefined;
1526
+ void this.navigate(url.href, { from: entry?.path, how });
1471
1527
  return true;
1472
1528
  });
1473
1529
  }
@@ -1499,7 +1555,7 @@ export class PanelStackController implements PanelStack {
1499
1555
  }
1500
1556
 
1501
1557
  openPanelStack(path: string, beneath?: readonly string[]): Promise<boolean> {
1502
- return this.navigate(path, null, false, beneath);
1558
+ return this.navigate(path, { how: "open", beneath });
1503
1559
  }
1504
1560
 
1505
1561
  closePanel(path?: string): Promise<boolean> {
@@ -1528,6 +1584,17 @@ export class PanelStackController implements PanelStack {
1528
1584
  this.opts.linkNavigation = mode;
1529
1585
  }
1530
1586
 
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
+
1531
1598
  /**
1532
1599
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
1533
1600
  * panel, oldest first, the ones on screen right now in bold, pinned ones
@@ -1854,25 +1921,21 @@ export class PanelStackController implements PanelStack {
1854
1921
  if (child !== container) chrome += child.getBoundingClientRect().width;
1855
1922
  }
1856
1923
 
1857
- // The standard panel is SHELL_PX wide, capped by the window; what it leaves
1858
- // beside the sidebar is the *standard* content area. Widths are a pure
1859
- // function of the window — never of what else is open — so a panel NEVER
1860
- // resizes because a neighbour came or went; only a window resize (the
1861
- // snap pass in `layout`) changes them:
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:
1862
1930
  // - "full" fills the standard content area exactly;
1863
1931
  // - "half" is half of it whenever that half is still a usable column, and
1864
1932
  // the whole of it on narrower screens;
1865
1933
  // - "screen" ignores the standard width and takes everything the window
1866
1934
  // has — which also means nothing ever fits beside it.
1867
- const full = Math.max(0, Math.min(SHELL_PX, total) - chrome);
1935
+ const screen = Math.max(0, total - chrome);
1936
+ const full = Math.min(this.opts.fullWidth, screen);
1868
1937
  const halved = full / 2;
1869
- return {
1870
- total,
1871
- chrome,
1872
- half: halved >= PAIR_MIN_PX ? halved : full,
1873
- full,
1874
- screen: Math.max(0, total - chrome),
1875
- };
1938
+ return { total, chrome, half: halved >= PAIR_MIN_PX ? halved : full, full, screen };
1876
1939
  }
1877
1940
 
1878
1941
  /**
@@ -1919,13 +1982,16 @@ export class PanelStackController implements PanelStack {
1919
1982
 
1920
1983
  const stacking = this.opts.columns !== "single";
1921
1984
 
1922
- // A window resize (or the very first pass) must be adopted instantly —
1923
- // geometry tracking the window through a 450ms transition reads as lag,
1924
- // and a shell animating itself into place on load reads as a glitch.
1985
+ // A window resize — or the app resizing the shell itself, by changing
1986
+ // `navWidth` or `fullWidth` — must be adopted instantly: geometry tracking
1987
+ // the window through a 450ms transition reads as lag, and a shell
1988
+ // animating itself into place on its first pass reads as a glitch. Only
1989
+ // what a *panel* did is worth animating, and none of those three are.
1925
1990
  // `.s-shell-snap` suppresses every standing transition for this one pass.
1926
- const snap = this.lastBodyW !== geom.total;
1991
+ const was = this.lastGeom;
1992
+ const snap = was == null || was.total !== geom.total || was.chrome !== geom.chrome || was.full !== geom.full;
1927
1993
  if (snap) {
1928
- this.lastBodyW = geom.total;
1994
+ this.lastGeom = geom;
1929
1995
  shell.classList.add("s-shell-snap");
1930
1996
  }
1931
1997
 
@@ -1949,7 +2015,7 @@ export class PanelStackController implements PanelStack {
1949
2015
  // The content area holds the run, but is never smaller than the standard
1950
2016
  // panel (a lone small leaves its other half open — which is exactly where
1951
2017
  // the next small lands, without anything on screen moving) and never
1952
- // wider than the window. So the panel is the familiar 1280px until extra
2018
+ // wider than the window. So the page holds its standard width until extra
1953
2019
  // columns genuinely fit, and stretches — centred — to hold the ones that
1954
2020
  // do; with a "screen" up that's the window's edges.
1955
2021
  const area = Math.min(geom.screen, Math.max(geom.full, runSum));
@@ -1961,11 +2027,11 @@ export class PanelStackController implements PanelStack {
1961
2027
  if (!entry.width) entry.width = width(entry);
1962
2028
  }
1963
2029
 
1964
- // The chrome above and below the body caps itself to the ensemble width,
1965
- // keeping everything centred and aligned however far the area stretches.
1966
- // The consumers transition their max-width (see main.ts), so the
1967
- // recentring plays along with the panel that caused it instead of
1968
- // snapping.
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).
1969
2035
  shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
1970
2036
 
1971
2037
  // Phase 1 — every panel's *start* state for this frame. Panels already on