@xingwangzhe/stalux 1.43.1 → 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 (85) 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/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 +1 -1
  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 +44 -24
  73. package/src/scripts/route-out.ts +11 -4
  74. package/src/scripts/swup-router.ts +11 -10
  75. package/src/scripts/tags-cloud.ts +8 -3
  76. package/src/styles/base.css +2 -2
  77. package/src/styles/generated-article.css +1 -1
  78. package/src/styles/generated-links.css +1 -1
  79. package/src/styles/generated-waline.css +1 -1
  80. package/src/styles/generated-words.css +1 -1
  81. package/src/styles/generated.css +1 -1
  82. package/src/styles/integrations.css +1 -17
  83. package/src/styles/module-motion.css +174 -0
  84. package/src/styles/motion.css +2 -26
  85. 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,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,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. */
@@ -125,12 +125,15 @@ export function installSwupRouter(): Swup {
125
125
  in: async (_done, { visit }) => {
126
126
  const context = contexts.get(visit);
127
127
  if (!context?.motion) return;
128
- context.motion.start(document.querySelector<HTMLElement>(MAIN), {
129
- preset: context.preset,
130
- content: true,
131
- browserMotion: context.browserMotion,
132
- skip: context.controller.signal.aborted,
133
- });
128
+ context.motion.start(
129
+ document.querySelector<HTMLElement>("[data-stalux-motion-focus]"),
130
+ {
131
+ preset: context.preset,
132
+ content: true,
133
+ browserMotion: context.browserMotion,
134
+ skip: context.controller.signal.aborted,
135
+ },
136
+ );
134
137
  await context.motion.finished;
135
138
  },
136
139
  },
@@ -251,7 +254,7 @@ export function installSwupRouter(): Swup {
251
254
  // Keep the old page readable until HTML, styles and fonts are ready.
252
255
  if (context.navigationType !== "traverse" && context.preset !== "none") {
253
256
  context.releaseOutgoing = await animateRouteOut(
254
- document.querySelector<HTMLElement>(MAIN),
257
+ document.querySelector<HTMLElement>("[data-stalux-motion-focus]"),
255
258
  context.controller.signal,
256
259
  );
257
260
  if (context.controller.signal.aborted || visit !== swup.visit) {
@@ -266,8 +269,6 @@ export function installSwupRouter(): Swup {
266
269
  html.dataset.staluxVtPreset = context.preset;
267
270
  context.motion = prepareRouteMotion(dispatchNavigation("astro:before-swap", context, next));
268
271
  if (context.controller.signal.aborted) return;
269
- if (context.navigationType !== "traverse" && context.preset !== "none")
270
- html.dataset.staluxMotionPending = "";
271
272
  await replace(visit, args);
272
273
  context.releaseOutgoing?.();
273
274
  if (context.controller.signal.aborted) return;
@@ -327,7 +328,7 @@ export function installSwupRouter(): Swup {
327
328
  const context = contexts.get(visit);
328
329
  if (!context || context !== activeContext || visit !== swup.visit) return;
329
330
  delete document.documentElement.dataset.staluxMotionPending;
330
- context?.motion?.start(document.querySelector<HTMLElement>(MAIN), {
331
+ context?.motion?.start(document.querySelector<HTMLElement>("[data-stalux-motion-focus]"), {
331
332
  preset: context.preset,
332
333
  content: true,
333
334
  skip: !visit.animation.animate || context.controller.signal.aborted,
@@ -35,16 +35,18 @@ function parseTags(value: string | undefined): LinkTag[] {
35
35
  }
36
36
  }
37
37
 
38
- registerPageLifecycle("tags-cloud", () => {
38
+ export function mountTagsCloud(): (() => void) | undefined {
39
39
  const container = document.getElementById("tags-canvas");
40
40
  if (!container) return;
41
41
  const desktop = window.matchMedia("(min-width: 769px)");
42
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
42
43
  const controller = new AbortController();
43
44
  let cloud: import("@xingwangzhe/tags-cloud").TagCloud | undefined;
44
45
  let visible = false;
45
46
  let loading = false;
46
47
  let disposed = false;
47
- const active = () => !disposed && desktop.matches && visible && !document.hidden;
48
+ const active = () =>
49
+ !disposed && desktop.matches && !reducedMotion.matches && visible && !document.hidden;
48
50
  const update = async () => {
49
51
  if (!active()) {
50
52
  // destroy also cancels the library's rAF; pause() only skips drawing.
@@ -94,6 +96,7 @@ registerPageLifecycle("tags-cloud", () => {
94
96
  );
95
97
  observer.observe(container);
96
98
  desktop.addEventListener("change", update, { signal: controller.signal });
99
+ reducedMotion.addEventListener("change", update, { signal: controller.signal });
97
100
  document.addEventListener("visibilitychange", update, { signal: controller.signal });
98
101
  return () => {
99
102
  disposed = true;
@@ -101,4 +104,6 @@ registerPageLifecycle("tags-cloud", () => {
101
104
  observer.disconnect();
102
105
  cloud?.destroy();
103
106
  };
104
- });
107
+ }
108
+
109
+ registerPageLifecycle("tags-cloud", mountTagsCloud);
@@ -272,7 +272,7 @@
272
272
  background: #2a2a2a;
273
273
  }
274
274
 
275
- /* 背景层 - 双层随机背景,在路由切换时交叉淡入淡出 */
275
+ /* 持久背景层:完整文档首次解码后淡显,软导航保持原图。 */
276
276
  .bg-layer {
277
277
  position: fixed;
278
278
  inset: 0;
@@ -284,7 +284,7 @@
284
284
  filter: var(--stalux-bg-filter, none);
285
285
  pointer-events: none;
286
286
  opacity: 0;
287
- transition: opacity 650ms ease-in-out;
287
+ transition: opacity 200ms ease-out;
288
288
  /* 禁止浏览器强制色彩模式(高对比度/暗黑模式)调整背景图颜色 */
289
289
  forced-color-adjust: none;
290
290
  /* 关键:防止 Safari/WebKit 在暗色模式下自动调整 SVG 背景图颜色 */