@xingwangzhe/stalux 1.43.1 → 1.45.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.
Files changed (91) hide show
  1. package/README.md +5 -3
  2. package/package.json +4 -2
  3. package/public/background/pattern-1.min.webp +0 -0
  4. package/public/background/pattern-10.min.webp +0 -0
  5. package/public/background/pattern-11.min.webp +0 -0
  6. package/public/background/pattern-12.min.webp +0 -0
  7. package/public/background/pattern-13.min.webp +0 -0
  8. package/public/background/pattern-14.min.webp +0 -0
  9. package/public/background/pattern-15.min.webp +0 -0
  10. package/public/background/pattern-16.min.webp +0 -0
  11. package/public/background/pattern-17.min.webp +0 -0
  12. package/public/background/pattern-18.min.webp +0 -0
  13. package/public/background/pattern-19.min.webp +0 -0
  14. package/public/background/pattern-2.min.webp +0 -0
  15. package/public/background/pattern-20.min.webp +0 -0
  16. package/public/background/pattern-21.min.webp +0 -0
  17. package/public/background/pattern-22.min.webp +0 -0
  18. package/public/background/pattern-23.min.webp +0 -0
  19. package/public/background/pattern-24.min.webp +0 -0
  20. package/public/background/pattern-25.min.webp +0 -0
  21. package/public/background/pattern-26.min.webp +0 -0
  22. package/public/background/pattern-27.min.webp +0 -0
  23. package/public/background/pattern-28.min.webp +0 -0
  24. package/public/background/pattern-29.min.webp +0 -0
  25. package/public/background/pattern-3.min.webp +0 -0
  26. package/public/background/pattern-30.min.webp +0 -0
  27. package/public/background/pattern-31.min.webp +0 -0
  28. package/public/background/pattern-32.min.webp +0 -0
  29. package/public/background/pattern-33.min.webp +0 -0
  30. package/public/background/pattern-34.min.webp +0 -0
  31. package/public/background/pattern-35.min.webp +0 -0
  32. package/public/background/pattern-36.min.webp +0 -0
  33. package/public/background/pattern-37.min.webp +0 -0
  34. package/public/background/pattern-38.min.webp +0 -0
  35. package/public/background/pattern-39.min.webp +0 -0
  36. package/public/background/pattern-4.min.webp +0 -0
  37. package/public/background/pattern-40.min.webp +0 -0
  38. package/public/background/pattern-41.min.webp +0 -0
  39. package/public/background/pattern-42.min.webp +0 -0
  40. package/public/background/pattern-5.min.webp +0 -0
  41. package/public/background/pattern-6.min.webp +0 -0
  42. package/public/background/pattern-7.min.webp +0 -0
  43. package/public/background/pattern-8.min.webp +0 -0
  44. package/public/background/pattern-9.min.webp +0 -0
  45. package/src/components/stalux/archives/archivesList.astro +8 -2
  46. package/src/components/stalux/archives/toArchive.astro +1 -1
  47. package/src/components/stalux/categories/categoriesCard.astro +1 -1
  48. package/src/components/stalux/common/author.astro +1 -1
  49. package/src/components/stalux/footer/FooterBadges.astro +5 -1
  50. package/src/components/stalux/layout/PageHeading.astro +2 -2
  51. package/src/components/stalux/layout/footer.astro +5 -1
  52. package/src/components/stalux/links/linkCard.astro +7 -1
  53. package/src/components/stalux/links/socialMedia.astro +1 -0
  54. package/src/components/stalux/posts/ArticleSummary.astro +6 -1
  55. package/src/components/stalux/posts/PostTaxonomy.astro +2 -1
  56. package/src/components/stalux/posts/WalineComment.astro +1 -0
  57. package/src/components/stalux/posts/postCC.astro +1 -1
  58. package/src/components/stalux/posts/postCard.astro +4 -1
  59. package/src/components/stalux/posts/postContent.astro +1 -1
  60. package/src/components/stalux/posts/postLeft.astro +2 -2
  61. package/src/components/stalux/posts/postNavigation.astro +5 -1
  62. package/src/components/stalux/posts/postRight.astro +5 -1
  63. package/src/components/stalux/posts/postToc.astro +2 -2
  64. package/src/components/stalux/tags/tagHeader.astro +1 -1
  65. package/src/components/stalux/tags/tagsCloud.astro +3 -1
  66. package/src/components/stalux/words/wordCard.astro +5 -1
  67. package/src/internal/public-assets.ts +3 -3
  68. package/src/internal/style-names.generated.ts +20 -22
  69. package/src/layouts/PostLayout.astro +8 -0
  70. package/src/layouts/Stalux.astro +3 -1
  71. package/src/pages/404.astro +2 -1
  72. package/src/pages/index.astro +5 -1
  73. package/src/scripts/background.ts +54 -113
  74. package/src/scripts/list-reveal.ts +81 -0
  75. package/src/scripts/module-motion.ts +73 -0
  76. package/src/scripts/random-posts.ts +85 -47
  77. package/src/scripts/route-motion.ts +44 -24
  78. package/src/scripts/route-out.ts +11 -4
  79. package/src/scripts/smooth-scroll.ts +87 -0
  80. package/src/scripts/swup-router.ts +23 -15
  81. package/src/scripts/tags-cloud.ts +8 -3
  82. package/src/styles/base.css +2 -2
  83. package/src/styles/generated-article.css +1 -1
  84. package/src/styles/generated-links.css +1 -1
  85. package/src/styles/generated-waline.css +1 -1
  86. package/src/styles/generated-words.css +1 -1
  87. package/src/styles/generated.css +1 -1
  88. package/src/styles/integrations.css +1 -17
  89. package/src/styles/module-motion.css +174 -0
  90. package/src/styles/motion.css +2 -26
  91. package/src/utils/route-motion-policy.ts +5 -1
