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