staffa 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/dist/components/main.d.ts +5 -0
- package/dist/components/main.js +35 -13
- package/dist/components/panels.d.ts +51 -8
- package/dist/components/panels.js +181 -83
- package/dist/staffa.esm.js +1 -1
- package/package.json +1 -1
- package/skill/MainOptions.md +5 -0
- package/skill/Page.md +13 -2
- package/skill/SKILL.md +6 -2
- package/src/components/main.ts +39 -12
- package/src/components/panels.ts +214 -89
package/src/components/panels.ts
CHANGED
|
@@ -111,8 +111,19 @@ export interface Page<P = Record<string, string | number | string[]>> {
|
|
|
111
111
|
*
|
|
112
112
|
* A panel's width depends only on the size of the window, never on what else
|
|
113
113
|
* is open, so opening or closing a panel never resizes the ones already on
|
|
114
|
-
* screen.
|
|
115
|
-
*
|
|
114
|
+
* screen.
|
|
115
|
+
*
|
|
116
|
+
* The panel is sized from this **before** your handler runs, so anything that
|
|
117
|
+
* measures its own box has a real one from the first frame. What it is sized
|
|
118
|
+
* at is whatever this says at that moment, which for a brand-new panel is the
|
|
119
|
+
* default: a handler that *assigns* `layout` is drawn at the medium width and
|
|
120
|
+
* reflowed immediately after — in time for the frame, but not for a
|
|
121
|
+
* measurement taken in the same breath.
|
|
122
|
+
*
|
|
123
|
+
* Assigning it later works just as well. When your data arrives and you find
|
|
124
|
+
* you want the wide one, the panel reflows to its new width without being
|
|
125
|
+
* redrawn — so nothing in it is rebuilt or loses its state — and the columns
|
|
126
|
+
* beside it move over.
|
|
116
127
|
*/
|
|
117
128
|
layout?: "small" | "medium" | "large";
|
|
118
129
|
/**
|
|
@@ -279,6 +290,14 @@ const SHELL_PX = 1280;
|
|
|
279
290
|
const GUTTER_PX = 24;
|
|
280
291
|
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
281
292
|
const PAIR_MIN_PX = 360;
|
|
293
|
+
/**
|
|
294
|
+
* Panels are layered by their depth in the stack, two `z-index` steps per panel:
|
|
295
|
+
* a panel sits on the odd layer for its depth, and a *closing* one drops to the
|
|
296
|
+
* even layer just below, where it is frozen for the length of its fade. So a
|
|
297
|
+
* panel that replaces another comes in over it, while one that closes fades out
|
|
298
|
+
* over whatever it was covering — which is the way round both should read.
|
|
299
|
+
*/
|
|
300
|
+
const LAYER_STEP = 2;
|
|
282
301
|
|
|
283
302
|
// ─── Module-level styling ────────────────────────────────────────────────────
|
|
284
303
|
|
|
@@ -286,16 +305,19 @@ A.insertGlobalCss({
|
|
|
286
305
|
":root": `--s-panel-ms:${PANEL_MS}ms`,
|
|
287
306
|
// The clipping viewport that the columns slide through. Panels are absolutely
|
|
288
307
|
// positioned inside it, with their width and x offset set from JS (see
|
|
289
|
-
// `layout()`), so they can animate between arrangements.
|
|
290
|
-
|
|
308
|
+
// `layout()`), so they can animate between arrangements. `isolation` keeps the
|
|
309
|
+
// layers they stack themselves in (see LAYER_STEP) to themselves: the region
|
|
310
|
+
// as a whole still sits under the shell's own chrome — the sticky top bar, and
|
|
311
|
+
// the nav page that slides across the body — however deep the stack gets.
|
|
312
|
+
".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:hidden isolation:isolate",
|
|
291
313
|
".s-panel": {
|
|
292
314
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
293
315
|
// transformed element is composited, which costs it subpixel text
|
|
294
316
|
// antialiasing. `transform` is used only to play the enter/exit slides,
|
|
295
|
-
// where the compositing is what makes them cheap. There is deliberately
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
317
|
+
// where the compositing is what makes them cheap. There is deliberately no
|
|
318
|
+
// `width` transition: a width changes only when the window resizes or when
|
|
319
|
+
// the page itself asks for another layout, and animating one would reflow
|
|
320
|
+
// the column's content on every frame of it.
|
|
299
321
|
// Every duration is `--s-panel-ms`, so a column's move, its neighbour's fade
|
|
300
322
|
// and the chrome recentering around them all run as one motion. The drift
|
|
301
323
|
// eases out (it should read as a slow settle) while the fade runs *linear*
|
|
@@ -303,6 +325,10 @@ A.insertGlobalCss({
|
|
|
303
325
|
// zero, which looks like the panel vanishing rather than fading.
|
|
304
326
|
// No `overflow:hidden` here: the scroll container below clips the content
|
|
305
327
|
// itself, and the pair hairline sits in the gutter *outside* the panel.
|
|
328
|
+
// Layering is set from JS (`layout()` and `beginClose`) rather than left to
|
|
329
|
+
// DOM order: a closing panel is no longer part of the reactive list, so
|
|
330
|
+
// where its element sits among the live ones is Aberdeen's business, not a
|
|
331
|
+
// thing to depend on. `LAYER_*` says what the numbers mean.
|
|
306
332
|
"&":
|
|
307
333
|
"position:absolute top:0 bottom:0 left:0 display:flex flex-direction:column " +
|
|
308
334
|
"visibility:visible transition: left var(--s-panel-ms) ease, transform var(--s-panel-ms) ease-out, opacity var(--s-panel-ms) linear, visibility 0s;",
|
|
@@ -324,8 +350,8 @@ A.insertGlobalCss({
|
|
|
324
350
|
// dropped, which is what makes the panel settle instead of jumping.
|
|
325
351
|
"&.s-panel-enter": "opacity:0 transition:none transform: translateX(8cqw);",
|
|
326
352
|
// On its way out: fading where it stands, drifting the same short distance,
|
|
327
|
-
// and out of reach while it does. It
|
|
328
|
-
//
|
|
353
|
+
// and out of reach while it does. It leaves the DOM when the fade itself
|
|
354
|
+
// ends (see `playExit`), never part-way through it.
|
|
329
355
|
"&.s-panel-closing": "opacity:0 pointer-events:none transform: translateX(8cqw);",
|
|
330
356
|
// Crowded out from under the visible run. It keeps its DOM (and thus its
|
|
331
357
|
// scroll position and half-typed forms), so `display:none` is out —
|
|
@@ -390,16 +416,35 @@ interface PanelEntry {
|
|
|
390
416
|
placed?: boolean;
|
|
391
417
|
/** Whether its `loading` hold has already expired, so it can't hold again. */
|
|
392
418
|
holdDone?: boolean;
|
|
393
|
-
/** What the panel
|
|
419
|
+
/** What the panel asks for, kept in step with its `$page.layout`. */
|
|
394
420
|
layout: "small" | "medium" | "large";
|
|
395
421
|
/**
|
|
396
|
-
* The width it was last laid out at.
|
|
397
|
-
*
|
|
398
|
-
*
|
|
422
|
+
* The width it was last laid out at. Set before the panel's content is first
|
|
423
|
+
* drawn, so that content has a real box to measure itself against. Visible
|
|
424
|
+
* panels get a fresh value every pass (widths are a pure function of the
|
|
425
|
+
* content area and small-pairing); hidden and closing panels keep this, so
|
|
426
|
+
* nothing invisible ever reflows.
|
|
399
427
|
*/
|
|
400
428
|
width: number;
|
|
401
429
|
}
|
|
402
430
|
|
|
431
|
+
/**
|
|
432
|
+
* What the shell measures out to, and with it the width every panel size gets.
|
|
433
|
+
* A pure function of the window, so it is the same for every panel in a pass.
|
|
434
|
+
*/
|
|
435
|
+
interface Geometry {
|
|
436
|
+
/** The body row: everything the columns and the sidebar share. */
|
|
437
|
+
total: number;
|
|
438
|
+
/** What sits beside the columns — the sidebar and its hairline, if shown. */
|
|
439
|
+
chrome: number;
|
|
440
|
+
/** Half the standard content area, or all of it when a half would be too narrow. */
|
|
441
|
+
small: number;
|
|
442
|
+
/** The standard content area: the 1280px page minus the chrome. */
|
|
443
|
+
medium: number;
|
|
444
|
+
/** Everything the window has beside the chrome, with no upper limit. */
|
|
445
|
+
large: number;
|
|
446
|
+
}
|
|
447
|
+
|
|
403
448
|
// ─── Controller ──────────────────────────────────────────────────────────────
|
|
404
449
|
|
|
405
450
|
/** Options the panel stack needs from its shell. */
|
|
@@ -430,6 +475,8 @@ export class PanelController {
|
|
|
430
475
|
*/
|
|
431
476
|
$state = A.proxy({ paths: [] as string[], topId: 0 });
|
|
432
477
|
private containerEl?: HTMLElement;
|
|
478
|
+
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
479
|
+
private geom?: Geometry;
|
|
433
480
|
/** The body width at the last layout; a change means a window resize → snap. */
|
|
434
481
|
private lastBodyW = -1;
|
|
435
482
|
private layoutQueued = false;
|
|
@@ -569,6 +616,9 @@ export class PanelController {
|
|
|
569
616
|
* rule 5 promises to keep.
|
|
570
617
|
*/
|
|
571
618
|
private commit(target: string[], nav: string): void {
|
|
619
|
+
// The panels this commit mounts size themselves as they draw, so make them
|
|
620
|
+
// measure the shell as it is now rather than trusting the last pass's numbers.
|
|
621
|
+
this.geom = undefined;
|
|
572
622
|
const existing = new Map(this.live.map((entry) => [entry.path, entry]));
|
|
573
623
|
const next: PanelEntry[] = [];
|
|
574
624
|
for (const path of target) {
|
|
@@ -621,32 +671,49 @@ export class PanelController {
|
|
|
621
671
|
}
|
|
622
672
|
|
|
623
673
|
/**
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
674
|
+
* Take a panel out of the shell. The *scope* goes now: its cleaners run this
|
|
675
|
+
* tick, so whatever the panel registered with `A.clean` — subscriptions,
|
|
676
|
+
* timers, an open portal — is torn down when the panel closes, not when its
|
|
677
|
+
* animation is over. Only the element lingers, to play that animation, which
|
|
678
|
+
* is what the `destroy=` hook in `drawPanel` is for: Aberdeen hands the
|
|
679
|
+
* element to {@link playExit} instead of removing it.
|
|
630
680
|
*/
|
|
631
681
|
private beginClose(entry: PanelEntry): void {
|
|
632
682
|
entry.closing = true;
|
|
633
|
-
|
|
683
|
+
// Frozen one layer below where it was, which is still above everything it
|
|
684
|
+
// was covering: it fades out over the panel it uncovers, and under the one
|
|
685
|
+
// that takes its place (see LAYER_STEP). Set here, while the element is
|
|
686
|
+
// still ours — a moment later the scope, and with it `entry.el`, is gone.
|
|
687
|
+
if (entry.el) entry.el.style.zIndex = String(LAYER_STEP * this.live.indexOf(entry));
|
|
688
|
+
this.byId.delete(entry.id);
|
|
689
|
+
delete this.$ids[String(entry.id)];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* A closed panel's send-off, run by Aberdeen once the panel's scope is gone (so
|
|
694
|
+
* the content it shows is frozen, which is exactly what a departing column
|
|
695
|
+
* should be): it fades where it stands, inert, and leaves the DOM when the fade
|
|
696
|
+
* itself ends. Removing it on a fixed timer instead would race the transition —
|
|
697
|
+
* pull the element a frame early and the panel appears to fade half-way and
|
|
698
|
+
* then vanish. The timeout is just a fallback for when no `transitionend` is
|
|
699
|
+
* coming at all (transitions off, or an element that never got placed).
|
|
700
|
+
*/
|
|
701
|
+
private playExit(entry: PanelEntry, el: HTMLElement): void {
|
|
702
|
+
// Only a close is worth animating. A panel being *redrawn* (a reactive
|
|
703
|
+
// dependency in its handler) replaces its element through here too, and that
|
|
704
|
+
// one simply goes, so the new one isn't drawn over a ghost of the old.
|
|
705
|
+
if (!entry.closing) { el.remove(); return; }
|
|
706
|
+
el.classList.add("s-panel-closing");
|
|
707
|
+
el.setAttribute("inert", "");
|
|
634
708
|
const drop = () => {
|
|
635
|
-
|
|
636
|
-
this.byId.delete(entry.id);
|
|
637
|
-
delete this.$ids[String(entry.id)];
|
|
638
|
-
};
|
|
639
|
-
if (el) {
|
|
640
|
-
el.classList.add("s-panel-closing");
|
|
641
|
-
el.setAttribute("inert", "");
|
|
642
|
-
el.addEventListener("transitionend", (e: TransitionEvent) => {
|
|
643
|
-
if (e.target === el && e.propertyName === "opacity") drop();
|
|
644
|
-
});
|
|
645
|
-
}
|
|
646
|
-
const timer = setTimeout(() => {
|
|
709
|
+
clearTimeout(timer);
|
|
647
710
|
this.timers.delete(timer);
|
|
648
|
-
|
|
649
|
-
}
|
|
711
|
+
el.remove();
|
|
712
|
+
};
|
|
713
|
+
el.addEventListener("transitionend", (e: TransitionEvent) => {
|
|
714
|
+
if (e.target === el && e.propertyName === "opacity") drop();
|
|
715
|
+
});
|
|
716
|
+
const timer = setTimeout(drop, PANEL_MS + 80);
|
|
650
717
|
this.timers.add(timer);
|
|
651
718
|
}
|
|
652
719
|
|
|
@@ -801,6 +868,11 @@ export class PanelController {
|
|
|
801
868
|
*/
|
|
802
869
|
drawStack(): void {
|
|
803
870
|
const container = A("div.s-panels role=main", () => {
|
|
871
|
+
// Published before the first panel draws, rather than from the return
|
|
872
|
+
// value below: a panel sizes itself from the shell's measurements (see
|
|
873
|
+
// `measure`), and the first ones do that while this very call is still
|
|
874
|
+
// running. `A()` without arguments is "the element we're in".
|
|
875
|
+
this.containerEl = A() as HTMLElement;
|
|
804
876
|
A.onEach(
|
|
805
877
|
this.$ids,
|
|
806
878
|
(_order, id) => this.drawPanel(Number(id)),
|
|
@@ -808,7 +880,6 @@ export class PanelController {
|
|
|
808
880
|
);
|
|
809
881
|
}) as HTMLElement;
|
|
810
882
|
|
|
811
|
-
this.containerEl = container;
|
|
812
883
|
if (typeof ResizeObserver !== "undefined") {
|
|
813
884
|
const ro = new ResizeObserver(() => this.layout());
|
|
814
885
|
// The region *and* the body it sits in: the region alone misses a shell
|
|
@@ -825,8 +896,30 @@ export class PanelController {
|
|
|
825
896
|
private drawPanel(id: number): void {
|
|
826
897
|
const entry = this.byId.get(id);
|
|
827
898
|
if (!entry) return;
|
|
899
|
+
let el: HTMLElement | undefined;
|
|
900
|
+
|
|
901
|
+
// How much room the panel wants, resolved *before* its content is drawn: an
|
|
902
|
+
// element that arrives without a width has no box for its content to measure
|
|
903
|
+
// itself against until the next frame's layout pass, which is a frame too
|
|
904
|
+
// late for anything that sizes itself from its container. So the panel is
|
|
905
|
+
// created at the width the window gives its layout — "medium" until the page
|
|
906
|
+
// says otherwise. Reactively, too: a page that changes its mind later (when
|
|
907
|
+
// its data arrives, say) reflows in place rather than being redrawn, and the
|
|
908
|
+
// columns beside it slide over to make room.
|
|
909
|
+
A(() => {
|
|
910
|
+
const asked = entry.$page.layout;
|
|
911
|
+
entry.layout = asked === "small" || asked === "large" ? asked : "medium";
|
|
912
|
+
const width = this.roomFor(entry.layout);
|
|
913
|
+
if (!width) return;
|
|
914
|
+
entry.width = width;
|
|
915
|
+
// The first run has no element to put it on yet — it's created with this
|
|
916
|
+
// width, just below. Later runs are the page changing its layout.
|
|
917
|
+
if (!el) return;
|
|
918
|
+
el.style.width = `${width}px`;
|
|
919
|
+
this.scheduleLayout();
|
|
920
|
+
});
|
|
828
921
|
|
|
829
|
-
|
|
922
|
+
el = A(`section.s-panel${entry.width ? ` w:${entry.width}px` : ""}`, "destroy=", (node: HTMLElement) => this.playExit(entry, node), () => {
|
|
830
923
|
const contentEl = A("div.s-content", () => {
|
|
831
924
|
entry.draw(entry.$page);
|
|
832
925
|
// After the content, so there is something to scroll when restoring.
|
|
@@ -843,18 +936,12 @@ export class PanelController {
|
|
|
843
936
|
});
|
|
844
937
|
}) as HTMLElement;
|
|
845
938
|
|
|
846
|
-
// How much room the panel wants, settled right after its handler's
|
|
847
|
-
// synchronous run — deliberately once: a column that changed its mind
|
|
848
|
-
// later would reflow itself and shove its neighbours around.
|
|
849
|
-
const asked = A.peek(entry.$page, "layout");
|
|
850
|
-
entry.layout = asked === "small" || asked === "large" ? asked : "medium";
|
|
851
|
-
|
|
852
939
|
entry.el = el;
|
|
853
|
-
//
|
|
854
|
-
// the panel its
|
|
855
|
-
// upcoming frame, before anything is painted. A redraw (a
|
|
856
|
-
// dependency inside the handler) lands here too, with a brand-new
|
|
857
|
-
// that has to be placed again before it may animate.
|
|
940
|
+
// It has its width, but nothing animates from the arbitrary initial spot;
|
|
941
|
+
// `layout()` gives the panel its place in the run (and turns transitions
|
|
942
|
+
// back on) in the upcoming frame, before anything is painted. A redraw (a
|
|
943
|
+
// reactive dependency inside the handler) lands here too, with a brand-new
|
|
944
|
+
// element that has to be placed again before it may animate.
|
|
858
945
|
entry.placed = false;
|
|
859
946
|
el.style.transition = "none";
|
|
860
947
|
A.clean(() => { if (entry.el === el) entry.el = undefined; });
|
|
@@ -879,6 +966,67 @@ export class PanelController {
|
|
|
879
966
|
});
|
|
880
967
|
}
|
|
881
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Measure the shell, and with it the width the window gives a panel of each
|
|
971
|
+
* layout. Measured on the *shell*, not on the panel region: the region's width
|
|
972
|
+
* is the layout engine's own output, so reading it back would nail the layout
|
|
973
|
+
* to whatever it happened to be a frame ago. Fractional widths throughout — a
|
|
974
|
+
* rounded column edge would drift a pixel away from the chrome above it.
|
|
975
|
+
*
|
|
976
|
+
* `undefined` while the shell has no width to speak of (it isn't in a document
|
|
977
|
+
* yet, or it's `display:none`); the next pass tries again.
|
|
978
|
+
*/
|
|
979
|
+
private measure(): Geometry | undefined {
|
|
980
|
+
const container = this.containerEl;
|
|
981
|
+
const inner = container?.parentElement;
|
|
982
|
+
const body = inner?.parentElement;
|
|
983
|
+
if (!container || !inner || !body) return undefined;
|
|
984
|
+
const total = body.getBoundingClientRect().width;
|
|
985
|
+
if (!total) return undefined;
|
|
986
|
+
|
|
987
|
+
// Everything that sits beside the columns: the sidebar and its hairline,
|
|
988
|
+
// either of which may be display:none on a narrow shell.
|
|
989
|
+
let chrome = 0;
|
|
990
|
+
for (const child of inner.children) {
|
|
991
|
+
if (child !== container) chrome += child.getBoundingClientRect().width;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// The standard page is SHELL_PX wide, capped by the window; what it leaves
|
|
995
|
+
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
996
|
+
// function of the window — never of what else is open — so a panel NEVER
|
|
997
|
+
// resizes because a neighbour came or went; only a window resize (the
|
|
998
|
+
// snap pass in `layout`) changes them:
|
|
999
|
+
// - "medium" fills the standard content area exactly;
|
|
1000
|
+
// - "small" is half of it (minus the gutter) whenever that half is still
|
|
1001
|
+
// a usable column, and the whole of it on narrower screens;
|
|
1002
|
+
// - "large" ignores the standard width and takes everything the window
|
|
1003
|
+
// has — which also means nothing ever fits beside it.
|
|
1004
|
+
const medium = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
1005
|
+
const half = (medium - GUTTER_PX) / 2;
|
|
1006
|
+
return {
|
|
1007
|
+
total,
|
|
1008
|
+
chrome,
|
|
1009
|
+
small: half >= PAIR_MIN_PX ? half : medium,
|
|
1010
|
+
medium,
|
|
1011
|
+
large: Math.max(0, total - chrome),
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* The measurements this pass runs on. Taken once per layout pass and per
|
|
1017
|
+
* commit, and shared with the panels drawn in between — they all size
|
|
1018
|
+
* themselves against the same shell, and a `getBoundingClientRect()` each
|
|
1019
|
+
* would be a forced reflow each, in the middle of building their DOM.
|
|
1020
|
+
*/
|
|
1021
|
+
private geometry(): Geometry | undefined {
|
|
1022
|
+
return (this.geom ??= this.measure());
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** How wide a panel of this layout is, right now; 0 while the shell can't be measured. */
|
|
1026
|
+
private roomFor(layout: PanelEntry["layout"]): number {
|
|
1027
|
+
return this.geometry()?.[layout] ?? 0;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
882
1030
|
/**
|
|
883
1031
|
* Size and position every panel, and publish the width of the whole ensemble
|
|
884
1032
|
* (sidebar + separator + columns) for the shell to centre itself on.
|
|
@@ -889,22 +1037,18 @@ export class PanelController {
|
|
|
889
1037
|
*/
|
|
890
1038
|
private layout(): void {
|
|
891
1039
|
const container = this.containerEl;
|
|
892
|
-
const inner = container?.parentElement;
|
|
893
|
-
const body = inner?.parentElement;
|
|
894
1040
|
const shell = container?.closest<HTMLElement>(".s-main");
|
|
895
|
-
if (!container || !
|
|
1041
|
+
if (!container || !shell) return;
|
|
896
1042
|
const n = this.live.length;
|
|
897
1043
|
// A panel that hasn't drawn yet has no width to contribute, which would make
|
|
898
1044
|
// this pass's arithmetic (and any enter animation it triggers) meaningless.
|
|
899
1045
|
// Every mount schedules another pass, so simply wait for it.
|
|
900
1046
|
if (!n || this.live.some((entry) => !entry.el)) return;
|
|
901
1047
|
|
|
902
|
-
//
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
const total = body.getBoundingClientRect().width;
|
|
907
|
-
if (!total) return;
|
|
1048
|
+
// This pass measures afresh — it is the one thing that runs after a resize.
|
|
1049
|
+
this.geom = undefined;
|
|
1050
|
+
const geom = this.geometry();
|
|
1051
|
+
if (!geom) return;
|
|
908
1052
|
|
|
909
1053
|
const stacking = this.opts.stacking !== false;
|
|
910
1054
|
|
|
@@ -912,35 +1056,13 @@ export class PanelController {
|
|
|
912
1056
|
// geometry tracking the window through a 450ms transition reads as lag,
|
|
913
1057
|
// and a shell animating itself into place on load reads as a glitch.
|
|
914
1058
|
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
915
|
-
const snap = this.lastBodyW !== total;
|
|
1059
|
+
const snap = this.lastBodyW !== geom.total;
|
|
916
1060
|
if (snap) {
|
|
917
|
-
this.lastBodyW = total;
|
|
1061
|
+
this.lastBodyW = geom.total;
|
|
918
1062
|
shell.classList.add("s-shell-snap");
|
|
919
1063
|
}
|
|
920
1064
|
|
|
921
|
-
|
|
922
|
-
// either of which may be display:none on a narrow shell.
|
|
923
|
-
let chrome = 0;
|
|
924
|
-
for (const child of inner.children) {
|
|
925
|
-
if (child !== container) chrome += child.getBoundingClientRect().width;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
// The standard page is SHELL_PX wide, capped by the window; what it leaves
|
|
929
|
-
// beside the sidebar is the *standard* content area. Widths are a pure
|
|
930
|
-
// function of the window — never of what else is open — so a panel NEVER
|
|
931
|
-
// resizes because a neighbour came or went; only a window resize (the
|
|
932
|
-
// snap pass above) changes them:
|
|
933
|
-
// - "medium" fills the standard content area exactly;
|
|
934
|
-
// - "small" is half of it (minus the gutter) whenever that half is still
|
|
935
|
-
// a usable column, and the whole of it on narrower screens;
|
|
936
|
-
// - "large" ignores the standard width and takes everything the window
|
|
937
|
-
// has — which also means nothing ever fits beside it.
|
|
938
|
-
const stdRoom = Math.max(0, Math.min(SHELL_PX, total) - chrome);
|
|
939
|
-
const fullRoom = Math.max(0, total - chrome);
|
|
940
|
-
const halfW = (stdRoom - GUTTER_PX) / 2;
|
|
941
|
-
const smallW = halfW >= PAIR_MIN_PX ? halfW : stdRoom;
|
|
942
|
-
const width = (entry: PanelEntry) =>
|
|
943
|
-
entry.layout === "small" ? smallW : entry.layout === "large" ? fullRoom : stdRoom;
|
|
1065
|
+
const width = (entry: PanelEntry) => geom[entry.layout];
|
|
944
1066
|
|
|
945
1067
|
// The visible run: as many top-of-stack panels as the window fits, at the
|
|
946
1068
|
// sizes the window gives them. The top panel always shows.
|
|
@@ -949,7 +1071,7 @@ export class PanelController {
|
|
|
949
1071
|
if (stacking) {
|
|
950
1072
|
for (let i = n - 2; i >= 0; i--) {
|
|
951
1073
|
const sum = runSum + GUTTER_PX + width(this.live[i]);
|
|
952
|
-
if (sum >
|
|
1074
|
+
if (sum > geom.large) break;
|
|
953
1075
|
runSum = sum;
|
|
954
1076
|
first = i;
|
|
955
1077
|
}
|
|
@@ -961,7 +1083,7 @@ export class PanelController {
|
|
|
961
1083
|
// wider than the window. So the page is the familiar 1280px until extra
|
|
962
1084
|
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
963
1085
|
// do; with a "large" up that's the window's edges.
|
|
964
|
-
const area = Math.min(
|
|
1086
|
+
const area = Math.min(geom.large, Math.max(geom.medium, runSum));
|
|
965
1087
|
|
|
966
1088
|
for (let i = first; i < n; i++) this.live[i].width = width(this.live[i]);
|
|
967
1089
|
// Panels that have never been visible get their would-be width too, so a
|
|
@@ -975,7 +1097,7 @@ export class PanelController {
|
|
|
975
1097
|
// The consumers transition their max-width (see main.ts), so the
|
|
976
1098
|
// recentring plays along with the panel that caused it instead of
|
|
977
1099
|
// snapping.
|
|
978
|
-
shell.style.setProperty("--s-shell-w", `${chrome + area}px`);
|
|
1100
|
+
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
979
1101
|
|
|
980
1102
|
// Phase 1 — every panel's *start* state for this frame. Panels already on
|
|
981
1103
|
// screen simply move (their standing transition animates it); freshly
|
|
@@ -988,8 +1110,10 @@ export class PanelController {
|
|
|
988
1110
|
const el = entry.el!;
|
|
989
1111
|
const shown = i >= first;
|
|
990
1112
|
// Visible panels are left-aligned in the content area, a gutter apart;
|
|
991
|
-
// hidden ones park at its left edge, keeping their last width.
|
|
992
|
-
|
|
1113
|
+
// hidden ones park at its left edge, keeping their last width. Deeper
|
|
1114
|
+
// panels layer over shallower ones, each on the odd layer for its depth
|
|
1115
|
+
// (see LAYER_STEP).
|
|
1116
|
+
place(el, shown ? x : 0, entry.width, LAYER_STEP * i + 1);
|
|
993
1117
|
if (shown) x += entry.width + GUTTER_PX;
|
|
994
1118
|
el.classList.toggle("s-panel-sep", shown && i > first);
|
|
995
1119
|
// Hidden panels fade out over the left edge and, once faded, stop being
|
|
@@ -1036,10 +1160,11 @@ export class PanelController {
|
|
|
1036
1160
|
}
|
|
1037
1161
|
}
|
|
1038
1162
|
|
|
1039
|
-
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide
|
|
1040
|
-
function place(el: HTMLElement, x: number, width: number): void {
|
|
1163
|
+
/** Put a panel at rest: `x` from the region's left edge, `width` pixels wide, on layer `z`. */
|
|
1164
|
+
function place(el: HTMLElement, x: number, width: number, z: number): void {
|
|
1041
1165
|
el.style.left = `${x}px`;
|
|
1042
1166
|
el.style.width = `${width}px`;
|
|
1167
|
+
el.style.zIndex = String(z);
|
|
1043
1168
|
}
|
|
1044
1169
|
|
|
1045
1170
|
// ─── Guards ──────────────────────────────────────────────────────────────────
|