@@ -1,133 +1,74 @@
1
1
  /**
2
- * 双层随机背景 — 纯客户端实现
2
+ * 每个完整文档只随机选择一次的会话背景。
3
3
  *
4
- * SVG 以静态 URL(/background/pattern-N.min.svg)硬编码,由集成在
5
- * config:setup 时复制到用户项目 public/background/。不经过 Astro 资源
6
- * 管线(import.meta.glob 会给 SVG 打上每次构建随机的 __ASTRO_ASSET_IMAGE__
7
- * 占位符,破坏增量构建依赖图 hash),也不做任何构建期扫描。
8
- *
9
- * 背景行为:
10
- * - SSR 只渲染灰墙底色(--stalux-bg-color),不渲染任何背景图,构建产物确定。
11
- * - 首次加载与 VT 软导航每次都会随机选一张背景:astro:page-load 在
12
- * window load(首访)与 swap 完成后、过渡动画期间(软导航)触发,
13
- * 先低优先级解码目标背景,等待内容过渡完成后再淡入,不与内容快照争抢绘制。
4
+ * SSR 保持确定的深色底和原有双层 DOM,不内联背景 URL。文档解析完成即
5
+ * 低优先级请求并异步解码一张无损 WebP,无需等待 window.load。首次成功时从暗底
6
+ * 用 200ms opacity 淡显;减少动态效果时直接显示。解码慢或失败仍保持暗底。
7
+ * Swup 只替换 main,背景沿用同一张,不按软导航重选/解码或再次交叉淡入。
8
+ * 自定义 __STALUX_BG_URLS__ 继续生效;saveData 下保留暗底,不请求装饰图片。
14
9
  */
15
10
  import { createClientLogger } from "./logger";
16
- import { prepareRouteMotion, type RouteMotionEvent } from "./route-motion";
17
11
 
18
12
  const logger = createClientLogger("background");
19
13
 
20
14
  declare global {
21
15
  interface Window {
22
16
  __STALUX_BG_URLS__?: string[];
17
+ __STALUX_BG_LISTENER__?: boolean;
23
18
  }
24
19
  }
25
20
 
26
- /** 背景 SVG 静态 URL 列表(与主题包 public/background/ 的 1-42 号一一对应) */
27
21
  const BACKGROUND_URLS: string[] = Array.from(
28
22
  { length: 42 },
29
- (_, i) => `/background/pattern-${i + 1}.min.svg`,
23
+ (_, i) => `/background/pattern-${i + 1}.min.webp`,
30
24
  );
31
25
 
