staffa 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,10 @@
1
1
  import A, { OPAQUE } from "aberdeen";
2
2
  import * as route from "aberdeen/route";
3
3
  import { drawSlot } from "../core.js";
4
- import { circle as dotIcon, externalLink as newTabIcon, link as linkIcon, pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon, } from "../icons.js";
5
- import { SURFACE_SHEEN } from "../theme.js";
4
+ import { circle as dotIcon, pin as pinIcon, pinOff as pinOffIcon, slash as sepIcon, x as closeIcon, } from "../icons.js";
5
+ import { PANEL_SHEEN } from "../theme.js";
6
6
  import { addContextMenu } from "./menu.js";
7
7
  import { scrollStrip, revealInStrip } from "./tabs.js";
8
- import { toast } from "./toast.js";
9
8
  /**
10
9
  * The matchers a `[name=matcher]` segment can use. A matcher returns the param's
11
10
  * value, or `undefined` to fail the match, in which case the path falls through
@@ -111,10 +110,9 @@ function matchRoute(r, segments) {
111
110
  // ─── Constants ───────────────────────────────────────────────────────────────
112
111
  /**
113
112
  * The one duration every bit of shell motion shares: the enter/exit fades, the
114
- * `left` moves of columns shifting sideways, the ensemble-width transition the
115
- * body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
116
- * panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
117
- * and JS can't drift apart.
113
+ * `left` moves of columns shifting sideways, and the narrow-screen nav panel's
114
+ * slide. Published as the `--s-panel-ms` custom property below, so CSS and JS
115
+ * can't drift apart.
118
116
  *
119
117
  * Short enough to read as *the screen responded*, rather than as an animation
120
118
  * being played at you: a panel arriving is navigation, and navigation should
@@ -123,8 +121,17 @@ function matchRoute(r, segments) {
123
121
  const PAGE_MS = 250;
124
122
  /** How long a freshly pushed `loading` panel holds its enter animation. */
125
123
  const LOADING_HOLD_MS = 300;
126
- /** Don't pair smalls when half the content area would be narrower than this. */
127
- const PAIR_MIN_PX = 360;
124
+ /**
125
+ * The bounds of a column. At least 360px — about the narrowest phone still in
126
+ * common use, so it is where a panel's layout is aimed. And at most 540: that
127
+ * is the width the multi-column regime never reaches (`area / floor(area /
128
+ * 360)` stays under it), so capping the lone column of a 540–720px area to it
129
+ * too — centred, rather than stretched — makes an ask's ceiling uniform: never
130
+ * wider than its column count × 540. A content area narrower than 360 still
131
+ * gets its single column, just narrower than the minimum.
132
+ */
133
+ const SMALL_MIN_PX = 360;
134
+ export const SMALL_MAX_PX = 540;
128
135
  /**
129
136
  * Panels are layered by their depth in the stack, two `z-index` steps per panel:
130
137
  * a panel sits on the odd layer for its depth, and a *closing* one drops to the
@@ -142,16 +149,18 @@ A.insertGlobalCss({
142
149
  // layers they stack themselves in (see LAYER_STEP) to themselves: the region
143
150
  // as a whole still sits under the shell's own chrome — the sticky top bar, and
144
151
  // the nav panel that slides across the body — however deep the stack gets.
145
- // The region paints the panel's sheen over its own box, and every panel shows
146
- // a slice of that same gradient (see `.s-panel` below), so the columns and
147
- // the ground beside them are one continuous surface.
152
+ // The region paints the columns' sheen over its own box, and every panel
153
+ // paints the very same one (see `.s-panel` below) — which, being a straight
154
+ // vertical wash over boxes of one height, comes out identical whatever a
155
+ // column's width, so the columns and the ground beside them are one
156
+ // continuous surface.
148
157
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
149
158
  // and anything that ever scrolls it — find-in-page reaching for text in a
150
159
  // parked column, an in-page anchor, an extension — shifts every column
151
160
  // sideways, permanently, because nothing here would ever scroll it back.
152
161
  // `clip` clips without being scrollable at all, closing the whole class.
153
162
  ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
154
- SURFACE_SHEEN,
163
+ PANEL_SHEEN,
155
164
  ".s-panel": {
156
165
  // A panel rests at a plain `left` offset and carries no transform: a
157
166
  // transformed element is composited, which costs it subpixel text
@@ -174,17 +183,16 @@ A.insertGlobalCss({
174
183
  //
175
184
  // Every panel paints an opaque ground, because panels animate over one
176
185
  // another — entering, leaving, being crowded out — and two transparent ones
177
- // mean text sliding over text. It takes the panel's own sheen, the one
178
- // `.s-s, body` paints in theme.ts, resolved here against the inherited
179
- // `--s-bg` (a panel is not a surface, so it has to paint it itself).
186
+ // mean text sliding over text. It takes {@link PANEL_SHEEN}, resolved here
187
+ // against the inherited `--s-bg` (a panel is not a surface, so it has to
188
+ // paint it itself).
180
189
  //
181
- // Painted per panel, over the panel's own box, which is as good as it
182
- // needs to be: the sheen is a 9%-either-way wash over a whole column, so
183
- // two columns' worth of it meeting at a hairline is not something the eye
184
- // picks out. The region (`.s-panels` above) paints the same wash, so the
185
- // ground beside a lone column matches it just as closely.
190
+ // Painted per panel, over the panel's own box — and yet seamless with its
191
+ // neighbours and with the ground beside them, because that wash runs
192
+ // straight down: it takes its extent from the height these boxes all share,
193
+ // never from their differing widths. See PANEL_SHEEN for why that matters.
186
194
  "&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
187
- SURFACE_SHEEN + " " +
195
+ PANEL_SHEEN + " " +
188
196
  "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
189
197
  // The hairline between two columns, fading out at both ends — the same
190
198
  // treatment as the sidebar's `.s-nav-sep`. Columns tile the area with no
@@ -621,7 +629,7 @@ export class PanelStackController {
621
629
  path,
622
630
  draw,
623
631
  $ui: A.proxy({ holding: false }),
624
- maxWidth: "full",
632
+ maxWidth: "medium",
625
633
  width: 0,
626
634
  };
627
635
  // `close` closes *this* panel, current or not, and `open` navigates
@@ -767,31 +775,32 @@ export class PanelStackController {
767
775
  return settling;
768
776
  }
769
777
  /**
770
- * Make the stack's `index`th panel current: the URL and the visible run move
771
- * to it, while the panels right of it stay open, parked past the right edge
772
- * of the viewport. Nothing closes; it is a history entry, so the browser's
773
- * back button returns the focus to where it was. What a click on a
774
- * breadcrumb — any link to an open panel — comes down to.
778
+ * Make the stack's `index`th panel current without closing anything, leaving
779
+ * the panels right of it parked out of sight.
780
+ *
781
+ * Only ever a step around a panel that refuses to close — nothing else is
782
+ * left sitting after the current one — so this is Escape's way past an
783
+ * unsaved panel, and the way back to one. It is a history entry, so the
784
+ * browser's back button returns the focus to where it was.
775
785
  */
