staffa 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  *
@@ -123,12 +123,6 @@ function matchRoute(r, segments) {
123
123
  const PAGE_MS = 250;
124
124
  /** How long a freshly pushed `loading` panel holds its enter animation. */
125
125
  const LOADING_HOLD_MS = 300;
126
- /**
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.
130
- */
131
- const SHELL_PX = 1280;
132
126
  /** Don't pair smalls when half the content area would be narrower than this. */
133
127
  const PAIR_MIN_PX = 360;
134
128
  /**
@@ -151,7 +145,12 @@ A.insertGlobalCss({
151
145
  // The region paints the panel's sheen over its own box, and every panel shows
152
146
  // a slice of that same gradient (see `.s-panel` below), so the columns and
153
147
  // 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 " +
148
+ // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
149
+ // and anything that ever scrolls it — find-in-page reaching for text in a
150
+ // parked column, an in-page anchor, an extension — shifts every column
151
+ // sideways, permanently, because nothing here would ever scroll it back.
152
+ // `clip` clips without being scrollable at all, closing the whole class.
153
+ ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
155
154
  SURFACE_SHEEN,
156
155
  ".s-panel": {
157
156
  // A panel rests at a plain `left` offset and carries no transform: a
@@ -245,13 +244,18 @@ A.insertGlobalCss({
245
244
  // the weight change alone is ambiguous in a short crumb, the colour alone
246
245
  // too subtle. No padding of its own — the first crumb has to start on the
247
246
  // 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 " +
247
+ // The flex is how a tight row is shared out. Every crumb grows from the
248
+ // same 4rem basis in equal shares, freezing at its own text
249
+ // (`max-width:max-content`) so with room to spare every title shows in
250
+ // full, and under pressure it is the *longest* crumbs that give way
251
+ // first, equalising downward while short ones keep every character. No
252
+ // crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
253
+ // that point the row overflows and the strip scrolls — which is what
254
+ // keeps a deep stack on a phone readable. (Crumbs allowed to shrink
255
+ // would ellipsise to a row of stubs instead, and the stack would never
256
+ // scroll.)
257
+ "&": "flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
258
+ "white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
255
259
  "transition: color 0.12s;",
256
260
  "&.s-crumb-on": "font-weight:600 fg:$s-text",
257
261
  // The same hover treatment as a menu item. The panel you are on is a plain
@@ -349,8 +353,8 @@ export class PanelStackController {
349
353
  containerEl;
350
354
  /** The shell's measurements, shared by everything drawn since they were taken. */
351
355
  geom;
352
- /** The body width at the last layout; a change means a window resize → snap. */
353
- lastBodyW = -1;
356
+ /** The measurements the last layout ran on; a change in them → snap. */
357
+ lastGeom;
354
358
  layoutQueued = false;
355
359
  timers = new Set();
356
360
  /** The arrangement the navigation in flight is heading for; see {@link intended}. */
@@ -620,9 +624,12 @@ export class PanelStackController {
620
624
  maxWidth: "full",
621
625
  width: 0,
622
626
  };
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.
627
+ // `close` closes *this* panel, current or not, and `open` navigates
628
+ // *from* it, through the very implementation a link click uses (see
629
+ // `navigate`). Both resolve the panel's place in the stack at call
630
+ // time, so they keep working after a splice has moved it; an `open`
631
+ // from a panel that has since closed falls back to a derived stack,
632
+ // like a link from nowhere.
626
633
  //
627
634
  // `visible` starts at what the panel's place implies: shown when it sits
628
635
  // at or before the current panel (a pushed panel always does), hidden when
@@ -636,6 +643,7 @@ export class PanelStackController {
636
643
  visible,
637
644
  pinned: pinned || undefined,
638
645
  close: () => this.closePath(entry.path),
646
+ open: (href, how) => this.navigate(href, { from: entry.path, how }),
639
647
  });
640
648
  return entry;
641
649
  }