32
- /** 优先使用注入列表(若用户自定义),否则用内置硬编码列表 */
33
- const backgroundImages: string[] = window.__STALUX_BG_URLS__?.length
34
- ? window.__STALUX_BG_URLS__
35
- : BACKGROUND_URLS;
36
-
37
- function getLayerEl(layer: "a" | "b"): HTMLElement | null {
38
- return document.querySelector(`[data-stalux-background="${layer}"]`);
39
- }
40
-
41
- function setLayerOpacity(layer: "a" | "b", opacity: number): void {
42
- const el = getLayerEl(layer);
43
- if (el) el.style.opacity = String(opacity);
44
- }
45
-
46
- function getActiveLayer(): "a" | "b" {
47
- const aOpacity = Number(getLayerEl("a")?.style.opacity ?? 0);
48
- const bOpacity = Number(getLayerEl("b")?.style.opacity ?? 0);
49
- return bOpacity > aOpacity ? "b" : "a";
50
- }
51
-
52
- function pickBackgroundIndex(previous: number): number {
53
- if (backgroundImages.length < 2) return 0;
54
- let index = Math.floor(Math.random() * backgroundImages.length);
55
- while (index === previous) {
56
- index = Math.floor(Math.random() * backgroundImages.length);
57
- }
58
- return index;
59
- }
60
-
61
- function crossfadeBackground(index: number): void {
62
- const activeLayer = getActiveLayer();
63
- const nextLayer: "a" | "b" = activeLayer === "a" ? "b" : "a";
64
- const next = getLayerEl(nextLayer);
65
- if (!next || !backgroundImages[index]) {
66
- logger.debug("background layer unavailable; skipped");
67
- return;
68
- }
69
- logger.debug(`crossfade layer=${nextLayer}; index=${index}`);
70
-
71
- next.style.backgroundImage = `url('${backgroundImages[index]}')`;
72
- setLayerOpacity(nextLayer, 1);
73
- setLayerOpacity(activeLayer, 0);
74
- document.body.dataset.staluxBgIndex = String(index);
75
- document.body.dataset.staluxBgLayer = nextLayer;
76
- }
77
-
78
- let generation = 0;
79
- let prepared: Promise<number | undefined> | undefined;
80
- let transitionFinished: Promise<unknown> = Promise.resolve();
81
-
82
- function prepareBackground(): Promise<number | undefined> {
83
- const previousIndex = Number(document.body.dataset.staluxBgIndex ?? -1);
84
- const index = pickBackgroundIndex(previousIndex);
85
- const image = new Image();
86
- image.decoding = "async";
87
- image.fetchPriority = "low";
88
- image.src = backgroundImages[index] ?? "";
89
- return image.decode().then(
90
- () => index,
91
- () => undefined,
92
- );
93
- }
94
-
95
- let firstCommitStarted = false;
96
-
97
- function initializeBackground(): void {
98
- const active = getLayerEl(getActiveLayer());
99
- if (!prepared && active?.style.backgroundImage) return;
100
- // 首访的早提交已经发起,但解码还没落地:此时 window.load 上的 page-load
101
- // 不是新页面,不能再解码并淡入第二张。
102
- if (!prepared && firstCommitStarted) return;
103
- if (!prepared) firstCommitStarted = true;
104
- const current = generation;
105
- const ready = prepared ?? prepareBackground();
106
- prepared = undefined;
107
- // Decode off the navigation path, and keep the full-screen background repaint
108
- // out of the content's View Transition. A superseded navigation never commits.
109
- void Promise.all([ready, transitionFinished]).then(([index]) => {
110
- if (current === generation && index !== undefined) crossfadeBackground(index);
111
- });
112
- }
113
-
114
- // 背景层跨页保留;before-swap 只记录过渡完成信号,不阻塞或修改内容快照。
115
- // page-load 再提交已解码的背景,非 VT 浏览器也采用同一清理/去重流程。
116
- if (!(window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTENER__) {
117
- (window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTENER__ = true;
118
- document.addEventListener("astro:before-preparation", () => {
119
- generation++;
26
+ /** Install once per full document; this module is also harmless during SSR. */
27
+ export function installSessionBackground(): void {
28
+ if (typeof window === "undefined" || typeof document === "undefined") return;
29
+ if (window.__STALUX_BG_LISTENER__) return;
30
+ window.__STALUX_BG_LISTENER__ = true;
31
+ const start = () => {
120
32
  const connection = (navigator as Navigator & { connection?: { saveData?: boolean } })
121
33
  .connection;
122
- prepared = connection?.saveData ? undefined : prepareBackground();
123
- });
124
- document.addEventListener("astro:before-swap", (event) => {
125
- transitionFinished = prepareRouteMotion(event as RouteMotionEvent).finished;
126
- });
127
- document.addEventListener("astro:page-load", initializeBackground);
128
- // 首访的 page-load 由 window.load 派发,任何慢图片或卡死的第三方资源都会把首屏背景
129
- // 拖成一片纯色;文档解析完就提交首张背景,软导航仍由 page-load 负责。
130
- const readyState = document.readyState;
131
- if (readyState === "interactive" || readyState === "complete") initializeBackground();
132
- else document.addEventListener("DOMContentLoaded", initializeBackground, { once: true });
34
+ if (connection?.saveData) return;
35
+ const a = document.querySelector<HTMLElement>('[data-stalux-background="a"]');
36
+ const b = document.querySelector<HTMLElement>('[data-stalux-background="b"]');
37
+ if (!a || !b || a.style.backgroundImage || b.style.backgroundImage) return;
38
+ const custom = Boolean(window.__STALUX_BG_URLS__?.length);
39
+ const urls = window.__STALUX_BG_URLS__?.length
40
+ ? window.__STALUX_BG_URLS__
41
+ : BACKGROUND_URLS;
42
+ const index = Math.floor(Math.random() * urls.length);
43
+ const source = urls[index];
44
+ if (!source) return;
45
+ const image = new Image();
46
+ image.decoding = "async";
47
+ image.fetchPriority = "low";
48
+ image.src = source;
49
+ void image.decode().then(
50
+ () => {
51
+ // These two shell nodes retain their identities during Swup visits.
52
+ // No navigation event can start another image or reset this result.
53
+ if (!a.isConnected || !b.isConnected) return;
54
+ // Original SVGs have only a viewBox: auto sizes them as contain.
55
+ // Raster files have intrinsic dimensions, so retain that sizing
56
+ // explicitly. Custom URLs keep the existing auto semantics.
57
+ if (!custom) b.style.backgroundSize = "contain";
58
+ b.style.backgroundImage = `url('${source}')`;
59
+ b.style.opacity = "1";
60
+ a.style.opacity = "0";
61
+ document.body.dataset.staluxBgIndex = String(index);
62
+ document.body.dataset.staluxBgLayer = "b";
63
+ },
64
+ () => {
65
+ logger.debug("session background failed to decode; keeping dark canvas");
66
+ },
67
+ );
68
+ };
69
+ if (document.readyState === "loading")
70
+ document.addEventListener("DOMContentLoaded", start, { once: true });
71
+ else start();
133
72
  }
73
+
74
+ installSessionBackground();
@@ -0,0 +1,81 @@
1
+ const CONTAINER = "[data-stalux-reveal]";
2
+ const ITEM = ":scope > [data-stalux-reveal-item]";
3
+ // A cascade is only ever a bounded group cue: never one observer per row, and
4
+ // never a queue long enough to keep the reader waiting for the last item.
5
+ const MAX_CONTAINERS = 48;
6
+ const MAX_ITEMS = 16;
7
+ const STAGGER_MS = 22;
8
+ const MAX_DELAY_MS = 176;
9
+ const DURATION_MS = 240;
10
+
11
+ /**
12
+ * Lists cascade in once, when their group becomes visible.
13
+ *
14
+ * Text is authored fully visible; the effect only dims a group that is about to
15
+ * be read. Rows outside the group cap, restored history entries, reduced
16
+ * motion, persisted shells and hidden documents are left untouched.
17
+ */
18
+ export function mountListReveal(): (() => void) | undefined {
19
+ if (typeof IntersectionObserver !== "function") return;
20
+ const preference = window.matchMedia("(prefers-reduced-motion: reduce)");
21
+ if (preference.matches || document.hidden) return;
22
+ // Returning to a history entry restores what the reader already saw.
23
+ if (document.documentElement.dataset.staluxVtPreset === "restore") return;
24
+ const containers = [...document.querySelectorAll<HTMLElement>(CONTAINER)]
25
+ .filter((element) => !element.closest("[data-swup-persist]"))
26
+ .slice(0, MAX_CONTAINERS);
27
+ if (!containers.length) return;
28
+
29
+ const active = new Set<Animation>();
30
+ const cancel = () => {
31
+ for (const animation of active) animation.cancel();
32
+ active.clear();
33
+ };
34
+ const reveal = (container: HTMLElement) => {
35
+ if (document.hidden || preference.matches) return;
36
+ const items = [...container.querySelectorAll<HTMLElement>(ITEM)].slice(0, MAX_ITEMS);
37
+ for (const [index, item] of items.entries()) {
38
+ if (typeof item.animate !== "function") continue;
39
+ try {
40
+ const animation = item.animate(
41
+ {
42
+ opacity: [0.6, 1],
43
+ transform: ["translateY(8px)", "translateY(0)"],
44
+ },
45
+ {
46
+ duration: DURATION_MS,
47
+ delay: Math.min(index * STAGGER_MS, MAX_DELAY_MS),
48
+ easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
49
+ // Dimmed during the stagger delay instead of popping in.
50
+ fill: "backwards",
51
+ },
52
+ );
53
+ active.add(animation);
54
+ const release = () => {
55
+ active.delete(animation);
56
+ animation.cancel();
57
+ };
58
+ void animation.finished.then(release, release);
59
+ } catch {
60
+ /* An unsupported effect leaves the authored visible list. */
61
+ }
62
+ }
63
+ };
64
+ const observer = new IntersectionObserver(
65
+ (entries) => {
66
+ for (const entry of entries) {
67
+ if (!entry.isIntersecting) continue;
68
+ observer.unobserve(entry.target);
69
+ reveal(entry.target as HTMLElement);
70
+ }
71
+ },
72
+ { threshold: 0.05 },
73
+ );
74
+ for (const container of containers) observer.observe(container);
75
+ preference.addEventListener("change", cancel);
76
+ return () => {
77
+ observer.disconnect();
78
+ preference.removeEventListener("change", cancel);
79
+ cancel();
80
+ };
81
+ }
@@ -0,0 +1,73 @@
1
+ import { registerPageLifecycle } from "./page-runtime";
2
+
3
+ const SELECTOR =
4
+ '[data-stalux-module="summary"], [data-stalux-module="license"], [data-stalux-module="comments"], [data-stalux-module="sidebar"], [data-stalux-module="post-navigation"]';
5
+ const MAX_REGIONS = 12;
6
+
7
+ /** Bounded secondary cues. Text is visible even without observers or animation. */
8
+ export function mountModuleMotion(): (() => void) | undefined {
9
+ if (
10
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
11
+ typeof IntersectionObserver !== "function"
12
+ )
13
+ return;
14
+ const elements = [...document.querySelectorAll<HTMLElement>(SELECTOR)].slice(0, MAX_REGIONS);
15
+ if (!elements.length) return;
16
+ const active = new Set<Animation>();
17
+ const motionPreference = window.matchMedia("(prefers-reduced-motion: reduce)");
18
+ const cancel = () => {
19
+ for (const animation of active) animation.cancel();
20
+ active.clear();
21
+ };
22
+ const observer = new IntersectionObserver(
23
+ (entries) => {
24
+ for (const entry of entries) {
25
+ if (!entry.isIntersecting) continue;
26
+ const element = entry.target as HTMLElement;
27
+ observer.unobserve(element);
28
+ // The router owns a single bounded lead region. Do not layer an
29
+ // observer entry over that motion or make long comments slide.
30
+ if (
31
+ document.hidden ||
32
+ motionPreference.matches ||
33
+ document.documentElement.classList.contains("is-changing") ||
34
+ document.documentElement.dataset.staluxVtPreset ||
35
+ // A persisted node kept its identity across the visit; it was
36
+ // never rebuilt, so re-entering it would be a second cue.
37
+ element.closest("[data-swup-persist]") ||
38
+ element.closest("[data-stalux-motion-region]") ||
39
+ entry.boundingClientRect.height > Math.min(420, window.innerHeight * 0.6) ||
40
+ typeof element.animate !== "function"
41
+ )
42
+ continue;
43
+ try {
44
+ const animation = element.animate(
45
+ { opacity: [0.88, 1] },
46
+ {
47
+ duration: 140,
48
+ easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
49
+ },
50
+ );
51
+ active.add(animation);
52
+ const release = () => {
53
+ active.delete(animation);
54
+ animation.cancel();
55
+ };
56
+ void animation.finished.then(release, release);
57
+ } catch {
58
+ /* Unsupported effects leave the authored visible state. */
59
+ }
60
+ }
61
+ },
62
+ { threshold: 0.08 },
63
+ );
64
+ for (const element of elements) observer.observe(element);
65
+ motionPreference.addEventListener("change", cancel);
66
+ return () => {
67
+ observer.disconnect();
68
+ motionPreference.removeEventListener("change", cancel);
69
+ cancel();
70
+ };
71
+ }
72
+
73
+ registerPageLifecycle("module-motion", mountModuleMotion);
@@ -3,21 +3,6 @@ import { registerPageLifecycle } from "./page-runtime";
3
3
 
4
4
  const logger = createClientLogger("random-posts");
5
5
 
6
- const sleep = (ms: number, signal: AbortSignal) =>
7
- new Promise<void>((resolve) => {
8
- const finish = () => {
9
- signal.removeEventListener("abort", aborted);
10
- resolve();
11
- };
12
- const timer = window.setTimeout(finish, ms);
13
- const aborted = () => {
14
- window.clearTimeout(timer);
15
- finish();
16
- };
17
- if (signal.aborted) aborted();
18
- else signal.addEventListener("abort", aborted, { once: true });
19
- });
20
-
21
6
  function shuffle<T extends object>(items: T[]): T[] {
22
7
  const result = [...items];
23
8
  const bytes = new Uint32Array(1);
@@ -47,45 +32,98 @@ function render(posts: { abbrlink: string; title: string }[]) {
47
32
  .join("");
48
33
  }
49
34
 
50
- async function refresh(container: HTMLElement, fallback: string, signal: AbortSignal) {
51
- const list = container.querySelector<HTMLElement>("#random-posts-list");
52
- if (!list) return;
53
- list.style.opacity = "0.5";
54
- await sleep(300, signal);
55
- if (signal.aborted) return;
56
- try {
57
- logger.debug("refresh started");
58
- const posts = shuffle(await fetchPosts(signal)).slice(
59
- 0,
60
- Number(container.dataset.count ?? 5),
61
- );
62
- if (signal.aborted) return;
63
- logger.debug(`refresh completed; posts=${posts.length}`);
64
- list.innerHTML = posts.length
65
- ? render(posts)
66
- : `<li class="no-posts">${container.dataset.noPosts ?? fallback}</li>`;
67
- } catch (error) {
68
- if (signal.aborted) return;
69
- logger.error("refresh failed; showing fallback", error);
70
- list.innerHTML = `<li class="error">${container.dataset.loadFailed ?? fallback}</li>`;
71
- } finally {
72
- if (!signal.aborted) list.style.opacity = "1";
73
- }
74
- }
75
-
76
- registerPageLifecycle("random-posts", () => {
35
+ export function mountRandomPosts(): (() => void) | undefined {
77
36
  const container = document.querySelector<HTMLElement>("[data-stalux-random-posts]");
78
- if (!container) return;
37
+ const list = container?.querySelector<HTMLElement>("#random-posts-list");
38
+ if (!container || !list) return;
79
39
  const controller = new AbortController();
40
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
80
41
  const fallback = container.dataset.loadError ?? "Failed to load posts";
42
+ let request: AbortController | undefined;
43
+ let animation: Animation | undefined;
44
+ let revision = 0;
45
+ let disposed = false;
46
+ const cancelAnimation = () => {
47
+ const previous = animation;
48
+ animation = undefined;
49
+ previous?.cancel();
50
+ };
51
+ const reveal = () => {
52
+ if (reducedMotion.matches || document.hidden || typeof list.animate !== "function") return;
53
+ // A persisted sidebar keeps its node across visits; fading it again would
54
+ // read as the fixed shell blinking, which is exactly what it must not do.
55
+ if (container.closest("[data-swup-persist]")) return;
56
+ try {
57
+ // Real titles can wrap the sidebar beyond the same bounded-region
58
+ // budget used for route effects. Keep that result immediately readable.
59
+ if (list.getBoundingClientRect().height > Math.min(420, window.innerHeight * 0.6))
60
+ return;
61
+ const current = list.animate(
62
+ { opacity: [0.82, 1] },
63
+ { duration: 100, easing: "ease-out" },
64
+ );
65
+ animation = current;
66
+ const release = () => {
67
+ // A completed old effect cannot release a newer refresh cue.
68
+ if (animation === current) animation = undefined;
69
+ current.cancel();
70
+ };
71
+ void current.finished.then(release, release);
72
+ } catch {
73
+ // The authored list stays fully readable when animation is unavailable.
74
+ }
75
+ };
76
+ const refresh = async () => {
77
+ // Latest click wins, including fetch implementations whose pending JSON
78
+ // decoding cannot be interrupted by AbortSignal.
79
+ request?.abort();
80
+ const current = new AbortController();
81
+ request = current;
82
+ const token = ++revision;
83
+ const isCurrent = () => !disposed && token === revision && !current.signal.aborted;
84
+ cancelAnimation();
85
+ container.setAttribute("aria-busy", "true");
86
+ try {
87
+ logger.debug("refresh started");
88
+ const posts = shuffle(await fetchPosts(current.signal)).slice(
89
+ 0,
90
+ Number(container.dataset.count ?? 5),
91
+ );
92
+ if (!isCurrent()) return;
93
+ logger.debug(`refresh completed; posts=${posts.length}`);
94
+ list.innerHTML = posts.length
95
+ ? render(posts)
96
+ : `<li class="no-posts">${container.dataset.noPosts ?? fallback}</li>`;
97
+ reveal();
98
+ } catch (error) {
99
+ if (!isCurrent()) return;
100
+ logger.error("refresh failed; showing fallback", error);
101
+ list.innerHTML = `<li class="error">${container.dataset.loadFailed ?? fallback}</li>`;
102
+ } finally {
103
+ if (isCurrent()) {
104
+ request = undefined;
105
+ container.removeAttribute("aria-busy");
106
+ }
107
+ }
108
+ };
81
109
  container.querySelector("#refresh-random-posts")?.addEventListener(
82
110
  "click",
83
111
  (event) => {
84
112
  event.preventDefault();
85
- void refresh(container, fallback, controller.signal);
113
+ void refresh();
86
114
  },
87
115
  { signal: controller.signal },
88
116
  );
89
- void refresh(container, fallback, controller.signal);
90
- return () => controller.abort();
91
- });
117
+ reducedMotion.addEventListener("change", cancelAnimation, { signal: controller.signal });
118
+ void refresh();
119
+ return () => {
120
+ disposed = true;
121
+ revision++;
122
+ request?.abort();
123
+ controller.abort();
124
+ cancelAnimation();
125
+ container.removeAttribute("aria-busy");
126
+ };
127
+ }
128
+
129
+ registerPageLifecycle("random-posts", mountRandomPosts);
@@ -12,6 +12,7 @@ export interface RouteMotionEvent {
12
12
 
13
13
  export interface RouteMotionOptions {
14
14
  skip?: boolean;
15
+ /** Use a bounded introductory region around the marked heading. */
15
16
  content?: boolean;
16
17
  preset?: RouteMotionPreset;
17
18
  browserMotion?: boolean;
@@ -62,21 +63,42 @@ function completion() {
62
63
  return { promise, resolve };
63
64
  }
64
65
 
65
- /** A deep history restore must not scroll to or animate an off-screen title. */
66
- function isVisibleHeading(heading: HTMLElement): boolean {
67
- if (!heading.isConnected || !heading.hasAttribute("data-stalux-motion-focus")) return false;
68
- const rect = heading.getBoundingClientRect();
69
- return (
70
- rect.width > 0 &&
71
- rect.height > 0 &&
72
- rect.height <= Math.min(320, window.innerHeight / 2) &&
73
- rect.top < window.innerHeight &&
74
- rect.bottom > 0 &&
75
- rect.left < window.innerWidth &&
76
- rect.right > 0
77
- );
66
+ /** A route transition never captures a long article, list, or off-screen heading. */
67
+ export function getRouteMotionTarget(focus: HTMLElement | null): HTMLElement | null {
68
+ if (!focus?.isConnected || !focus.hasAttribute("data-stalux-motion-focus")) return null;
69
+ const visible = (element: HTMLElement, maxHeight: number) => {
70
+ const rect = element.getBoundingClientRect();
71
+ return (
72
+ rect.width > 0 &&
73
+ rect.height > 0 &&
74
+ rect.height <= maxHeight &&
75
+ rect.top < window.innerHeight &&
76
+ rect.bottom > 0 &&
77
+ rect.left < window.innerWidth &&
78
+ rect.right > 0
79
+ );
80
+ };
81
+ if (!visible(focus, Math.min(320, window.innerHeight / 2))) return null;
82
+ const region = focus.closest?.<HTMLElement>("[data-stalux-motion-region]");
83
+ return region?.isConnected && visible(region, Math.min(420, window.innerHeight * 0.6))
84
+ ? region
85
+ : focus;
78
86
  }
79
87
 
88
+ const regionMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
89
+ glide: { opacity: 0.72, duration: 160 },
90
+ article: { opacity: 0.72, duration: 180, y: 4 },
91
+ enter: { opacity: 0.72, duration: 180, y: 6 },
92
+ up: { opacity: 0.8, duration: 160, y: -4 },
93
+ taxonomy: { opacity: 0.72, duration: 160, x: 6 },
94
+ previous: { opacity: 0.76, duration: 180, x: -8 },
95
+ next: { opacity: 0.76, duration: 180, x: 8 },
96
+ panel: { opacity: 0.76, duration: 160, y: 4 },
97
+ home: { opacity: 0.76, duration: 180, y: 4 },
98
+ restore: { opacity: 1, duration: 0 },
99
+ error: { opacity: 0.85, duration: 100 },
100
+ };
101
+
80
102
  /** Share Swup's actual content animation with background and analytics consumers. */
81
103
  export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
82
104
  const native = event.viewTransition;
@@ -130,35 +152,33 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
130
152
  try {
131
153
  const preset = options?.preset ?? "glide";
132
154
  const mobile = window.matchMedia?.("(max-width: 768px)").matches;
155
+ const target = getRouteMotionTarget(heading);
133
156
  if (
134
- !heading ||
157
+ !target ||
135
158
  preset === "none" ||
136
159
  (options?.content && preset === "restore") ||
137
160
  options?.skip ||
138
161
  options?.browserMotion ||
139
162
  document.hidden ||
140
163
  window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
141
- typeof heading.animate !== "function" ||
142
- !(options?.content ? heading.isConnected : isVisibleHeading(heading)) ||
164
+ typeof target.animate !== "function" ||
143
165
  (mobile && preset === "restore")
144
166
  ) {
145
167
  animationDone.resolve();
146
168
  return;
147
169
  }
148
- const spec = options?.content
149
- ? { opacity: 0, duration: mobile ? 180 : 220, y: mobile ? 8 : 16 }
150
- : headingMotions[preset];
170
+ const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
151
171
  const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
152
- // Content uses a short vertical entry on both breakpoints. Legacy
153
- // heading cues retain their motionless phone fallback.
154
- if ((options?.content || !mobile) && (spec.x || spec.y)) {
172
+ // Phones use a pure fade. Explicit previous/next alone imply a
173
+ // lateral reading direction; history never replays the entry.
174
+ if (!mobile && (spec.x || spec.y)) {
155
175
  keyframes.transform = [
156
176
  `translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
157
177
  "translate3d(0px, 0px, 0)",
158
178
  ];
159
179
  }
160
- animation = heading.animate(keyframes, {
161
- duration: options?.content ? spec.duration : mobile ? 80 : spec.duration,
180
+ animation = target.animate(keyframes, {
181
+ duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
162
182
  easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
163
183
  fill: "both",
164
184
  });
@@ -1,3 +1,5 @@
1
+ import { getRouteMotionTarget } from "./route-motion";
2
+
1
3
  /** Fade the old content only after destination assets are ready. */
2
4
  export async function animateRouteOut(
3
5
  element: HTMLElement | null,
@@ -10,16 +12,21 @@ export async function animateRouteOut(
10
12
  window.matchMedia("(prefers-reduced-motion: reduce)").matches
11
13
  )
12
14
  return () => {};
15
+ const target = getRouteMotionTarget(element);
16
+ if (!target || typeof target.animate !== "function") return () => {};
13
17
  let animation: Animation | undefined;
14
18
  const cancel = () => animation?.cancel();
19
+ const mobile = window.matchMedia("(max-width: 768px)").matches;
15
20
  try {
16
- animation = element.animate(
17
- { opacity: [1, 0], transform: ["translateY(0)", "translateY(-8px)"] },
18
- { duration: 140, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" },
21
+ animation = target.animate(
22
+ mobile
23
+ ? { opacity: [1, 0.82] }
24
+ : { opacity: [1, 0.82], transform: ["translateY(0)", "translateY(-4px)"] },
25
+ { duration: 80, easing: "cubic-bezier(0.4, 0, 1, 1)", fill: "forwards" },
19
26
  );
20
27
  signal.addEventListener("abort", cancel, { once: true });
21
28
  await animation.finished;
22
- // Keep the old page hidden until swap, but release effects on abort.
29
+ // Keep the bounded outgoing cue until swap; the body stays readable.
23
30
  // Swup detaches this node; no persistent shell is animated.
24
31
  } catch {
25
32
  /* Cancellation and unsupported animation must not block a visit. */