@xingwangzhe/stalux 1.43.0 → 1.44.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 (87) hide show
  1. package/README.md +6 -2
  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/toArchive.astro +1 -1
  46. package/src/components/stalux/categories/categoriesCard.astro +1 -1
  47. package/src/components/stalux/common/author.astro +1 -1
  48. package/src/components/stalux/footer/FooterBadges.astro +5 -1
  49. package/src/components/stalux/layout/PageHeading.astro +2 -2
  50. package/src/components/stalux/layout/footer.astro +5 -1
  51. package/src/components/stalux/links/linkCard.astro +7 -1
  52. package/src/components/stalux/links/socialMedia.astro +1 -0
  53. package/src/components/stalux/posts/ArticleSummary.astro +6 -1
  54. package/src/components/stalux/posts/WalineComment.astro +1 -0
  55. package/src/components/stalux/posts/postCC.astro +1 -1
  56. package/src/components/stalux/posts/postCard.astro +4 -1
  57. package/src/components/stalux/posts/postContent.astro +1 -1
  58. package/src/components/stalux/posts/postLeft.astro +1 -1
  59. package/src/components/stalux/posts/postNavigation.astro +5 -1
  60. package/src/components/stalux/posts/postRight.astro +2 -2
  61. package/src/components/stalux/tags/tagHeader.astro +1 -1
  62. package/src/components/stalux/tags/tagsCloud.astro +3 -1
  63. package/src/components/stalux/words/wordCard.astro +5 -1
  64. package/src/internal/public-assets.ts +3 -3
  65. package/src/internal/style-names.generated.ts +20 -22
  66. package/src/layouts/Stalux.astro +3 -1
  67. package/src/pages/404.astro +2 -1
  68. package/src/pages/index.astro +5 -1
  69. package/src/scripts/background.ts +54 -113
  70. package/src/scripts/module-motion.ts +70 -0
  71. package/src/scripts/random-posts.ts +82 -47
  72. package/src/scripts/route-motion.ts +48 -24
  73. package/src/scripts/route-out.ts +40 -0
  74. package/src/scripts/smooth-scroll.ts +52 -0
  75. package/src/scripts/swup-router.ts +25 -9
  76. package/src/scripts/tags-cloud.ts +8 -3
  77. package/src/styles/base.css +2 -2
  78. package/src/styles/generated-article.css +1 -1
  79. package/src/styles/generated-links.css +1 -1
  80. package/src/styles/generated-waline.css +1 -1
  81. package/src/styles/generated-words.css +1 -1
  82. package/src/styles/generated.css +1 -1
  83. package/src/styles/integrations.css +1 -17
  84. package/src/styles/module-motion.css +174 -0
  85. package/src/styles/motion.css +2 -21
  86. package/src/utils/route-motion-policy.ts +5 -1
  87. package/src/utils/toc-scrollspy.ts +21 -14
@@ -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,70 @@
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
+ element.closest("[data-stalux-motion-region]") ||
36
+ entry.boundingClientRect.height > Math.min(420, window.innerHeight * 0.6) ||
37
+ typeof element.animate !== "function"
38
+ )
39
+ continue;
40
+ try {
41
+ const animation = element.animate(
42
+ { opacity: [0.88, 1] },
43
+ {
44
+ duration: 140,
45
+ easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
46
+ },
47
+ );
48
+ active.add(animation);
49
+ const release = () => {
50
+ active.delete(animation);
51
+ animation.cancel();
52
+ };
53
+ void animation.finished.then(release, release);
54
+ } catch {
55
+ /* Unsupported effects leave the authored visible state. */
56
+ }
57
+ }
58
+ },
59
+ { threshold: 0.08 },
60
+ );
61
+ for (const element of elements) observer.observe(element);
62
+ motionPreference.addEventListener("change", cancel);
63
+ return () => {
64
+ observer.disconnect();
65
+ motionPreference.removeEventListener("change", cancel);
66
+ cancel();
67
+ };
68
+ }
69
+
70
+ 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,95 @@ 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
+ try {
54
+ // Real titles can wrap the sidebar beyond the same bounded-region
55
+ // budget used for route effects. Keep that result immediately readable.
56
+ if (list.getBoundingClientRect().height > Math.min(420, window.innerHeight * 0.6))
57
+ return;
58
+ const current = list.animate(
59
+ { opacity: [0.82, 1] },
60
+ { duration: 100, easing: "ease-out" },
61
+ );
62
+ animation = current;
63
+ const release = () => {
64
+ // A completed old effect cannot release a newer refresh cue.
65
+ if (animation === current) animation = undefined;
66
+ current.cancel();
67
+ };
68
+ void current.finished.then(release, release);
69
+ } catch {
70
+ // The authored list stays fully readable when animation is unavailable.
71
+ }
72
+ };
73
+ const refresh = async () => {
74
+ // Latest click wins, including fetch implementations whose pending JSON
75
+ // decoding cannot be interrupted by AbortSignal.
76
+ request?.abort();
77
+ const current = new AbortController();
78
+ request = current;
79
+ const token = ++revision;
80
+ const isCurrent = () => !disposed && token === revision && !current.signal.aborted;
81
+ cancelAnimation();
82
+ container.setAttribute("aria-busy", "true");
83
+ try {
84
+ logger.debug("refresh started");
85
+ const posts = shuffle(await fetchPosts(current.signal)).slice(
86
+ 0,
87
+ Number(container.dataset.count ?? 5),
88
+ );
89
+ if (!isCurrent()) return;
90
+ logger.debug(`refresh completed; posts=${posts.length}`);
91
+ list.innerHTML = posts.length
92
+ ? render(posts)
93
+ : `<li class="no-posts">${container.dataset.noPosts ?? fallback}</li>`;
94
+ reveal();
95
+ } catch (error) {
96
+ if (!isCurrent()) return;
97
+ logger.error("refresh failed; showing fallback", error);
98
+ list.innerHTML = `<li class="error">${container.dataset.loadFailed ?? fallback}</li>`;
99
+ } finally {
100
+ if (isCurrent()) {
101
+ request = undefined;
102
+ container.removeAttribute("aria-busy");
103
+ }
104
+ }
105
+ };
81
106
  container.querySelector("#refresh-random-posts")?.addEventListener(
82
107
  "click",
83
108
  (event) => {
84
109
  event.preventDefault();
85
- void refresh(container, fallback, controller.signal);
110
+ void refresh();
86
111
  },
87
112
  { signal: controller.signal },
88
113
  );