776
- focusAt(index, search, hash) {
786
+ focusAt(index) {
777
787
  const arr = this.intended();
778
788
  if (index < 0 || index >= arr.stack.length || index === arr.focus)
779
789
  return Promise.resolve(false);
780
790
  const target = { stack: arr.stack, focus: index };
781
791
  const path = arr.stack[index];
782
792
  return this.issue(target, () => {
783
- // The panel gets its own last search and hash back, unless the link
784
- // that brought us here carries its own.
793
+ // The panel gets its own last search and hash back (see PanelEntry.search).
785
794
  const entry = this.$state.live.find((e) => e.path === path);
786
- return route.go({ path, search: search ?? entry?.search, hash: hash ?? entry?.hash, state: this.stateFor(target) });
795
+ return route.go({ path, search: entry?.search, hash: entry?.hash, state: this.stateFor(target) });
787
796
  });
788
797
  }
789
798
  /**
790
799
  * One step back along the stack — what Escape does (`main()` calls this;
791
- * it is not {@link PanelStack} API). At the stack's end this closes the
792
- * current panel; mid-stack — with panels parked to the right — or when the
793
- * panel holds {@link Panel.unsaved} work, the panel stays open and the
794
- * focus just moves to the panel on its left, parking the one it leaves.
800
+ * it is not {@link PanelStack} API). Normally that closes the current panel,
801
+ * which is the stack's end. When it holds {@link Panel.unsaved} work — or
802
+ * panels sit parked beyond it — it stays open instead, and the focus
803
+ * simply moves to the panel on its left.
795
804
  * Resolves `false` at the stack's start, where there is no left to go.
796
805
  */
797
806
  back() {
@@ -884,11 +893,14 @@ export class PanelStackController {
884
893
  * that know it.
885
894
  *
886
895
  * `how` is the link's `data-panel` attribute (or the caller's word for
887
- * it): absent — like a link without the attribute — it is the shell's
888
- * `linkNavigation` default, an unrecognised value is a push on top of
889
- * `from`, `"replace"` swaps `from` out rather than stacking on it, and
890
- * `"open"` drops `from` altogether so the target arrives with its own
891
- * stack, the way a nav item's link does.
896
+ * it), picking how much of `from`'s context the target keeps: a push (the
897
+ * default, and what unrecognised values fall back to) keeps `from` and
898
+ * builds on it, `"replace"` keeps only what is beneath `from`, and
899
+ * `"open"` keeps nothing — the target arrives with its own stack, the way
900
+ * a nav item's link does. Absent, it is the shell's `linkNavigation`
901
+ * default, like a link without the attribute. A target that is already
902
+ * open is returned to by a push, and *moved* — alive, state intact — by
903
+ * the other two: the stack never holds a path twice.
892
904
  *
