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.
- package/dist/components/main.d.ts +2 -2
- package/dist/components/main.js +9 -7
- package/dist/components/panels.d.ts +31 -11
- package/dist/components/panels.js +244 -126
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.js +14 -5
- package/package.json +3 -3
- package/skill/MainOptions.md +2 -2
- package/skill/Panel.md +6 -5
- package/src/components/main.ts +11 -9
- package/src/components/panels.ts +242 -125
- package/src/theme.ts +14 -5
package/src/components/panels.ts
CHANGED
|
@@ -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
|
|
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
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
-
*
|
|
419
|
-
*
|
|
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
|
|
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
|
|
446
|
-
//
|
|
447
|
-
//
|
|
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
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
//
|
|
484
|
-
"&.s-panel-closing": "opacity:0 pointer-events:none",
|
|
485
|
-
// The
|
|
486
|
-
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
//
|
|
491
|
-
//
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
/**
|
|
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
|
|
1052
|
-
//
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
|
|
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,
|
|
1123
|
+
private beginClose(entry: PanelEntry, anchor?: string): void {
|
|
1102
1124
|
entry.closing = true;
|
|
1103
|
-
entry.
|
|
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
|
-
*
|
|
1115
|
-
*
|
|
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
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1823
|
-
//
|
|
1824
|
-
|
|
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 —
|
|
1834
|
-
//
|
|
1835
|
-
//
|
|
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
|
-
//
|
|
1843
|
-
|
|
1844
|
-
//
|
|
1845
|
-
|
|
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
|
|
1853
|
-
|
|
1854
|
-
|
|
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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
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
|
-
//
|
|
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 —
|
|
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
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
200
|
-
|
|
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.
|