89
- void refresh(container, fallback, controller.signal);
90
- return () => controller.abort();
91
- });
114
+ reducedMotion.addEventListener("change", cancelAnimation, { signal: controller.signal });
115
+ void refresh();
116
+ return () => {
117
+ disposed = true;
118
+ revision++;
119
+ request?.abort();
120
+ controller.abort();
121
+ cancelAnimation();
122
+ container.removeAttribute("aria-busy");
123
+ };
124
+ }
125
+
126
+ registerPageLifecycle("random-posts", mountRandomPosts);
@@ -12,6 +12,8 @@ export interface RouteMotionEvent {
12
12
 
13
13
  export interface RouteMotionOptions {
14
14
  skip?: boolean;
15
+ /** Use a bounded introductory region around the marked heading. */
16
+ content?: boolean;
15
17
  preset?: RouteMotionPreset;
16
18
  browserMotion?: boolean;
17
19
  }
@@ -30,8 +32,8 @@ interface HeadingMotion {
30
32
  y?: number;
31
33
  }
32
34
 
33
- // F / E / U / T / S / L / R / X from the route design. Only the incoming
34
- // heading moves; article prose, lists, sidebars and the persistent shell do not.
35
+ // Legacy heading cues remain available to integrations. The router requests
36
+ // one content animation; it never stacks a second animation on the heading.
35
37
  const headingMotions: Record<Exclude<RouteMotionPreset, "none">, HeadingMotion> = {
36
38
  glide: { opacity: 0.86, duration: 100 },
37
39
  article: { opacity: 0.86, duration: 100 },
@@ -61,22 +63,43 @@ function completion() {
61
63
  return { promise, resolve };
62
64
  }
63
65
 
64
- /** A deep history restore must not scroll to or animate an off-screen title. */
65
- function isVisibleHeading(heading: HTMLElement): boolean {
66
- if (!heading.isConnected || !heading.hasAttribute("data-stalux-motion-focus")) return false;
67
- const rect = heading.getBoundingClientRect();
68
- return (
69
- rect.width > 0 &&
70
- rect.height > 0 &&
71
- rect.height <= Math.min(320, window.innerHeight / 2) &&
72
- rect.top < window.innerHeight &&
73
- rect.bottom > 0 &&
74
- rect.left < window.innerWidth &&
75
- rect.right > 0
76
- );
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;
77
86
  }
78
87
 
79
- /** Share Swup's actual heading animation with background and analytics consumers. */
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
+
102
+ /** Share Swup's actual content animation with background and analytics consumers. */
80
103
  export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
81
104
  const native = event.viewTransition;
82
105
  const key = native ?? event;
@@ -129,32 +152,33 @@ export function prepareRouteMotion(event: RouteMotionEvent): RouteMotion {
129
152
  try {
130
153
  const preset = options?.preset ?? "glide";
131
154
  const mobile = window.matchMedia?.("(max-width: 768px)").matches;
155
+ const target = getRouteMotionTarget(heading);
132
156
  if (
133
- !heading ||
157
+ !target ||
134
158
  preset === "none" ||
159
+ (options?.content && preset === "restore") ||
135
160
  options?.skip ||
136
161
  options?.browserMotion ||
137
162
  document.hidden ||
138
163
  window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ||
139
- typeof heading.animate !== "function" ||
140
- !isVisibleHeading(heading) ||
164
+ typeof target.animate !== "function" ||
141
165
  (mobile && preset === "restore")
142
166
  ) {
143
167
  animationDone.resolve();
144
168
  return;
145
169
  }
146
- const spec = headingMotions[preset];
170
+ const spec = options?.content ? regionMotions[preset] : headingMotions[preset];
147
171
  const keyframes: PropertyIndexedKeyframes = { opacity: [spec.opacity, 1] };
148
- // Phone routes use the specified pure fade. Unrelated article
149
- // links also fade: lateral motion requires explicit prev/next.
172
+ // Phones use a pure fade. Explicit previous/next alone imply a
173
+ // lateral reading direction; history never replays the entry.
150
174
  if (!mobile && (spec.x || spec.y)) {
151
175
  keyframes.transform = [
152
176
  `translate3d(${spec.x ?? 0}px, ${spec.y ?? 0}px, 0)`,
153
177
  "translate3d(0px, 0px, 0)",
154
178
  ];
155
179
  }
156
- animation = heading.animate(keyframes, {
157
- duration: mobile ? 80 : spec.duration,
180
+ animation = target.animate(keyframes, {
181
+ duration: mobile ? (options?.content ? 120 : 80) : spec.duration,
158
182
  easing: "cubic-bezier(0.2, 0.7, 0.2, 1)",
159
183
  fill: "both",
160
184
  });
@@ -0,0 +1,40 @@
1
+ import { getRouteMotionTarget } from "./route-motion";
2
+
3
+ /** Fade the old content only after destination assets are ready. */
4
+ export async function animateRouteOut(
5
+ element: HTMLElement | null,
6
+ signal: AbortSignal,
7
+ ): Promise<() => void> {
8
+ if (
9
+ !element ||
10
+ signal.aborted ||
11
+ document.hidden ||
12
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
13
+ )
14
+ return () => {};
15
+ const target = getRouteMotionTarget(element);
16
+ if (!target || typeof target.animate !== "function") return () => {};
17
+ let animation: Animation | undefined;
18
+ const cancel = () => animation?.cancel();
19
+ const mobile = window.matchMedia("(max-width: 768px)").matches;
20
+ try {
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" },
26
+ );
27
+ signal.addEventListener("abort", cancel, { once: true });
28
+ await animation.finished;
29
+ // Keep the bounded outgoing cue until swap; the body stays readable.
30
+ // Swup detaches this node; no persistent shell is animated.
31
+ } catch {
32
+ /* Cancellation and unsupported animation must not block a visit. */
33
+ } finally {
34
+ if (signal.aborted) cancel();
35
+ }
36
+ return () => {
37
+ signal.removeEventListener("abort", cancel);
38
+ cancel();
39
+ };
40
+ }
@@ -0,0 +1,52 @@
1
+ let stopActive: (() => void) | undefined;
2
+
3
+ export function cancelPageScroll(): void {
4
+ stopActive?.();
5
+ }
6
+
7
+ /** Native scrolling with bounded completion and explicit listener cleanup. */
8
+ export function scrollPage(
9
+ element: Element,
10
+ top: number,
11
+ left: number,
12
+ animate: boolean,
13
+ start: () => void,
14
+ end: () => void,
15
+ ): void {
16
+ stopActive?.();
17
+ start();
18
+ const smooth = animate && !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
19
+ if (
20
+ !smooth ||
21
+ (Math.abs(element.scrollTop - top) < 1 && Math.abs(element.scrollLeft - left) < 1)
22
+ ) {
23
+ element.scrollTo({ top, left, behavior: "instant" });
24
+ end();
25
+ return;
26
+ }
27
+ const target =
28
+ element === document.documentElement || element === document.body ? window : element;
29
+ let finished = false;
30
+ const done = () => {
31
+ if (finished) return;
32
+ finished = true;
33
+ clearTimeout(timer);
34
+ target.removeEventListener("scrollend", done);
35
+ window.removeEventListener("wheel", interrupt);
36
+ window.removeEventListener("touchstart", interrupt);
37
+ window.removeEventListener("keydown", interrupt);
38
+ if (stopActive === interrupt) stopActive = undefined;
39
+ end();
40
+ };
41
+ const interrupt = () => {
42
+ element.scrollTo({ top: element.scrollTop, left: element.scrollLeft, behavior: "instant" });
43
+ done();
44
+ };
45
+ const timer = setTimeout(done, 1500);
46
+ stopActive = interrupt;
47
+ target.addEventListener("scrollend", done, { once: true });
48
+ window.addEventListener("wheel", interrupt, { passive: true, once: true });
49
+ window.addEventListener("touchstart", interrupt, { passive: true, once: true });
50
+ window.addEventListener("keydown", interrupt, { once: true });
51
+ element.scrollTo({ top, left, behavior: "smooth" });
52
+ }