staffa 0.12.0 → 0.13.0
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 +7 -5
- package/dist/components/main.d.ts +25 -0
- package/dist/components/main.js +60 -26
- package/dist/components/panels.d.ts +75 -21
- package/dist/components/panels.js +102 -77
- package/dist/staffa.esm.js +1 -1
- package/package.json +3 -3
- package/skill/MainOptions.md +29 -0
- package/skill/Panel.md +41 -5
- package/skill/PanelStack.md +5 -0
- package/skill/SKILL.md +7 -5
- package/src/components/main.ts +86 -28
- package/src/components/panels.ts +148 -82
|
@@ -112,7 +112,7 @@ function matchRoute(r, segments) {
|
|
|
112
112
|
/**
|
|
113
113
|
* The one duration every bit of shell motion shares: the enter/exit fades, the
|
|
114
114
|
* `left` moves of columns shifting sideways, the ensemble-width transition the
|
|
115
|
-
*
|
|
115
|
+
* body row follows (see `--s-shell-w` in main.ts), and the narrow-screen nav
|
|
116
116
|
* panel's slide. Published as the `--s-panel-ms` custom property below, so CSS
|
|
117
117
|
* and JS can't drift apart.
|
|
118
118
|
*
|
|
@@ -123,12 +123,6 @@ function matchRoute(r, segments) {
|
|
|
123
123
|
const PAGE_MS = 250;
|
|
124
124
|
/** How long a freshly pushed `loading` panel holds its enter animation. */
|
|
125
125
|
const LOADING_HOLD_MS = 300;
|
|
126
|
-
/**
|
|
127
|
-
* The standard page width: sidebar plus content area, capped by the window.
|
|
128
|
-
* `"full"` fills the content-area part of this exactly; only a `"screen"`
|
|
129
|
-
* page makes the shell grow past it.
|
|
130
|
-
*/
|
|
131
|
-
const SHELL_PX = 1280;
|
|
132
126
|
/** Don't pair smalls when half the content area would be narrower than this. */
|
|
133
127
|
const PAIR_MIN_PX = 360;
|
|
134
128
|
/**
|
|
@@ -151,7 +145,12 @@ A.insertGlobalCss({
|
|
|
151
145
|
// The region paints the panel's sheen over its own box, and every panel shows
|
|
152
146
|
// a slice of that same gradient (see `.s-panel` below), so the columns and
|
|
153
147
|
// the ground beside them are one continuous surface.
|
|
154
|
-
|
|
148
|
+
// `overflow:clip`, not `hidden`: a hidden box is still a scroll container,
|
|
149
|
+
// and anything that ever scrolls it — find-in-page reaching for text in a
|
|
150
|
+
// parked column, an in-page anchor, an extension — shifts every column
|
|
151
|
+
// sideways, permanently, because nothing here would ever scroll it back.
|
|
152
|
+
// `clip` clips without being scrollable at all, closing the whole class.
|
|
153
|
+
".s-panels": "flex:1 min-width:0 min-height:0 position:relative overflow:clip isolation:isolate " +
|
|
155
154
|
SURFACE_SHEEN,
|
|
156
155
|
".s-panel": {
|
|
157
156
|
// A panel rests at a plain `left` offset and carries no transform: a
|
|
@@ -245,13 +244,18 @@ A.insertGlobalCss({
|
|
|
245
244
|
// the weight change alone is ambiguous in a short crumb, the colour alone
|
|
246
245
|
// too subtle. No padding of its own — the first crumb has to start on the
|
|
247
246
|
// same pixel as the app's name above it, and the gap below spaces the row.
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
247
|
+
// The flex is how a tight row is shared out. Every crumb grows from the
|
|
248
|
+
// same 4rem basis in equal shares, freezing at its own text
|
|
249
|
+
// (`max-width:max-content`) — so with room to spare every title shows in
|
|
250
|
+
// full, and under pressure it is the *longest* crumbs that give way
|
|
251
|
+
// first, equalising downward while short ones keep every character. No
|
|
252
|
+
// crumb drops below min(its text, 4rem) though: `flex-shrink:0`, so past
|
|
253
|
+
// that point the row overflows and the strip scrolls — which is what
|
|
254
|
+
// keeps a deep stack on a phone readable. (Crumbs allowed to shrink
|
|
255
|
+
// would ellipsise to a row of stubs instead, and the stack would never
|
|
256
|
+
// scroll.)
|
|
257
|
+
"&": "flex: 1 0 4rem; font-size:0.85em line-height:1.5 fg:$s-muted text-decoration:none " +
|
|
258
|
+
"white-space:nowrap max-width:max-content overflow:hidden text-overflow:ellipsis " +
|
|
255
259
|
"transition: color 0.12s;",
|
|
256
260
|
"&.s-crumb-on": "font-weight:600 fg:$s-text",
|
|
257
261
|
// The same hover treatment as a menu item. The panel you are on is a plain
|
|
@@ -349,8 +353,8 @@ export class PanelStackController {
|
|
|
349
353
|
containerEl;
|
|
350
354
|
/** The shell's measurements, shared by everything drawn since they were taken. */
|
|
351
355
|
geom;
|
|
352
|
-
/** The
|
|
353
|
-
|
|
356
|
+
/** The measurements the last layout ran on; a change in them → snap. */
|
|
357
|
+
lastGeom;
|
|
354
358
|
layoutQueued = false;
|
|
355
359
|
timers = new Set();
|
|
356
360
|
/** The arrangement the navigation in flight is heading for; see {@link intended}. */
|
|
@@ -620,9 +624,12 @@ export class PanelStackController {
|
|
|
620
624
|
maxWidth: "full",
|
|
621
625
|
width: 0,
|
|
622
626
|
};
|
|
623
|
-
// `close` closes *this* panel, current or not
|
|
624
|
-
//
|
|
625
|
-
//
|
|
627
|
+
// `close` closes *this* panel, current or not, and `open` navigates
|
|
628
|
+
// *from* it, through the very implementation a link click uses (see
|
|
629
|
+
// `navigate`). Both resolve the panel's place in the stack at call
|
|
630
|
+
// time, so they keep working after a splice has moved it; an `open`
|
|
631
|
+
// from a panel that has since closed falls back to a derived stack,
|
|
632
|
+
// like a link from nowhere.
|
|
626
633
|
//
|
|
627
634
|
// `visible` starts at what the panel's place implies: shown when it sits
|
|
628
635
|
// at or before the current panel (a pushed panel always does), hidden when
|
|
@@ -636,6 +643,7 @@ export class PanelStackController {
|
|
|
636
643
|
visible,
|
|
637
644
|
pinned: pinned || undefined,
|
|
638
645
|
close: () => this.closePath(entry.path),
|
|
646
|
+
open: (href, how) => this.navigate(href, { from: entry.path, how }),
|
|
639
647
|
});
|
|
640
648
|
return entry;
|
|
641
649
|
}
|
|
@@ -865,17 +873,31 @@ export class PanelStackController {
|
|
|
865
873
|
});
|
|
866
874
|
}
|
|
867
875
|
/**
|
|
868
|
-
* Navigate to `href
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
* the
|
|
876
|
+
* Navigate to `href` — the one implementation behind a link click,
|
|
877
|
+
* {@link Panel.open} and the stack's own methods, so none of them can
|
|
878
|
+
* behave differently.
|
|
879
|
+
*
|
|
880
|
+
* `from` is the path of the panel the navigation starts from — the one
|
|
881
|
+
* the link lives in — or absent when it has none: a nav item, or a call
|
|
882
|
+
* that means the whole stack, which is then built instead (see
|
|
883
|
+
* {@link deriveStack}), or taken outright from `beneath`, for callers
|
|
884
|
+
* that know it.
|
|
885
|
+
*
|
|
886
|
+
* `how` is the link's `data-panel` attribute (or the caller's word for
|
|
887
|
+
* it): absent — like a link without the attribute — it is the shell's
|
|
888
|
+
* `linkNavigation` default, an unrecognised value is a push on top of
|
|
889
|
+
* `from`, `"replace"` swaps `from` out rather than stacking on it, and
|
|
890
|
+
* `"open"` drops `from` altogether so the target arrives with its own
|
|
891
|
+
* stack, the way a nav item's link does.
|
|
873
892
|
*
|
|
874
893
|
* Resolves the way every {@link PanelStack} method does: `true` once the
|
|
875
894
|
* navigation lands, `false` when it doesn't (already there counts as
|
|
876
895
|
* landed).
|
|
877
896
|
*/
|
|
878
|
-
navigate(href,
|
|
897
|
+
navigate(href, { from, how, beneath } = {}) {
|
|
898
|
+
const mode = how ?? this.opts.linkNavigation;
|
|
899
|
+
const origin = mode === "open" ? null : from ?? null;
|
|
900
|
+
const replace = mode === "replace";
|
|
879
901
|
return A.peek(() => {
|
|
880
902
|
let url;
|
|
881
903
|
try {
|
|
@@ -933,43 +955,36 @@ export class PanelStackController {
|
|
|
933
955
|
pushPath(path, replace) {
|
|
934
956
|
return A.peek(() => {
|
|
935
957
|
const arr = this.intended();
|
|
936
|
-
return this.navigate(path, arr.stack[arr.focus]
|
|
958
|
+
return this.navigate(path, { from: arr.stack[arr.focus], how: replace ? "replace" : "push" });
|
|
937
959
|
});
|
|
938
960
|
}
|
|
939
961
|
// ── Link interception ──────────────────────────────────────────────────
|
|
940
962
|
/**
|
|
941
963
|
* Link handling through `route.interceptLinks()`, whose handler hook hands us
|
|
942
|
-
* the anchor so we can decide what the click *means
|
|
943
|
-
* `.s-panel` (which decides what the click truncates), the `data-panel`
|
|
944
|
-
* attribute, and return-to-an-open-panel semantics. The exclusion rules
|
|
964
|
+
* the anchor so we can decide what the click *means*. The exclusion rules
|
|
945
965
|
* (targets, downloads, modified clicks, external URLs) live in Aberdeen; the
|
|
946
966
|
* close guards run in `checkChange` when our navigation reaches the router.
|
|
947
967
|
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
952
|
-
*
|
|
953
|
-
* an unrecognised value is a `push`.
|
|
968
|
+
* A link inside a panel is that panel's {@link Panel.open}, the `data-panel`
|
|
969
|
+
* attribute as its `how` (see {@link navigate}, the shared implementation).
|
|
970
|
+
* A link that isn't inside any panel — a nav item, one in a dialog — has no
|
|
971
|
+
* panel to build on, so it replaces the stack as a whole, exactly as a cold
|
|
972
|
+
* link to the same URL would open it.
|
|
954
973
|
*/
|
|
955
974
|
interceptLinks() {
|
|
956
975
|
route.interceptLinks((url, anchor) => {
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
origin = this.$state.live[this.$state.focus]?.path ?? null;
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
void this.navigate(url.href, origin, mode === "replace");
|
|
976
|
+
const how = anchor.getAttribute("data-panel") ?? undefined;
|
|
977
|
+
// The panel the link lives in: the enclosing `.s-panel`, or — for the
|
|
978
|
+
// current panel's actions, promoted into the top bar on a narrow shell
|
|
979
|
+
// (see main.ts), outside every `.s-panel` — the current panel, whose
|
|
980
|
+
// own chrome they remain at every width.
|
|
981
|
+
const panelEl = anchor.closest(".s-panel");
|
|
982
|
+
const entry = panelEl
|
|
983
|
+
? this.$state.live.find((e) => e.el === panelEl)
|
|
984
|
+
: anchor.closest(".s-panel-origin")
|
|
985
|
+
? this.$state.live[this.$state.focus]
|
|
986
|
+
: undefined;
|
|
987
|
+
void this.navigate(url.href, { from: entry?.path, how });
|
|
973
988
|
return true;
|
|
974
989
|
});
|
|
975
990
|
}
|
|
@@ -993,7 +1008,7 @@ export class PanelStackController {
|
|
|
993
1008
|
return this.pushPath(path, true);
|
|
994
1009
|
}
|
|
995
1010
|
openPanelStack(path, beneath) {
|
|
996
|
-
return this.navigate(path,
|
|
1011
|
+
return this.navigate(path, { how: "open", beneath });
|
|
997
1012
|
}
|
|
998
1013
|
closePanel(path) {
|
|
999
1014
|
return A.peek(() => {
|
|
@@ -1018,6 +1033,17 @@ export class PanelStackController {
|
|
|
1018
1033
|
setLinkNavigation(mode) {
|
|
1019
1034
|
this.opts.linkNavigation = mode;
|
|
1020
1035
|
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Adopt a changed `fullWidth`: one layout pass, nothing redrawn. A changed
|
|
1038
|
+
* `navWidth` needs no counterpart — resizing the sidebar resizes the column
|
|
1039
|
+
* region, which the layout engine is already observing.
|
|
1040
|
+
*/
|
|
1041
|
+
setFullWidth(px) {
|
|
1042
|
+
if (this.opts.fullWidth === px)
|
|
1043
|
+
return;
|
|
1044
|
+
this.opts.fullWidth = px;
|
|
1045
|
+
this.scheduleLayout();
|
|
1046
|
+
}
|
|
1021
1047
|
/**
|
|
1022
1048
|
* The breadcrumb stack, drawn by `main()` into the top bar: every open
|
|
1023
1049
|
* panel, oldest first, the ones on screen right now in bold, pinned ones
|
|
@@ -1345,25 +1371,21 @@ export class PanelStackController {
|
|
|
1345
1371
|
if (child !== container)
|
|
1346
1372
|
chrome += child.getBoundingClientRect().width;
|
|
1347
1373
|
}
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
// snap pass in
|
|
1374
|
+
// What the window has beside the sidebar, and within that the *standard*
|
|
1375
|
+
// content area: the width the app gave a "full" panel, or all there is
|
|
1376
|
+
// when the window has less. Widths are a pure function of the window —
|
|
1377
|
+
// never of what else is open — so a panel NEVER resizes because a
|
|
1378
|
+
// neighbour came or went; only a window resize (the snap pass in
|
|
1379
|
+
// `layout`) changes them:
|
|
1353
1380
|
// - "full" fills the standard content area exactly;
|
|
1354
1381
|
// - "half" is half of it whenever that half is still a usable column, and
|
|
1355
1382
|
// the whole of it on narrower screens;
|
|
1356
1383
|
// - "screen" ignores the standard width and takes everything the window
|
|
1357
1384
|
// has — which also means nothing ever fits beside it.
|
|
1358
|
-
const
|
|
1385
|
+
const screen = Math.max(0, total - chrome);
|
|
1386
|
+
const full = Math.min(this.opts.fullWidth, screen);
|
|
1359
1387
|
const halved = full / 2;
|
|
1360
|
-
return {
|
|
1361
|
-
total,
|
|
1362
|
-
chrome,
|
|
1363
|
-
half: halved >= PAIR_MIN_PX ? halved : full,
|
|
1364
|
-
full,
|
|
1365
|
-
screen: Math.max(0, total - chrome),
|
|
1366
|
-
};
|
|
1388
|
+
return { total, chrome, half: halved >= PAIR_MIN_PX ? halved : full, full, screen };
|
|
1367
1389
|
}
|
|
1368
1390
|
/**
|
|
1369
1391
|
* The measurements this pass runs on. Taken once per layout pass and per
|
|
@@ -1407,13 +1429,16 @@ export class PanelStackController {
|
|
|
1407
1429
|
if (!geom)
|
|
1408
1430
|
return;
|
|
1409
1431
|
const stacking = this.opts.columns !== "single";
|
|
1410
|
-
// A window resize
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1432
|
+
// A window resize — or the app resizing the shell itself, by changing
|
|
1433
|
+
// `navWidth` or `fullWidth` — must be adopted instantly: geometry tracking
|
|
1434
|
+
// the window through a 450ms transition reads as lag, and a shell
|
|
1435
|
+
// animating itself into place on its first pass reads as a glitch. Only
|
|
1436
|
+
// what a *panel* did is worth animating, and none of those three are.
|
|
1413
1437
|
// `.s-shell-snap` suppresses every standing transition for this one pass.
|
|
1414
|
-
const
|
|
1438
|
+
const was = this.lastGeom;
|
|
1439
|
+
const snap = was == null || was.total !== geom.total || was.chrome !== geom.chrome || was.full !== geom.full;
|
|
1415
1440
|
if (snap) {
|
|
1416
|
-
this.
|
|
1441
|
+
this.lastGeom = geom;
|
|
1417
1442
|
shell.classList.add("s-shell-snap");
|
|
1418
1443
|
}
|
|
1419
1444
|
const width = (entry) => geom[entry.maxWidth];
|
|
@@ -1435,7 +1460,7 @@ export class PanelStackController {
|
|
|
1435
1460
|
// The content area holds the run, but is never smaller than the standard
|
|
1436
1461
|
// panel (a lone small leaves its other half open — which is exactly where
|
|
1437
1462
|
// the next small lands, without anything on screen moving) and never
|
|
1438
|
-
// wider than the window. So the
|
|
1463
|
+
// wider than the window. So the page holds its standard width until extra
|
|
1439
1464
|
// columns genuinely fit, and stretches — centred — to hold the ones that
|
|
1440
1465
|
// do; with a "screen" up that's the window's edges.
|
|
1441
1466
|
const area = Math.min(geom.screen, Math.max(geom.full, runSum));
|
|
@@ -1447,11 +1472,11 @@ export class PanelStackController {
|
|
|
1447
1472
|
if (!entry.width)
|
|
1448
1473
|
entry.width = width(entry);
|
|
1449
1474
|
}
|
|
1450
|
-
// The
|
|
1451
|
-
//
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1475
|
+
// The body row caps itself to the ensemble width, keeping the columns
|
|
1476
|
+
// centred however far the area stretches, and transitions its max-width
|
|
1477
|
+
// (see main.ts) so the recentring plays along with the panel that caused
|
|
1478
|
+
// it. The bars above and below don't follow — they hold at the standard
|
|
1479
|
+
// page width (also main.ts).
|
|
1455
1480
|
shell.style.setProperty("--s-shell-w", `${geom.chrome + area}px`);
|
|
1456
1481
|
// Phase 1 — every panel's *start* state for this frame. Panels already on
|
|
1457
1482
|
// screen simply move (their standing transition animates it); freshly
|