@xingwangzhe/stalux 1.20.1 → 1.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -193,7 +193,7 @@ When a WebMCP-aware browser opens your site, these read-only tools are registere
193
193
  | `stalux_current_post` | Metadata of the post currently being viewed | `/api/posts.json` |
194
194
  | `stalux_random_post` | Pick a random post's metadata | `/api/posts.json` |
195
195
  | `stalux_search_posts` | Full-text search across posts | Pagefind `/pagefind/` |
196
- | `stalux_read_post` | Fetch a post's raw Markdown | `/posts/{abbrlink}.md` |
196
+ | `stalux_read_post` | Fetch a post's normalized Markdown | `/posts/{abbrlink}.md` |
197
197
  | `stalux_site_info` | Site title, URL, description + pointers to `llms.txt` / `llms-full.txt` | `site.yml` (build-time) |
198
198
 
199
199
  All tools are `readOnlyHint: true` — they never modify any state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xingwangzhe/stalux",
3
- "version": "1.20.1",
3
+ "version": "1.20.3",
4
4
  "description": "A powerful, modern Astro blog theme — use as template or install as plugin",
5
5
  "keywords": [
6
6
  "astro",
@@ -78,7 +78,7 @@
78
78
  "@astrojs/rss": "^4.0.19",
79
79
  "@astrojs/sitemap": "^3.7.3",
80
80
  "@expressive-code/plugin-line-numbers": "^0.44.1",
81
- "@lucide/astro": "^1.28.0",
81
+ "@lucide/astro": "^1.31.0",
82
82
  "@mcp-b/webmcp-polyfill": "^4.0.0",
83
83
  "@pagefind/component-ui": "^1.5.2",
84
84
  "@waline/client": "^3.15.2",
@@ -99,9 +99,8 @@
99
99
  "temml": "^0.13.4"
100
100
  },
