staffa 0.18.0 → 0.18.2

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
  /**
@@ -415,11 +416,12 @@ function matchRoute(r: { segs: Seg[] }, segments: string[]): Record<string, any>
415
416
 
416
417
  /**
417
418
  * The one duration every bit of shell motion shares: the enter/exit fades, the
418
- * sideways `left` moves, the narrow-screen nav slide. Published below as the
419
- * `--s-panel-ms` custom property, so CSS and JS can't drift apart.
419
+ * slides, the narrow-screen nav slide. Interpolated into every transition as a
420
+ * literal the shared constant is what keeps CSS and JS in step — and also
421
+ * published as the `--s-panel-ms` custom property for app CSS.
420
422
  */
421
- const PAGE_MS = 250;
422
- /** How long a freshly pushed `loading` panel holds its enter animation. */
423
+ export const PAGE_MS = 250;
424
+ /** How long a freshly pushed `loading` panel holds its fade-in. */
423
425
  const LOADING_HOLD_MS = 300;
424
426
  /**
425
427
  * The bounds of a column: at most 540px — the width columns aim for, the area
@@ -428,13 +430,6 @@ const LOADING_HOLD_MS = 300;
428
430
  */
429
431
  const SMALL_MIN_PX = 360;
430
432
  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
433
 
439
434
  // ─── Module-level styling ────────────────────────────────────────────────────
440
435
 
