staffa 0.17.2 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -193,7 +193,7 @@ export interface Panel<P = Record<string, string | number | string[]>> {
193
193
  * Every size is capped at the content area, so on a phone they all come to
194
194
  * the same thing: one screen at a time. And a width depends only on the
195
195
  * window, never on what else is open, so opening or closing a panel never
196
- * resizes another — the run of columns just recentres in the area.
196
+ * resizes another — the run of columns just shifts over in the area.
197
197
  *
198
198
  * The ask is a ceiling only; there is no matching floor, since the window can
199
199
  * be any width. Aim your layout at 360px — about the narrowest phone still in
@@ -211,10 +211,11 @@ export interface Panel<P = Record<string, string | number | string[]>> {
211
211
  maxWidth?: PanelSize;
212
212
  /**
213
213
  * Set this while you're fetching what the panel needs, and back to `false`
214
- * when you're done. A new panel waits a moment before sliding in, so it can
215
- * arrive with real content instead of empty; if the wait drags on it slides
216
- * in anyway and shows a loading indicator until the flag clears. It only
217
- * affects the animation; the stack and the URL never wait for it.
214
+ * when you're done. A new panel slides into place right away but waits a
215
+ * moment before fading in, so it can appear with real content instead of
216
+ * empty; if the wait drags on it fades in anyway and shows a loading
217
+ * indicator until the flag clears. It only affects the animation; the
218
+ * stack and the URL never wait for it.
218
219
  */
219
220
  loading?: boolean;
220
221
  /**
@@ -419,7 +420,7 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
419
420
  * `--s-panel-ms` custom property, so CSS and JS can't drift apart.
420
421
  */
421
422
  const PAGE_MS = 250;
422
- /** How long a freshly pushed `loading` panel holds its enter animation. */
423
+ /** How long a freshly pushed `loading` panel holds its fade-in. */
423
424
  const LOADING_HOLD_MS = 300;
424
425
  /**
425
426
  * The bounds of a column: at most 540px — the width columns aim for, the area
@@ -428,13 +429,6 @@ const LOADING_HOLD_MS = 300;
428
429
  */
429
430
  const SMALL_MIN_PX = 360;
430
431
  export const SMALL_MAX_PX = 540;
431
- /**
432
- * Two `z-index` steps per panel: a panel sits on the odd layer for its depth in
433
- * the stack, a *closing* one on the even layer just below. So a replacement
434
- * comes in over the panel it replaces, while a closing panel fades out over
435
- * whatever it was covering.
436
- */
437
- const LAYER_STEP = 2;
438
432
 
439
433
  // ─── Module-level styling ────────────────────────────────────────────────────
440
434
 
@@ -442,9 +436,9 @@ A.insertGlobalCss({
442
436
  ":root": `--s-panel-ms:${PAGE_MS}ms`,
443
437
  // The clipping viewport the columns slide through; panels are absolutely
444
438
  // positioned inside it, sized and offset from JS (see `layout()`).
445
- // `isolation` keeps their z-index layers (see LAYER_STEP) below the shell's
446
- // own chrome. It paints the same PANEL_SHEEN as every panel, so columns and
447
- // the ground beside them read as one surface.
439
+ // `isolation` keeps their z-index layers below the shell's own chrome. It
440
+ // paints the same PANEL_SHEEN as every panel, so columns and the ground
441
+ // beside them read as one surface.
448
442
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
449
443
  // and anything that scrolls it (find-in-page, an in-page anchor) shifts every
450
444
  // column sideways permanently, with nothing to scroll it back.
@@ -452,43 +446,56 @@ A.insertGlobalCss({
452
446
  "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
453
447
  PANEL_SHEEN,
454
448
  ".s-panel": {
455
- // A panel rests at a plain `left` offset with no transform: a transformed
456
- // element is composited, costing it subpixel text antialiasing. Transform
457
- // is used only for the enter/exit slides. No `width` transition either —
458
- // animating one reflows the column's content every frame.
459
- // The fade is deliberately `linear` while the drift eases out: an eased
460
- // opacity spends its last stretch near zero, reading as a vanish.
461
- // Layering is set from JS (`layout()`, `beginClose`), not DOM order: a
462
- // closing panel is no longer in the reactive list, so its element's
463
- // position among the live ones is Aberdeen's business. See LAYER_STEP.
449
+ // The shell knows three motions, and this vocabulary is all of them:
450
+ // a panel that MOVES animates its `left` every mover in a pass shares
451
+ // this one duration and ease-out, so panels travelling the same distance
452
+ // travel as one a CREATED panel joins the strip beside the old position
453
+ // of the panels beneath it and rides their slide while fading in, and a
454
+ // CLOSED one fades out where it stood. Nothing else ever animates.
455
+ // A plain `left`, never a transform: a transformed element is
456
+ // composited, costing it subpixel text antialiasing. No `width`
457
+ // transition either animating one reflows the column every frame. And
458
+ // the fade is `linear` while the moves ease out: an eased opacity spends
459
+ // its last stretch near zero, reading as a vanish.
460
+ // Layering is fixed per state, not per stack depth: live panels never
461
+ // overlap each other (the strip keeps them adjacent, see `layout()`), so
462
+ // only the fading ones need an order — below, in both directions, per
463
+ // the classes underneath.
464
464
  // PANEL_SHEEN gives every panel an opaque ground (panels animate over one
465
465
  // another, and two transparent ones mean text sliding over text).
466
466
  "&":
467
467
  "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
468
468
  PANEL_SHEEN + " " +
469
- "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
469
+ "z-index:2 transition: left var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear;",
470
470
  // The hairline between two columns, fading at both ends (like the sidebar's
471
471
  // `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
472
472
  // padding — so this sits exactly on the boundary.
473
473
  "&.s-panel-sep::before":
474
474
  "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
475
475
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
476
- // Enter and exit share one vocabulary: a fade over a short 8cqw drift
477
- // (`cqw`: `.s-main` is the container). This start state is applied with
478
- // transitions off and then dropped, so the panel settles instead of jumping.
479
- "&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
480
- // On its way out; leaves the DOM when the fade ends (see `playExit`).
481
- "&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
482
- // Off screen but open: crowded out at the left edge (`hidden`) or parked
483
- // past the right one (`parked`). Both keep their DOM — and so their scroll
484
- // position and half-typed forms hence `visibility`, not `display:none`.
485
- // Transitioning it counts as *visible* for the whole fade, flipping at the
486
- // very end; the rule above (`visibility 0s`) reveals it again instantly.
476
+ // Still owed or playing its entry fade: beneath the settled panels, so
477
+ // whatever slides across its spot passes over it. Dropped once the fade
478
+ // is over (see `releaseEnter`).
479
+ "&.s-panel-new": "z-index:1",
480
+ // On its way out: it fades where it stood, beneath every live panel
481
+ // (declared after `.s-panel-new`, so closing mid-enter drops a panel to
482
+ // the bottom), and leaves the DOM when the fade ends (see `playExit`).
483
+ "&.s-panel-closing": "z-index:0 opacity:0 pointer-events:none",
484
+ // The fade-in's start state: a newcomer wears it from creation until its
485
+ // content is ready usually the very pass that placed it, later for a
486
+ // `loading` panel holding out for data sliding invisibly meanwhile.
487
+ // Dropping the class is what starts the fade (see `releaseEnter`).
488
+ "&.s-panel-enter": "opacity:0",
489
+ // Off screen but open: the strip simply continues past the viewport's
490
+ // edges, so these rest at their true positions, clipped — being crowded
491
+ // out or revealed is an ordinary move, not a fade. Both keep their DOM —
492
+ // and so their scroll position and half-typed forms — hence
493
+ // `visibility`, not `display:none`: it holds through the move out
494
+ // (flipping only at its end) and lifts instantly on the move back in
495
+ // (the base transition above doesn't list it).
487
496
  "&.s-panel-hidden, &.s-panel-parked":
488
- "opacity:0 visibility:hidden " +
489
- "transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
490
- "&.s-panel-hidden": "transform: translateX(-8cqw);",
491
- "&.s-panel-parked": "transform: translateX(8cqw);",
497
+ "visibility:hidden " +
498
+ "transition: left var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility var(--s-panel-ms);",
492
499
  },
493
500
  // The scroll container, with the column's own padding. Its scrollbar sits
494
501
  // flush against the column edge (unlike content mode's inset one), so it meets
@@ -594,7 +601,7 @@ interface PanelEntry {
594
601
  hash?: string;
595
602
  /** Set once the panel is on its way out, playing its exit animation. */
596
603
  closing?: boolean;
597
- /** Set while an enter animation is still to be played. */
604
+ /** Set while the fade-in is still owed (a `loading` hold can owe it past placement). */
598
605
  enter?: boolean;
599
606
  /** Whether the panel has been through a full layout pass (and so may animate). */
600
607
  placed?: boolean;
@@ -1035,9 +1042,10 @@ export class PanelStackController implements PanelStack {
1035
1042
  continue;
1036
1043
  }
1037
1044
  const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
1038
- // An initial load just appears, and so do panels *revealed* by a back:
1039
- // they belong underneath the ones sliding away.
1040
- if (nav !== "load" && nav !== "back") entry.enter = true;
1045
+ // An initial load just appears; any later navigation animates its new
1046
+ // panels in beneath what's already there, so a back that re-creates
1047
+ // a panel plays out under the one it takes away.
1048
+ if (nav !== "load") entry.enter = true;
1041
1049
  next.push(entry);
1042
1050
  this.$open[path] = entry;
1043
1051
  }
@@ -1085,10 +1093,6 @@ export class PanelStackController implements PanelStack {
1085
1093
  private beginClose(entry: PanelEntry): void {
1086
1094
  entry.closing = true;
1087
1095
  entry.$panel.visible = false;
1088
- // Frozen one layer below where it was, so it fades out over the panel it
1089
- // uncovers and under the one replacing it (see LAYER_STEP). Set here, while
1090
- // the element is still ours — a moment later `entry.el` is gone.
1091
- if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
1092
1096
  delete this.$open[entry.path];
1093
1097
  }
1094
1098
 
@@ -1801,9 +1805,11 @@ export class PanelStackController implements PanelStack {
1801
1805
  }
1802
1806
  }
1803
1807
 
1804
- // The content area is a fixed width, so a run that doesn't fill it centres
1805
- // rather than hanging off the left edge the chrome around it never moves.
1806
- const left = (geom.area - runSum) / 2;
1808
+ // A run that doesn't fill the fixed-width area centres in it unless
1809
+ // panels sit crowded out on its left: then it hugs the left edge instead,
1810
+ // so the strip crosses that edge with no gap and a reveal is a plain
1811
+ // slide back in.
1812
+ const left = first > 0 ? 0 : (geom.area - runSum) / 2;
1807
1813
 
1808
1814
  for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
1809
1815
  // Never-visible panels get their would-be width, so a reveal doesn't start
@@ -1812,55 +1818,89 @@ export class PanelStackController implements PanelStack {
1812
1818
  if (!entry.width) entry.width = width(entry);
1813
1819
  }
1814
1820
 
1815
- // Phase 1 — every panel's *start* state for this frame. Panels on screen
1816
- // simply move; freshly mounted ones still have transitions off, so what is
1817
- // set here is adopted instantly and becomes the "before" of their enter.
1818
- const fresh: PanelEntry[] = [];
1821
+ // Phase 1 — lay the strip out for this frame: each panel flush against
1822
+ // its neighbours, the visible run [first..cur] in the viewport, earlier
1823
+ // panels continuing off its left edge and parked ones held past its
1824
+ // right. Placed panels get their new positions — their standing
1825
+ // transitions carry them there. Newcomers, transitions still off, get
1826
+ // their *start* state instead: their strip position anchored to the OLD
1827
+ // position of the nearest placed panel beneath them (`delta`), so the
1828
+ // slide in is one motion with the panels making room. With nothing
1829
+ // beneath them to come from — or nothing moving — that start is where
1830
+ // they already stand, and they simply fade in.
1831
+ const fresh: { entry: PanelEntry; x: number }[] = [];
1819
1832
  let x = left;
1833
+ let delta = 0;
1834
+ for (let i = 0; i < first; i++) x -= live[i].width;
1820
1835
  for (let i = 0; i < n; i++) {
1821
1836
  const entry = live[i];
1822
1837
  const el = entry.el!;
1823
1838
  const shown = i >= first && i <= cur;
1824
- // Visible columns tile the run left to right; crowded-out ones rest at
1825
- // its left edge and parked ones just past its right, both keeping their
1826
- // last width. Deeper panels layer over shallower (see LAYER_STEP).
1827
- place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
1839
+ // Parked panels never dip into the viewport, however short the run.
1840
+ if (i === cur + 1) x = Math.max(x, geom.area);
1841
+ // A panel that mounts while still fetching holds its fade for a
1842
+ // moment, so it can appear with real content instead of empty.
1843
+ if (entry.enter || !entry.placed) {
1844
+ if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
1845
+ else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
1846
+ }
1847
+ if (entry.placed) {
1848
+ delta = parseFloat(el.style.left) - x;
1849
+ el.style.left = `${x}px`;
1850
+ // A fade held back for content starts the moment its hold lifts.
1851
+ if (entry.enter && !entry.$ui.holding) this.releaseEnter(entry);
1852
+ } else {
1853
+ fresh.push({ entry, x });
1854
+ const entering = entry.enter && shown;
1855
+ el.style.left = `${entering ? x + delta : x}px`;
1856
+ if (entering) el.classList.add("s-panel-enter", "s-panel-new");
1857
+ }
1858
+ el.style.width = `${entry.width}px`;
1859
+ x += entry.width;
1828
1860
  // Written only on a change, so per-panel UI hanging off `visible` or
1829
1861
  // `width` isn't rebuilt by every pass.
1830
1862
  if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
1831
1863
  if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
1832
- if (shown) x += entry.width;
1833
1864
  el.classList.toggle("s-panel-sep", shown && i > first);
1834
- // Off-screen panels fade out and stop rendering, keeping their DOM.
1865
+ // Off-screen panels stop rendering, keeping their DOM.
1835
1866
  el.classList.toggle("s-panel-hidden", i < first);
1836
1867
  el.classList.toggle("s-panel-parked", i > cur);
1837
1868
  el.toggleAttribute("inert", !shown);
1838
- if (entry.placed) continue;
1839
- fresh.push(entry);
1840
- // A panel that mounts while still fetching holds here for a moment, so
1841
- // it can enter with real content instead of an empty column.
1842
- if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
1843
- else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
1844
- // Already at its resting place; the enter is the offset and transparency
1845
- // it starts from, one edge to the right.
1846
- if (entry.enter && shown) el.classList.add("s-panel-enter");
1847
1869
  }
1848
1870
 
1849
1871
  // Phase 2 — reading a layout property forces the browser to adopt those
1850
1872
  // start states (and a snap pass's transition-free geometry) to animate from.
1851
1873
  if (fresh.length || snap) void container.offsetWidth;
1852
1874
  if (snap) shell.classList.remove("s-shell-snap");
1853
- // Phase 3 — transitions back on, start state dropped, and off they go.
1854
- for (const entry of fresh) {
1855
- if (entry.$ui.holding) continue;
1856
- entry.el!.style.transition = "";
1857
- entry.el!.classList.remove("s-panel-enter");
1858
- entry.enter = false;
1875
+ // Phase 3 — newcomers get their transitions and their resting place: the
1876
+ // slide starts now, and the fade with it — unless the panel is holding
1877
+ // for its content, which keeps the fade's start state on until then.
1878
+ for (const { entry, x } of fresh) {
1879
+ const el = entry.el!;
1880
+ el.style.transition = "";
1881
+ el.style.left = `${x}px`;
1859
1882
  entry.placed = true;
1883
+ if (!entry.$ui.holding) this.releaseEnter(entry);
1860
1884
  }
1861
1885
  }
1862
1886
 
1863
- /** Let a `loading` panel's enter animation wait but not indefinitely. */
1887
+ /** Start (or skip) a newcomer's fade-in; any slide is already underway. */
1888
+ private releaseEnter(entry: PanelEntry): void {
1889
+ entry.enter = false;
1890
+ const el = entry.el;
1891
+ if (!el || !el.classList.contains("s-panel-enter")) return;
1892
+ el.classList.remove("s-panel-enter");
1893
+ // Keep it beneath its elders until the fade is over — by timer, a hair
1894
+ // past it, since a resize can snap the fade short without ever firing a
1895
+ // `transitionend`.
1896
+ const timer = setTimeout(() => {
1897
+ this.timers.delete(timer);
1898
+ el.classList.remove("s-panel-new");
1899
+ }, PAGE_MS + 80);
1900
+ this.timers.add(timer);
1901
+ }
1902
+
1903
+ /** Let a `loading` panel's fade-in wait — but not indefinitely. */
1864
1904
  private holdEnter(entry: PanelEntry): void {
1865
1905
  const timer = setTimeout(() => {
1866
1906
  this.timers.delete(timer);
@@ -1874,13 +1914,6 @@ export class PanelStackController implements PanelStack {
1874
1914
  }
1875
1915
  }
1876
1916
 
1877
- /** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
1878
- function place(el: HTMLElement, x: number, width: number, z: number): void {
1879
- el.style.left = `${x}px`;
1880
- el.style.width = `${width}px`;
1881
- el.style.zIndex = String(z);
1882
- }
1883
-
1884
1917
  // ─── Helpers ─────────────────────────────────────────────────────────────────
1885
1918
 
1886
1919
  function sameStack(a: string[], b: string[]): boolean {