feat(motion): 动效层三件——背景辉光/入场/心跳,页脚撤双入口

- 背景鼠标辉光:ink 色 4% 径向渐变跟随(pointer:fine×非
  reduced-motion 才有 JS,raf 节流 document 级监听 VT 存活)——
  丙灰阶语言的"活背景",触屏/减动效自动隐藏
- 正文入场 rise-in 12px/0.4s(VT 换页自然重放)+ 列表行悬停
  1px 微浮;全量 prefers-reduced-motion 豁免
- 首页"最近心跳":构建期拉 blog 仓最新 commit(gitea API 8s 超时
  静默降级),脉冲点+相对时长——本站自己的脉搏
- 页脚撤「装备/制作」入口(页面保留,用户决策:只删入口)
- E2E 加固:g-chords 修好后 location.href 与 goto 竞态(en 页弦
  导航迟到提交打断 goto)——中断重试模式
- 过程教训:str.replace 静默无操作再犯(锚点瞄错组件文件)——
  脚本未进产物却报"全落地",replace 必须 assert 配对 grep 产物

验证: --mx 鼠标双跳 15.6% 实测跟随;E2E 31/31 PASS(竞态重试后
      确定性);构建 34 页 exit 0

Spec: none (动效层+页脚撤口+测试加固,车道级交付)

Co-authored-by: GLM-5.3
This commit is contained in:
wait4xandGLM-5.3 committed 2026-10-25 22:09:41 +08:00
1 parent 0b6524f53c
commit 363cc616f7
6 files changed
+110 -5

No files matched your search