@@ -442,9 +437,9 @@ A.insertGlobalCss({
442
437
  ":root": `--s-panel-ms:${PAGE_MS}ms`,
443
438
  // The clipping viewport the columns slide through; panels are absolutely
444
439
  // 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.
440
+ // `isolation` keeps their z-index layers below the shell's own chrome. It
441
+ // paints the same PANEL_SHEEN as every panel, so columns and the ground
442
+ // beside them read as one surface.
448
443
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
449
444
  // and anything that scrolls it (find-in-page, an in-page anchor) shifts every
450
445
  // column sideways permanently, with nothing to scroll it back.
@@ -452,48 +447,65 @@ A.insertGlobalCss({
452
447
  "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
453
448
  PANEL_SHEEN,
454
449
  ".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.
450
+ // The shell knows three motions, and this vocabulary is all of them:
451
+ // a panel that MOVES slides every mover in a pass shares this one
452
+ // duration and ease-out, so panels travelling the same distance travel
453
+ // as one a CREATED panel joins the strip beside the old position of
454
+ // the panels beneath it and rides their slide while fading in, and a
455
+ // CLOSED one keeps its seat beside the surviving panel beneath it and
456
+ // rides that panel's slide while fading out its creation played
457
+ // backwards. Nothing else ever animates.
458
+ // A slide is a `transform` playing out to none (see `layout`), never an
459
+ // animated `left`: left is a layout property, so animating it would
460
+ // relayout and repaint every travelling column on every frame, where a
461
+ // transform moves composited layers. `left` always holds the resting
462
+ // position — it is not in the transition list — and the transform
463
+ // exists only while a panel travels, so text gets its subpixel
464
+ // antialiasing back the moment it settles. No `width` transition
465
+ // either — animating one reflows the column every frame. And the fade
466
+ // is `linear` while the moves ease out: an eased opacity spends its
467
+ // last stretch near zero, reading as a vanish.
468
+ // Layering is fixed per state, not per stack depth: live panels never
469
+ // overlap each other (the strip keeps them adjacent, see `layout()`), so
470
+ // only the fading ones need an order — below, in both directions, per
471
+ // the classes underneath.
464
472
  // PANEL_SHEEN gives every panel an opaque ground (panels animate over one
465
473
  // another, and two transparent ones mean text sliding over text).
466
474
  "&":
467
475
  "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
468
476
  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;",
477
+ `z-index:2 transition: transform ${PAGE_MS}ms ease-out, opacity ${PAGE_MS}ms linear;`,
470
478
  // The hairline between two columns, fading at both ends (like the sidebar's
471
479
  // `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
472
480
  // padding — so this sits exactly on the boundary.
473
481
  "&.s-panel-sep::before":
474
482
  "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
475
483
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
476
- // Enter and exit share one vocabulary: a fade, drifting a short 8cqw
477
- // (`cqw`: `.s-main` is the container) only when the navigation really
478
- // moves a push opening a new column, a back closing one. A panel
479
- // replacing another simply fades in on top of it (see LAYER_STEP), the
480
- // old one fading in place beneath. The enter start state is applied with
481
- // transitions off and then dropped, so the panel settles instead of jumping.
482
- "&.s-panel-enter": "opacity:0 transition:none",
483
- // On its way out; leaves the DOM when the fade ends (see `playExit`).
484
- "&.s-panel-closing": "opacity:0 pointer-events:none",
485
- // The sideways drift, as an enter's start state or a close's destination.
486
- "&.s-panel-drift": "transform: translateX(8cqw);",
487
- // Off screen but open: crowded out at the left edge (`hidden`) or parked
488
- // past the right one (`parked`). Both keep their DOM — and so their scroll
489
- // position and half-typed forms — hence `visibility`, not `display:none`.
490
- // Transitioning it counts as *visible* for the whole fade, flipping at the
491
- // very end; the rule above (`visibility 0s`) reveals it again instantly.
492
- "&.s-panel-hidden, &.s-panel-parked":
493
- "opacity:0 visibility:hidden " +
494
- "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);",
495
- "&.s-panel-hidden": "transform: translateX(-8cqw);",
496
- "&.s-panel-parked": "transform: translateX(8cqw);",
484
+ // Still owed or playing its entry fade: beneath the settled panels, so
485
+ // whatever slides across its spot passes over it. Dropped once the fade
486
+ // is over (see `releaseEnter`).
487
+ "&.s-panel-new": "z-index:1",
488
+ // On its way out: it fades, still riding the strip (see `layout`),
489
+ // beneath every live panel (declared after `.s-panel-new`, so closing
490
+ // mid-enter drops a panel to the bottom), and leaves the DOM when the
491
+ // fade ends (see `playExit`).
492
+ "&.s-panel-closing": "z-index:0 opacity:0 pointer-events:none",
493
+ // The fade-in's start state: a newcomer wears it from creation until its
494
+ // content is ready — usually the very pass that placed it, later for a
495
+ // `loading` panel holding out for data sliding invisibly meanwhile.
496
+ // Dropping the class is what starts the fade (see `releaseEnter`).
497
+ "&.s-panel-enter": "opacity:0",
498
+ // Off screen but open: the strip simply continues past the viewport's
499
+ // edges, so these rest at their true positions, clipped being crowded
500
+ // out or revealed is an ordinary move, not a fade. Both keep their DOM —
501
+ // and so their scroll position and half-typed forms — hence
502
+ // `visibility`, not `display:none`. The flip is timed by `layout()`,
503
+ // never transitioned: a panel sliding offstage gets the class once its
504
+ // slide ends and drops it the moment it is revealed. `visibility` is
505
+ // not compositable, and Chrome composites an element's transitions as
506
+ // a group, so listing it here would drag the slide itself onto the
507
+ // main thread — relaid out and repainted every frame.
508
+ "&.s-panel-hidden, &.s-panel-parked": "visibility:hidden",
497
509
  },
498
510
  // The scroll container, with the column's own padding. Its scrollbar sits
499
511
  // flush against the column edge (unlike content mode's inset one), so it meets
@@ -599,14 +611,24 @@ interface PanelEntry {
599
611
  hash?: string;
600
612
  /** Set once the panel is on its way out, playing its exit animation. */
601
613
  closing?: boolean;
602
- /** Set while an enter animation is still to be played. */
614
+ /**
615
+ * The nearest surviving panel beneath this one when it closed. The exit
616
+ * rides that panel's motion (see `layout`), the mirror of how a newcomer
617
+ * rides the panels it opens over, so a close plays the open backwards.
618
+ */
619
+ anchor?: string;
620
+ /** Set while the fade-in is still owed (a `loading` hold can owe it past placement). */
603
621
  enter?: boolean;
604
- /** Whether that enter — or the exit, once `closing` — carries the sideways drift. */
605
- drift?: boolean;
606
622
  /** Whether the panel has been through a full layout pass (and so may animate). */
607
623
  placed?: boolean;
608
624
  /** Whether its `loading` hold has already expired, so it can't hold again. */
609
625
  holdDone?: boolean;
626
+ /**
627
+ * Whether the last layout pass put this panel off screen. The deferred
628
+ * visibility flip (see `layout`) consults it when it fires, so a panel
629
+ * revealed again before its slide out ended is never hidden by mistake.
630
+ */
631
+ offstage?: boolean;
610
632
  /** What the panel asks for, kept in step with its `$panel.maxWidth`. */
611
633
  maxWidth: PanelSize;
612
634
  /**
@@ -813,6 +835,8 @@ export class PanelStackController implements PanelStack {
813
835
  private lastGeom?: Geometry;
814
836
  private layoutQueued = false;
815
837
  private timers = new Set<ReturnType<typeof setTimeout>>();
838
+ /** Elements playing their exit fade, each riding its anchor's motion (see `layout`). */
839
+ private exiting = new Set<{ el: HTMLElement; anchor?: string; ride: number }>();
816
840
  /** The arrangement the navigation in flight is heading for; see {@link intended}. */
817
841
  private intent: Arrangement | null = null;
818
842
  /** The navigation the router hasn't settled yet, if any. */
@@ -1032,12 +1056,6 @@ export class PanelStackController implements PanelStack {
1032
1056
  const pinned = route.current.state.pinned;
1033
1057
  const seedPins = new Set<string>(Array.isArray(pinned) ? pinned.map(String) : []);
1034
1058
  const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
1035
- // A replacement — something enters while something closes — just fades:
1036
- // the new panel in on top, the old ones out in place beneath it (see the
1037
- // `.s-panel-drift` comment). A pure push or a pure close drifts sideways.
1038
- const entering = nav !== "load" && nav !== "back";
1039
- const drift = !(entering && target.stack.some((p) => !existing.has(p)) &&
1040
- this.$state.live.some((e) => !target.stack.includes(e.path)));
1041
1059
  const next: PanelEntry[] = [];
1042
1060
  for (const path of target.stack) {
1043
1061
  const kept = existing.get(path);
@@ -1048,16 +1066,20 @@ export class PanelStackController implements PanelStack {
1048
1066
  continue;
1049
1067
  }
1050
1068
  const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
1051
- // An initial load just appears, and so do panels *revealed* by a back:
1052
- // they belong underneath the ones sliding away.
1053
- if (entering) {
1054
- entry.enter = true;
1055
- entry.drift = drift;
1056
- }
1069
+ // An initial load just appears; any later navigation animates its new
1070
+ // panels in beneath what's already there, so a back that re-creates
1071
+ // a panel plays out under the one it takes away.
1072
+ if (nav !== "load") entry.enter = true;
1057
1073
  next.push(entry);
1058
1074
  this.$open[path] = entry;
1059
1075
  }
1060
- for (const entry of existing.values()) this.beginClose(entry, drift);
1076
+ // What remains in `existing` closes, each remembering the nearest
1077
+ // surviving panel beneath it — the anchor its exit fade rides.
1078
+ let anchor: string | undefined;
1079
+ for (const entry of this.$state.live) {
1080
+ if (existing.has(entry.path)) this.beginClose(entry, anchor);
1081
+ else anchor = entry.path;
1082
+ }
1061
1083
  this.$state.live = next;
1062
1084
  this.$state.focus = Math.min(target.focus, next.length - 1);
1063
1085
  this.scheduleLayout();
@@ -1098,41 +1120,54 @@ export class PanelStackController implements PanelStack {
1098
1120
  * at the end of the animation. Only the element lingers to play that animation,
1099
1121
  * which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
1100
1122
  */
1101
- private beginClose(entry: PanelEntry, drift: boolean): void {
1123
+ private beginClose(entry: PanelEntry, anchor?: string): void {
1102
1124
  entry.closing = true;
1103
- entry.drift = drift;
1125
+ entry.anchor = anchor;
1104
1126
  entry.$panel.visible = false;
1105
- // Frozen one layer below where it was, so it fades out over the panel it
1106
- // uncovers and under the one replacing it (see LAYER_STEP). Set here, while
1107
- // the element is still ours — a moment later `entry.el` is gone.
1108
- if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
1109
1127
  delete this.$open[entry.path];
1110
1128
  }
1111
1129
 
1112
1130
  /**
1113
- * A closed panel's send-off, run by Aberdeen once its scope is gone: it fades
1114
- * where it stands, inert, and leaves the DOM on `transitionend`. A fixed timer
1115
- * would race the transition and pull the element a frame early, making the
1116
- * panel appear to fade half-way and vanish; the timeout is only a fallback for
1117
- * when no `transitionend` is coming (transitions off, element never placed).
1131
+ * A closed panel's send-off, run by Aberdeen once its scope is gone: it fades,
1132
+ * inert, riding its anchor's slide (see `layout`), and leaves the DOM once
1133
+ * the fade is over (see {@link afterFade}).
1118
1134
  */
1119
1135
  private playExit(entry: PanelEntry, el: HTMLElement): void {
1120
1136
  // A panel being *redrawn* replaces its element through here too; that one
1121
1137
  // simply goes, so the new one isn't drawn over a ghost of the old.
1122
1138
  if (!entry.closing) { el.remove(); return; }
1123
1139
  el.classList.add("s-panel-closing");
1124
- if (entry.drift) el.classList.add("s-panel-drift");
1125
1140
  el.setAttribute("inert", "");
1126
- const drop = () => {
1127
- clearTimeout(timer);
1128
- this.timers.delete(timer);
1141
+ // Only a placed panel has a seat on the strip to ride out from.
1142
+ const exit = entry.placed ? { el, anchor: entry.anchor, ride: 0 } : null;
1143
+ if (exit) this.exiting.add(exit);
1144
+ this.afterTransition(el, "opacity", () => {
1145
+ if (exit) this.exiting.delete(exit);
1129
1146
  el.remove();
1130
- };
1131
- el.addEventListener("transitionend", (e: TransitionEvent) => {
1132
- if (e.target === el && e.propertyName === "opacity") drop();
1133
1147
  });
1134
- const timer = setTimeout(drop, PAGE_MS + 80);
1148
+ }
1149
+
1150
+ /**
1151
+ * Run `done` once `el`'s transition of `prop` is over. The real signal is
1152
+ * `transitionend` — or `transitioncancel`, for one a resize snaps short —
1153
+ * so the transition's actual length rules, however long: DevTools' slowed
1154
+ * animations stretch it tenfold without touching any timer. The timer only
1155
+ * stands in for a transition that never starts at all (transitions off,
1156
+ * element never placed, nothing to travel), which is why one proving real
1157
+ * (`transitionrun`) disarms it.
1158
+ */
1159
+ private afterTransition(el: HTMLElement, prop: string, done: () => void): void {
1160
+ let called = false;
1161
+ const timer = setTimeout(() => finish(), PAGE_MS + 80);
1135
1162
  this.timers.add(timer);
1163
+ const disarm = () => { clearTimeout(timer); this.timers.delete(timer); };
1164
+ const finish = () => { disarm(); if (!called) { called = true; done(); } };
1165
+ const forProp = (fn: () => void) => (e: TransitionEvent) => {
1166
+ if (e.target === el && e.propertyName === prop) fn();
1167
+ };
1168
+ el.addEventListener("transitionrun", forProp(disarm));
1169
+ el.addEventListener("transitionend", forProp(finish));
1170
+ el.addEventListener("transitioncancel", forProp(finish));
1136
1171
  }
1137
1172
 
1138
1173
  // ── Navigation ─────────────────────────────────────────────────────────
@@ -1819,9 +1854,11 @@ export class PanelStackController implements PanelStack {
1819
1854
  }
1820
1855
  }
1821
1856
 
1822
- // The content area is a fixed width, so a run that doesn't fill it centres
1823
- // rather than hanging off the left edge the chrome around it never moves.
1824
- const left = (geom.area - runSum) / 2;
1857
+ // A run that doesn't fill the fixed-width area centres in it unless
1858
+ // panels sit crowded out on its left: then it hugs the left edge instead,
1859
+ // so the strip crosses that edge with no gap and a reveal is a plain
1860
+ // slide back in.
1861
+ const left = first > 0 ? 0 : (geom.area - runSum) / 2;
1825
1862
 
1826
1863
  for (let i = first; i <= cur; i++) live[i].width = width(live[i]);
1827
1864
  // Never-visible panels get their would-be width, so a reveal doesn't start
@@ -1830,58 +1867,139 @@ export class PanelStackController implements PanelStack {
1830
1867
  if (!entry.width) entry.width = width(entry);
1831
1868
  }
1832
1869
 
1833
- // Phase 1 — every panel's *start* state for this frame. Panels on screen
1834
- // simply move; freshly mounted ones still have transitions off, so what is
1835
- // set here is adopted instantly and becomes the "before" of their enter.
1870
+ // Phase 1 — lay the strip out for this frame: each panel flush against
1871
+ // its neighbours, the visible run [first..cur] in the viewport, earlier
1872
+ // panels continuing off its left edge and parked ones held past its
1873
+ // right. `left` gets each new resting position outright — it never
1874
+ // animates — and the slide to it is a FLIP: a mover is held at its
1875
+ // current visual spot by a transform (its mid-slide offset plus the
1876
+ // distance), a newcomer at its start beside the OLD position of the
1877
+ // nearest placed panel beneath it (`delta`, so the slide in is one
1878
+ // motion with the panels making room; with nothing beneath it to come
1879
+ // from, it simply fades in place). Phase 3 plays every held transform
1880
+ // out to none. A snap pass holds nothing: geometry must land, not
1881
+ // travel.
1836
1882
  const fresh: PanelEntry[] = [];
1883
+ const movers: HTMLElement[] = [];
1884
+ const deltas = new Map<string, number>();
1885
+ // All reads before all writes: a mover may still be mid-slide, and its
1886
+ // current offset must come out of the computed style before this pass
1887
+ // dirties it (one style recalc, then cached).
1888
+ const txs = new Map<PanelEntry, number>();
1889
+ if (!snap) for (const entry of live) { if (entry.placed) txs.set(entry, transformX(entry.el!)); }
1837
1890
  let x = left;
1891
+ let delta = 0;
1892
+ for (let i = 0; i < first; i++) x -= live[i].width;
1838
1893
  for (let i = 0; i < n; i++) {
1839
1894
  const entry = live[i];
1840
1895
  const el = entry.el!;
1841
1896
  const shown = i >= first && i <= cur;
1842
- // Visible columns tile the run left to right; crowded-out ones rest at
1843
- // its left edge and parked ones just past its right, both keeping their
1844
- // last width. Deeper panels layer over shallower (see LAYER_STEP).
1845
- place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
1897
+ // Parked panels never dip into the viewport, however short the run.
1898
+ if (i === cur + 1) x = Math.max(x, geom.area);
1899
+ // A panel that mounts while still fetching holds its fade for a
1900
+ // moment, so it can appear with real content instead of empty.
1901
+ if (entry.enter || !entry.placed) {
1902
+ if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
1903
+ else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
1904
+ }
1905
+ if (entry.placed) {
1906
+ delta = parseFloat(el.style.left) - x;
1907
+ deltas.set(entry.path, delta);
1908
+ if (delta && !snap) {
1909
+ // Hold the visual spot while `left` jumps beneath it. The
1910
+ // inline transition keeps the transform still — and only it:
1911
+ // running fades carry on, and one starting this pass (a
1912
+ // release below) still gets its linear curve.
1913
+ el.style.transition = `opacity ${PAGE_MS}ms linear`;
1914
+ el.style.transform = `translateX(${(txs.get(entry) ?? 0) + delta}px)`;
1915
+ movers.push(el);
1916
+ }
1917
+ el.style.left = `${x}px`;
1918
+ // A fade held back for content starts the moment its hold lifts.
1919
+ if (entry.enter && !entry.$ui.holding) this.releaseEnter(entry);
1920
+ } else {
1921
+ fresh.push(entry);
1922
+ el.style.left = `${x}px`;
1923
+ if (entry.enter && shown) {
1924
+ el.style.transform = `translateX(${delta}px)`;
1925
+ el.classList.add("s-panel-enter", "s-panel-new");
1926
+ }
1927
+ }
1928
+ el.style.width = `${entry.width}px`;
1929
+ x += entry.width;
1846
1930
  // Written only on a change, so per-panel UI hanging off `visible` or
1847
1931
  // `width` isn't rebuilt by every pass.
1848
1932
  if (entry.$panel.visible !== shown) entry.$panel.visible = shown;
1849
1933
  if (entry.$panel.width !== entry.width) entry.$panel.width = entry.width;
1850
- if (shown) x += entry.width;
1851
1934
  el.classList.toggle("s-panel-sep", shown && i > first);
1852
- // Off-screen panels fade out and stop rendering, keeping their DOM.
1853
- el.classList.toggle("s-panel-hidden", i < first);
1854
- el.classList.toggle("s-panel-parked", i > cur);
1935
+ // Off-screen panels stop rendering, keeping their DOM. A revealed
1936
+ // panel is visible at once; one sliding offstage stays visible for
1937
+ // the whole slide, its visibility class deferred to the slide's
1938
+ // end — but applied outright when it is already invisible, about
1939
+ // to be (a newcomer offstage from birth), or snapping.
1940
+ const hidden = i < first;
1941
+ const wasOff = el.classList.contains("s-panel-hidden") || el.classList.contains("s-panel-parked");
1942
+ if (shown) {
1943
+ el.classList.remove("s-panel-hidden", "s-panel-parked");
1944
+ } else if (wasOff || !entry.placed || snap) {
1945
+ el.classList.toggle("s-panel-hidden", hidden);
1946
+ el.classList.toggle("s-panel-parked", !hidden);
1947
+ } else {
1948
+ this.afterTransition(el, "transform", () => {
1949
+ if (entry.el !== el || !entry.offstage) return;
1950
+ el.classList.toggle("s-panel-hidden", hidden);
1951
+ el.classList.toggle("s-panel-parked", !hidden);
1952
+ });
1953
+ }
1954
+ entry.offstage = !shown;
1855
1955
  el.toggleAttribute("inert", !shown);
1856
- if (entry.placed) continue;
1857
- fresh.push(entry);
1858
- // A panel that mounts while still fetching holds here for a moment, so
1859
- // it can enter with real content instead of an empty column.
1860
- if (!entry.$panel.loading || entry.holdDone) entry.$ui.holding = false;
1861
- else if (!entry.$ui.holding) { entry.$ui.holding = true; this.holdEnter(entry); }
1862
- // Already at its resting place; the enter is the transparency (and,
1863
- // when drifting, the offset one edge to the right) it starts from.
1864
- if (entry.enter && shown) {
1865
- el.classList.add("s-panel-enter");
1866
- if (entry.drift) el.classList.add("s-panel-drift");
1956
+ }
1957
+
1958
+ // Closing panels keep their seat on the strip while they fade: each
1959
+ // travels exactly as far as its anchor the surviving panel it stood
1960
+ // on so a close slides back out the way the open slid in, and one
1961
+ // whose anchor stays put is the plain crossfade it should be. A plain
1962
+ // retarget, no holding: the transition picks the slide up from wherever
1963
+ // the element is now.
1964
+ for (const exit of this.exiting) {
1965
+ const d = exit.anchor == null ? undefined : deltas.get(exit.anchor);
1966
+ if (d) {
1967
+ exit.ride -= d;
1968
+ exit.el.style.transform = `translateX(${exit.ride}px)`;
1867
1969
  }
1868
1970
  }
1869
1971
 
1870
1972
  // Phase 2 — reading a layout property forces the browser to adopt those
1871
- // start states (and a snap pass's transition-free geometry) to animate from.
1872
- if (fresh.length || snap) void container.offsetWidth;
1973
+ // held spots (and a snap pass's transition-free geometry) to slide from.
1974
+ if (fresh.length || movers.length || snap) void container.offsetWidth;
1873
1975
  if (snap) shell.classList.remove("s-shell-snap");
1874
- // Phase 3 — transitions back on, start state dropped, and off they go.
1976
+ // Phase 3 — every held transform plays out to none: the slides start
1977
+ // now, and the newcomers' fades with them — unless a panel is holding
1978
+ // for its content, which keeps the fade's start state on until then.
1979
+ for (const el of movers) {
1980
+ el.style.transition = "";
1981
+ el.style.transform = "";
1982
+ }
1875
1983
  for (const entry of fresh) {
1876
- if (entry.$ui.holding) continue;
1877
- entry.el!.style.transition = "";
1878
- entry.el!.classList.remove("s-panel-enter", "s-panel-drift");
1879
- entry.enter = false;
1984
+ const el = entry.el!;
1985
+ el.style.transition = "";
1986
+ el.style.transform = "";
1880
1987
  entry.placed = true;
1988
+ if (!entry.$ui.holding) this.releaseEnter(entry);
1881
1989
  }
1882
1990
  }
1883
1991
 
1884
- /** Let a `loading` panel's enter animation wait but not indefinitely. */
1992
+ /** Start (or skip) a newcomer's fade-in; any slide is already underway. */
1993
+ private releaseEnter(entry: PanelEntry): void {
1994
+ entry.enter = false;
1995
+ const el = entry.el;
1996
+ if (!el || !el.classList.contains("s-panel-enter")) return;
1997
+ el.classList.remove("s-panel-enter");
1998
+ // Keep it beneath its elders until the fade is over.
1999
+ this.afterTransition(el, "opacity", () => el.classList.remove("s-panel-new"));
2000
+ }
2001
+
2002
+ /** Let a `loading` panel's fade-in wait — but not indefinitely. */
1885
2003
  private holdEnter(entry: PanelEntry): void {
1886
2004
  const timer = setTimeout(() => {
1887
2005
  this.timers.delete(timer);
@@ -1895,19 +2013,18 @@ export class PanelStackController implements PanelStack {
1895
2013
  }
1896
2014
  }
1897
2015
 
1898
- /** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
1899
- function place(el: HTMLElement, x: number, width: number, z: number): void {
1900
- el.style.left = `${x}px`;
1901
- el.style.width = `${width}px`;
1902
- el.style.zIndex = String(z);
1903
- }
1904
-
1905
2016
  // ─── Helpers ─────────────────────────────────────────────────────────────────
1906
2017
 
1907
2018
  function sameStack(a: string[], b: string[]): boolean {
1908
2019
  return a.length === b.length && a.every((v, i) => v === b[i]);
1909
2020
  }
1910
2021
 
2022
+ /** The X offset of `el`'s computed transform — where a slide has it right now. */
2023
+ function transformX(el: HTMLElement): number {
2024
+ const t = getComputedStyle(el).transform;
2025
+ return t && t !== "none" ? new DOMMatrixReadOnly(t).m41 : 0;
2026
+ }
2027
+
1911
2028
  /**
1912
2029
  * The first non-empty text inside `el`, trimmed and capped at a name-like
1913
2030
  * length — the stand-in title for a panel that never set one.
package/src/theme.ts CHANGED
@@ -32,9 +32,11 @@ import A from "aberdeen";
32
32
  * declaration, at the given angle. A shared constant rather than a `--s-sheen`
33
33
  * custom property: `var()`s inside a custom property resolve where it is
34
34
  * *defined*, so every surface would get the page's wash instead of its own.
35
+ * The shorthand names `$s-bg` as well as the gradient: a `background:` that
36
+ * gave only an image would reset the colour under it to transparent.
35
37
  */
36
38
  const sheen = (angle: string) =>
37
- `background: linear-gradient(${angle}, color-mix(in oklab, $s-bg, white 9%), color-mix(in oklab, $s-bg, black 9%));`;
39
+ `background: $s-bg linear-gradient(${angle}, color-mix(in oklab, $s-bg, white 9%), color-mix(in oklab, $s-bg, black 9%));`;
38
40
 
39
41
  /** The surface sheen: the wash every surface (and the page) is painted with. */
40
42
  export const SURFACE_SHEEN = sheen("170deg");
@@ -120,7 +122,14 @@ A.insertGlobalCss({
120
122
  // A lightweight reset: bare semantic HTML, with less ugly defaults.
121
123
  "*, *::before, *::after": "box-sizing:border-box",
122
124
  html: "text-size-adjust:100%",
123
- body: "m:0 p:$3 line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased background-color:$s-bg text:$s-text",
125
+ // The page wears the same wash as a surface, on a page at least as tall as
126
+ // what that wash is painted on: <body>'s background is propagated to the
127
+ // canvas, which covers the whole viewport, while the gradient is still
128
+ // *sized* from the root box. So a page shorter than the viewport — a bare
129
+ // spinner while an app boots — would tile its wash down the rest of the
130
+ // canvas in bands. `dvh`, not `vh`: a mobile viewport that grows as the URL
131
+ // bar retracts would open a band again.
132
+ body: "m:0 p:$3 min-height:100dvh line-height:1.5 font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; -webkit-font-smoothing:antialiased text:$s-text " + SURFACE_SHEEN,
124
133
  // The contextual link colour: `--s-link` on neutral surfaces, the ink on accent ones.
125
134
  a: "color: $s-link-fg; text-decoration:underline text-underline-offset:2px; transition: color 0.12s, filter 0.12s;",
126
135
  "a:hover": "filter: brightness(1.15)",
@@ -196,9 +205,9 @@ A.insertGlobalCss({
196
205
  "--s-muted: color-mix(in oklab, $s-text, $s-bg 42%); " +
197
206
  "--s-faint: color-mix(in oklab, $s-text, $s-bg 80%); " +
198
207
  "color:$s-text accent-color:$s-accent scrollbar-width:thin scrollbar-color: $s-faint transparent;",
199
- // Subtle single-colour gradient sheen, painted on every surface (and the page).
200
- ".s-s, body": SURFACE_SHEEN,
201
- ".s-s": "r:$s-radius",
208
+ // Subtle single-colour gradient sheen, painted on every surface (the page
209
+ // gets its own, up in the reset).
210
+ ".s-s": SURFACE_SHEEN + " r:$s-radius",
202
211
  // A neutral surface owns a hairline border, so a card reads as a card without
203
212
  // any component help. `:where()` keeps it zero-specificity, so a bar that wants
204
213
  // only a divider overrides it with a single plain rule.