@xingwangzhe/stalux 1.46.1 → 1.46.5
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/package.json +3 -2
- package/src/components/stalux/archives/archivesList.astro +1 -1
- package/src/components/stalux/categories/categoriesList.astro +6 -1
- package/src/components/stalux/common/Weather.astro +1 -1
- package/src/components/stalux/common/date.astro +1 -1
- package/src/components/stalux/footer/FooterBeian.astro +2 -0
- package/src/components/stalux/layout/head.astro +1 -1
- package/src/components/stalux/links/linkList.astro +1 -1
- package/src/components/stalux/posts/postContent.astro +5 -1
- package/src/components/stalux/posts/postList.astro +6 -1
- package/src/components/stalux/posts/postRight.astro +11 -4
- package/src/components/stalux/words/wordList.astro +11 -8
- package/src/index.ts +1 -1
- package/src/internal/compact-styles.ts +1 -1
- package/src/internal/font-slices.ts +3 -3
- package/src/internal/page-output-cache.ts +6 -7
- package/src/internal/social-card-renderer.ts +3 -5
- package/src/pages/og/[image].[format].ts +1 -1
- package/src/schemas/config.ts +5 -5
- package/src/scripts/analytics-runtime.ts +1 -1
- package/src/scripts/archive-descriptions.ts +1 -1
- package/src/scripts/back-to-top.ts +7 -6
- package/src/scripts/background.ts +2 -2
- package/src/scripts/countdown.ts +1 -1
- package/src/scripts/date-time.ts +1 -1
- package/src/scripts/list-reveal.ts +214 -40
- package/src/scripts/module-motion.ts +5 -3
- package/src/scripts/navigation-font.ts +4 -1
- package/src/scripts/navigation.ts +1 -1
- package/src/scripts/page-runtime.ts +6 -4
- package/src/scripts/random-posts.ts +2 -2
- package/src/scripts/route-motion.ts +67 -56
- package/src/scripts/route-out.ts +12 -14
- package/src/scripts/runtime-counter.ts +1 -1
- package/src/scripts/shell-hydration.ts +60 -0
- package/src/scripts/smooth-scroll.ts +4 -4
- package/src/scripts/swup-internals.d.ts +31 -0
- package/src/scripts/swup-router.ts +53 -11
- package/src/scripts/waline.ts +1 -1
- package/src/scripts/weather.ts +148 -82
- package/src/scripts/webmcp.ts +24 -20
- package/src/utils/ai-discovery.ts +1 -1
- package/src/utils/cc.ts +2 -2
- package/src/utils/collections-stats.ts +4 -2
- package/src/utils/dayjs.ts +2 -2
- package/src/utils/i18n.ts +2 -2
- package/src/utils/seo-document.ts +4 -4
- package/src/utils/taxonomy.ts +10 -12
|
@@ -4,13 +4,17 @@ const ITEM = ":scope > [data-stalux-reveal-item]";
|
|
|
4
4
|
// never a queue long enough to keep the reader waiting for the last item.
|
|
5
5
|
const MAX_CONTAINERS = 48;
|
|
6
6
|
const MAX_ITEMS = 16;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// The whole unroll budget is lead-in + max delay + row duration. At the previous
|
|
8
|
+
// 150 + 400 + 380 the tail alone ran 930ms after the swap, which readers report
|
|
9
|
+
// as the page still moving a second later. Every number here is half of that, so
|
|
10
|
+
// the same sheet reads in one motion without the wait.
|
|
11
|
+
const STAGGER_MS = 22;
|
|
12
|
+
const MAX_DELAY_MS = 200;
|
|
13
|
+
const DURATION_MS = 190;
|
|
10
14
|
// When a route cue is already moving the page body, the sheet waits for it to
|
|
11
15
|
// mostly settle before unrolling. Starting both at once makes the rows wipe
|
|
12
16
|
// open while the whole page is still semi-transparent, which reads as a smudge.
|
|
13
|
-
const ROUTE_LEAD_IN_MS =
|
|
17
|
+
const ROUTE_LEAD_IN_MS = 75;
|
|
14
18
|
// Strong ease-out: the row arrives fast and settles softly. The built-in
|
|
15
19
|
// ease-out is too weak to read at this size.
|
|
16
20
|
const EASING = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
@@ -20,6 +24,154 @@ const EASING = "cubic-bezier(0.23, 1, 0.32, 1)";
|
|
|
20
24
|
const BLEED = "-10px";
|
|
21
25
|
const UNROLL_START = `inset(${BLEED} ${BLEED} 100% ${BLEED})`;
|
|
22
26
|
const UNROLL_END = `inset(${BLEED} ${BLEED} ${BLEED} ${BLEED})`;
|
|
27
|
+
const UNROLL_KEYFRAMES: Keyframe[] = [
|
|
28
|
+
{
|
|
29
|
+
clipPath: UNROLL_START,
|
|
30
|
+
opacity: 0.4,
|
|
31
|
+
transform: "translate3d(0, -10px, 0)",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
clipPath: UNROLL_END,
|
|
35
|
+
opacity: 1,
|
|
36
|
+
transform: "translate3d(0, 0, 0)",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
// A swap can leave a row waiting at the start frame until it is scrolled into
|
|
40
|
+
// view. Keep the bookkeeping bounded; a runaway map would pin removed nodes.
|
|
41
|
+
const MAX_HELD = MAX_CONTAINERS * MAX_ITEMS;
|
|
42
|
+
// A held row that never reaches the viewport would sit clipped forever if the
|
|
43
|
+
// visit were abandoned before the observer ever ran. Releasing it after this
|
|
44
|
+
// long is safe: an unheld row falls back to the original path, which starts the
|
|
45
|
+
// wipe on its own without a flash.
|
|
46
|
+
const HOLD_TIMEOUT_MS = 5000;
|
|
47
|
+
|
|
48
|
+
interface Held {
|
|
49
|
+
animation: Animation;
|
|
50
|
+
stagger: number;
|
|
51
|
+
timer: ReturnType<typeof setTimeout>;
|
|
52
|
+
}
|
|
53
|
+
// Rows and groups are only ever queried, measured and animated, all of which
|
|
54
|
+
// Element already provides. Typing them as HTMLElement forced a cast on the
|
|
55
|
+
// observer's target for no gain.
|
|
56
|
+
const held = new Map<Element, Held>();
|
|
57
|
+
|
|
58
|
+
function canReveal(): boolean {
|
|
59
|
+
if (typeof IntersectionObserver !== "function") return false;
|
|
60
|
+
if (typeof document === "undefined") return false;
|
|
61
|
+
if (document.hidden) return false;
|
|
62
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
|
|
63
|
+
// Returning to a history entry restores what the reader already saw.
|
|
64
|
+
return document.documentElement.dataset.staluxVtPreset !== "restore";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function groups(): Element[] {
|
|
68
|
+
return [...document.querySelectorAll<Element>(CONTAINER)]
|
|
69
|
+
.filter(
|
|
70
|
+
(element) =>
|
|
71
|
+
// A retained shell kept its identity, so its list was already
|
|
72
|
+
// read: cueing it again would be a second animation. Contents
|
|
73
|
+
// that shell-hydration just replaced are new content and are
|
|
74
|
+
// explicitly marked as such.
|
|
75
|
+
element.hasAttribute("data-stalux-shell-fresh") ||
|
|
76
|
+
!element.closest("[data-swup-persist]"),
|
|
77
|
+
)
|
|
78
|
+
.slice(0, MAX_CONTAINERS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rows(group: Element): Element[] {
|
|
82
|
+
return [...group.querySelectorAll<Element>(ITEM)].slice(0, MAX_ITEMS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Only a group the reader can already see is worth holding.
|
|
87
|
+
*
|
|
88
|
+
* Every held row costs a live effect on the compositor. An archive page carries
|
|
89
|
+
* one group per month, so holding all of them at swap time built hundreds of
|
|
90
|
+
* paused effects inside the swap frame — a 200-400ms task that dropped the frame
|
|
91
|
+
* the entry cue needed. Groups further down the page keep the authored visible
|
|
92
|
+
* state and start their wipe when they scroll in, which never flashed anyway.
|
|
93
|
+
*/
|
|
94
|
+
function nearViewport(group: Element): boolean {
|
|
95
|
+
const rect = group.getBoundingClientRect();
|
|
96
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
97
|
+
const margin = window.innerHeight * 0.5;
|
|
98
|
+
return rect.top < window.innerHeight + margin && rect.bottom > -margin;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function staggerOf(index: number): number {
|
|
102
|
+
return Math.min(index * STAGGER_MS, MAX_DELAY_MS);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function leadIn(): number {
|
|
106
|
+
// A route cue leaves its preset on the document element while it runs.
|
|
107
|
+
return document.documentElement.dataset.staluxVtPreset ? ROUTE_LEAD_IN_MS : 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Hold every incoming row on the wipe's first frame the moment the router has
|
|
112
|
+
* its new document, before the browser paints it.
|
|
113
|
+
*
|
|
114
|
+
* Without this the swap paints the finished list for one frame, the observer
|
|
115
|
+
* fires a frame later, and the rows appear to flash, vanish, then unroll. A
|
|
116
|
+
* paused effect still paints its `backwards` fill, so priming here makes the
|
|
117
|
+
* first painted frame the *start* of the wipe: the reader only ever sees the
|
|
118
|
+
* sheet hidden, then unrolling.
|
|
119
|
+
*/
|
|
120
|
+
export function primeListReveal(): void {
|
|
121
|
+
if (!canReveal()) return;
|
|
122
|
+
// Rows from the document that was just replaced can never be revealed
|
|
123
|
+
// again; drop their paused effects instead of pinning detached nodes.
|
|
124
|
+
for (const [row, entry] of held) {
|
|
125
|
+
if (row.isConnected) continue;
|
|
126
|
+
held.delete(row);
|
|
127
|
+
clearTimeout(entry.timer);
|
|
128
|
+
entry.animation.cancel();
|
|
129
|
+
}
|
|
130
|
+
for (const group of groups()) {
|
|
131
|
+
// A group that is not laid out (the desktop tag cloud hides the mobile
|
|
132
|
+
// card groups, and vice versa) could never play its effect, and a group
|
|
133
|
+
// that is still off-screen must not add effects to the swap frame.
|
|
134
|
+
if (!nearViewport(group)) continue;
|
|
135
|
+
for (const [index, row] of rows(group).entries()) {
|
|
136
|
+
if (held.has(row) || held.size >= MAX_HELD) continue;
|
|
137
|
+
if (typeof row.animate !== "function") continue;
|
|
138
|
+
try {
|
|
139
|
+
const animation = row.animate(UNROLL_KEYFRAMES, {
|
|
140
|
+
duration: DURATION_MS,
|
|
141
|
+
delay: 0,
|
|
142
|
+
easing: EASING,
|
|
143
|
+
// The paused effect still paints this first frame, which is
|
|
144
|
+
// what removes the flash.
|
|
145
|
+
fill: "backwards",
|
|
146
|
+
});
|
|
147
|
+
animation.pause();
|
|
148
|
+
// A paused effect only ever settles by rejecting when the hold is
|
|
149
|
+
// released, and nothing awaits it yet: without a handler that is an
|
|
150
|
+
// unhandled rejection in the console. `reveal` attaches the real
|
|
151
|
+
// handler when it takes the effect over.
|
|
152
|
+
void animation.finished.catch(() => undefined);
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
// The visit was abandoned: release the row so the authored
|
|
155
|
+
// visible state returns rather than staying clipped.
|
|
156
|
+
if (!held.has(row)) return;
|
|
157
|
+
held.delete(row);
|
|
158
|
+
animation.cancel();
|
|
159
|
+
}, HOLD_TIMEOUT_MS);
|
|
160
|
+
held.set(row, { animation, stagger: staggerOf(index), timer });
|
|
161
|
+
} catch {
|
|
162
|
+
/* An unsupported effect leaves the authored visible row. */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function cancelHeld(): void {
|
|
169
|
+
for (const [row, entry] of held) {
|
|
170
|
+
held.delete(row);
|
|
171
|
+
clearTimeout(entry.timer);
|
|
172
|
+
entry.animation.cancel();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
23
175
|
|
|
24
176
|
/**
|
|
25
177
|
* Rows unroll into place like paper: each one wipes open from its top edge
|
|
@@ -32,50 +184,50 @@ const UNROLL_END = `inset(${BLEED} ${BLEED} ${BLEED} ${BLEED})`;
|
|
|
32
184
|
* clipped state can outlive the animation.
|
|
33
185
|
*/
|
|
34
186
|
export function mountListReveal(): (() => void) | undefined {
|
|
35
|
-
if (
|
|
187
|
+
if (!canReveal()) return;
|
|
36
188
|
const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
37
|
-
|
|
38
|
-
// Returning to a history entry restores what the reader already saw.
|
|
39
|
-
if (document.documentElement.dataset.staluxVtPreset === "restore") return;
|
|
40
|
-
const containers = [...document.querySelectorAll<HTMLElement>(CONTAINER)]
|
|
41
|
-
.filter((element) => !element.closest("[data-swup-persist]"))
|
|
42
|
-
.slice(0, MAX_CONTAINERS);
|
|
189
|
+
const containers = groups();
|
|
43
190
|
if (!containers.length) return;
|
|
44
|
-
// A route cue leaves its preset on the document element while it runs.
|
|
45
|
-
const leadIn = document.documentElement.dataset.staluxVtPreset ? ROUTE_LEAD_IN_MS : 0;
|
|
46
191
|
|
|
47
192
|
const active = new Set<Animation>();
|
|
48
|
-
|
|
193
|
+
// Disposal runs on every same-document remount (page-runtime remounts on
|
|
194
|
+
// page-load). Releasing the held rows there would paint the finished list
|
|
195
|
+
// for a frame and clip it again on the next mount, which is the very flash
|
|
196
|
+
// this file removes. Held rows are released by the observer, by the timeout
|
|
197
|
+
// or by a reduced-motion switch instead.
|
|
198
|
+
const cancelActive = () => {
|
|
49
199
|
for (const animation of active) animation.cancel();
|
|
50
200
|
active.clear();
|
|
51
201
|
};
|
|
52
|
-
const reveal = (container:
|
|
202
|
+
const reveal = (container: Element) => {
|
|
53
203
|
if (document.hidden || preference.matches) return;
|
|
54
|
-
const
|
|
55
|
-
for (const [index,
|
|
56
|
-
if (typeof
|
|
204
|
+
const lead = leadIn();
|
|
205
|
+
for (const [index, row] of rows(container).entries()) {
|
|
206
|
+
if (typeof row.animate !== "function") continue;
|
|
207
|
+
// Prefer the effect created during the swap: it already owns the
|
|
208
|
+
// start frame, so starting it here never shows a finished row.
|
|
209
|
+
const primed = held.get(row);
|
|
210
|
+
if (primed) {
|
|
211
|
+
held.delete(row);
|
|
212
|
+
clearTimeout(primed.timer);
|
|
213
|
+
}
|
|
57
214
|
try {
|
|
58
|
-
const animation =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
clipPath: UNROLL_START,
|
|
62
|
-
opacity: 0.4,
|
|
63
|
-
transform: "translate3d(0, -10px, 0)",
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
clipPath: UNROLL_END,
|
|
67
|
-
opacity: 1,
|
|
68
|
-
transform: "translate3d(0, 0, 0)",
|
|
69
|
-
},
|
|
70
|
-
],
|
|
71
|
-
{
|
|
215
|
+
const animation =
|
|
216
|
+
primed?.animation ??
|
|
217
|
+
row.animate(UNROLL_KEYFRAMES, {
|
|
72
218
|
duration: DURATION_MS,
|
|
73
|
-
delay:
|
|
219
|
+
delay: lead + staggerOf(index),
|
|
74
220
|
easing: EASING,
|
|
75
221
|
// Hidden during the stagger delay instead of popping in.
|
|
76
222
|
fill: "backwards",
|
|
77
|
-
}
|
|
78
|
-
)
|
|
223
|
+
});
|
|
224
|
+
if (primed) {
|
|
225
|
+
// The stagger is counted from now, not from the swap.
|
|
226
|
+
primed.animation.effect?.updateTiming({
|
|
227
|
+
delay: lead + primed.stagger,
|
|
228
|
+
});
|
|
229
|
+
primed.animation.play();
|
|
230
|
+
}
|
|
79
231
|
active.add(animation);
|
|
80
232
|
const release = () => {
|
|
81
233
|
active.delete(animation);
|
|
@@ -84,7 +236,8 @@ export function mountListReveal(): (() => void) | undefined {
|
|
|
84
236
|
};
|
|
85
237
|
void animation.finished.then(release, release);
|
|
86
238
|
} catch {
|
|
87
|
-
|
|
239
|
+
// A paused row would stay clipped forever otherwise.
|
|
240
|
+
primed?.animation.cancel();
|
|
88
241
|
}
|
|
89
242
|
}
|
|
90
243
|
};
|
|
@@ -93,16 +246,37 @@ export function mountListReveal(): (() => void) | undefined {
|
|
|
93
246
|
for (const entry of entries) {
|
|
94
247
|
if (!entry.isIntersecting) continue;
|
|
95
248
|
observer.unobserve(entry.target);
|
|
96
|
-
reveal(entry.target
|
|
249
|
+
reveal(entry.target);
|
|
97
250
|
}
|
|
98
251
|
},
|
|
99
252
|
{ threshold: 0.05 },
|
|
100
253
|
);
|
|
101
254
|
for (const container of containers) observer.observe(container);
|
|
102
|
-
|
|
255
|
+
const stopMotion = () => {
|
|
256
|
+
cancelActive();
|
|
257
|
+
// The reader asked for less motion: nothing may stay clipped.
|
|
258
|
+
cancelHeld();
|
|
259
|
+
};
|
|
260
|
+
preference.addEventListener("change", stopMotion);
|
|
103
261
|
return () => {
|
|
104
262
|
observer.disconnect();
|
|
105
|
-
preference.removeEventListener("change",
|
|
106
|
-
|
|
263
|
+
preference.removeEventListener("change", stopMotion);
|
|
264
|
+
cancelActive();
|
|
107
265
|
};
|
|
108
266
|
}
|
|
267
|
+
|
|
268
|
+
// The router dispatches this synchronously after replacing the main node and
|
|
269
|
+
// before the browser paints, which is the only moment where the incoming rows
|
|
270
|
+
// can be held without a visible flash. Registered once per document even when
|
|
271
|
+
// both the shared layout and the article layout import this module.
|
|
272
|
+
declare global {
|
|
273
|
+
interface Window {
|
|
274
|
+
__staluxListRevealPrime?: boolean;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (typeof document !== "undefined" && typeof window !== "undefined") {
|
|
278
|
+
if (!window.__staluxListRevealPrime) {
|
|
279
|
+
window.__staluxListRevealPrime = true;
|
|
280
|
+
document.addEventListener("astro:after-swap", primeListReveal);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -11,7 +11,7 @@ export function mountModuleMotion(): (() => void) | undefined {
|
|
|
11
11
|
typeof IntersectionObserver !== "function"
|
|
12
12
|
)
|
|
13
13
|
return;
|
|
14
|
-
const elements = [...document.querySelectorAll<
|
|
14
|
+
const elements = [...document.querySelectorAll<Element>(SELECTOR)].slice(0, MAX_REGIONS);
|
|
15
15
|
if (!elements.length) return;
|
|
16
16
|
const active = new Set<Animation>();
|
|
17
17
|
const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
@@ -23,7 +23,9 @@ export function mountModuleMotion(): (() => void) | undefined {
|
|
|
23
23
|
(entries) => {
|
|
24
24
|
for (const entry of entries) {
|
|
25
25
|
if (!entry.isIntersecting) continue;
|
|
26
|
-
|
|
26
|
+
// Element already covers everything used below (closest, animate),
|
|
27
|
+
// so the observer's target needs no assertion.
|
|
28
|
+
const element = entry.target;
|
|
27
29
|
observer.unobserve(element);
|
|
28
30
|
// The router owns a single bounded lead region. Do not layer an
|
|
29
31
|
// observer entry over that motion or make long comments slide.
|
|
@@ -44,7 +46,7 @@ export function mountModuleMotion(): (() => void) | undefined {
|
|
|
44
46
|
const animation = element.animate(
|
|
45
47
|
{ opacity: [0.88, 1] },
|
|
46
48
|
{
|
|
47
|
-
duration:
|
|
49
|
+
duration: 70,
|
|
48
50
|
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
49
51
|
},
|
|
50
52
|
);
|
|
@@ -16,7 +16,10 @@ function warmFont(href: string): Promise<FontFace> {
|
|
|
16
16
|
});
|
|
17
17
|
const ready = face.load();
|
|
18
18
|
fonts.set(href, { face, ready });
|
|
19
|
-
if (fonts.size > MAX_FONTS)
|
|
19
|
+
if (fonts.size > MAX_FONTS) {
|
|
20
|
+
const oldest = fonts.keys().next();
|
|
21
|
+
if (!oldest.done) fonts.delete(oldest.value);
|
|
22
|
+
}
|
|
20
23
|
void ready.catch(() => {
|
|
21
24
|
if (fonts.get(href)?.ready === ready) fonts.delete(href);
|
|
22
25
|
});
|
|
@@ -38,7 +38,7 @@ registerPageLifecycle("navigation", () => {
|
|
|
38
38
|
const navOverlay = document.querySelector<HTMLElement>("[data-ref='stalux-nav-overlay']");
|
|
39
39
|
const buttonLeft = document.querySelector<HTMLElement>("[data-ref='stalux-nav-scroll-left']");
|
|
40
40
|
const buttonRight = document.querySelector<HTMLElement>("[data-ref='stalux-nav-scroll-right']");
|
|
41
|
-
if (!navList
|
|
41
|
+
if (!(navList && buttonLeft && buttonRight)) return;
|
|
42
42
|
|
|
43
43
|
const controller = new AbortController();
|
|
44
44
|
const listenerOptions = { signal: controller.signal };
|
|
@@ -12,12 +12,14 @@ interface RuntimeRegistration {
|
|
|
12
12
|
unregister: PageDisposer;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
declare global {
|
|
16
|
+
interface Window {
|
|
17
|
+
__staluxPageRuntime?: Map<string, RuntimeRegistration>;
|
|
18
|
+
}
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface PageRuntimeEnvironment {
|
|
20
|
-
host:
|
|
22
|
+
host: Pick<Window, "__staluxPageRuntime">;
|
|
21
23
|
target: RuntimeTarget;
|
|
22
24
|
queueTask: (callback: () => void) => void;
|
|
23
25
|
}
|
|
@@ -25,7 +27,7 @@ export interface PageRuntimeEnvironment {
|
|
|
25
27
|
function getBrowserEnvironment(): PageRuntimeEnvironment | undefined {
|
|
26
28
|
if (typeof window === "undefined" || typeof document === "undefined") return undefined;
|
|
27
29
|
return {
|
|
28
|
-
host: window
|
|
30
|
+
host: window,
|
|
29
31
|
target: document,
|
|
30
32
|
queueTask: queueMicrotask,
|
|
31
33
|
};
|
|
@@ -10,7 +10,7 @@ function shuffle<T extends object>(items: T[]): T[] {
|
|
|
10
10
|
const j = (crypto.getRandomValues(bytes)[0] ?? 0) % (i + 1);
|
|
11
11
|
const current = result[i];
|
|
12
12
|
const target = result[j];
|
|
13
|
-
if (!current
|
|
13
|
+
if (!(current && target)) throw new RangeError("Shuffle index is out of bounds");
|
|
14
14
|
result[i] = target;
|
|
15
15
|
result[j] = current;
|
|
16
16
|
}
|
|
@@ -35,7 +35,7 @@ function render(posts: { abbrlink: string; title: string }[]) {
|
|
|
35
35
|
export function mountRandomPosts(): (() => void) | undefined {
|
|
36
36
|
const container = document.querySelector<HTMLElement>("[data-stalux-random-posts]");
|
|
37
37
|
const list = container?.querySelector<HTMLElement>("#random-posts-list");
|
|
38
|
-
if (!container
|
|
38
|
+
if (!(container && list)) return;
|
|
39
39
|
const controller = new AbortController();
|
|
40
40
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
41
41
|
const fallback = container.dataset.loadError ?? "Failed to load posts";
|
|
@@ -37,17 +37,17 @@ interface HeadingMotion {
|
|
|
37
37
|
// Legacy heading cues remain available to integrations. The router requests
|
|
38
38
|
// one content animation; it never stacks a second animation on the heading.
|
|
39
39
|
const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
|
|
40
|
-
glide: { opacity: 0.86, duration:
|
|
41
|
-
article: { opacity: 0.86, duration:
|
|
42
|
-
enter: { opacity: 0.86, duration:
|
|
43
|
-
up: { opacity: 0.9, duration:
|
|
44
|
-
taxonomy: { opacity: 0.85, duration:
|
|
45
|
-
previous: { opacity: 0.9, duration:
|
|
46
|
-
next: { opacity: 0.9, duration:
|
|
47
|
-
panel: { opacity: 0.9, duration:
|
|
48
|
-
home: { opacity: 0.9, duration:
|
|
49
|
-
restore: { opacity: 0.96, duration:
|
|
50
|
-
error: { opacity: 0.9, duration:
|
|
40
|
+
glide: { opacity: 0.86, duration: 50 },
|
|
41
|
+
article: { opacity: 0.86, duration: 50 },
|
|
42
|
+
enter: { opacity: 0.86, duration: 70, y: 6 },
|
|
43
|
+
up: { opacity: 0.9, duration: 60, y: -4 },
|
|
44
|
+
taxonomy: { opacity: 0.85, duration: 60 },
|
|
45
|
+
previous: { opacity: 0.9, duration: 70, x: -8 },
|
|
46
|
+
next: { opacity: 0.9, duration: 70, x: 8 },
|
|
47
|
+
panel: { opacity: 0.9, duration: 60, y: 4 },
|
|
48
|
+
home: { opacity: 0.9, duration: 60, y: 4 },
|
|
49
|
+
restore: { opacity: 0.96, duration: 40 },
|
|
50
|
+
error: { opacity: 0.9, duration: 40 },
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
const MAIN = "main[data-stalux-route]";
|
|
@@ -55,41 +55,50 @@ const ARTICLE = "article.stalux-article";
|
|
|
55
55
|
// Authored per page type by the theme, not inferred from layout: the article
|
|
56
56
|
// body, archive list, tag cloud, post list, word list, link list, home block.
|
|
57
57
|
const CONTENT = "[data-stalux-motion-content]";
|
|
58
|
+
// The one node a page nominates as its cue stage. Preferred over measuring,
|
|
59
|
+
// because measuring an article column forces a full layout of a document that
|
|
60
|
+
// is tens of thousands of pixels tall, before it has ever been painted.
|
|
61
|
+
const STAGE = "[data-stalux-motion-stage]";
|
|
58
62
|
// A persist node marks the three-column article shell. Those sidebars keep
|
|
59
63
|
// their identity across visits and must never travel with a route cue.
|
|
60
64
|
const PERSIST = "[data-swup-persist]";
|
|
61
65
|
|
|
62
66
|
interface StageMotion {
|
|
63
67
|
opacity: number;
|
|
64
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Enter offset in px. Positive y enters from below, positive x from the right.
|
|
70
|
+
* Zero means a pure cross-fade: the column is replaced in place.
|
|
71
|
+
*/
|
|
65
72
|
distance: number;
|
|
66
73
|
axis: "x" | "y";
|
|
67
74
|
duration: number;
|
|
68
75
|
}
|
|
69
76
|
|
|
70
77
|
// One entry per route kind, because the components are not interchangeable: an
|
|
71
|
-
// article opens from below
|
|
72
|
-
//
|
|
78
|
+
// article opens from below and going back up to an index pulls away upward.
|
|
79
|
+
//
|
|
80
|
+
// Article-to-article is the exception: the reader keeps the same shell (sidebars,
|
|
81
|
+
// header) and only the column changes, so the column fades in place instead of
|
|
82
|
+
// travelling. Sliding a 28,000px column sideways made the whole page look like it
|
|
83
|
+
// jerked, and the shell already tells the reader where they are.
|
|
73
84
|
const stageMotions: Record<Exclude<RouteMotionPreset, "none">, StageMotion> = {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
previous: { opacity: 0, distance: -36, axis: "x", duration: 300 },
|
|
77
|
-
next: { opacity: 0, distance: 36, axis: "x", duration: 300 },
|
|
85
|
+
previous: { opacity: 0, distance: 0, axis: "x", duration: 150 },
|
|
86
|
+
next: { opacity: 0, distance: 0, axis: "x", duration: 150 },
|
|
78
87
|
// Entering a different article without a reading-direction trigger.
|
|
79
|
-
article: { opacity: 0, distance:
|
|
88
|
+
article: { opacity: 0, distance: 0, axis: "y", duration: 150 },
|
|
80
89
|
// Reaching an article from any list or the home block.
|
|
81
|
-
enter: { opacity: 0, distance: 56, axis: "y", duration:
|
|
82
|
-
home: { opacity: 0, distance: 48, axis: "y", duration:
|
|
90
|
+
enter: { opacity: 0, distance: 56, axis: "y", duration: 170 },
|
|
91
|
+
home: { opacity: 0, distance: 48, axis: "y", duration: 160 },
|
|
83
92
|
// Leaving an article for the archive or a taxonomy index.
|
|
84
|
-
up: { opacity: 0, distance: -44, axis: "y", duration:
|
|
93
|
+
up: { opacity: 0, distance: -44, axis: "y", duration: 150 },
|
|
85
94
|
// Moving between two taxonomy detail pages.
|
|
86
|
-
taxonomy: { opacity: 0, distance: 36, axis: "y", duration:
|
|
95
|
+
taxonomy: { opacity: 0, distance: 36, axis: "y", duration: 140 },
|
|
87
96
|
// About / links / words: single-panel pages.
|
|
88
|
-
panel: { opacity: 0, distance: 44, axis: "y", duration:
|
|
89
|
-
glide: { opacity: 0, distance: 40, axis: "y", duration:
|
|
97
|
+
panel: { opacity: 0, distance: 44, axis: "y", duration: 150 },
|
|
98
|
+
glide: { opacity: 0, distance: 40, axis: "y", duration: 140 },
|
|
90
99
|
// History traversal restores what the reader already saw.
|
|
91
|
-
restore: { opacity: 0, distance: -36, axis: "y", duration:
|
|
92
|
-
error: { opacity: 0, distance: 28, axis: "y", duration:
|
|
100
|
+
restore: { opacity: 0, distance: -36, axis: "y", duration: 140 },
|
|
101
|
+
error: { opacity: 0, distance: 28, axis: "y", duration: 130 },
|
|
93
102
|
};
|
|
94
103
|
|
|
95
104
|
const laidOut = (element: HTMLElement): boolean => {
|
|
@@ -100,14 +109,17 @@ const laidOut = (element: HTMLElement): boolean => {
|
|
|
100
109
|
/**
|
|
101
110
|
* The single node a route cue may animate for the current document.
|
|
102
111
|
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
112
|
+
* A page can nominate its stage; only when it does not is the largest authored
|
|
113
|
+
* content stage measured. Measuring is the fallback, not the rule: on an article
|
|
114
|
+
* the column is tens of thousands of pixels tall and measuring it forces a full
|
|
115
|
+
* layout of the incoming document at the exact moment the entry cue needs to
|
|
116
|
+
* start. Sidebars (persisted shells), the header and the footer never qualify.
|
|
107
117
|
*/
|
|
108
118
|
export function getRouteStage(): HTMLElement | null {
|
|
109
119
|
const main = document.querySelector<HTMLElement>(MAIN);
|
|
110
120
|
if (!main?.isConnected) return null;
|
|
121
|
+
const authored = main.querySelector<HTMLElement>(STAGE);
|
|
122
|
+
if (authored?.isConnected && !authored.closest(PERSIST)) return authored;
|
|
111
123
|
const candidates: HTMLElement[] = [];
|
|
112
124
|
const article = main.querySelector<HTMLElement>(ARTICLE);
|
|
113
125
|
if (article) candidates.push(article);
|
|
@@ -156,7 +168,7 @@ function completion() {
|
|
|
156
168
|
|
|
157
169
|
/** A route transition never captures a long article, list, or off-screen heading. */
|
|
158
170
|
export function getRouteMotionTarget(focus: HTMLElement | null): HTMLElement | null {
|
|
159
|
-
if (!focus?.isConnected
|
|
171
|
+
if (!(focus?.isConnected && focus.hasAttribute("data-stalux-motion-focus"))) return null;
|
|
160
172
|
const visible = (element: HTMLElement, maxHeight: number) => {
|
|
161
173
|
const rect = element.getBoundingClientRect();
|
|
162
174
|
return (
|
|
@@ -177,17 +189,17 @@ export function getRouteMotionTarget(focus: HTMLElement | null): HTMLElement | n
|
|
|
177
189
|
}
|
|
178
190
|
|
|
179
191
|
const regionMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
|
|
180
|
-
glide: { opacity: 0.72, duration:
|
|
181
|
-
article: { opacity: 0.72, duration:
|
|
182
|
-
enter: { opacity: 0.72, duration:
|
|
183
|
-
up: { opacity: 0.8, duration:
|
|
184
|
-
taxonomy: { opacity: 0.72, duration:
|
|
185
|
-
previous: { opacity: 0.76, duration:
|
|
186
|
-
next: { opacity: 0.76, duration:
|
|
187
|
-
panel: { opacity: 0.76, duration:
|
|
188
|
-
home: { opacity: 0.76, duration:
|
|
192
|
+
glide: { opacity: 0.72, duration: 80 },
|
|
193
|
+
article: { opacity: 0.72, duration: 90, y: 4 },
|
|
194
|
+
enter: { opacity: 0.72, duration: 90, y: 6 },
|
|
195
|
+
up: { opacity: 0.8, duration: 80, y: -4 },
|
|
196
|
+
taxonomy: { opacity: 0.72, duration: 80, x: 6 },
|
|
197
|
+
previous: { opacity: 0.76, duration: 90, x: -8 },
|
|
198
|
+
next: { opacity: 0.76, duration: 90, x: 8 },
|
|
199
|
+
panel: { opacity: 0.76, duration: 80, y: 4 },
|
|
200
|
+
home: { opacity: 0.76, duration: 90, y: 4 },
|
|
189
201
|
restore: { opacity: 1, duration: 0 },
|
|
190
|
-
error: { opacity: 0.85, duration:
|
|
202
|
+
error: { opacity: 0.85, duration: 50 },
|
|
191
203
|
};
|
|
192
204
|
|
|
193
205
|
/** Share Swup's actual content animation with background and analytics consumers. */
|
|
@@ -281,19 +293,18 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
281
293
|
spec.axis === "x"
|
|
282
294
|
? `translate3d(${distance}px, 0, 0)`
|
|
283
295
|
: `translate3d(0, ${distance}px, 0)`;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
);
|
|
296
|
+
// A zero distance is a cross-fade in place. Authoring a
|
|
297
|
+
// translate3d(0,0,0) would put a 28,000px column on its own
|
|
298
|
+
// compositor layer for nothing.
|
|
299
|
+
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
300
|
+
if (distance !== 0) keyframes.transform = [from, "translate3d(0, 0, 0)"];
|
|
301
|
+
animation = stage.animate(keyframes, {
|
|
302
|
+
duration: mobile ? Math.round(spec.duration * 0.9) : spec.duration,
|
|
303
|
+
// Strong ease-out: quick arrival, soft settle. The
|
|
304
|
+
// built-in ease-out is too weak to read at this size.
|
|
305
|
+
easing: "cubic-bezier(0.23, 1, 0.32, 1)",
|
|
306
|
+
fill: "both",
|
|
307
|
+
});
|
|
297
308
|
} else {
|
|
298
309
|
const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
|
|
299
310
|
const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
|
|
@@ -306,7 +317,7 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
|
|
|
306
317
|
];
|
|
307
318
|
}
|
|
308
319
|
animation = target.animate(keyframes, {
|
|
309
|
-
duration: mobile ? (options?.content ?
|
|
320
|
+
duration: mobile ? (options?.content ? 60 : 40) : spec.duration,
|
|
310
321
|
easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
|
|
311
322
|
fill: "both",
|
|
312
323
|
});
|
package/src/scripts/route-out.ts
CHANGED
|
@@ -16,8 +16,7 @@ export async function animateRouteOut(
|
|
|
16
16
|
const spec = getStageExit(preset);
|
|
17
17
|
const stage = spec ? getRouteStage() : null;
|
|
18
18
|
if (
|
|
19
|
-
!spec ||
|
|
20
|
-
!stage ||
|
|
19
|
+
!(spec && stage) ||
|
|
21
20
|
signal.aborted ||
|
|
22
21
|
document.hidden ||
|
|
23
22
|
typeof stage.animate !== "function" ||
|
|
@@ -28,6 +27,10 @@ export async function animateRouteOut(
|
|
|
28
27
|
const distance = mobile ? Math.round(spec.distance * 0.6) : spec.distance;
|
|
29
28
|
const to =
|
|
30
29
|
spec.axis === "x" ? `translate3d(${distance}px, 0, 0)` : `translate3d(0, ${distance}px, 0)`;
|
|
30
|
+
// Article-to-article has no distance: the column fades out in place, matching
|
|
31
|
+
// the entry fade, so the shell stays put and only the content disappears.
|
|
32
|
+
const keyframes: PropertyIndexedKeyframes = { opacity: [1, 0] };
|
|
33
|
+
if (distance !== 0) keyframes.transform = ["translate3d(0, 0, 0)", to];
|
|
31
34
|
let animation: Animation | undefined;
|
|
32
35
|
const cancel = () => {
|
|
33
36
|
const active = animation;
|
|
@@ -39,18 +42,13 @@ export async function animateRouteOut(
|
|
|
39
42
|
}
|
|
40
43
|
};
|
|
41
44
|
try {
|
|
42
|
-
animation = stage.animate(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// is the one place ease-in is correct; entrances use ease-out.
|
|
50
|
-
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
|
51
|
-
fill: "forwards",
|
|
52
|
-
},
|
|
53
|
-
);
|
|
45
|
+
animation = stage.animate(keyframes, {
|
|
46
|
+
duration: mobile ? Math.round(spec.duration * 0.62) : Math.round(spec.duration * 0.58),
|
|
47
|
+
// Strong ease-in: the outgoing document accelerates away. An exit
|
|
48
|
+
// is the one place ease-in is correct; entrances use ease-out.
|
|
49
|
+
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
|
50
|
+
fill: "forwards",
|
|
51
|
+
});
|
|
54
52
|
signal.addEventListener("abort", cancel, { once: true });
|
|
55
53
|
await animation.finished;
|
|
56
54
|
// The stage node survives the swap, so this filled effect is what holds
|