893
905
  * Resolves the way every {@link PanelStack} method does: `true` once the
894
906
  * navigation lands, `false` when it doesn't (already there counts as
@@ -907,47 +919,59 @@ export class PanelStackController {
907
919
  return Promise.resolve(false);
908
920
  }
909
921
  const path = normalizePath(url.pathname);
910
- const search = Object.fromEntries(new URLSearchParams(url.search));
911
- const hash = url.hash;
912
922
  const arr = this.intended();
913
- // A link to a panel that is already open is a return, not a navigation —
914
- // a stack never holds the same path twice. Returning just moves the
915
- // focus: the panels right of the target stay open, parked past the right
916
- // edge, and nothing closes. That is the whole behaviour of a breadcrumb,
917
- // which is exactly such a link.
923
+ // Whatever we navigate to ends up on top of the stack; all that differs
924
+ // is what it lands on.
918
925
  const open = beneath ? -1 : arr.stack.indexOf(path);
919
- if (open >= 0 && open !== arr.focus) {
920
- return this.focusAt(open, url.search ? search : undefined, hash || undefined);
926
+ let target;
927
+ if (open >= 0 && mode !== "replace" && mode !== "open") {
928
+ // A push to a path that is already open is a return: the panel takes
929
+ // back its own place, and whatever was stacked on top of it closes.
930
+ // (A stack never holds the same path twice, so there is no second
931
+ // copy to open — and a breadcrumb is exactly such a link.) Pinned
932
+ // panels above it are the exception, as ever: they stay in their
933
+ // order, parked past the panel we return to, one crumb click away.
934
+ const above = this.pinnedIn(arr.stack.slice(open + 1), []);
935
+ target = { stack: [...arr.stack.slice(0, open + 1), ...above], focus: open };
936
+ }
937
+ else {
938
+ // The target opens on top of the panel the link sits in, or in its
939
+ // place for a `replace`, closing the panels after it. Without an
940
+ // originating panel there is no stack to build on, so it is the
941
+ // caller's own `beneath` or one derived from the path — which is what
942
+ // makes a nav click and a deep link to the same URL land identically
943
+ // (bar the pins, which a fresh tab doesn't have).
944
+ const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
945
+ const raw = beneath
946
+ ? beneath.map(normalizePath)
947
+ : originIndex < 0
948
+ ? this.deriveStack(path).slice(0, -1)
949
+ : arr.stack.slice(0, replace ? originIndex : originIndex + 1);
950
+ // A stack never holds the same path twice (rendering reconciles by
951
+ // path), so the target is dropped from the base — a `replace` or
952
+ // `open` may well aim at a path that is open mid-stack, whose panel
953
+ // then simply *moves* to the top, alive — and a caller-supplied
954
+ // `beneath` is deduplicated for the same reason.
955
+ const base = raw.filter((p, i, all) => p !== path && all.indexOf(p) === i);
956
+ // Pinned panels ride along, keeping their order, beneath the new one
957
+ // (unsaved ones the commit itself keeps, parked — see `propose`). A
958
+ // replaced origin closes, pin or no pin: replacing is the panel's own
959
+ // doing, not somewhere else navigating over it.
960
+ const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
961
+ target = { stack: [...under, path], focus: under.length };
921
962
  }
922
- if (open >= 0) {
923
- // The target is the panel we're already on. Going nowhere — but the link
924
- // may still carry a different search or hash, which belong to the
925
- // current panel: record that as a history entry, leaving the stack alone
926
- // (the panel reconciles by path, so it isn't even redrawn).
927
- if (url.search === location.search && (url.hash || "") === (location.hash || ""))
928
- return Promise.resolve(true);
929
- return this.issue(arr, () => route.go({ path, search, hash, state: this.stateFor(arr) }));
963
+ // Search and hash belong to the current panel only, so a panel we return
964
+ // to gets its own back (see PanelEntry.search) — unless the link carries
965
+ // its own, which win.
966
+ const returning = open >= 0 ? this.$state.live.find((e) => e.path === path) : undefined;
967
+ const search = url.search ? Object.fromEntries(new URLSearchParams(url.search)) : returning?.search ?? {};
968
+ const hash = url.hash || returning?.hash || "";
969
+ // Going nowhere at all: this panel, on this arrangement, with the query
970
+ // the link asks for. Not even a history entry.
971
+ if (target.focus === arr.focus && sameStack(target.stack, arr.stack)
972
+ && url.search === location.search && (url.hash || "") === (location.hash || "")) {
973
+ return Promise.resolve(true);
930
974
  }
931
- // A new panel: it opens at the stack's end and becomes current. The
932
- // panels after the origin close — except pinned ones, which ride along,
933
- // keeping their order, beneath the new panel (and unsaved ones, which
934
- // the commit itself keeps, parked — see `propose`). Without an
935
- // originating panel there is no stack to build on, so derive one — a
936
- // nav click and a deep link to the same URL land identically (bar the
937
- // pins, which a fresh tab doesn't have).
938
- const originIndex = origin == null ? -1 : arr.stack.indexOf(origin);
939
- // A stack never holds the same path twice (rendering reconciles by
940
- // path), so a caller-supplied `beneath` is deduplicated, not just
941
- // filtered against the target.
942
- const base = beneath
943
- ? beneath.map(normalizePath).filter((p, i, all) => p !== path && all.indexOf(p) === i)
944
- : originIndex < 0
945
- ? this.deriveStack(path).slice(0, -1)
946
- : arr.stack.slice(0, replace ? originIndex : originIndex + 1);
947
- // A replaced origin closes, pin or no pin: replacing is the panel's own
948
- // doing, not somewhere else navigating over it.
949
- const under = [...base, ...this.pinnedIn(arr.stack, [...base, path, replace ? origin : null])];
950
- const target = { stack: [...under, path], focus: under.length };
951
975
  return this.issue(target, () => route.go({ path, search, hash, state: this.stateFor(target) }));
952
976
  });
