staffa 0.12.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.
package/README.md CHANGED
@@ -190,7 +190,7 @@ Navigating faster than the shell can settle is fine: closing travels through the
190
190
 
191
191
  Below the width two columns need, everything takes the whole content area whatever it asked for. Those numbers assume a nav sidebar of around 170px; without one, add that back. Nothing fits beside a `"full"` on a standard 1280px page, but on a wide enough window a `"half"` still can, and the page grows past 1280px to hold both.
192
192
 
193
- A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone `"half"` leaves its other half empty, and that is exactly where the next one lands. When more columns fit than the standard 1280px page holds (three halves, say), the page itself grows, staying centred, to hold them.
193
+ A column's width depends only on the size of the window, never on what else is open. So opening or closing a panel never resizes the ones already on screen, and never reflows what someone was reading. A lone `"half"` leaves its other half empty, and that is exactly where the next one lands. When more columns fit than the standard 1280px page holds (three halves, say), the page itself grows, staying centred, to hold them — though the top bar and footer keep to the standard width, so the chrome holds still while the columns come and go.
194
194
 
195
195
  Columns tile that area, separated by a hairline and no gutter — a column brings its own padding, so their contents stay comfortably apart regardless.
196
196
 
@@ -8,7 +8,7 @@ import { drawMenu, isFloatingMenuOpen, consumeBranchNav, anyCurrent } from "./me
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 } from "./panels.js";
11
+ import { PanelStackController, SHELL_PX } from "./panels.js";
12
12
  A.insertGlobalCss({
13
13
  ".s-main": {
14
14
  // container-type so @container queries below can respond to shell width.
@@ -26,8 +26,10 @@ A.insertGlobalCss({
26
26
  // The bar reads `[leading] [title] …spacer… [trailing]`. The spacer is the
27
27
  // trailing slot's own growth: it takes the free space and right-aligns
28
28
  // itself in it, which is what lets a search box live there. When the two
29
- // compete, the title truncates first but only down to a floor, past
30
- // which the trailing slot shrinks instead: a wide search box must not
29
+ // compete, the titles give way first: the trailing slot's near-zero
30
+ // shrink factor keeps a row of actions at its natural width while the
31
+ // crumbs absorb the squeeze — but only down to the titles' floor, past
32
+ // which the trailing slot shrinks after all: a wide search box must not
31
33
  // starve the titles to nothing (the crumb strip's overlay buttons would
32
34
  // escape their zero-width strip, over the ☰ beside it).
33
35
  "> header > .s-bar, > footer > .s-bar": "display:flex align-items:center width:100% margin-inline:auto gap:$3 padding: $2 $3;",
@@ -48,13 +50,16 @@ A.insertGlobalCss({
48
50
  // which their classes then provide. (`filter:none` keeps the global
49
51
  // `a:hover` brighten off the gradient text.)
50
52
  "> header a.s-logo, > header a.s-title": "text-decoration:none filter:none cursor:pointer",
51
- "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 1 auto; min-width:0",
53
+ "> header .s-menu": "display:flex align-items:center justify-content:flex-end gap:$2 flex: 1 0.1 auto; min-width:0",
52
54
  // Body always wraps <main> (with or without a sidebar) so max-width centering
53
55
  // and scrollbar alignment work identically in both cases.
54
56
  // .s-body centres .s-body-inner; .s-body-inner caps the content to maxWidth.
55
57
  // It's also the positioning + clipping context for the narrow-screen nav panel,
56
- // which slides in and out across its left edge.
57
- ".s-body": "flex:1 overflow:hidden display:flex flex-direction:row min-height:0 justify-content:center position:relative",
58
+ // which slides in and out across its left edge. `overflow:clip` rather than
59
+ // `hidden` for the same reason as `.s-panels`: a hidden box can still be
60
+ // scrolled (find-in-page, an anchor, an extension), and a stray scroll here
61
+ // would shove the whole row — sidebar and columns — out of place for good.
62
+ ".s-body": "flex:1 overflow:clip display:flex flex-direction:row min-height:0 justify-content:center position:relative",
58
63
  ".s-body-inner": "flex:1 min-width:0 display:flex flex-direction:row min-height:0",
59
64
  // Put the sidebar on the right (content fills the left) for right-hand navs.
60
65
  "&.s-nav-right .s-body-inner": "flex-direction:row-reverse",
@@ -85,20 +90,24 @@ A.insertGlobalCss({
85
90
  // Routed mode takes its width from the stack instead of from
86
91
  // `maxWidth`: the layout engine publishes the ensemble width (sidebar +
87
92
  // separator + content area) as --s-shell-w — the standard 1280px page
88
- // normally, the window's edges while a "screen" page is up — and the body
89
- // row and the bars cap themselves to it. So the chrome lines up with the
90
- // columns and the lot stays centred in the shell.
91
- "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%);",
92
- "&.s-routed > header > .s-bar": "max-width: var(--s-shell-w, 100%);",
93
- "&.s-routed > footer > .s-bar": "max-width: var(--s-shell-w, 100%);",
94
- // Changing the custom property animates the max-widths consuming it, with no
95
- // JS in the loop: the chrome recentres in step with the panel whose arrival
96
- // or departure moved it, over the same --s-panel-ms (see panels.ts). During
97
- // a window resize (and the very first pass) the layout engine raises
98
- // `.s-shell-snap` so the new width is adopted instantly instead of chasing
99
- // the window through a transition.
100
- "&.s-routed > .s-body > .s-body-inner, &.s-routed > header > .s-bar, &.s-routed > footer > .s-bar": "transition: max-width var(--s-panel-ms) ease;",
101
- "&.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": "transition:none",
93
+ // normally, wider while the columns outgrow it (a "screen" page, or
94
+ // extra columns fitting a wide window) and the body row caps itself
95
+ // to it, staying centred around the columns. Changing the custom
96
+ // property animates the max-width consuming it, with no JS in the loop:
97
+ // the body recentres in step with the panel whose arrival or departure
98
+ // moved it, over the same --s-panel-ms (see panels.ts). During a window
99
+ // resize (and the very first pass) the layout engine raises
100
+ // `.s-shell-snap` so the new width is adopted instantly instead of
101
+ // chasing the window through a transition.
102
+ "&.s-routed > .s-body > .s-body-inner": "max-width: var(--s-shell-w, 100%); transition: max-width var(--s-panel-ms) ease;",
103
+ "&.s-routed.s-shell-snap > .s-body > .s-body-inner": "transition:none",
104
+ // The bars don't follow the ensemble past the standard page: a header
105
+ // stretching to the window's edges and back with every "screen" panel
106
+ // reads as the whole app flexing, so the chrome holds still and only
107
+ // the columns grow. (Below the standard width the ensemble is simply
108
+ // the window, which only a resize changes — so the bars never animate,
109
+ // and take no part in the transition above.)
110
+ [`&.s-routed > header > .s-bar, &.s-routed > footer > .s-bar`]: `max-width:${SHELL_PX}px`,
102
111
  },
103
112
  // Sidebar nav panel. Items reuse the shared `.s-menu-item` /
104
113
  // `.s-menu-sep` styles from menu.ts, so the sidebar and the floating
@@ -161,13 +161,19 @@ export interface Panel<P = Record<string, string | number | string[]>> {
161
161
  * column fits beside it. For lists and detail forms.
162
162
  * - `"full"` (the default) — the whole content area, up to ~1100px.
163
163
  * - `"screen"` — the whole window, unbounded: boards, wide tables, dense
164
- * dashboards. While one is open the shell itself stretches to the screen
165
- * edges instead of stopping at the standard 1280px page.
164
+ * dashboards. While one is open the columns stretch to the screen edges
165
+ * instead of stopping at the standard 1280px page; the top bar and
166
+ * footer hold the standard width throughout.
166
167
  *
167
168
  * Below the width two columns need, everything takes the content area
168
169
  * whatever it asked for. Widths depend only on the window, never on what
169
170
  * else is open, so opening or closing a panel never resizes another.
170
171
  *
172
+ * This is a *layout regime*, not a width guarantee: handle whatever width
173
+ * the bucket yields, and ask only for what your content can actually use —
174
+ * a screen that would cap its own content narrower than its ask is holding
175
+ * room that would have let another column fit beside it.
176
+ *
171
177
  * Set it at the top of your handler and the panel is already that wide when
172
178
  * you draw (see {@link Panel.width}); set it later — when your data tells you
173
179
  * — and the panel reflows without being redrawn, keeping its state, while
@@ -243,7 +249,40 @@ export interface Panel<P = Record<string, string | number | string[]>> {
243
249
  * ```
244
250
  */
245
251
  close(): Promise<boolean>;
252
+ /**
253
+ * Opens `href` exactly as a click on a link inside this panel does — the
254
+ * shell's own link handling runs through this very call, so the two can't
255
+ * drift apart. By default that is a push: the target opens on top of this
256
+ * panel, closing the panels after it first (pinned ones ride along
257
+ * beneath the new panel, unsaved ones park), and a path that is already
258
+ * open is returned to rather than opened twice. `how` plays the part of a
259
+ * link's `data-panel` attribute: `"replace"` puts the target in this
260
+ * panel's place, `"open"` leaves the panel behind and gives the target
261
+ * its own stack, and omitting it follows the shell's
262
+ * {@link MainOptions.linkNavigation}, like a link without the attribute.
263
+ *
264
+ * This is the one for navigation that can't be a link: a row's click
265
+ * handler, a keyboard shortcut acting on this screen. The stack's
266
+ * {@link PanelStack.pushPanel} builds on the *current* panel instead — a
267
+ * different panel exactly when the interaction happened in a column
268
+ * beside it, where it would pile the new panel on top of the open detail
269
+ * rather than pruning back to this one.
270
+ *
271
+ * @example
272
+ * ```ts
273
+ * A("div.row click=", () => void $panel.open(`/contacts/${id}`), ...);
274
+ * ```
275
+ */
276
+ open(href: string, how?: "push" | "replace" | "open"): Promise<boolean>;
246
277
  }
278
+ /**
279
+ * The standard page width: sidebar plus content area, capped by the window.
280
+ * `"full"` fills the content-area part of this exactly; only a `"screen"`
281
+ * page makes the shell grow past it. The top bar and footer keep to this
282
+ * width even then (see main.ts), so the chrome holds still while the
283
+ * columns stretch.
284
+ */
285
+ export declare const SHELL_PX = 1280;
247
286
  /** Options the stack needs from its shell. */
248
287
  export interface PanelStackOptions {
249
288
  routes: Routes;
@@ -315,6 +354,11 @@ export interface PanelStack {
315
354
  * than opening it twice, and a panel holding {@link Panel.unsaved} work is
316
355
  * never closed, only parked. That's what a plain link does, and what
317
356
  * `data-panel=push` says outright.
357
+ *
358
+ * Note that a link builds on the panel it is *drawn in*, which is the
359
+ * current panel only while no column beside it has the focus. Code
360
+ * navigating on behalf of a particular screen — a row's click handler —
361
+ * wants that panel's own {@link Panel.open} instead.
318
362
  */
319
363
  pushPanel(path: string): Promise<boolean>;
320
364
  /**
@@ -581,11 +625,22 @@ export declare class PanelStackController implements PanelStack {
581
625
  */
582
626
  private closePath;
583
627
  /**
584
- * Navigate to `href`. `origin` is the path of the panel the link lives in, or
585
- * `null` when it has none a nav item, or a programmatic call, which builds
586
- * the whole stack instead (see {@link deriveStack}). `replace` swaps the
587
- * originating panel rather than stacking on top of it, and `beneath` says what
588
- * the stack under the target is outright, for callers that know.
628
+ * Navigate to `href` the one implementation behind a link click,
629
+ * {@link Panel.open} and the stack's own methods, so none of them can
630
+ * behave differently.
631
+ *
632
+ * `from` is the path of the panel the navigation starts from the one
633
+ * the link lives in — or absent when it has none: a nav item, or a call
634
+ * that means the whole stack, which is then built instead (see
635
+ * {@link deriveStack}), or taken outright from `beneath`, for callers
636
+ * that know it.
637
+ *
638
+ * `how` is the link's `data-panel` attribute (or the caller's word for
639
+ * it): absent — like a link without the attribute — it is the shell's
640
+ * `linkNavigation` default, an unrecognised value is a push on top of
641
+ * `from`, `"replace"` swaps `from` out rather than stacking on it, and
642
+ * `"open"` drops `from` altogether so the target arrives with its own
643
+ * stack, the way a nav item's link does.
589
644
  *
590
645
  * Resolves the way every {@link PanelStack} method does: `true` once the
591
646
  * navigation lands, `false` when it doesn't (already there counts as
@@ -596,18 +651,15 @@ export declare class PanelStackController implements PanelStack {
596
651
  private pushPath;
597
652
  /**
598
653
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
599
- * the anchor so we can decide what the click *means*: the originating
600
- * `.s-panel` (which decides what the click truncates), the `data-panel`
601
- * attribute, and return-to-an-open-panel semantics. The exclusion rules
654
+ * the anchor so we can decide what the click *means*. The exclusion rules
602
655
  * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
603
656
  * close guards run in `checkChange` when our navigation reaches the router.
604
657
  *
605
- * `data-panel` names which of the three {@link PanelStack} navigations the
606
- * click is: `push`, `replace`, or `open`, which drops the
607
- * originating panel so the target arrives with its own stack beneath it,
608
- * exactly as a nav item's link does. A link that doesn't say gets the
609
- * shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
610
- * an unrecognised value is a `push`.
658
+ * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
659
+ * attribute as its `how` (see {@link navigate}, the shared implementation).
660
+ * A link that isn't inside any panel a nav item, one in a dialog has no
661
+ * panel to build on, so it replaces the stack as a whole, exactly as a cold
662
+ * link to the same URL would open it.
611
663
  */
612
664
  private interceptLinks;
613
665
  get currentPanel(): Panel | undefined;
@@ -112,7 +112,7 @@ function matchRoute(r, segments) {
112
112
  /**
113
113
  * The one duration every bit of shell motion shares: the enter/exit fades, the
114
114
  * `left` moves of columns shifting sideways, the ensemble-width transition the
115
- * chrome follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
115
+ * body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
116
116
  * panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
117
117
  * and JS can't drift apart.
118
118
  *
@@ -126,9 +126,11 @@ const LOADING_HOLD_MS = 300;
126
126
  /**
127
127
  * The standard page width: sidebar plus content area, capped by the window.
128
128
  * `"full"` fills the content-area part of this exactly; only a `"screen"`
129
- * page makes the shell grow past it.
129
+ * page makes the shell grow past it. The top bar and footer keep to this
130
+ * width even then (see main.ts), so the chrome holds still while the
131
+ * columns stretch.
130
132
  */
131
- const SHELL_PX = 1280;
133
+ export const SHELL_PX = 1280;
132
134
  /** Don't pair smalls when half the content area would be narrower than this. */
133
135
  const PAIR_MIN_PX = 360;
134
136
  /**
@@ -151,7 +153,12 @@ A.insertGlobalCss({
151
153
  // The region paints the panel's sheen over its own box, and every panel shows
152
154
  // a slice of that same gradient (see `.s-panel` below), so the columns and
153
155
  // the ground beside them are one continuous surface.
154
- ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate " +
156
+ // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
157
+ // and anything that ever scrolls it — find-in-page reaching for text in a
158
+ // parked column, an in-page anchor, an extension — shifts every column
159
+ // sideways, permanently, because nothing here would ever scroll it back.
160
+ // `clip` clips without being scrollable at all, closing the whole class.
161
+ ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
155
162
  SURFACE_SHEEN,
156
163
  ".s-panel": {
157
164
  // A panel rests at a plain `left` offset and carries no transform: a
@@ -245,13 +252,18 @@ A.insertGlobalCss({
245
252
  // the weight change alone is ambiguous in a short crumb, the colour alone
246
253
  // too subtle. No padding of its own — the first crumb has to start on the
247
254
  // same pixel as the app's name above it, and the gap below spaces the row.
248
- // `flex-shrink:0` because the crumbs are the strip's flex items and carry
249
- // `overflow:hidden`, which resolves their automatic minimum size to zero:
250
- // left to shrink they would ellipsise themselves down to stubs rather than
251
- // overflow, and the stack would never scroll. One long title still caps at
252
- // 14rem that is this crumb's own business, not the row running out.
253
- "&": "flex-shrink:0 font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
254
- "white-space:nowrap max-width:14rem overflow:hidden text-overflow:ellipsis " +
255
+ // The flex is how a tight row is shared out. Every crumb grows from the
256
+ // same 4rem basis in equal shares, freezing at its own text
257
+ // (`max-width:max-content`) so with room to spare every title shows in
258
+ // full, and under pressure it is the *longest* crumbs that give way
259
+ // first, equalising downward while short ones keep every character. No
260
+ // crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
261
+ // that point the row overflows and the strip scrolls — which is what
262
+ // keeps a deep stack on a phone readable. (Crumbs allowed to shrink
263
+ // would ellipsise to a row of stubs instead, and the stack would never
264
+ // scroll.)
265
+ "&": "flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
266
+ "white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
255
267
  "transition: color 0.12s;",
256
268
  "&.s-crumb-on": "font-weight:600 fg:$s-text",
257
269
  // The same hover treatment as a menu item. The panel you are on is a plain
@@ -620,9 +632,12 @@ export class PanelStackController {
620
632
  maxWidth: "full",
621
633
  width: 0,
622
634
  };
623
- // `close` closes *this* panel, current or not. It resolves the panel's
624
- // place in the stack at call time, so it keeps working after a splice has
625
- // moved it and quietly resolves false once the panel is gone.
635
+ // `close` closes *this* panel, current or not, and `open` navigates
636
+ // *from* it, through the very implementation a link click uses (see
637
+ // `navigate`). Both resolve the panel's place in the stack at call
638
+ // time, so they keep working after a splice has moved it; an `open`
639
+ // from a panel that has since closed falls back to a derived stack,
640
+ // like a link from nowhere.
626
641
  //
627
642
  // `visible` starts at what the panel's place implies: shown when it sits
628
643
  // at or before the current panel (a pushed panel always does), hidden when
@@ -636,6 +651,7 @@ export class PanelStackController {
636
651
  visible,
637
652
  pinned: pinned || undefined,
638
653
  close: () => this.closePath(entry.path),
654
+ open: (href, how) => this.navigate(href, { from: entry.path, how }),
639
655
  });
640
656
  return entry;
641
657
  }
@@ -865,17 +881,31 @@ export class PanelStackController {
865
881
  });
866
882
  }
867
883
  /**
868
- * Navigate to `href`. `origin` is the path of the panel the link lives in, or
869
- * `null` when it has none a nav item, or a programmatic call, which builds
870
- * the whole stack instead (see {@link deriveStack}). `replace` swaps the
871
- * originating panel rather than stacking on top of it, and `beneath` says what
872
- * the stack under the target is outright, for callers that know.
884
+ * Navigate to `href` the one implementation behind a link click,
885
+ * {@link Panel.open} and the stack's own methods, so none of them can
886
+ * behave differently.
887
+ *
888
+ * `from` is the path of the panel the navigation starts from the one
889
+ * the link lives in — or absent when it has none: a nav item, or a call
890
+ * that means the whole stack, which is then built instead (see
891
+ * {@link deriveStack}), or taken outright from `beneath`, for callers
892
+ * that know it.
893
+ *
894
+ * `how` is the link's `data-panel` attribute (or the caller's word for
895
+ * it): absent — like a link without the attribute — it is the shell's
896
+ * `linkNavigation` default, an unrecognised value is a push on top of
897
+ * `from`, `"replace"` swaps `from` out rather than stacking on it, and
898
+ * `"open"` drops `from` altogether so the target arrives with its own
899
+ * stack, the way a nav item's link does.
873
900
  *
874
901
  * Resolves the way every {@link PanelStack} method does: `true` once the
875
902
  * navigation lands, `false` when it doesn't (already there counts as
876
903
  * landed).
877
904
  */
878
- navigate(href, origin, replace = false, beneath) {
905
+ navigate(href, { from, how, beneath } = {}) {
906
+ const mode = how ?? this.opts.linkNavigation;
907
+ const origin = mode === "open" ? null : from ?? null;
908
+ const replace = mode === "replace";
879
909
  return A.peek(() => {
880
910
  let url;
881
911
  try {
@@ -933,43 +963,36 @@ export class PanelStackController {
933
963
  pushPath(path, replace) {
934
964
  return A.peek(() => {
935
965
  const arr = this.intended();
936
- return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
966
+ return this.navigate(path, { from: arr.stack[arr.focus], how: replace ? "replace" : "push" });
937
967
  });
938
968
  }
939
969
  // ── Link interception ──────────────────────────────────────────────────
940
970
  /**
941
971
  * Link handling through `route.interceptLinks()`, whose handler hook hands us
942
- * the anchor so we can decide what the click *means*: the originating
943
- * `.s-panel` (which decides what the click truncates), the `data-panel`
944
- * attribute, and return-to-an-open-panel semantics. The exclusion rules
972
+ * the anchor so we can decide what the click *means*. The exclusion rules
945
973
  * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
946
974
  * close guards run in `checkChange` when our navigation reaches the router.
947
975
  *
948
- * `data-panel` names which of the three {@link PanelStack} navigations the
949
- * click is: `push`, `replace`, or `open`, which drops the
950
- * originating panel so the target arrives with its own stack beneath it,
951
- * exactly as a nav item's link does. A link that doesn't say gets the
952
- * shell's {@link PanelStackOptions.linkNavigation} (`push` by default);
953
- * an unrecognised value is a `push`.
976
+ * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
977
+ * attribute as its `how` (see {@link navigate}, the shared implementation).
978
+ * A link that isn't inside any panel a nav item, one in a dialog has no
979
+ * panel to build on, so it replaces the stack as a whole, exactly as a cold
980
+ * link to the same URL would open it.
954
981
  */
955
982
  interceptLinks() {
956
983
  route.interceptLinks((url, anchor) => {
957
- const mode = anchor.getAttribute("data-panel") ?? this.opts.linkNavigation;
958
- let origin = null;
959
- if (mode !== "open") {
960
- const panel = anchor.closest(".s-panel");
961
- if (panel) {
962
- origin = this.$state.live.find((entry) => entry.el === panel)?.path ?? null;
963
- }
964
- else if (anchor.closest(".s-panel-origin")) {
965
- // The current panel's actions, promoted into the top bar on a
966
- // narrow shell (see main.ts), sit outside every `.s-panel` — but
967
- // they are still the current panel's own chrome, so a link among
968
- // them builds on that panel, exactly as it does at full width.
969
- origin = this.$state.live[this.$state.focus]?.path ?? null;
970
- }
971
- }
972
- void this.navigate(url.href, origin, mode === "replace");
984
+ const how = anchor.getAttribute("data-panel") ?? undefined;
985
+ // The panel the link lives in: the enclosing `.s-panel`, or — for the
986
+ // current panel's actions, promoted into the top bar on a narrow shell
987
+ // (see main.ts), outside every `.s-panel` — the current panel, whose
988
+ // own chrome they remain at every width.
989
+ const panelEl = anchor.closest(".s-panel");
990
+ const entry = panelEl
991
+ ? this.$state.live.find((e) => e.el === panelEl)
992
+ : anchor.closest(".s-panel-origin")
993
+ ? this.$state.live[this.$state.focus]
994
+ : undefined;
995
+ void this.navigate(url.href, { from: entry?.path, how });
973
996
  return true;
974
997
  });
975
998
  }
@@ -993,7 +1016,7 @@ export class PanelStackController {
993
1016
  return this.pushPath(path, true);
994
1017
  }
995
1018
  openPanelStack(path, beneath) {
996
- return this.navigate(path, null, false, beneath);
1019
+ return this.navigate(path, { how: "open", beneath });
997
1020
  }
998
1021
  closePanel(path) {
999
1022
  return A.peek(() => {
@@ -1447,11 +1470,11 @@ export class PanelStackController {
1447
1470
  if (!entry.width)
1448
1471
  entry.width = width(entry);
1449
1472
  }
1450
- // The chrome above and below the body caps itself to the ensemble width,
1451
- // keeping everything centred and aligned however far the area stretches.
1452
- // The consumers transition their max-width (see main.ts), so the
1453
- // recentring plays along with the panel that caused it instead of
1454
- // snapping.
1473
+ // The body row caps itself to the ensemble width, keeping the columns
1474
+ // centred however far the area stretches, and transitions its max-width
1475
+ // (see main.ts) so the recentring plays along with the panel that caused
1476
+ // it. The bars above and below don't follow they hold at the standard
1477
+ // page width (also main.ts).
1455
1478
  shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
1456
1479
  // Phase 1 — every panel's *start* state for this frame. Panels already on
1457
1480
  // screen simply move (their standing transition animates it); freshly