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.
@@ -110,11 +110,12 @@ function matchRoute(r, segments) {
110
110
  // ─── Constants ───────────────────────────────────────────────────────────────
111
111
  /**
112
112
  * The one duration every bit of shell motion shares: the enter/exit fades, the
113
- * sideways `left` moves, the narrow-screen nav slide. Published below as the
114
- * `--s-panel-ms` custom property, so CSS and JS can't drift apart.
113
+ * slides, the narrow-screen nav slide. Interpolated into every transition as a
114
+ * literal the shared constant is what keeps CSS and JS in step — and also
115
+ * published as the `--s-panel-ms` custom property for app CSS.
115
116
  */
116
- const PAGE_MS = 250;
117
- /** How long a freshly pushed `loading` panel holds its enter animation. */
117
+ export const PAGE_MS = 250;
118
+ /** How long a freshly pushed `loading` panel holds its fade-in. */
118
119
  const LOADING_HOLD_MS = 300;
119
120
  /**
120
121
  * The bounds of a column: at most 540px — the width columns aim for, the area
@@ -123,66 +124,77 @@ const LOADING_HOLD_MS = 300;
123
124
  */
124
125
  const SMALL_MIN_PX = 360;
125
126
  export const SMALL_MAX_PX = 540;
126
- /**
127
- * Two `z-index` steps per panel: a panel sits on the odd layer for its depth in
128
- * the stack, a *closing* one on the even layer just below. So a replacement
129
- * comes in over the panel it replaces, while a closing panel fades out over
130
- * whatever it was covering.
131
- */
132
- const LAYER_STEP = 2;
133
127
  // ─── Module-level styling ────────────────────────────────────────────────────
134
128
  A.insertGlobalCss({
135
129
  ":root": `--s-panel-ms:${PAGE_MS}ms`,
136
130
  // The clipping viewport the columns slide through; panels are absolutely
137
131
  // positioned inside it, sized and offset from JS (see `layout()`).
138
- // `isolation` keeps their z-index layers (see LAYER_STEP) below the shell's
139
- // own chrome. It paints the same PANEL_SHEEN as every panel, so columns and
140
- // the ground beside them read as one surface.
132
+ // `isolation` keeps their z-index layers below the shell's own chrome. It
133
+ // paints the same PANEL_SHEEN as every panel, so columns and the ground
134
+ // beside them read as one surface.
141
135
  // `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
142
136
  // and anything that scrolls it (find-in-page, an in-page anchor) shifts every
143
137
  // column sideways permanently, with nothing to scroll it back.
144
138
  ".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
145
139
  PANEL_SHEEN,
146
140
  ".s-panel": {
147
- // A panel rests at a plain `left` offset with no transform: a transformed
148
- // element is composited, costing it subpixel text antialiasing. Transform
149
- // is used only for the enter/exit slides. No `width` transition either —
150
- // animating one reflows the column's content every frame.
151
- // The fade is deliberately `linear` while the drift eases out: an eased
152
- // opacity spends its last stretch near zero, reading as a vanish.
153
- // Layering is set from JS (`layout()`, `beginClose`), not DOM order: a
154
- // closing panel is no longer in the reactive list, so its element's
155
- // position among the live ones is Aberdeen's business. See LAYER_STEP.
141
+ // The shell knows three motions, and this vocabulary is all of them:
142
+ // a panel that MOVES slides every mover in a pass shares this one
143
+ // duration and ease-out, so panels travelling the same distance travel
144
+ // as one a CREATED panel joins the strip beside the old position of
145
+ // the panels beneath it and rides their slide while fading in, and a
146
+ // CLOSED one keeps its seat beside the surviving panel beneath it and
147
+ // rides that panel's slide while fading out its creation played
148
+ // backwards. Nothing else ever animates.
149
+ // A slide is a `transform` playing out to none (see `layout`), never an
150
+ // animated `left`: left is a layout property, so animating it would
151
+ // relayout and repaint every travelling column on every frame, where a
152
+ // transform moves composited layers. `left` always holds the resting
153
+ // position — it is not in the transition list — and the transform
154
+ // exists only while a panel travels, so text gets its subpixel
155
+ // antialiasing back the moment it settles. No `width` transition
156
+ // either — animating one reflows the column every frame. And the fade
157
+ // is `linear` while the moves ease out: an eased opacity spends its
158
+ // last stretch near zero, reading as a vanish.
159
+ // Layering is fixed per state, not per stack depth: live panels never
160
+ // overlap each other (the strip keeps them adjacent, see `layout()`), so
161
+ // only the fading ones need an order — below, in both directions, per
162
+ // the classes underneath.
156
163
  // PANEL_SHEEN gives every panel an opaque ground (panels animate over one
157
164
  // another, and two transparent ones mean text sliding over text).
158
165
  "&": "position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
159
166
  PANEL_SHEEN + " " +
160
- "visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
167
+ `z-index:2 transition: transform ${PAGE_MS}ms ease-out, opacity ${PAGE_MS}ms linear;`,
161
168
  // The hairline between two columns, fading at both ends (like the sidebar's
162
169
  // `.s-nav-sep`). Columns tile with no gutter — each brings its own `$3` of
163
170
  // padding — so this sits exactly on the boundary.
164
171
  "&.s-panel-sep::before": "content:'' position:absolute left:0 top:0.6rem bottom:0.6rem width:1px z-index:1 " +
165
172
  "background: linear-gradient(to bottom, transparent, $s-faint 18%, $s-faint 82%, transparent);",
166
- // Enter and exit share one vocabulary: a fade, drifting a short 8cqw
167
- // (`cqw`: `.s-main` is the container) only when the navigation really
168
- // moves a push opening a new column, a back closing one. A panel
169
- // replacing another simply fades in on top of it (see LAYER_STEP), the
170
- // old one fading in place beneath. The enter start state is applied with
171
- // transitions off and then dropped, so the panel settles instead of jumping.
172
- "&.s-panel-enter": "opacity:0 transition:none",
173
- // On its way out; leaves the DOM when the fade ends (see `playExit`).
174
- "&.s-panel-closing": "opacity:0 pointer-events:none",
175
- // The sideways drift, as an enter's start state or a close's destination.
176
- "&.s-panel-drift": "transform: translateX(8cqw);",
177
- // Off screen but open: crowded out at the left edge (`hidden`) or parked
178
- // past the right one (`parked`). Both keep their DOM — and so their scroll
179
- // position and half-typed forms — hence `visibility`, not `display:none`.
180
- // Transitioning it counts as *visible* for the whole fade, flipping at the
181
- // very end; the rule above (`visibility 0s`) reveals it again instantly.
182
- "&.s-panel-hidden, &.s-panel-parked": "opacity:0 visibility:hidden " +
183
- "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);",
184
- "&.s-panel-hidden": "transform: translateX(-8cqw);",
185
- "&.s-panel-parked": "transform: translateX(8cqw);",
173
+ // Still owed or playing its entry fade: beneath the settled panels, so
174
+ // whatever slides across its spot passes over it. Dropped once the fade
175
+ // is over (see `releaseEnter`).
176
+ "&.s-panel-new": "z-index:1",
177
+ // On its way out: it fades, still riding the strip (see `layout`),
178
+ // beneath every live panel (declared after `.s-panel-new`, so closing
179
+ // mid-enter drops a panel to the bottom), and leaves the DOM when the
180
+ // fade ends (see `playExit`).
181
+ "&.s-panel-closing": "z-index:0 opacity:0 pointer-events:none",
182
+ // The fade-in's start state: a newcomer wears it from creation until its
183
+ // content is ready — usually the very pass that placed it, later for a
184
+ // `loading` panel holding out for data sliding invisibly meanwhile.
185
+ // Dropping the class is what starts the fade (see `releaseEnter`).
186
+ "&.s-panel-enter": "opacity:0",
187
+ // Off screen but open: the strip simply continues past the viewport's
188
+ // edges, so these rest at their true positions, clipped being crowded
189
+ // out or revealed is an ordinary move, not a fade. Both keep their DOM —
190
+ // and so their scroll position and half-typed forms hence
191
+ // `visibility`, not `display:none`. The flip is timed by `layout()`,
192
+ // never transitioned: a panel sliding offstage gets the class once its
193
+ // slide ends and drops it the moment it is revealed. `visibility` is
194
+ // not compositable, and Chrome composites an element's transitions as
195
+ // a group, so listing it here would drag the slide itself onto the
196
+ // main thread — relaid out and repainted every frame.
197
+ "&.s-panel-hidden, &.s-panel-parked": "visibility:hidden",
186
198
  },
187
199
  // The scroll container, with the column's own padding. Its scrollbar sits
188
200
  // flush against the column edge (unlike content mode's inset one), so it meets
@@ -289,6 +301,8 @@ export class PanelStackController {
289
301
  lastGeom;
290
302
  layoutQueued = false;
291
303
  timers = new Set();
304
+ /** Elements playing their exit fade, each riding its anchor's motion (see `layout`). */
305
+ exiting = new Set();
292
306
  /** The arrangement the navigation in flight is heading for; see {@link intended}. */
293
307
  intent = null;
294
308
  /** The navigation the router hasn't settled yet, if any. */
@@ -497,12 +511,6 @@ export class PanelStackController {
497
511
  const pinned = route.current.state.pinned;
498
512
  const seedPins = new Set(Array.isArray(pinned) ? pinned.map(String) : []);
499
513
  const existing = new Map(this.$state.live.map((entry) => [entry.path, entry]));
500
- // A replacement — something enters while something closes — just fades:
501
- // the new panel in on top, the old ones out in place beneath it (see the
502
- // `.s-panel-drift` comment). A pure push or a pure close drifts sideways.
503
- const entering = nav !== "load" && nav !== "back";
504
- const drift = !(entering && target.stack.some((p) => !existing.has(p)) &&
505
- this.$state.live.some((e) => !target.stack.includes(e.path)));
506
514
  const next = [];
507
515
  for (const path of target.stack) {
508
516
  const kept = existing.get(path);
@@ -513,17 +521,23 @@ export class PanelStackController {
513
521
  continue;
514
522
  }
515
523
  const entry = this.createEntry(path, next.length <= target.focus, seedPins.has(path));
516
- // An initial load just appears, and so do panels *revealed* by a back:
517
- // they belong underneath the ones sliding away.
518
- if (entering) {
524
+ // An initial load just appears; any later navigation animates its new
525
+ // panels in beneath what's already there, so a back that re-creates
526
+ // a panel plays out under the one it takes away.
527
+ if (nav !== "load")
519
528
  entry.enter = true;
520
- entry.drift = drift;
521
- }
522
529
  next.push(entry);
523
530
  this.$open[path] = entry;
524
531
  }
525
- for (const entry of existing.values())
526
- this.beginClose(entry, drift);
532
+ // What remains in `existing` closes, each remembering the nearest
533
+ // surviving panel beneath it — the anchor its exit fade rides.
534
+ let anchor;
535
+ for (const entry of this.$state.live) {
536
+ if (existing.has(entry.path))
537
+ this.beginClose(entry, anchor);
538
+ else
539
+ anchor = entry.path;
540
+ }
527
541
  this.$state.live = next;
528
542
  this.$state.focus = Math.min(target.focus, next.length - 1);
529
543
  this.scheduleLayout();
@@ -562,23 +576,16 @@ export class PanelStackController {
562
576
  * at the end of the animation. Only the element lingers to play that animation,
563
577
  * which is what `drawPanel`'s `destroy=` hook hands to {@link playExit}.
564
578
  */
565
- beginClose(entry, drift) {
579
+ beginClose(entry, anchor) {
566
580
  entry.closing = true;
567
- entry.drift = drift;
581
+ entry.anchor = anchor;
568
582
  entry.$panel.visible = false;
569
- // Frozen one layer below where it was, so it fades out over the panel it
570
- // uncovers and under the one replacing it (see LAYER_STEP). Set here, while
571
- // the element is still ours — a moment later `entry.el` is gone.
572
- if (entry.el)
573
- entry.el.style.zIndex = String(LAYER_STEP * this.$state.live.indexOf(entry));
574
583
  delete this.$open[entry.path];
575
584
  }
576
585
  /**
577
- * A closed panel's send-off, run by Aberdeen once its scope is gone: it fades
578
- * where it stands, inert, and leaves the DOM on `transitionend`. A fixed timer
579
- * would race the transition and pull the element a frame early, making the
580
- * panel appear to fade half-way and vanish; the timeout is only a fallback for
581
- * when no `transitionend` is coming (transitions off, element never placed).
586
+ * A closed panel's send-off, run by Aberdeen once its scope is gone: it fades,
587
+ * inert, riding its anchor's slide (see `layout`), and leaves the DOM once
588
+ * the fade is over (see {@link afterFade}).
582
589
  */
583
590
  playExit(entry, el) {
584
591
  // A panel being *redrawn* replaces its element through here too; that one
@@ -588,20 +595,42 @@ export class PanelStackController {
588
595
  return;
589
596
  }
590
597
  el.classList.add("s-panel-closing");
591
- if (entry.drift)
592
- el.classList.add("s-panel-drift");
593
598
  el.setAttribute("inert", "");
594
- const drop = () => {
595
- clearTimeout(timer);
596
- this.timers.delete(timer);
599
+ // Only a placed panel has a seat on the strip to ride out from.
600
+ const exit = entry.placed ? { el, anchor: entry.anchor, ride: 0 } : null;
601
+ if (exit)
602
+ this.exiting.add(exit);
603
+ this.afterTransition(el, "opacity", () => {
604
+ if (exit)
605
+ this.exiting.delete(exit);
597
606
  el.remove();
598
- };
599
- el.addEventListener("transitionend", (e) => {
600
- if (e.target === el && e.propertyName === "opacity")
601
- drop();
602
607
  });
603
- const timer = setTimeout(drop, PAGE_MS + 80);
608
+ }
609
+ /**
610
+ * Run `done` once `el`'s transition of `prop` is over. The real signal is
611
+ * `transitionend` — or `transitioncancel`, for one a resize snaps short —
612
+ * so the transition's actual length rules, however long: DevTools' slowed
613
+ * animations stretch it tenfold without touching any timer. The timer only
614
+ * stands in for a transition that never starts at all (transitions off,
615
+ * element never placed, nothing to travel), which is why one proving real
616
+ * (`transitionrun`) disarms it.
617
+ */
618
+ afterTransition(el, prop, done) {
619
+ let called = false;
620
+ const timer = setTimeout(() => finish(), PAGE_MS + 80);
604
621
  this.timers.add(timer);
622
+ const disarm = () => { clearTimeout(timer); this.timers.delete(timer); };
623
+ const finish = () => { disarm(); if (!called) {
624
+ called = true;
625
+ done();
626
+ } };
627
+ const forProp = (fn) => (e) => {
628
+ if (e.target === el && e.propertyName === prop)
629
+ fn();
630
+ };
631
+ el.addEventListener("transitionrun", forProp(disarm));
632
+ el.addEventListener("transitionend", forProp(finish));
633
+ el.addEventListener("transitioncancel", forProp(finish));
605
634
  }
606
635
  // ── Navigation ─────────────────────────────────────────────────────────
607
636
  /**
@@ -1265,9 +1294,11 @@ export class PanelStackController {
1265
1294
  first = i;
1266
1295
  }
1267
1296
  }
1268
- // The content area is a fixed width, so a run that doesn't fill it centres
1269
- // rather than hanging off the left edge the chrome around it never moves.
1270
- const left = (geom.area - runSum) / 2;
1297
+ // A run that doesn't fill the fixed-width area centres in it unless
1298
+ // panels sit crowded out on its left: then it hugs the left edge instead,
1299
+ // so the strip crosses that edge with no gap and a reveal is a plain
1300
+ // slide back in.
1301
+ const left = first > 0 ? 0 : (geom.area - runSum) / 2;
1271
1302
  for (let i = first; i <= cur; i++)
1272
1303
  live[i].width = width(live[i]);
1273
1304
  // Never-visible panels get their would-be width, so a reveal doesn't start
@@ -1276,68 +1307,156 @@ export class PanelStackController {
1276
1307
  if (!entry.width)
1277
1308
  entry.width = width(entry);
1278
1309
  }
1279
- // Phase 1 — every panel's *start* state for this frame. Panels on screen
1280
- // simply move; freshly mounted ones still have transitions off, so what is
1281
- // set here is adopted instantly and becomes the "before" of their enter.
1310
+ // Phase 1 — lay the strip out for this frame: each panel flush against
1311
+ // its neighbours, the visible run [first..cur] in the viewport, earlier
1312
+ // panels continuing off its left edge and parked ones held past its
1313
+ // right. `left` gets each new resting position outright — it never
1314
+ // animates — and the slide to it is a FLIP: a mover is held at its
1315
+ // current visual spot by a transform (its mid-slide offset plus the
1316
+ // distance), a newcomer at its start beside the OLD position of the
1317
+ // nearest placed panel beneath it (`delta`, so the slide in is one
1318
+ // motion with the panels making room; with nothing beneath it to come
1319
+ // from, it simply fades in place). Phase 3 plays every held transform
1320
+ // out to none. A snap pass holds nothing: geometry must land, not
1321
+ // travel.
1282
1322
  const fresh = [];
1323
+ const movers = [];
1324
+ const deltas = new Map();
1325
+ // All reads before all writes: a mover may still be mid-slide, and its
1326
+ // current offset must come out of the computed style before this pass
1327
+ // dirties it (one style recalc, then cached).
1328
+ const txs = new Map();
1329
+ if (!snap)
1330
+ for (const entry of live) {
1331
+ if (entry.placed)
1332
+ txs.set(entry, transformX(entry.el));
1333
+ }
1283
1334
  let x = left;
1335
+ let delta = 0;
1336
+ for (let i = 0; i < first; i++)
1337
+ x -= live[i].width;
1284
1338
  for (let i = 0; i < n; i++) {
1285
1339
  const entry = live[i];
1286
1340
  const el = entry.el;
1287
1341
  const shown = i >= first && i <= cur;
1288
- // Visible columns tile the run left to right; crowded-out ones rest at
1289
- // its left edge and parked ones just past its right, both keeping their
1290
- // last width. Deeper panels layer over shallower (see LAYER_STEP).
1291
- place(el, shown ? x : i > cur ? left + runSum : left, entry.width, LAYER_STEP * i + 1);
1342
+ // Parked panels never dip into the viewport, however short the run.
1343
+ if (i === cur + 1)
1344
+ x = Math.max(x, geom.area);
1345
+ // A panel that mounts while still fetching holds its fade for a
1346
+ // moment, so it can appear with real content instead of empty.
1347
+ if (entry.enter || !entry.placed) {
1348
+ if (!entry.$panel.loading || entry.holdDone)
1349
+ entry.$ui.holding = false;
1350
+ else if (!entry.$ui.holding) {
1351
+ entry.$ui.holding = true;
1352
+ this.holdEnter(entry);
1353
+ }
1354
+ }
1355
+ if (entry.placed) {
1356
+ delta = parseFloat(el.style.left) - x;
1357
+ deltas.set(entry.path, delta);
1358
+ if (delta && !snap) {
1359
+ // Hold the visual spot while `left` jumps beneath it. The
1360
+ // inline transition keeps the transform still — and only it:
1361
+ // running fades carry on, and one starting this pass (a
1362
+ // release below) still gets its linear curve.
1363
+ el.style.transition = `opacity ${PAGE_MS}ms linear`;
1364
+ el.style.transform = `translateX(${(txs.get(entry) ?? 0) + delta}px)`;
1365
+ movers.push(el);
1366
+ }
1367
+ el.style.left = `${x}px`;
1368
+ // A fade held back for content starts the moment its hold lifts.
1369
+ if (entry.enter && !entry.$ui.holding)
1370
+ this.releaseEnter(entry);
1371
+ }
1372
+ else {
1373
+ fresh.push(entry);
1374
+ el.style.left = `${x}px`;
1375
+ if (entry.enter && shown) {
1376
+ el.style.transform = `translateX(${delta}px)`;
1377
+ el.classList.add("s-panel-enter", "s-panel-new");
1378
+ }
1379
+ }
1380
+ el.style.width = `${entry.width}px`;
1381
+ x += entry.width;
1292
1382
  // Written only on a change, so per-panel UI hanging off `visible` or
1293
1383
  // `width` isn't rebuilt by every pass.
1294
1384
  if (entry.$panel.visible !== shown)
1295
1385
  entry.$panel.visible = shown;
1296
1386
  if (entry.$panel.width !== entry.width)
1297
1387
  entry.$panel.width = entry.width;
1298
- if (shown)
1299
- x += entry.width;
1300
1388
  el.classList.toggle("s-panel-sep", shown && i > first);
1301
- // Off-screen panels fade out and stop rendering, keeping their DOM.
1302
- el.classList.toggle("s-panel-hidden", i < first);
1303
- el.classList.toggle("s-panel-parked", i > cur);
1304
- el.toggleAttribute("inert", !shown);
1305
- if (entry.placed)
1306
- continue;
1307
- fresh.push(entry);
1308
- // A panel that mounts while still fetching holds here for a moment, so
1309
- // it can enter with real content instead of an empty column.
1310
- if (!entry.$panel.loading || entry.holdDone)
1311
- entry.$ui.holding = false;
1312
- else if (!entry.$ui.holding) {
1313
- entry.$ui.holding = true;
1314
- this.holdEnter(entry);
1389
+ // Off-screen panels stop rendering, keeping their DOM. A revealed
1390
+ // panel is visible at once; one sliding offstage stays visible for
1391
+ // the whole slide, its visibility class deferred to the slide's
1392
+ // end — but applied outright when it is already invisible, about
1393
+ // to be (a newcomer offstage from birth), or snapping.
1394
+ const hidden = i < first;
1395
+ const wasOff = el.classList.contains("s-panel-hidden") || el.classList.contains("s-panel-parked");
1396
+ if (shown) {
1397
+ el.classList.remove("s-panel-hidden", "s-panel-parked");
1315
1398
  }
1316
- // Already at its resting place; the enter is the transparency (and,
1317
- // when drifting, the offset one edge to the right) it starts from.
1318
- if (entry.enter && shown) {
1319
- el.classList.add("s-panel-enter");
1320
- if (entry.drift)
1321
- el.classList.add("s-panel-drift");
1399
+ else if (wasOff || !entry.placed || snap) {
1400
+ el.classList.toggle("s-panel-hidden", hidden);
1401
+ el.classList.toggle("s-panel-parked", !hidden);
1402
+ }
1403
+ else {
1404
+ this.afterTransition(el, "transform", () => {
1405
+ if (entry.el !== el || !entry.offstage)
1406
+ return;
1407
+ el.classList.toggle("s-panel-hidden", hidden);
1408
+ el.classList.toggle("s-panel-parked", !hidden);
1409
+ });
1410
+ }
1411
+ entry.offstage = !shown;
1412
+ el.toggleAttribute("inert", !shown);
1413
+ }
1414
+ // Closing panels keep their seat on the strip while they fade: each
1415
+ // travels exactly as far as its anchor — the surviving panel it stood
1416
+ // on — so a close slides back out the way the open slid in, and one
1417
+ // whose anchor stays put is the plain crossfade it should be. A plain
1418
+ // retarget, no holding: the transition picks the slide up from wherever
1419
+ // the element is now.
1420
+ for (const exit of this.exiting) {
1421
+ const d = exit.anchor == null ? undefined : deltas.get(exit.anchor);
1422
+ if (d) {
1423
+ exit.ride -= d;
1424
+ exit.el.style.transform = `translateX(${exit.ride}px)`;
1322
1425
  }
1323
1426
  }
1324
1427
  // Phase 2 — reading a layout property forces the browser to adopt those
1325
- // start states (and a snap pass's transition-free geometry) to animate from.
1326
- if (fresh.length || snap)
1428
+ // held spots (and a snap pass's transition-free geometry) to slide from.
1429
+ if (fresh.length || movers.length || snap)
1327
1430
  void container.offsetWidth;
1328
1431
  if (snap)
1329
1432
  shell.classList.remove("s-shell-snap");
1330
- // Phase 3 — transitions back on, start state dropped, and off they go.
1433
+ // Phase 3 — every held transform plays out to none: the slides start
1434
+ // now, and the newcomers' fades with them — unless a panel is holding
1435
+ // for its content, which keeps the fade's start state on until then.
1436
+ for (const el of movers) {
1437
+ el.style.transition = "";
1438
+ el.style.transform = "";
1439
+ }
1331
1440
  for (const entry of fresh) {
1332
- if (entry.$ui.holding)
1333
- continue;
1334
- entry.el.style.transition = "";
1335
- entry.el.classList.remove("s-panel-enter", "s-panel-drift");
1336
- entry.enter = false;
1441
+ const el = entry.el;
1442
+ el.style.transition = "";
1443
+ el.style.transform = "";
1337
1444
  entry.placed = true;
1445
+ if (!entry.$ui.holding)
1446
+ this.releaseEnter(entry);
1338
1447
  }
1339
1448
  }
1340
- /** Let a `loading` panel's enter animation wait but not indefinitely. */
1449
+ /** Start (or skip) a newcomer's fade-in; any slide is already underway. */
1450
+ releaseEnter(entry) {
1451
+ entry.enter = false;
1452
+ const el = entry.el;
1453
+ if (!el || !el.classList.contains("s-panel-enter"))
1454
+ return;
1455
+ el.classList.remove("s-panel-enter");
1456
+ // Keep it beneath its elders until the fade is over.
1457
+ this.afterTransition(el, "opacity", () => el.classList.remove("s-panel-new"));
1458
+ }
1459
+ /** Let a `loading` panel's fade-in wait — but not indefinitely. */
1341
1460
  holdEnter(entry) {
1342
1461
  const timer = setTimeout(() => {
1343
1462
  this.timers.delete(timer);
@@ -1350,16 +1469,15 @@ export class PanelStackController {
1350
1469
  this.timers.add(timer);
1351
1470
  }
1352
1471
  }
1353
- /** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
1354
- function place(el, x, width, z) {
1355
- el.style.left = `${x}px`;
1356
- el.style.width = `${width}px`;
1357
- el.style.zIndex = String(z);
1358
- }
1359
1472
  // ─── Helpers ─────────────────────────────────────────────────────────────────
1360
1473
  function sameStack(a, b) {
1361
1474
  return a.length === b.length && a.every((v, i) => v === b[i]);
1362
1475
  }
1476
+ /** The X offset of `el`'s computed transform — where a slide has it right now. */
1477
+ function transformX(el) {
1478
+ const t = getComputedStyle(el).transform;
1479
+ return t && t !== "none" ? new DOMMatrixReadOnly(t).m41 : 0;
1480
+ }
1363
1481
  /**
1364
1482
  * The first non-empty text inside `el`, trimmed and capped at a name-like
1365
1483
  * length — the stand-in title for a panel that never set one.