-4
View File
@@ -20,10 +20,6 @@ const locale = propLocale ?? localeOf(Astro.url.pathname);
<a href="https://beian.miit.gov.cn/" rel="noopener noreferrer" target="_blank" class="link"> <a href="https://beian.miit.gov.cn/" rel="noopener noreferrer" target="_blank" class="link">
{t('footer_icp', locale)} {t('footer_icp', locale)}
</a> </a>
·
<a href={locale === 'en' ? '/en/stack/' : '/stack/'} class="link">{t('footer_stack', locale)}</a>
·
<a href={locale === 'en' ? '/en/colophon/' : '/colophon/'} class="link">{t('footer_colophon', locale)}</a>
</p> </p>
<!-- T7 注入:built from {commit} · {time} · Astro(链到 gitea commit) --> <!-- T7 注入:built from {commit} · {time} · Astro(链到 gitea commit) -->
<slot name="build-info" /> <slot name="build-info" />
+19
View File
@@ -44,6 +44,22 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
<meta name="theme-color" content="#191d23" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#191d23" media="(prefers-color-scheme: dark)" />
<title>{title}</title> <title>{title}</title>
<ThemeScript /> <ThemeScript />
<script is:inline>
// 背景辉光跟随(一次性注册:document 级监听 × raf 节流,VT 换页存活);
// pointer:fine 且非 reduced-motion 才启用,其余设备辉光层保持隐藏
if (matchMedia('(pointer: fine)').matches && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
let raf = 0, mx = 50, my = 30;
document.addEventListener('mousemove', (e) => {
mx = (e.clientX / innerWidth) * 100;
my = (e.clientY / innerHeight) * 100;
if (!raf) raf = requestAnimationFrame(() => {
raf = 0;
document.documentElement.style.setProperty('--mx', mx + '%');
document.documentElement.style.setProperty('--my', my + '%');
});
}, { passive: true });
}
</script>
<ClientRouter /> <ClientRouter />
<!-- 非阻塞字体 CSS:preload+onload 回切(Lighthouse P0-1:渲染阻塞 708KB 元凶) --> <!-- 非阻塞字体 CSS:preload+onload 回切(Lighthouse P0-1:渲染阻塞 708KB 元凶) -->
<link rel="preload" as="style" href={sansVar} onload="this.rel='stylesheet'" /> <link rel="preload" as="style" href={sansVar} onload="this.rel='stylesheet'" />
@@ -76,6 +92,9 @@ const withSlash = (p: string) => (p.endsWith('/') ? p : `${p}/`);
<link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} /> <link rel="alternate" hreflang="x-default" href={new URL(withSlash(zhPath), Astro.site)} />
</head> </head>
<body> <body>
<!-- 背景辉光:跟随鼠标的 ink 色径向渐变(丙灰阶语言,3-4% 透明度;
pointer:fine + 非 reduced-motion 才有 JS 驱动,其余静态隐藏) -->
<div class="bg-glow" aria-hidden="true"></div>
<a class="skip-link" href="#main">跳到正文</a> <a class="skip-link" href="#main">跳到正文</a>
<Header /> <Header />
<main id="main" class="container"> <main id="main" class="container">
+38
View File
@@ -0,0 +1,38 @@
/**
* 首页"最近心跳":构建期拉 blog 仓最新 commit——本站自己的脉搏。
* 失败静默(返回 null,首页渲染"很久以前"兜底文案),绝不阻塞构建。
*/
export interface Heartbeat {
/** commit 首行(截 40 字符) */
msg: string;
/** commit 时间(ISO) */
when: string;
}
const API = 'https://git.wait4x.online/api/v1/repos/wait4x/blog/commits?limit=1';
export async function fetchHeartbeat(): Promise<Heartbeat | null> {
try {
const res = await fetch(API, {
headers: { Accept: 'application/json', 'User-Agent': 'wait4x-blog-build' },
signal: AbortSignal.timeout(8_000),
});
if (!res.ok) return null;
const [c] = (await res.json()) as Array<{ commit?: { message?: string; author?: { date?: string } } }>;
const msg = c?.commit?.message?.split('\n')[0]?.slice(0, 40);
const when = c?.commit?.author?.date;
return msg && when ? { msg, when } : null;
} catch {
return null;
}
}
/** 相对时长(中文短句;en 页由调用方换词条) */
export function agoZh(iso: string, now = Date.now()): string {
const h = Math.floor((now - new Date(iso).getTime()) / 3_600_000);
if (h < 1) return '刚刚';
if (h < 24) return `${h} 小时前`;
const d = Math.floor(h / 24);
if (d < 30) return `${d} 天前`;
return `${Math.floor(d / 30)} 个月前`;
}
+24
View File
@@ -6,6 +6,7 @@ import IdCard from '../components/IdCard.astro';
import PostList from '../components/PostList.astro'; import PostList from '../components/PostList.astro';
import ProjectWall from '../components/geek/ProjectWall.astro'; import ProjectWall from '../components/geek/ProjectWall.astro';
import Heatmap from '../components/geek/Heatmap.astro'; import Heatmap from '../components/geek/Heatmap.astro';
import { fetchHeartbeat, agoZh } from '../lib/heartbeat';
import { t } from '../i18n/ui'; import { t } from '../i18n/ui';
const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort( const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.tags.includes('hidden'))).sort(
@@ -13,6 +14,7 @@ const posts = (await getCollection('posts', ({ data }) => !data.draft && !data.t
); );
// 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切 // 最新影像取集合前 4 张;照片时间序由照片管线(exif 车道)治理,此处只裁切
const photos = (await getCollection('photos')).slice(0, 4); const photos = (await getCollection('photos')).slice(0, 4);
const heartbeat = await fetchHeartbeat();
--- ---
<BaseLayout title="wait4x"> <BaseLayout title="wait4x">
@@ -99,9 +101,31 @@ const photos = (await getCollection('photos')).slice(0, 4);
}} }}
/> />
<ProjectWall /> <ProjectWall />
{heartbeat && (
<p class="heartbeat" aria-label="最近心跳">
<span class="beat-dot" aria-hidden="true"></span>
最近心跳:{agoZh(heartbeat.when)} · {heartbeat.msg}
</p>
)}
</BaseLayout> </BaseLayout>
<style> <style>
.heartbeat {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-3);
margin-top: var(--space-2);
display: flex;
align-items: center;
gap: 8px;
}
.beat-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--ink-2);
animation: beat 2.4s ease-in-out infinite;
}
@keyframes beat { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .beat-dot { animation: none; } }
.socials { .socials {
display: flex; display: flex;
align-items: center; align-items: center;
+23
View File
@@ -189,6 +189,29 @@ hr { border: none; border-top: 1px solid var(--line); margin-block: var(--space-
} }
/* ---------- 动效总闸(唯一曲线已入 tokens) ---------- */ /* ---------- 动效总闸(唯一曲线已入 tokens) ---------- */
/* ===== 动效层(Phase 5e):背景辉光 + 入场 + 微交互 ===== */
:root { --mx: 50%; --my: 30%; }
.bg-glow {
position: fixed; inset: 0; z-index: -1; pointer-events: none;
background: radial-gradient(600px circle at var(--mx) var(--my),
color-mix(in oklab, var(--ink-1) 4%, transparent), transparent 70%);
opacity: 0; transition: opacity 0.6s ease;
}
@media (pointer: fine) {
.bg-glow { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .bg-glow { opacity: 0; } }
}
/* 正文入场:一次性轻上浮(VT 换页内容替换自然重放,克制 12px/0.4s) */
#main > * { animation: rise-in 0.4s ease both; }
@keyframes rise-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
#main > * { animation: none; }
}
/* 列表行微交互(桌面悬停语言:ink 系已有变色,补 1px 上浮) */
@media (pointer: fine) {
.postlist .row { transition: transform 0.15s ease; }
.postlist .row:hover { transform: translateY(-1px); }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *, *::before, *::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
+6 -1
View File
@@ -103,7 +103,12 @@ await page.waitForLoadState('networkidle');
ok('g-chords 导航到图库', page.url().includes('/gallery')); ok('g-chords 导航到图库', page.url().includes('/gallery'));
// 6) 图库:网格可见(灯箱不再自动打开)+ 灯箱交互 // 6) 图库:网格可见(灯箱不再自动打开)+ 灯箱交互
await page.goto(BASE + '/gallery/', { waitUntil: 'networkidle' }); // g-chords 的 location.href 与 goto 竞态(修好的弦导航在 en 页跳 /en/gallery/,
// 迟到提交会打断 goto)——中断重试一次
for (let i = 0; ; i++) {
try { await page.goto(BASE + '/gallery/', { waitUntil: 'networkidle' }); break; }
catch (e) { if (i >= 1) throw e; await page.waitForTimeout(400); }
}
ok('图库·灯箱默认关闭', await page.locator('#lb[open]').count() === 0); ok('图库·灯箱默认关闭', await page.locator('#lb[open]').count() === 0);
ok('图库·网格照片可见', (await page.locator('.wall .shot img:visible').count()) >= 15, `${await page.locator('.wall .shot img:visible').count()} 张`); ok('图库·网格照片可见', (await page.locator('.wall .shot img:visible').count()) >= 15, `${await page.locator('.wall .shot img:visible').count()} 张`);
// fullPage 截图不必然触发视口外懒加载:先整页滚动一圈再截(视觉复核 19/21 教训) // fullPage 截图不必然触发视口外懒加载:先整页滚动一圈再截(视觉复核 19/21 教训)