@@ -865,17 +873,31 @@ export class PanelStackController {
865
873
  });
866
874
  }
867
875
  /**
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.
876
+ * Navigate to `href` the one implementation behind a link click,
877
+ * {@link Panel.open} and the stack's own methods, so none of them can
878
+ * behave differently.
879
+ *
880
+ * `from` is the path of the panel the navigation starts from the one
881
+ * the link lives in — or absent when it has none: a nav item, or a call
882
+ * that means the whole stack, which is then built instead (see
883
+ * {@link deriveStack}), or taken outright from `beneath`, for callers
884
+ * that know it.
885
+ *
886
+ * `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.
873
892
  *
874
893
  * Resolves the way every {@link PanelStack} method does: `true` once the
875
894
  * navigation lands, `false` when it doesn't (already there counts as
876
895
  * landed).
877
896
  */
878
- navigate(href, origin, replace = false, beneath) {
897
+ navigate(href, { from, how, beneath } = {}) {
898
+ const mode = how ?? this.opts.linkNavigation;
899
+ const origin = mode === "open" ? null : from ?? null;
900
+ const replace = mode === "replace";
879
901
  return A.peek(() => {
880
902
  let url;
881
903
  try {
@@ -933,43 +955,36 @@ export class PanelStackController {
933
955
  pushPath(path, replace) {
934
956
  return A.peek(() => {
935
957
  const arr = this.intended();
936
- return this.navigate(path, arr.stack[arr.focus] ?? null, replace);
958
+ return this.navigate(path, { from: arr.stack[arr.focus], how: replace ? "replace" : "push" });
937
959
  });
938
960
  }
939
961
  // ── Link interception ──────────────────────────────────────────────────
940
962
  /**
941
963
  * 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
964
+ * the anchor so we can decide what the click *means*. The exclusion rules
945
965
  * (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
946
966
  * close guards run in `checkChange` when our navigation reaches the router.
947
967
  *
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`.
968
+ * A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
969
+ * attribute as its `how` (see {@link navigate}, the shared implementation).
970
+ * A link that isn't inside any panel a nav item, one in a dialog has no
971
+ * panel to build on, so it replaces the stack as a whole, exactly as a cold
972
+ * link to the same URL would open it.
954
973
  */
955
974
  interceptLinks() {
956
975
  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");
976
+ const how = anchor.getAttribute("data-panel") ?? undefined;
977
+ // The panel the link lives in: the enclosing `.s-panel`, or — for the
978
+ // current panel's actions, promoted into the top bar on a narrow shell
979
+ // (see main.ts), outside every `.s-panel` — the current panel, whose
980
+ // own chrome they remain at every width.
981
+ const panelEl = anchor.closest(".s-panel");
982
+ const entry = panelEl
983
+ ? this.$state.live.find((e) => e.el === panelEl)
984
+ : anchor.closest(".s-panel-origin")
985
+ ? this.$state.live[this.$state.focus]
986
+ : undefined;
987
+ void this.navigate(url.href, { from: entry?.path, how });
973
988
  return true;
974
989
  });
975
990
  }
@@ -993,7 +1008,7 @@ export class PanelStackController {
993
1008
  return this.pushPath(path, true);
994
1009
  }
995
1010
  openPanelStack(path, beneath) {
996
- return this.navigate(path, null, false, beneath);
1011
+ return this.navigate(path, { how: "open", beneath });
997
1012
  }
998
1013
  closePanel(path) {
999
1014
  return A.peek(() => {
@@ -1018,6 +1033,17 @@ export class PanelStackController {
1018
1033
  setLinkNavigation(mode) {
1019
1034
  this.opts.linkNavigation = mode;
1020
1035
  }
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
+ }
1021
1047
  /**
1022
1048
  * The breadcrumb stack, drawn by `main()` into the top bar: every open
1023
1049
  * panel, oldest first, the ones on screen right now in bold, pinned ones
@@ -1345,25 +1371,21 @@ export class PanelStackController {
1345
1371
  if (child !== container)
1346
1372
  chrome += child.getBoundingClientRect().width;
1347
1373
  }
1348
- // The standard panel is SHELL_PX wide, capped by the window; what it leaves
1349
- // beside the sidebar is the *standard* content area. Widths are a pure
1350
- // function of the window never of what else is open so a panel NEVER
1351
- // resizes because a neighbour came or went; only a window resize (the
1352
- // snap pass in `layout`) changes them:
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:
1353
1380
  // - "full" fills the standard content area exactly;
1354
1381
  // - "half" is half of it whenever that half is still a usable column, and
1355
1382
  // the whole of it on narrower screens;
1356
1383
  // - "screen" ignores the standard width and takes everything the window
1357
1384
  // has — which also means nothing ever fits beside it.
1358
- const full = Math.max(0, Math.min(SHELL_PX, total) - chrome);
1385
+ const screen = Math.max(0, total - chrome);
1386
+ const full = Math.min(this.opts.fullWidth, screen);
1359
1387
  const halved = full / 2;
1360
- return {
1361
- total,
1362
- chrome,
1363
- half: halved >= PAIR_MIN_PX ? halved : full,
1364
- full,
1365
- screen: Math.max(0, total - chrome),
1366
- };
1388
+ return { total, chrome, half: halved >= PAIR_MIN_PX ? halved : full, full, screen };
1367
1389
  }
1368
1390
  /**
1369
1391
  * The measurements this pass runs on. Taken once per layout pass and per
@@ -1407,13 +1429,16 @@ export class PanelStackController {
1407
1429
  if (!geom)
1408
1430
  return;
1409
1431
  const stacking = this.opts.columns !== "single";
1410
- // A window resize (or the very first pass) must be adopted instantly —
1411
- // geometry tracking the window through a 450ms transition reads as lag,
1412
- // and a shell animating itself into place on load reads as a glitch.
1432
+ // A window resize or the app resizing the shell itself, by changing
1433
+ // `navWidth` or `fullWidth` must be adopted instantly: geometry tracking
1434
+ // the window through a 450ms transition reads as lag, and a shell
1435
+ // animating itself into place on its first pass reads as a glitch. Only
1436
+ // what a *panel* did is worth animating, and none of those three are.
1413
1437
  // `.s-shell-snap` suppresses every standing transition for this one pass.
1414
- const snap = this.lastBodyW !== geom.total;
1438
+ const was = this.lastGeom;
1439
+ const snap = was == null || was.total !== geom.total || was.chrome !== geom.chrome || was.full !== geom.full;
1415
1440
  if (snap) {
1416
- this.lastBodyW = geom.total;
1441
+ this.lastGeom = geom;
1417
1442
  shell.classList.add("s-shell-snap");
1418
1443
  }
1419
1444
  const width = (entry) => geom[entry.maxWidth];
@@ -1435,7 +1460,7 @@ export class PanelStackController {
1435
1460
  // The content area holds the run, but is never smaller than the standard
1436
1461
  // panel (a lone small leaves its other half open — which is exactly where
1437
1462
  // the next small lands, without anything on screen moving) and never
1438
- // wider than the window. So the panel is the familiar 1280px until extra
1463
+ // wider than the window. So the page holds its standard width until extra
1439
1464
  // columns genuinely fit, and stretches — centred — to hold the ones that
1440
1465
  // do; with a "screen" up that's the window's edges.
1441
1466
  const area = Math.min(geom.screen, Math.max(geom.full, runSum));
@@ -1447,11 +1472,11 @@ export class PanelStackController {
1447
1472
  if (!entry.width)
1448
1473
  entry.width = width(entry);
1449
1474
  }
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.
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).
1455
1480
  shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
1456
1481
  // Phase 1 — every panel's *start* state for this frame. Panels already on
1457
1482
  // screen simply move (their standing transition animates it); freshly