953
977
  }
@@ -1021,7 +1045,9 @@ export class PanelStackController {
1021
1045
  // app that reads them off a proxy (or through a getter) can change them at
1022
1046
  // runtime and the shell adapts in place — nothing is redrawn, no panel
1023
1047
  // loses its state. Not {@link PanelStack} API: the app talks to `main()`'s
1024
- // options; these are how `main()` talks to the stack.
1048
+ // options; these are how `main()` talks to the stack. The shell's *widths*
1049
+ // need no counterpart here: `navWidth` and `maxWidth` both resize the
1050
+ // column region, which the layout engine is already observing.
1025
1051
  /** Adopt a changed `columns` setting: one layout pass, nothing redrawn. */
1026
1052
  setColumns(columns) {
1027
1053
  if (this.opts.columns === columns)
@@ -1033,26 +1059,15 @@ export class PanelStackController {
1033
1059
  setLinkNavigation(mode) {
1034
1060
  this.opts.linkNavigation = mode;
1035
1061
  }
1036
- /**
1037
- * Adopt a changed `fullWidth`: one layout pass, nothing redrawn. A changed
1038
- * `navWidth` needs no counterpart — resizing the sidebar resizes the column
1039
- * region, which the layout engine is already observing.
1040
- */
1041
- setFullWidth(px) {
1042
- if (this.opts.fullWidth === px)
1043
- return;
1044
- this.opts.fullWidth = px;
1045
- this.scheduleLayout();
1046
- }
1047
1062
  /**
1048
1063
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
1049
1064
  * panel, oldest first, the ones on screen right now in bold, pinned ones
1050
1065
  * wearing their pin. Every crumb but the current panel's is a plain link to
1051
- * that panel, and a link to an open panel is a focus move (see `navigate`) —
1052
- * so clicking along the stack closes nothing, in either direction, and the
1053
- * panels right of the current one wait just past the viewport's edge.
1054
- * Right-click (or long-press) offers pinning, and closing just that one
1055
- * panel — the close that splices it out of the middle when it isn't last.
1066
+ * that panel, and a link to an open panel returns to it (see `navigate`) —
1067
+ * so clicking a crumb goes back to that panel and closes what was stacked on
1068
+ * top of it, pinned and unsaved panels excepted. Right-click (or long-press)
1069
+ * offers pinning, and closing just that one panel — the close that splices
1070
+ * it out of the middle when it isn't last.
1056
1071
  */
1057
1072
  drawCrumbs() {
1058
1073
  // The very same row `S.tabs` puts its tab strip in: it scrolls when the
@@ -1103,24 +1118,9 @@ export class PanelStackController {
1103
1118
  pinIcon({ size: "0.85em", attrs: ".s-crumb-pin" }); });
1104
1119
  // `||`, not `??`: the root path's last segment is the empty string.
1105
1120
  A(() => { A("#", entry?.$panel.title ?? entry?.$ui.fallback ?? (path.split("/").pop() || path)); });
1106
- // Taking over right-click means taking the browser's link menu away, so
1107
- // the two entries anyone actually reaches for on a link come first,
1108
- // where that menu would have had them, and the shell's own verbs sit
1109
- // below the rule.
1110
- addContextMenu({ items: [
1111
- {
1112
- // A real new tab, so it arrives cold and builds its own stack
1113
- // from the path — exactly what the same link middle-clicked does.
1114
- label: "Open in new tab",
1115
- icon: newTabIcon,
1116
- click: () => { window.open(path, "_blank", "noopener"); },
1117
- },
1118
- {
1119
- label: "Copy link",
1120
- icon: linkIcon,
1121
- click: () => void copyLink(path),
1122
- },
1123
- { separator: true },
1121
+ // `link` puts the browser's own link entries — Open in new tab, Copy
1122
+ // link — above the rule; the shell's own verbs sit below it.
1123
+ addContextMenu({ link: path, items: [
1124
1124
  {
1125
1125
  label: () => { A(() => { A("#", entry?.$panel.pinned ? "Unpin" : "Pin"); }); },
1126
1126
  icon: () => { A(() => { (entry?.$panel.pinned ? pinOffIcon : pinIcon)(); }); },
@@ -1173,33 +1173,34 @@ export class PanelStackController {
1173
1173
  }
1174
1174
  /**
1175
1175
  * While any open panel holds unsaved work, closing the tab — or navigating
1176
- * the whole browser away — runs into the browser's own are-you-sure. When
1177
- * the user stays, the unsaved panel is brought back on screen if it wasn't,
1178
- * so what held the tab is in front of them rather than parked out of sight.
1176
+ * the whole browser away — runs into the browser's own are-you-sure, with
1177
+ * the unsaved panel brought on screen as the question is raised, so what is
1178
+ * holding the tab is in front of the user rather than parked out of sight.
1179
1179
  */
1180
1180
  guardTabClose() {
1181
1181
  if (typeof window === "undefined")
1182
1182
  return;
1183
- let leaving = false;
1184
- const onHide = () => { leaving = true; };
1185
1183
  const onBeforeUnload = (e) => {
1186
- // Being asked again means we weren't gone after all (a bfcache restore).
1187
- leaving = false;
1188
1184
  const dirty = this.$state.live.find((entry) => entry.$panel.unsaved);
1189
1185
  if (!dirty)
1190
1186
  return;
1191
1187
  e.preventDefault();
1192
1188
  e.returnValue = true; // Chrome/Edge < 119
1193
- // This task only ever amounts to anything if the user cancels: a
1194
- // confirmed leave unloads the document (`pagehide`) first.
1195
- const path = dirty.path;
1196
- setTimeout(() => {
1197
- if (leaving)
1198
- return;
1199
- const entry = this.$state.live.find((live) => live.path === path);
1200
- if (entry && !entry.$panel.visible)
1201
- void this.focusAt(this.intended().stack.indexOf(path));
1202
- }, 0);
1189
+ // Bring the unsaved panel on screen right here, so what is holding the
1190
+ // tab is in front of the user — behind the browser's dialog where the
1191
+ // browser paints that early, and the moment they choose to stay
1192
+ // otherwise. A confirmed leave unloads the document before any of it
1193
+ // is seen; the history entry the move makes is then where a back
1194
+ // navigation returns to, which is right: the panel that held the tab.
1195
+ // `visible` is written by the layout pass, which waits for an animation
1196
+ // frame — and the frame owed to the navigation that parked this panel
1197
+ // may not have landed, all the more so in a tab on its way out, which
1198
+ // may never paint again. Settle it first, or a panel parked a moment
1199
+ // ago still reads as on screen and the guard skips the very move it
1200
+ // exists to make.
1201
+ this.flushLayout();
1202
+ if (!dirty.$panel.visible)
1203
+ void this.focusAt(this.intended().stack.indexOf(dirty.path));
1203
1204
  };
1204
1205
  // Registered only while a panel actually holds unsaved work: a page with a
1205
1206
  // `beforeunload` listener is shut out of the browser's back/forward cache,
@@ -1209,11 +1210,7 @@ export class PanelStackController {
1209
1210
  if (!this.$state.live.some((entry) => entry.$panel.unsaved))
1210
1211
  return;
1211
1212
  window.addEventListener("beforeunload", onBeforeUnload);
1212
- window.addEventListener("pagehide", onHide);
1213
- A.clean(() => {
1214
- window.removeEventListener("beforeunload", onBeforeUnload);
1215
- window.removeEventListener("pagehide", onHide);
1216
- });
1213
+ A.clean(() => window.removeEventListener("beforeunload", onBeforeUnload));
1217
1214
  });
1218
1215
  }
1219
1216
  // ── Rendering ──────────────────────────────────────────────────────────
@@ -1237,13 +1234,11 @@ export class PanelStackController {
1237
1234
  A.onEach(this.$open, (entry) => this.drawPanel(entry), (entry) => entry.order);
1238
1235
  });
1239
1236
  if (typeof ResizeObserver !== "undefined") {
1237
+ // The region *is* the content area every width is measured from (see
1238
+ // `measure`), so watching it catches the lot: a window resize, the
1239
+ // sidebar coming or going, the shell's own `maxWidth` changing.
1240
1240
  const ro = new ResizeObserver(() => this.layout());
1241
- // The region *and* the body it sits in: the region alone misses a shell
1242
- // resize that the columns happen to absorb, which still re-resolves widths.
1243
1241
  ro.observe(container);
1244
- const body = container.parentElement?.parentElement;
1245
- if (body)
1246
- ro.observe(body);
1247
1242
  A.clean(() => ro.disconnect());
1248
1243
  }
1249
1244
  A.clean(() => { if (this.containerEl === container)
@@ -1256,13 +1251,13 @@ export class PanelStackController {
1256
1251
  // element that arrives without a width has no box for its content to measure
1257
1252
  // itself against until the next frame's layout pass, which is a frame too
1258
1253
  // late for anything that sizes itself from its container. So the panel is
1259
- // created at the width the window gives it — the "full" width until the panel
1260
- // says otherwise. Reactively, too: a panel that changes its mind later (when
1261
- // its data arrives, say) reflows in place rather than being redrawn, and the
1262
- // columns beside it slide over to make room.
1254
+ // created at the width the window gives it — the "medium" width until the
1255
+ // panel says otherwise. Reactively, too: a panel that changes its mind later
1256
+ // (when its data arrives, say) reflows in place rather than being redrawn,
1257
+ // and the columns beside it slide over to make room.
1263
1258
  A(() => {
1264
1259
  const asked = entry.$panel.maxWidth;
1265
- entry.maxWidth = asked === "half" || asked === "screen" ? asked : "full";
1260
+ entry.maxWidth = asked === "small" || asked === "large" || asked === "none" ? asked : "medium";
1266
1261
  const width = this.roomFor(entry.maxWidth);
1267
1262
  if (!width)
1268
1263
  return;
@@ -1340,52 +1335,51 @@ export class PanelStackController {
1340
1335
  if (this.layoutQueued)
1341
1336
  return;
1342
1337
  this.layoutQueued = true;
1343
- requestAnimationFrame(() => {
1344
- this.layoutQueued = false;
1345
- this.layout();
1346
- });
1338
+ requestAnimationFrame(() => this.flushLayout());
1347
1339
  }
1348
1340
  /**
1349
- * Measure the shell, and with it the width the window gives a panel of each
1350
- * layout. Measured on the *shell*, not on the column region: the region's width
1351
- * is the layout engine's own output, so reading it back would nail the layout
1352
- * to whatever it happened to be a frame ago. Fractional widths throughout — a
1353
- * rounded column edge would drift a pixel away from the chrome above it.
1341
+ * Run the pending layout pass now, rather than on the frame it is waiting
1342
+ * for. For the callers that have to *read* what only the pass knows —
1343
+ * `$panel.visible`, `$panel.width` — at a moment when waiting isn't an
1344
+ * option. Does nothing when no pass is owed.
1345
+ */
1346
+ flushLayout() {
1347
+ if (!this.layoutQueued)
1348
+ return;
1349
+ this.layoutQueued = false;
1350
+ this.layout();
1351
+ }
1352
+ /**
1353
+ * Measure the content area, and with it the width a panel of each size gets.
1354
+ *
1355
+ * The column region *is* the content area: it takes whatever the shell has
1356
+ * left beside the sidebar, capped by the shell's own `maxWidth` — all of it
1357
+ * CSS's doing, so there is nothing to add up here and nothing that could
1358
+ * drift from the width the bars above and below line up with. Fractional
1359
+ * widths throughout: a rounded column edge would drift a pixel away from that
1360
+ * chrome.
1361
+ *
1362
+ * The area divides into the narrowest whole number of columns that keeps each
1363
+ * at least {@link SMALL_MIN_PX} wide — the `"small"` unit every other size is
1364
+ * a multiple of, capped at the area itself. So 1080px is three columns of 360
1365
+ * and 1520px four of 380. An area too narrow for two is a single column,
1366
+ * itself capped at {@link SMALL_MAX_PX}: a small centres there instead of
1367
+ * stretching toward 720, so its ceiling holds, while the larger sizes still
1368
+ * take the whole area. A width is thus a pure function of the window: a panel
1369
+ * NEVER resizes because a neighbour came or went, and only a window resize
1370
+ * (the snap pass in `layout`) changes one.
1354
1371
  *
1355
1372
  * `undefined` while the shell has no width to speak of (it isn't in a document
1356
1373
  * yet, or it's `display:none`); the next pass tries again.
1357
1374
  */
1358
1375
  measure() {
1359
- const container = this.containerEl;
1360
- const inner = container?.parentElement;
1361
- const body = inner?.parentElement;
1362
- if (!container || !inner || !body)
1363
- return undefined;
1364
- const total = body.getBoundingClientRect().width;
1365
- if (!total)
1376
+ const el = this.containerEl;
1377
+ const area = el ? el.getBoundingClientRect().width : 0;
1378
+ if (!area)
1366
1379
  return undefined;
1367
- // Everything that sits beside the columns: the sidebar and its hairline,
1368
- // either of which may be display:none on a narrow shell.
1369
- let chrome = 0;
1370
- for (const child of inner.children) {
1371
- if (child !== container)
1372
- chrome += child.getBoundingClientRect().width;
1373
- }
1374
- // What the window has beside the sidebar, and within that the *standard*
1375
- // content area: the width the app gave a "full" panel, or all there is
1376
- // when the window has less. Widths are a pure function of the window —
1377
- // never of what else is open — so a panel NEVER resizes because a
1378
- // neighbour came or went; only a window resize (the snap pass in
1379
- // `layout`) changes them:
1380
- // - "full" fills the standard content area exactly;
1381
- // - "half" is half of it whenever that half is still a usable column, and
1382
- // the whole of it on narrower screens;
1383
- // - "screen" ignores the standard width and takes everything the window
1384
- // has — which also means nothing ever fits beside it.
1385
- const screen = Math.max(0, total - chrome);
1386
- const full = Math.min(this.opts.fullWidth, screen);
1387
- const halved = full / 2;
1388
- return { total, chrome, half: halved >= PAIR_MIN_PX ? halved : full, full, screen };
1380
+ const small = Math.min(area / Math.max(1, Math.floor(area / SMALL_MIN_PX)), SMALL_MAX_PX);
1381
+ const units = (n) => Math.min(n * small, area);
1382
+ return { area, size: { small, medium: units(2), large: units(3), none: area } };
1389
1383
  }
1390
1384
  /**
1391
1385
  * The measurements this pass runs on. Taken once per layout pass and per
@@ -1397,12 +1391,11 @@ export class PanelStackController {
1397
1391
  return (this.geom ??= this.measure());
1398
1392
  }
1399
1393
  /** How wide a panel asking for this is, right now; 0 while the shell can't be measured. */
1400
- roomFor(maxWidth) {
1401
- return this.geometry()?.[maxWidth] ?? 0;
1394
+ roomFor(size) {
1395
+ return this.geometry()?.size[size] ?? 0;
1402
1396
  }
1403
1397
  /**
1404
- * Size and position every panel, and publish the width of the whole ensemble
1405
- * (sidebar + separator + columns) for the shell to centre itself on.
1398
+ * Size and position every panel.
1406
1399
  *
1407
1400
  * This is everything CSS can't work out for itself: which panels exist, which
1408
1401
  * of them are visible, how wide each one is and where it sits. All the motion
@@ -1428,42 +1421,39 @@ export class PanelStackController {
1428
1421
  const geom = this.geometry();
1429
1422
  if (!geom)
1430
1423
  return;
1431
- const stacking = this.opts.columns !== "single";
1424
+ const single = this.opts.columns === "single";
1432
1425
  // A window resize — or the app resizing the shell itself, by changing
1433
- // `navWidth` or `fullWidth` — must be adopted instantly: geometry tracking
1426
+ // `navWidth` or `maxWidth` — must be adopted instantly: geometry tracking
1434
1427
  // the window through a 450ms transition reads as lag, and a shell
1435
1428
  // animating itself into place on its first pass reads as a glitch. Only
1436
1429
  // what a *panel* did is worth animating, and none of those three are.
1437
1430
  // `.s-shell-snap` suppresses every standing transition for this one pass.
1438
- const was = this.lastGeom;
1439
- const snap = was == null || was.total !== geom.total || was.chrome !== geom.chrome || was.full !== geom.full;
1431
+ const snap = this.lastGeom?.area !== geom.area;
1440
1432
  if (snap) {
1441
1433
  this.lastGeom = geom;
1442
1434
  shell.classList.add("s-shell-snap");
1443
1435
  }
1444
- const width = (entry) => geom[entry.maxWidth];
1436
+ const width = (entry) => geom.size[entry.maxWidth];
1445
1437
  // The visible run: as many columns as the window fits, at the sizes the
1446
1438
  // window gives them, ending at the current panel — which always shows.
1447
1439
  // Panels beyond it are parked past the right edge (see phase 1).
1448
1440
  const cur = Math.min(this.$state.focus, n - 1);
1449
1441
  let first = cur;
1450
1442
  let runSum = width(live[cur]);
1451
- if (stacking) {
1443
+ if (!single) {
1452
1444
  for (let i = cur - 1; i >= 0; i--) {
1453
1445
  const sum = runSum + width(live[i]);
1454
- if (sum > geom.screen)
1446
+ if (sum > geom.area)
1455
1447
  break;
1456
1448
  runSum = sum;
1457
1449
  first = i;
1458
1450
  }
1459
1451
  }
1460
- // The content area holds the run, but is never smaller than the standard
1461
- // panel (a lone small leaves its other half open — which is exactly where
1462
- // the next small lands, without anything on screen moving) and never
1463
- // wider than the window. So the page holds its standard width until extra
1464
- // columns genuinely fit, and stretches — centred — to hold the ones that
1465
- // do; with a "screen" up that's the window's edges.
1466
- const area = Math.min(geom.screen, Math.max(geom.full, runSum));
1452
+ // The content area is a fixed width, so a run that doesn't fill it sits
1453
+ // centred in it rather than hanging off its left edge. Everything around
1454
+ // the columns holds still meanwhile: the sidebar, the top bar and the
1455
+ // footer never move, however many columns come and go.
1456
+ const left = (geom.area - runSum) / 2;
1467
1457
  for (let i = first; i <= cur; i++)
1468
1458
  live[i].width = width(live[i]);
1469
1459
  // Panels that have never been visible get their would-be width too, so a
@@ -1472,28 +1462,22 @@ export class PanelStackController {
1472
1462
  if (!entry.width)
1473
1463
  entry.width = width(entry);
1474
1464
  }
1475
- // The body row caps itself to the ensemble width, keeping the columns
1476
- // centred however far the area stretches, and transitions its max-width
1477
- // (see main.ts) so the recentring plays along with the panel that caused
1478
- // it. The bars above and below don't follow — they hold at the standard
1479
- // page width (also main.ts).
1480
- shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
1481
1465
  // Phase 1 — every panel's *start* state for this frame. Panels already on
1482
1466
  // screen simply move (their standing transition animates it); freshly
1483
1467
  // mounted ones still have transitions switched off, so what we set here is
1484
1468
  // adopted instantly and becomes the "before" of their enter animation.
1485
1469
  const fresh = [];
1486
- let x = 0;
1470
+ let x = left;
1487
1471
  for (let i = 0; i < n; i++) {
1488
1472
  const entry = live[i];
1489
1473
  const el = entry.el;
1490
1474
  const shown = i >= first && i <= cur;
1491
- // Visible columns tile the content area, left to right. Panels crowded
1492
- // out from under the run rest at its left edge; panels beyond the
1493
- // current panel park just past its right edge — both keep their last
1494
- // width. Deeper panels layer over shallower ones, each on the odd
1495
- // layer for its depth (see LAYER_STEP).
1496
- place(el, shown ? x : i > cur ? area : 0, entry.width, LAYER_STEP * i + 1);
1475
+ // Visible columns tile the run, left to right. Panels crowded out from
1476
+ // under it rest at its left edge; panels beyond the current panel park
1477
+ // just past its right edge — both keep their last width. Deeper panels
1478
+ // layer over shallower ones, each on the odd layer for its depth (see
1479
+ // LAYER_STEP).
1480
+ place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
1497
1481
  // What `$panel.visible` and `$panel.width` report: this pass is the one
1498
1482
  // thing that knows them, window resizes included. Written only on a
1499
1483
  // change, so per-panel UI hanging off them isn't rebuilt by every pass.
@@ -1578,22 +1562,6 @@ function firstText(el) {
1578
1562
  return t.length > 48 ? `${t.slice(0, 47).trimEnd()}…` : t;
1579
1563
  }
1580
1564
  }
1581
- /**
1582
- * Put a panel's address on the clipboard, as the absolute URL someone can paste
1583
- * anywhere — which is what the browser's own "Copy link" would have given them.
1584
- * Confirmed with a toast, since a silent copy leaves you wondering; `writeText`
1585
- * needs a secure context, so a failure says so rather than lying.
1586
- */
1587
- async function copyLink(path) {
1588
- const url = new URL(path, location.href).href;
1589
- try {
1590
- await navigator.clipboard.writeText(url);
1591
- toast({ message: "Link copied." });
1592
- }
1593
- catch {
1594
- toast({ message: "Couldn't copy the link.", type: "danger" });
1595
- }
1596
- }
1597
1565
  function drawDefaultNotFound($panel) {
1598
1566
  A("p fg:$s-muted", () => A("#", `No panel at ${$panel.path}`));
1599
1567
  }