修复: 回到顶部按钮和进度条不显示的bug

This commit is contained in:
2026-09-21 09:47:35 +08:00
parent 219cb712a6
commit ec80ea7bd0
7 changed files with 185 additions and 10 deletions
+22
View File
@@ -48,6 +48,7 @@ pnpm dev
| `pnpm build` | 构建到 `dist/`,并跑 Pagefind 建立搜索索引 | | `pnpm build` | 构建到 `dist/`,并跑 Pagefind 建立搜索索引 |
| `pnpm preview` | 本地预览构建产物 | | `pnpm preview` | 本地预览构建产物 |
| `pnpm check` | Astro 的类型与内容校验(本项目没有单元测试框架,这就是唯一的自动检查) | | `pnpm check` | Astro 的类型与内容校验(本项目没有单元测试框架,这就是唯一的自动检查) |
| `pnpm verify:css` | 单独跑构建产物 CSS 自检(`pnpm build` 和 `deploy.sh` 里都会自动跑) |
| `pnpm new-thought "..."` | 快速创建一条「偶得」(见下) | | `pnpm new-thought "..."` | 快速创建一条「偶得」(见下) |
## 项目结构 ## 项目结构
@@ -97,6 +98,27 @@ onPage("my-widget", () => {
页面级的小交互如果**不需要 JS** 就别写 JS:阅读进度条和回到顶部按钮的显隐都由 页面级的小交互如果**不需要 JS** 就别写 JS:阅读进度条和回到顶部按钮的显隐都由
CSS 滚动驱动动画(`animation-timeline: scroll()`)负责,脚本只管点击与老浏览器降级。 CSS 滚动驱动动画(`animation-timeline: scroll()`)负责,脚本只管点击与老浏览器降级。
### ⚠️ 滚动驱动动画的两条硬规矩
2026-09-21 踩过一次:线上按钮不显示、本地 dev 一切正常。原因是 Lightning CSS
(Tailwind v4 的压缩器)把 `animation-timeline` 并进了 `animation` 简写,产出
`animation: linear both back-to-top-reveal scroll(root)` —— 而 WebKit 不认简写里的
timeline 分量([webkit#293276](https://wiki.webkit.org/show_bug.cgi?id=293276)),
**整条声明被判无效丢弃**,元素永远停在初始的隐藏样式;更坑的是
`CSS.supports("animation-timeline: scroll()")` 照样返回 true,运行时特性检测看不出问题
([lightningcss#815](https://github.com/parcel-bundler/lightningcss/issues/815))。因此:
1. **写 CSS 时**:`animation-timeline` / `animation-range` 那条规则的选择器要和
`animation` 简写那条**不同**(现有代码用 `.reading-progress > .reading-progress-bar`
和 `.back-to-top[data-back-to-top]`),否则压缩器会把两条相邻的同选择器规则合并。
刻意用后代 / 属性选择器而不是 `:where()`,是为了让特异性不低于简写那条 ——
`animation` 简写会把 `animation-timeline` 重置回 `auto`。
2. **写 JS 时**:不要用 `CSS.supports()` 判断滚动动画是否可用,直接读
`getComputedStyle(el).animationName` —— 只有这个才能反映「规则到底有没有活下来」。
`src/scripts/back-to-top.ts` 就是这么做的,CSS 那边一旦失效它会自动接管。
`pnpm build` 和 `deploy.sh` 都会跑 `scripts/verify-build-css.mjs` 拦截这类回退。
## 写作 ## 写作
内容集合定义在 `src/content/config.ts`,共三个: 内容集合定义在 `src/content/config.ts`,共三个:
+9
View File
@@ -84,6 +84,15 @@ if [ "$PAGES" -lt 5 ]; then
fi fi
fi fi
# CSS 自检:拦「滚动驱动动画被压缩器并进 animation 简写」这类只在构建产物里出现、
# 本地 dev 完全看不出来的回归(2026-09-21 的回到顶部按钮事故)。
# pnpm build 里已经跑过一次,这里再跑是为了覆盖 --skip-build 直接用旧 dist/ 的情况。
if ! node scripts/verify-build-css.mjs; then
echo ""
echo "❌ CSS 自检未通过,已中止部署(没有推送任何内容)"
exit 1
fi
# ---------- 3. 生产环境确认 ---------- # ---------- 3. 生产环境确认 ----------
if [ "$DO_CLOUD" -eq 1 ] && [ "$DRY_RUN" -eq 0 ] && [ "$ASSUME_YES" -eq 0 ]; then if [ "$DO_CLOUD" -eq 1 ] && [ "$DRY_RUN" -eq 0 ] && [ "$ASSUME_YES" -eq 0 ]; then
echo "" echo ""
+2 -1
View File
@@ -6,9 +6,10 @@
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "node scripts/fetch-reading.mjs --soft && astro build && pagefind --site dist", "build": "node scripts/fetch-reading.mjs --soft && astro build && node scripts/verify-build-css.mjs && pagefind --site dist",
"preview": "astro preview", "preview": "astro preview",
"check": "astro check", "check": "astro check",
"verify:css": "node scripts/verify-build-css.mjs",
"new-post": "node scripts/new-post.mjs", "new-post": "node scripts/new-post.mjs",
"new-thought": "node scripts/new-thought.mjs", "new-thought": "node scripts/new-thought.mjs",
"reading": "node scripts/fetch-reading.mjs" "reading": "node scripts/fetch-reading.mjs"
+96
View File
@@ -0,0 +1,96 @@
/* 构建产物 CSS 自检:拦住「滚动驱动动画被压缩器并进 animation 简写」这一类静默回归。
*
* 背景(2026-09-21 的实际事故):
* 线上「回到顶部」按钮不显示,本地 dev 却正常。
* 原因是 Lightning CSS(Tailwind v4 的 CSS 压缩器)把
* animation: back-to-top-reveal linear both;
* animation-timeline: scroll(root block);
* 合并成了
* animation: linear both back-to-top-reveal scroll(root);
* 而 WebKit 不接受 animation 简写里的 <single-animation-timeline> 分量
* (webkit#293276),于是**整条 animation 声明被判无效丢弃**,元素永远停在
* 初始的 opacity:0 / visibility:hidden。
*
* 最要命的是它只在构建产物里出现:astro dev 不做这个压缩,所以本地怎么试都正常;
* 而 CSS.supports("animation-timeline: scroll()") 又照样返回 true,
* 运行时特性检测也发现不了。见 lightningcss#815。
*
* 这个脚本在 build 之后跑,扫 dist 下所有 CSS:只要 animation 简写的值里出现
* scroll() / view() 时间线函数,就让构建失败。
*
* 用法:
* node scripts/verify-build-css.mjs # 失败时退出码 1
* node scripts/verify-build-css.mjs --warn # 只警告,不失败
*/
import fs from "node:fs"
import path from "node:path"
const DIST = "./dist"
const WARN_ONLY = process.argv.includes("--warn")
/** animation 简写里不该出现的时间线函数 */
const TIMELINE_FN = /(?:^|[^-\w])(?:scroll|view)\(/
/** 收集 dist 下所有 css 文件 */
function collectCssFiles(dir) {
if (!fs.existsSync(dir)) return []
const found = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) found.push(...collectCssFiles(full))
else if (entry.name.endsWith(".css")) found.push(full)
}
return found
}
/**
* 只检查 `animation:` 简写(以及 -webkit-animation:),不碰
* `animation-timeline:` / `animation-range:` 这两个长写属性 —— 后者是正确写法。
*/
function findShorthandViolations(css) {
const hits = []
// 负向回顾避免匹配到 animation-timeline / animation-name 这类长写属性
const re = /(?:^|[;{}(])\s*(?:-webkit-)?animation\s*:\s*([^;}]*)/g
let match
while ((match = re.exec(css)) !== null) {
const value = match[1]
if (TIMELINE_FN.test(value)) {
hits.push(value.trim().slice(0, 120))
}
}
return hits
}
const files = collectCssFiles(DIST)
if (files.length === 0) {
console.error(`❌ ${DIST} 下没有找到 CSS,构建产物异常`)
process.exit(1)
}
const problems = []
for (const file of files) {
const css = fs.readFileSync(file, "utf-8")
for (const value of findShorthandViolations(css)) {
problems.push({ file, value })
}
}
if (problems.length === 0) {
console.log(`✅ 构建产物 CSS 自检通过(${files.length} 个文件,没有时间线函数混进 animation 简写)`)
process.exit(0)
}
console.error("❌ 构建产物 CSS 自检失败:animation 简写里混进了时间线函数。")
console.error("")
for (const { file, value } of problems) {
console.error(` ${file}`)
console.error(` animation: ${value}`)
}
console.error("")
console.error(" 这类声明会被 WebKit 整条丢弃(webkit#293276),元素会停在初始样式上。")
console.error(" 修法:把 animation-timeline / animation-range 挪到**独立的规则块**里,")
console.error(" 不要和 animation 简写放在同一组声明中(lightningcss#815)。")
console.error("")
process.exit(WARN_ONLY ? 0 : 1)
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"updatedAt": "2026-09-21T01:32:16.851Z", "updatedAt": "2026-09-21T01:46:31.327Z",
"source": "weread", "source": "weread",
"books": [ "books": [
{ {
+22 -7
View File
@@ -7,16 +7,31 @@ import { onPage } from "./lifecycle"
* 淡入,不需要任何 scroll 监听(见 global.css 的 .back-to-top)。 * 淡入,不需要任何 scroll 监听(见 global.css 的 .back-to-top)。
* 这里只负责两件事: * 这里只负责两件事:
* 1. 点击后平滑回到顶部; * 1. 点击后平滑回到顶部;
* 2. 给不支持滚动驱动动画的浏览器(Firefox < 144 / Safari < 26)做降级。 * 2. 兜住「CSS 那条动画没生效」的所有情况 —— 既包括不支持滚动驱动动画的浏览器
* (Firefox < 144 / Safari < 26),也包括规则被构建压缩弄坏的情况(见下)。
*/ */
/** 与 global.css 里 animation-range 的起点保持一致 */ /** 与 global.css 里 animation-range 的起点呼应 */
const REVEAL_AFTER_PX = 480 const REVEAL_AFTER_PX = 480
const supportsScrollTimeline = () => /** global.css 中 @keyframes 的名字,用来实测动画有没有真的挂上 */
typeof CSS !== "undefined" && const ANIMATION_NAME = "back-to-top-reveal"
typeof CSS.supports === "function" &&
CSS.supports("animation-timeline: scroll()") /**
* 「CSS 那条滚动驱动动画到底有没有生效」——直接读计算样式,而不是做特性检测。
*
* 为什么不用 CSS.supports("animation-timeline: scroll()"):它只回答「这个语法浏览器认不认」,
* 回答不了「这条规则有没有在构建压缩中活下来」。实际的坑是:Lightning CSS 会把
* animation-timeline 合并进 animation 简写,而 WebKit 不认简写里的 timeline 分量,
* 整条声明被丢弃 —— 此时 CSS.supports 仍然返回 true,于是降级分支永远不执行,
* 按钮就永远停在 opacity:0。改读 animationName 之后,CSS 那边无论因为什么原因没生效,
* 这里都能立刻发现并接管(详见 global.css 里 .back-to-top 的注释)。
*/
const cssDrivesVisibility = (button: HTMLElement) =>
window
.getComputedStyle(button)
.animationName.split(",")
.some((part) => part.trim() === ANIMATION_NAME)
onPage("back-to-top", () => { onPage("back-to-top", () => {
const button = document.querySelector<HTMLElement>("[data-back-to-top]") const button = document.querySelector<HTMLElement>("[data-back-to-top]")
@@ -28,7 +43,7 @@ onPage("back-to-top", () => {
} }
button.addEventListener("click", onClick) button.addEventListener("click", onClick)
if (supportsScrollTimeline()) { if (cssDrivesVisibility(button)) {
return () => button.removeEventListener("click", onClick) return () => button.removeEventListener("click", onClick)
} }
+33 -1
View File
@@ -1142,6 +1142,18 @@ a.card-github:hover .github-logo {
@supports (animation-timeline: scroll()) { @supports (animation-timeline: scroll()) {
.reading-progress-bar { .reading-progress-bar {
animation: reading-progress-grow linear both; animation: reading-progress-grow linear both;
}
/*
* ⚠️ 这条规则的选择器故意比上面多一层 `.reading-progress >`,不是笔误。
*
* Lightning CSS 会合并「相邻且选择器完全相同」的规则:把 animation-timeline 并进
* 上面那条的 animation 简写,产物变成 `animation: … reading-progress-grow scroll(root)`,
* 而 WebKit 不认简写里的 timeline 分量,整条声明被丢弃 → 进度条永远 scaleX(0)。
* 选择器不同就没法合并。这里用后代选择器而不是 `:where()`,是为了让特异性**不低于**
* 上面那条 —— animation 简写会把 animation-timeline 重置回 auto,特异性更低会被重置压掉。
* 完整背景见下面 .back-to-top 段的长注释,以及 scripts/verify-build-css.mjs。
*/
.reading-progress > .reading-progress-bar {
animation-timeline: scroll(root block); animation-timeline: scroll(root block);
} }
} }
@@ -1229,8 +1241,28 @@ a.card-github:hover .github-logo {
/* 显隐交给滚动时间线,这里只留下颜色类过渡 */ /* 显隐交给滚动时间线,这里只留下颜色类过渡 */
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
animation: back-to-top-reveal linear both; animation: back-to-top-reveal linear both;
}
/*
* ⚠️⚠️ animation-timeline / animation-range 必须写在**独立的规则块**里,
* 不能和上面的 animation 简写放在同一组声明中。
*
* Lightning CSS(Tailwind v4 用的 CSS 压缩器)会把同一组声明里的 animation-timeline
* 合并进 animation 简写,构建产物变成:
* animation: linear both back-to-top-reveal scroll(root)
* 而 WebKit 目前不接受简写里的 <single-animation-timeline> 分量(webkit#293276),
* 于是**整条 animation 声明被判无效并丢弃** —— 按钮永远停在 opacity:0 /
* visibility:hidden。更坑的是 CSS.supports("animation-timeline: scroll()") 依然返回
* true,纯特性检测完全看不出问题;dev 模式不做这个压缩,所以只有构建产物会踩到。
* 参考 lightningcss#815。scripts/verify-build-css.mjs 会在构建后拦截这种合并回退。
*
* ⚠️ 所以这条规则的选择器故意写成 `.back-to-top[data-back-to-top]`(多一个属性选择器)
* —— 与上面那条选择器不同,压缩器就无法把它们合并。刻意用属性选择器而不是 :where(),
* 是为了让特异性**高于**上面那条:animation 简写会把 animation-timeline 重置回 auto,
* 特异性持平或更低都会被那个重置压掉。
*/
.back-to-top[data-back-to-top] {
animation-timeline: scroll(root block); animation-timeline: scroll(root block);
/* 滚过 320px 开始淡入,620px 完全就位(与 back-to-top.ts 的 480px 阈值呼应) */ /* 滚过 320px 开始淡入,620px 完全就位(与 back-to-top.ts 的降级阈值呼应) */
animation-range: 320px 620px; animation-range: 320px 620px;
} }
} }