101
101
  "devDependencies": {
102
- "@types/node": "^26.1.2",
103
102
  "astro": "^7.2.0",
104
- "knip": "^6.31.0",
103
+ "knip": "^6.32.0",
105
104
  "license-checker-rseidelsohn": "5.0.1",
106
105
  "oxfmt": "^0.62.0",
107
106
  "oxlint": "^1.77.0",
@@ -157,9 +157,9 @@ if (isArticle) {
157
157
  const ldJson = JSON.stringify(schema, null, 2).replace(/<\//g, "<\\/");
158
158
  ---
159
159
 
160
- <head transition:persist-props>
160
+ <head>
161
161
  <meta charset="utf-8" />
162
- <ClientRouter />
162
+ <ClientRouter fallback="animate" />
163
163
  <SEO
164
164
  title={effectiveTitle}
165
165
  description={effectiveDescription}
@@ -66,10 +66,12 @@ const walineConfig = {
66
66
  class="waline-comment-container"
67
67
  aria-label={t("comment.title")}
68
68
  data-waline-config={
69
- // base64 可逆混淆(非加密,仅避免 SSR 注入/转义问题),兼容 Node 与浏览器
70
- Buffer.from(
71
- new TextEncoder().encode(JSON.stringify(walineConfig)),
72
- ).toString("base64")
69
+ // base64 可逆混淆(非加密,仅避免 SSR 注入/转义问题)
70
+ btoa(
71
+ Array.from(new TextEncoder().encode(JSON.stringify(walineConfig)), (byte) =>
72
+ String.fromCharCode(byte),
73
+ ).join(""),
74
+ )
73
75
  }
74
76
  >
75
77
  <h2 class="waline-comment-title">{t("comment.title")}</h2>
@@ -9,6 +9,7 @@ import { getSiteData, getAuthorData, getHeadData, getNavsData, getLinksData, get
9
9
 
10
10
  interface Props {
11
11
  title: string;
12
+ transitionName?: string;
12
13
  date?: string;
13
14
  updated?: string;
14
15
  readingTime?: string;
@@ -35,8 +36,8 @@ const mdSourceUrl = exportMd
35
36
 
36
37
  <article
37
38
  class={style.articleContent}
38
- transition:name="post-content"
39
- transition:animate={fade({ duration: "1.2s" })}>
39
+ transition:name={props.transitionName ?? "post-content"}
40
+ transition:animate={fade({ duration: "0.45s" })}>
40
41
  <!-- Article Header -->
41
42
  <header class={style.articleHeader}>
42
43
  <h1 class={style.title}>{props.title}</h1>
@@ -3,7 +3,7 @@ import PostAuthor from "@components/stalux/posts/postAuthor.astro";
3
3
  import style from "@styles/components/posts/postSidebar.module.css";
4
4
  import SocialMedia from "@components/stalux/links/socialMedia.astro";
5
5
  import RandomPost from "@components/stalux/posts/randomPost.astro";
6
- import { fade } from "astro:transitions";
6
+ import { getSidebarAnimation } from "@utils/view-transitions";
7
7
  // 判断是否为文章页面
8
8
  const isPostPage = Astro.url.pathname.includes("/posts/");
9
9
  ---
@@ -11,7 +11,7 @@ const isPostPage = Astro.url.pathname.includes("/posts/");
11
11
  <aside
12
12
  class={style.sidebar}
13
13
  transition:name="left-sidebar"
14
- transition:animate={fade({ duration: "0.3s" })}
14
+ transition:animate={getSidebarAnimation("left")}
15
15
  >
16
16
  <PostAuthor />
17
17
  <div class={style.sidebarContainer}>
@@ -3,7 +3,7 @@ import style from "@styles/components/posts/postSidebar.module.css";
3
3
  import Date from "@components/stalux/common/date.astro";
4
4
  import PostToc from "@components/stalux/posts/postToc.astro";
5
5
  import PostTaxonomy from "@components/stalux/posts/PostTaxonomy.astro";
6
- import { fade } from "astro:transitions";
6
+ import { getSidebarAnimation } from "@utils/view-transitions";
7
7
  interface Props {
8
8
  headings?: Array<{ depth: number; slug: string; text: string }>;
9
9
  tags?: string[];
@@ -16,7 +16,7 @@ const { headings = [], tags = [], categories = [] } = Astro.props;
16
16
  <aside
17
17
  class={style.sidebarRight}
18
18
  transition:name="right-sidebar"
19
- transition:animate={fade({ duration: "0.3s" })}
19
+ transition:animate={getSidebarAnimation("right")}
20
20
  >
21
21
  <div class={style.sidebarContainer}>
22
22
  <Date />
@@ -28,19 +28,6 @@ const tocItems = headings
28
28
  depth: Math.min(Math.max(item.depth ?? 1, 1), 6),
29
29
  }));
30
30
 
31
- // 定义目录内容的过渡动画 - 平滑褶皱效果
32
- const tocContentAnimation = {
33
- old: {
34
- name: "fadeSlideDown",
35
- duration: "0.3s",
36
- easing: "ease-out",
37
- },
38
- new: {
39
- name: "fadeSlideDown",
40
- duration: "0.3s",
41
- easing: "ease-out",
42
- },
43
- };
44
31
  ---
45
32
 
46
33
  <nav transition:name="toc-container" data-toc-active-class={style.active} aria-label={t("taxonomy.toc")}>
@@ -4,7 +4,9 @@ import Footer from "@components/stalux/layout/footer.astro";
4
4
  import { getCollection } from "astro:content";
5
5
  import "@styles/base/init.css";
6
6
  import "@styles/shared/stagger.css";
7
+ import "@styles/shared/view-transitions.css";
7
8
  import { getAuthorData, getAiDiscoveryData, getPromoteData, getSiteData } from "@utils/config-utils";
9
+ import { getRouteAnimation, getRouteKind } from "@utils/view-transitions";
8
10
  // font-subset.ts 构建期生成的 manifest 副本(进入 Astro 依赖图,
9
11
  // manifest 变化 → 页面重建 → 增量构建 restored 的页面不会引用旧字体资源)
10
12
  import fontsManifest from "../internal/generated/fonts-manifest.json";
@@ -76,6 +78,8 @@ const llmPromote = llmPromoteRaw
76
78
  ?.replace(/\{url\}/g, siteConfig.url)
77
79
  ?.replace(/\{title\}/g, siteConfig.title)
78
80
  ?.replace(/\{cc\}/g, "CC-BY-NC-SA-4.0");
81
+ const routeKind = getRouteKind(Astro.url.pathname);
82
+ const routeAnimation = getRouteAnimation(routeKind);
79
83
  ---
80
84
 
81
85
  <!doctype html>
@@ -97,6 +101,7 @@ const llmPromote = llmPromoteRaw
97
101
  {fontSubsetUrl && <link rel="stylesheet" href={fontSubsetUrl} />}
98
102
  <script>
99
103
  import "../scripts/background.ts";
104
+ import "../scripts/view-transitions.ts";
100
105
  </script>
101
106
  {
102
107
  aiConfig?.conformance !== "disabled" && (
@@ -128,9 +133,23 @@ const llmPromote = llmPromoteRaw
128
133
  data-stalux-bg-index="0"
129
134
  >
130
135
  {llmPromote && <div style="display:none">{llmPromote}</div>}
131
- <div class="bg-layer bg-a" style="opacity: 1;" aria-hidden="true"></div>
132
- <div class="bg-layer bg-b" aria-hidden="true"></div>
133
- <main class="stalux-main">
136
+ <div
137
+ class="bg-layer bg-a"
138
+ style="opacity: 1;"
139
+ transition:persist="stalux-bg-a"
140
+ aria-hidden="true"
141
+ ></div>
142
+ <div
143
+ class="bg-layer bg-b"
144
+ transition:persist="stalux-bg-b"
145
+ aria-hidden="true"
146
+ ></div>
147
+ <main
148
+ class="stalux-main"
149
+ data-stalux-route={routeKind}
150
+ transition:name="stalux-page"
151
+ transition:animate={routeAnimation}
152
+ >
134
153
  <slot />
135
154
  </main>
136
155
  <Footer />
@@ -26,18 +26,36 @@ const { t } = createTranslator(siteConfig.lang || "zh-CN");
26
26
  </div>
27
27
  </div>
28
28
 
29
- <script define:vars={{ countdownText: t("page404.countdown", { n: "{n}" }) }}>
30
- let seconds = 5;
31
- const el = document.getElementById("countdown");
32
- const timer = setInterval(() => {
33
- seconds--;
34
- if (seconds <= 0) {
35
- clearInterval(timer);
36
- window.location.href = "/";
37
- } else {
38
- el.textContent = countdownText.replace("{n}", String(seconds));
39
- }
40
- }, 1000);
29
+ <script define:vars={{ countdownText: t("page404.countdown", { n: "{n}" }) }} is:inline>
30
+ let timer: number | undefined;
31
+
32
+ function startCountdown() {
33
+ if (timer !== undefined) window.clearInterval(timer);
34
+ let seconds = 5;
35
+ const el = document.getElementById("countdown");
36
+ if (!el) return;
37
+
38
+ timer = window.setInterval(() => {
39
+ seconds--;
40
+ if (seconds <= 0) {
41
+ if (timer !== undefined) window.clearInterval(timer);
42
+ timer = undefined;
43
+ const home = document.querySelector<HTMLAnchorElement>(".home-btn");
44
+ home?.click();
45
+ } else {
46
+ el.textContent = countdownText.replace("{n}", String(seconds));
47
+ }
48
+ }, 1000);
49
+ }
50
+
51
+ function stopCountdown() {
52
+ if (timer !== undefined) window.clearInterval(timer);
53
+ timer = undefined;
54
+ }
55
+
56
+ document.addEventListener("astro:before-swap", stopCountdown);
57
+ document.addEventListener("astro:page-load", startCountdown);
58
+ startCountdown();
41
59
  </script>
42
60
  </Stalux>
43
61
 
@@ -35,6 +35,7 @@ const author = authorConfig.name;
35
35
  <div style="max-width: 900px; margin: 2rem auto; padding: 0 1rem;">
36
36
  <PostContent
37
37
  title={aboutPage.data.title}
38
+ transitionName="about-content"
38
39
  date={undefined}
39
40
  updated={undefined}
40
41
  readingTime={undefined}
@@ -1,6 +1,3 @@
1
- import { readFile } from "node:fs/promises";
2
- import path from "node:path";
3
-
4
1
  import { isMarkdownExportEnabled, loadConfig, type ConfigMap } from "@utils/ai-discovery";
5
2
  import { buildCCName, buildCCLink } from "@utils/cc";
6
3
  import { createTranslator } from "@utils/i18n";
@@ -89,15 +86,11 @@ export const GET: APIRoute = async ({ props }) => {
89
86
 
90
87
  const { post } = props as { post: CollectionEntry<"posts"> };
91
88
 
92
- let markdown: string;
93
- if (post.filePath) {
94
- const abs = path.resolve(process.cwd(), post.filePath);
95
- markdown = await readFile(abs, "utf-8");
96
- } else {
97
- markdown = reconstructMarkdown(post);
98
- }
89
+ // 内容集合已经提供解析后的 frontmatter 和正文,导出一份内容等价的 Markdown。
90
+ // 不读取 post.filePath,避免把页面端点绑定到 Node 文件系统。
91
+ let markdown = reconstructMarkdown(post);
99
92
 
100
- // 追加版权脚注(不修改原始 md 文件)
93
+ // 追加版权脚注(不修改内容源文件)
101
94
  const footer = generateCCFooter(post, config);
102
95
  markdown += "\n\n---\n\n" + footer;
103
96
 
@@ -38,27 +38,49 @@ function setLayerOpacity(layer: "a" | "b", opacity: number): void {
38
38
  if (el) el.style.opacity = String(opacity);
39
39
  }
40
40
 
41
- function applyBackground(): void {
42
- // 客户端运行时随机选图,构建产物保持确定性
43
- const index = Math.floor(Math.random() * backgroundImages.length);
44
- const layer: "a" | "b" = "a";
45
- if (backgroundImages.length > 0 && backgroundImages[index]) {
46
- const el = getLayerEl(layer);
47
- if (el) el.style.backgroundImage = `url('${backgroundImages[index]}')`;
41
+ function getActiveLayer(): "a" | "b" {
42
+ const aOpacity = Number(getLayerEl("a")?.style.opacity ?? 0);
43
+ const bOpacity = Number(getLayerEl("b")?.style.opacity ?? 0);
44
+ return bOpacity > aOpacity ? "b" : "a";
45
+ }
46
+
47
+ function pickBackgroundIndex(previous: number): number {
48
+ if (backgroundImages.length < 2) return 0;
49
+ let index = Math.floor(Math.random() * backgroundImages.length);
50
+ while (index === previous) {
51
+ index = Math.floor(Math.random() * backgroundImages.length);
48
52
  }
49
- setLayerOpacity(layer, 1);
50
- setLayerOpacity("b", 0);
53
+ return index;
54
+ }
55
+
56
+ function crossfadeBackground(): void {
57
+ if (backgroundImages.length === 0) return;
58
+
59
+ const activeLayer = getActiveLayer();
60
+ const nextLayer: "a" | "b" = activeLayer === "a" ? "b" : "a";
61
+ const previousIndex = Number(document.body.dataset.staluxBgIndex ?? -1);
62
+ const index = pickBackgroundIndex(previousIndex);
63
+ const next = getLayerEl(nextLayer);
64
+ if (!next || !backgroundImages[index]) return;
65
+
66
+ next.style.backgroundImage = `url('${backgroundImages[index]}')`;
67
+ setLayerOpacity(nextLayer, 1);
68
+ setLayerOpacity(activeLayer, 0);
51
69
  document.body.dataset.staluxBgIndex = String(index);
52
- document.body.dataset.staluxBgLayer = layer;
70
+ document.body.dataset.staluxBgLayer = nextLayer;
71
+ }
72
+
73
+ function initializeBackground(): void {
74
+ const active = getLayerEl(getActiveLayer());
75
+ if (active?.style.backgroundImage) return;
76
+ crossfadeBackground();
53
77
  }
54
78
 
55
- // 首次加载与 VT 软导航都触发 astro:page-load:
56
- // - 首次加载:window load 后触发(router.js addEventListener("load", onPageLoad))。
57
- // - VT 软导航:swap 完成后、过渡动画期间触发(router.js onPageLoad()),
58
- // 此时设置随机背景,过渡中旧图淡出、新图淡入,无跳变闪变。
59
- // 注意:软导航不会重新执行打包的 module 脚本(监听器仍是首访注册的),
60
- // 所以这里不能做“只执行一次”的去重,每次 page-load 都必须随机;
61
- // 若软导航后新脚本被重新执行,重复监听导致随机两次也无妨(仍是随机图)。
62
- // body 的 data-stalux-bg-* 是 SSR 固定占位(a/0,为了构建确定性),
63
- // 不能再用它判断“服务端已渲染正确背景”,否则随机逻辑永不执行。
64
- document.addEventListener("astro:page-load", applyBackground);
79
+ // before-swap 在 View Transition 的快照之后、DOM 替换之前触发;背景层使用
80
+ // transition:persist 保留,因此可以在页面内容交换期间完成双层 crossfade。
81
+ // 非 VT 浏览器也会触发该生命周期,fallback 同样保持平滑。
82
+ if (!(window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTENER__) {
83
+ (window as Window & { __STALUX_BG_LISTENER__?: boolean }).__STALUX_BG_LISTENER__ = true;
84
+ document.addEventListener("astro:before-swap", crossfadeBackground);
85
+ document.addEventListener("astro:page-load", initializeBackground);
86
+ }
@@ -0,0 +1,49 @@
1
+ import { getRouteKind } from "@utils/view-transitions";
2
+
3
+ interface TransitionWindow extends Window {
4
+ __STALUX_VT_LISTENER__?: boolean;
5
+ }
6
+
7
+ function setRouteState(pathname: string): void {
8
+ const html = document.documentElement;
9
+ const route = getRouteKind(pathname);
10
+ html.dataset.staluxRoute = route;
11
+ document.querySelector("main.stalux-main")?.setAttribute("data-stalux-route", route);
12
+ }
13
+
14
+ interface NavigationEvent {
15
+ from: URL;
16
+ to: URL;
17
+ direction: string;
18
+ }
19
+
20
+ function setNavigationState(event: NavigationEvent): void {
21
+ const from = getRouteKind(new URL(event.from, window.location.href).pathname);
22
+ const to = getRouteKind(new URL(event.to, window.location.href).pathname);
23
+ const html = document.documentElement;
24
+ html.dataset.staluxVtFrom = from;
25
+ html.dataset.staluxVtTo = to;
26
+ html.dataset.staluxVtDirection = event.direction;
27
+ }
28
+
29
+ const transitionWindow = window as TransitionWindow;
30
+ if (!transitionWindow.__STALUX_VT_LISTENER__) {
31
+ transitionWindow.__STALUX_VT_LISTENER__ = true;
32
+
33
+ document.addEventListener("astro:before-preparation", (event) => {
34
+ setNavigationState(event as NavigationEvent);
35
+ });
36
+
37
+ document.addEventListener("astro:after-swap", () => {
38
+ setRouteState(window.location.pathname);
39
+ });
40
+
41
+ document.addEventListener("astro:page-load", () => {
42
+ setRouteState(window.location.pathname);
43
+ delete document.documentElement.dataset.staluxVtFrom;
44
+ delete document.documentElement.dataset.staluxVtTo;
45
+ delete document.documentElement.dataset.staluxVtDirection;
46
+ });
47
+ }
48
+
49
+ setRouteState(window.location.pathname);
@@ -435,10 +435,10 @@ function readPostTool(): WebMCPTool {
435
435
  name: "stalux_read_post",
436
436
  title: pick("读取文章 Markdown", "Read post Markdown"),
437
437
  description: pick(
438
- "按 abbrlink(文章永久链接 ID)读取一篇博客文章的原始 Markdown 全文,包含 frontmatter 与版权脚注。" +
438
+ "按 abbrlink(文章永久链接 ID)读取一篇博客文章的 Markdown 全文,包含规范化 frontmatter、正文与版权脚注。" +
439
439
  "需要文章完整内容、引用或摘要时使用;只要元信息用 stalux_get_post(更便宜)。" +
440
440
  "获取 abbrlink 可先用 stalux_list_posts 或 stalux_search_posts。",
441
- "Read the full raw Markdown of a post by abbrlink (permanent link ID), including frontmatter and copyright footer. " +
441
+ "Read the full Markdown of a post by abbrlink (permanent link ID), including normalized frontmatter, body and copyright footer. " +
442
442
  "Use it for full content, quotes or summarization; for metadata only, stalux_get_post is cheaper. " +
443
443
  "To get an abbrlink, first use stalux_list_posts or stalux_search_posts.",
444
444
  ),
@@ -263,7 +263,7 @@ ul {
263
263
  background: #2a2a2a;
264
264
  }
265
265
 
266
- /* 背景层 - 双层随机背景(不在 View Transition 中 crossfade,避免 SVG 双倍光栅化卡顿) */
266
+ /* 背景层 - 双层随机背景,在路由切换时交叉淡入淡出 */
267
267
  .bg-layer {
268
268
  position: fixed;
269
269
  inset: 0;
@@ -275,12 +275,19 @@ ul {
275
275
  filter: var(--stalux-bg-filter, none);
276
276
  pointer-events: none;
277
277
  opacity: 0;
278
+ transition: opacity 650ms ease-in-out;
278
279
  /* 禁止浏览器强制色彩模式(高对比度/暗黑模式)调整背景图颜色 */
279
280
  forced-color-adjust: none;
280
281
  /* 关键:防止 Safari/WebKit 在暗色模式下自动调整 SVG 背景图颜色 */
281
282
  color-scheme: only light;
282
283
  }
283
284
 
285
+ @media (prefers-reduced-motion: reduce) {
286
+ .bg-layer {
287
+ transition-duration: 0ms;
288
+ }
289
+ }
290
+
284
291
  /* footer 基础样式,保证页脚始终可见且不覆盖内容 */
285
292
  footer {
286
293
  padding: var(--space-lg) var(--space-md);
@@ -0,0 +1,356 @@
1
+ /* 页面外壳的路由专属入场动画;列表和卡片继续使用各自的 stagger。 */
2
+ @keyframes stalux-sidebar-left-out {
3
+ from {
4
+ opacity: 1;
5
+ transform: translateX(0);
6
+ }
7
+ to {
8
+ opacity: 0;
9
+ transform: translateX(-34px);
10
+ }
11
+ }
12
+
13
+ @keyframes stalux-sidebar-left-in {
14
+ from {
15
+ opacity: 0;
16
+ transform: translateX(-34px);
17
+ }
18
+ to {
19
+ opacity: 1;
20
+ transform: translateX(0);
21
+ }
22
+ }
23
+
24
+ @keyframes stalux-sidebar-left-back-in {
25
+ from {
26
+ opacity: 0;
27
+ transform: translateX(34px);
28
+ }
29
+ to {
30
+ opacity: 1;
31
+ transform: translateX(0);
32
+ }
33
+ }
34
+
35
+ @keyframes stalux-sidebar-left-back-out {
36
+ from {
37
+ opacity: 1;
38
+ transform: translateX(0);
39
+ }
40
+ to {
41
+ opacity: 0;
42
+ transform: translateX(34px);
43
+ }
44
+ }
45
+
46
+ @keyframes stalux-sidebar-right-in {
47
+ from {
48
+ opacity: 0;
49
+ transform: translateX(34px);
50
+ }
51
+ to {
52
+ opacity: 1;
53
+ transform: translateX(0);
54
+ }
55
+ }
56
+
57
+ @keyframes stalux-sidebar-right-back-in {
58
+ from {
59
+ opacity: 0;
60
+ transform: translateX(-34px);
61
+ }
62
+ to {
63
+ opacity: 1;
64
+ transform: translateX(0);
65
+ }
66
+ }
67
+
68
+ @keyframes stalux-sidebar-right-out {
69
+ from {
70
+ opacity: 1;
71
+ transform: translateX(0);
72
+ }
73
+ to {
74
+ opacity: 0;
75
+ transform: translateX(34px);
76
+ }
77
+ }
78
+
79
+ @keyframes stalux-sidebar-right-back-out {
80
+ from {
81
+ opacity: 1;
82
+ transform: translateX(0);
83
+ }
84
+ to {
85
+ opacity: 0;
86
+ transform: translateX(-34px);
87
+ }
88
+ }
89
+
90
+ @keyframes stalux-page-fade-out {
91
+ from {
92
+ opacity: 1;
93
+ }
94
+ to {
95
+ opacity: 0;
96
+ }
97
+ }
98
+
99
+ @keyframes stalux-page-home-in {
100
+ from {
101
+ opacity: 0;
102
+ transform: scale(0.98);
103
+ }
104
+ to {
105
+ opacity: 1;
106
+ transform: scale(1);
107
+ }
108
+ }
109
+
110
+ @keyframes stalux-page-home-back-in {
111
+ from {
112
+ opacity: 0;
113
+ transform: scale(1.02);
114
+ }
115
+ to {
116
+ opacity: 1;
117
+ transform: scale(1);
118
+ }
119
+ }
120
+
121
+ @keyframes stalux-page-vertical-in {
122
+ from {
123
+ opacity: 0;
124
+ transform: translateY(14px);
125
+ }
126
+ to {
127
+ opacity: 1;
128
+ transform: translateY(0);
129
+ }
130
+ }
131
+
132
+ @keyframes stalux-page-vertical-back-in {
133
+ from {
134
+ opacity: 0;
135
+ transform: translateY(-14px);
136
+ }
137
+ to {
138
+ opacity: 1;
139
+ transform: translateY(0);
140
+ }
141
+ }
142
+
143
+ @keyframes stalux-page-grid-in {
144
+ from {
145
+ opacity: 0;
146
+ transform: translateY(8px) scale(0.975);
147
+ }
148
+ to {
149
+ opacity: 1;
150
+ transform: translateY(0) scale(1);
151
+ }
152
+ }
153
+
154
+ @keyframes stalux-page-category-grid-in {
155
+ from {
156
+ opacity: 0;
157
+ transform: translateY(8px) scale(0.975);
158
+ }
159
+ to {
160
+ opacity: 1;
161
+ transform: translateY(0) scale(1);
162
+ }
163
+ }
164
+
165
+ @keyframes stalux-page-grid-back-in {
166
+ from {
167
+ opacity: 0;
168
+ transform: translateY(-8px) scale(0.985);
169
+ }
170
+ to {
171
+ opacity: 1;
172
+ transform: translateY(0) scale(1);
173
+ }
174
+ }
175
+
176
+ @keyframes stalux-page-category-grid-back-in {
177
+ from {
178
+ opacity: 0;
179
+ transform: translateY(-8px) scale(0.985);
180
+ }
181
+ to {
182
+ opacity: 1;
183
+ transform: translateY(0) scale(1);
184
+ }
185
+ }
186
+
187
+ @keyframes stalux-page-rise-in {
188
+ from {
189
+ opacity: 0;
190
+ transform: translateY(18px);
191
+ }
192
+ to {
193
+ opacity: 1;
194
+ transform: translateY(0);
195
+ }
196
+ }
197
+
198
+ @keyframes stalux-page-rise-back-in {
199
+ from {
200
+ opacity: 0;
201
+ transform: translateY(-18px);
202
+ }
203
+ to {
204
+ opacity: 1;
205
+ transform: translateY(0);
206
+ }
207
+ }
208
+
209
+ @keyframes stalux-page-content-in {
210
+ from {
211
+ opacity: 0;
212
+ transform: translateY(10px);
213
+ }
214
+ to {
215
+ opacity: 1;
216
+ transform: translateY(0);
217
+ }
218
+ }
219
+
220
+ @keyframes stalux-page-content-back-in {
221
+ from {
222
+ opacity: 0;
223
+ transform: translateY(-10px);
224
+ }
225
+ to {
226
+ opacity: 1;
227
+ transform: translateY(0);
228
+ }
229
+ }
230
+
231
+ @keyframes stalux-page-tag-cloud-in {
232
+ from {
233
+ opacity: 0;
234
+ transform: scale(0.985);
235
+ }
236
+ to {
237
+ opacity: 1;
238
+ transform: scale(1);
239
+ }
240
+ }
241
+
242
+ @keyframes stalux-page-tag-cloud-back-in {
243
+ from {
244
+ opacity: 0;
245
+ transform: scale(1.015);
246
+ }
247
+ to {
248
+ opacity: 1;
249
+ transform: scale(1);
250
+ }
251
+ }
252
+
253
+ @keyframes stalux-page-from-right-in {
254
+ from {
255
+ opacity: 0;
256
+ transform: translateX(22px);
257
+ }
258
+ to {
259
+ opacity: 1;
260
+ transform: translateX(0);
261
+ }
262
+ }
263
+
264
+ @keyframes stalux-page-from-left-in {
265
+ from {
266
+ opacity: 0;
267
+ transform: translateX(-22px);
268
+ }
269
+ to {
270
+ opacity: 1;
271
+ transform: translateX(0);
272
+ }
273
+ }
274
+
275
+ @keyframes stalux-page-article-in {
276
+ from {
277
+ opacity: 0;
278
+ transform: translateY(6px);
279
+ }
280
+ to {
281
+ opacity: 1;
282
+ transform: translateY(0);
283
+ }
284
+ }
285
+
286
+ @keyframes stalux-page-article-back-in {
287
+ from {
288
+ opacity: 0;
289
+ transform: translateY(-6px);
290
+ }
291
+ to {
292
+ opacity: 1;
293
+ transform: translateY(0);
294
+ }
295
+ }
296
+
297
+ @keyframes stalux-page-error-in {
298
+ from {
299
+ opacity: 0;
300
+ transform: scale(0.97);
301
+ }
302
+ to {
303
+ opacity: 1;
304
+ transform: scale(1);
305
+ }
306
+ }
307
+
308
+ @keyframes stalux-page-error-back-in {
309
+ from {
310
+ opacity: 0;
311
+ transform: scale(0.97);
312
+ }
313
+ to {
314
+ opacity: 1;
315
+ transform: scale(1);
316
+ }
317
+ }
318
+
319
+ @keyframes stalux-page-fade-in {
320
+ from {
321
+ opacity: 0;
322
+ }
323
+ to {
324
+ opacity: 1;
325
+ }
326
+ }
327
+
328
+ @keyframes stalux-page-fade-back-in {
329
+ from {
330
+ opacity: 0;
331
+ }
332
+ to {
333
+ opacity: 1;
334
+ }
335
+ }
336
+
337
+ ::view-transition-group(stalux-page) {
338
+ z-index: 1;
339
+ }
340
+
341
+ ::view-transition-old(stalux-page),
342
+ ::view-transition-new(stalux-page) {
343
+ transform-origin: center top;
344
+ }
345
+
346
+ @media (prefers-reduced-motion: reduce) {
347
+ ::view-transition-group(stalux-page),
348
+ ::view-transition-old(stalux-page),
349
+ ::view-transition-new(stalux-page) {
350
+ animation: none !important;
351
+ }
352
+
353
+ .stalux-main {
354
+ animation: none !important;
355
+ }
356
+ }
@@ -0,0 +1,134 @@
1
+ export type RouteKind =
2
+ | "home"
3
+ | "archive"
4
+ | "links"
5
+ | "words"
6
+ | "about"
7
+ | "tags-index"
8
+ | "tags-detail"
9
+ | "categories-index"
10
+ | "categories-detail"
11
+ | "article"
12
+ | "not-found"
13
+ | "other";
14
+
15
+ export type TransitionAnimation = {
16
+ name: string;
17
+ delay?: number | string;
18
+ duration?: number | string;
19
+ easing?: string;
20
+ fillMode?: string;
21
+ direction?: string;
22
+ };
23
+
24
+ export type SidebarSide = "left" | "right";
25
+
26
+ export type RouteAnimation = {
27
+ forwards: {
28
+ old: TransitionAnimation | TransitionAnimation[];
29
+ new: TransitionAnimation | TransitionAnimation[];
30
+ };
31
+ backwards: {
32
+ old: TransitionAnimation | TransitionAnimation[];
33
+ new: TransitionAnimation | TransitionAnimation[];
34
+ };
35
+ };
36
+
37
+ const easing = "cubic-bezier(0.25, 0.9, 0.2, 1)";
38
+ const exitAnimation: TransitionAnimation = {
39
+ name: "stalux-page-fade-out",
40
+ duration: "0.32s",
41
+ easing,
42
+ fillMode: "both",
43
+ };
44
+
45
+ function createRouteAnimation(name: string, backName = `${name}-back`): RouteAnimation {
46
+ return {
47
+ forwards: {
48
+ old: exitAnimation,
49
+ new: {
50
+ name: `stalux-page-${name}-in`,
51
+ duration: "0.42s",
52
+ easing,
53
+ fillMode: "both",
54
+ },
55
+ },
56
+ backwards: {
57
+ old: exitAnimation,
58
+ new: {
59
+ name: `stalux-page-${backName}-in`,
60
+ duration: "0.42s",
61
+ easing,
62
+ fillMode: "both",
63
+ },
64
+ },
65
+ };
66
+ }
67
+
68
+ function normalizedPath(pathname: string): string {
69
+ const path = pathname.replace(/\/+$/, "");
70
+ return path || "/";
71
+ }
72
+
73
+ export function getRouteKind(pathname: string): RouteKind {
74
+ const path = normalizedPath(pathname);
75
+
76
+ if (path === "/") return "home";
77
+ if (path === "/archives") return "archive";
78
+ if (path === "/links") return "links";
79
+ if (path === "/words") return "words";
80
+ if (path === "/about") return "about";
81
+ if (path === "/404") return "not-found";
82
+ if (path === "/tags") return "tags-index";
83
+ if (path.startsWith("/tags/")) return "tags-detail";
84
+ if (path === "/categories") return "categories-index";
85
+ if (path.startsWith("/categories/")) return "categories-detail";
86
+ if (path.startsWith("/posts/")) return "article";
87
+
88
+ return "other";
89
+ }
90
+
91
+ const routeAnimations: Record<RouteKind, RouteAnimation> = {
92
+ home: createRouteAnimation("home", "home-back"),
93
+ archive: createRouteAnimation("vertical", "vertical-back"),
94
+ links: createRouteAnimation("grid", "grid-back"),
95
+ words: createRouteAnimation("rise", "rise-back"),
96
+ about: createRouteAnimation("content", "content-back"),
97
+ "tags-index": createRouteAnimation("tag-cloud", "tag-cloud-back"),
98
+ "tags-detail": createRouteAnimation("from-right", "from-left"),
99
+ "categories-index": createRouteAnimation("category-grid", "category-grid-back"),
100
+ "categories-detail": createRouteAnimation("from-left", "from-right"),
101
+ article: createRouteAnimation("article", "article-back"),
102
+ "not-found": createRouteAnimation("error", "error-back"),
103
+ other: createRouteAnimation("fade", "fade-back"),
104
+ };
105
+
106
+ export function getRouteAnimation(routeKind: RouteKind): RouteAnimation {
107
+ return routeAnimations[routeKind];
108
+ }
109
+
110
+ function createSidebarAnimation(
111
+ side: SidebarSide,
112
+ phase: "in" | "out",
113
+ backwards: boolean,
114
+ ): TransitionAnimation {
115
+ return {
116
+ name: `stalux-sidebar-${side}-${backwards ? "back-" : ""}${phase}`,
117
+ duration: "0.8s",
118
+ easing,
119
+ fillMode: "both",
120
+ };
121
+ }
122
+
123
+ export function getSidebarAnimation(side: SidebarSide): RouteAnimation {
124
+ return {
125
+ forwards: {
126
+ old: createSidebarAnimation(side, "out", false),
127
+ new: createSidebarAnimation(side, "in", false),
128
+ },
129
+ backwards: {
130
+ old: createSidebarAnimation(side, "out", true),
131
+ new: createSidebarAnimation(side, "in", true),
132
+ },
133
+ };
134
+ }