diff --git a/README.md b/README.md index 0e9fe8d..ad708c7 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ pnpm dev | `pnpm build` | 构建到 `dist/`,并跑 Pagefind 建立搜索索引 | | `pnpm preview` | 本地预览构建产物 | | `pnpm check` | Astro 的类型与内容校验(本项目没有单元测试框架,这就是唯一的自动检查) | +| `pnpm verify:css` | 单独跑构建产物 CSS 自检(`pnpm build` 和 `deploy.sh` 里都会自动跑) | | `pnpm new-thought "..."` | 快速创建一条「偶得」(见下) | ## 项目结构 @@ -97,6 +98,27 @@ onPage("my-widget", () => { 页面级的小交互如果**不需要 JS** 就别写 JS:阅读进度条和回到顶部按钮的显隐都由 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`,共三个: diff --git a/deploy.sh b/deploy.sh index 47f3cab..5718666 100755 --- a/deploy.sh +++ b/deploy.sh @@ -84,6 +84,15 @@ if [ "$PAGES" -lt 5 ]; then 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. 生产环境确认 ---------- if [ "$DO_CLOUD" -eq 1 ] && [ "$DRY_RUN" -eq 0 ] && [ "$ASSUME_YES" -eq 0 ]; then echo "" diff --git a/package.json b/package.json index ab70774..c0313b1 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "scripts": { "dev": "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", "check": "astro check", + "verify:css": "node scripts/verify-build-css.mjs", "new-post": "node scripts/new-post.mjs", "new-thought": "node scripts/new-thought.mjs", "reading": "node scripts/fetch-reading.mjs" diff --git a/scripts/verify-build-css.mjs b/scripts/verify-build-css.mjs new file mode 100644 index 0000000..1928529 --- /dev/null +++ b/scripts/verify-build-css.mjs @@ -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 简写里的 分量 + * (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) diff --git a/src/data/reading.json b/src/data/reading.json index b22ff30..e8cc82d 100644 --- a/src/data/reading.json +++ b/src/data/reading.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-09-21T01:32:16.851Z", + "updatedAt": "2026-09-21T01:46:31.327Z", "source": "weread", "books": [ { diff --git a/src/scripts/back-to-top.ts b/src/scripts/back-to-top.ts index 1f16da7..422d4d2 100644 --- a/src/scripts/back-to-top.ts +++ b/src/scripts/back-to-top.ts @@ -7,16 +7,31 @@ import { onPage } from "./lifecycle" * 淡入,不需要任何 scroll 监听(见 global.css 的 .back-to-top)。 * 这里只负责两件事: * 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 supportsScrollTimeline = () => - typeof CSS !== "undefined" && - typeof CSS.supports === "function" && - CSS.supports("animation-timeline: scroll()") +/** global.css 中 @keyframes 的名字,用来实测动画有没有真的挂上 */ +const ANIMATION_NAME = "back-to-top-reveal" + +/** + * 「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", () => { const button = document.querySelector("[data-back-to-top]") @@ -28,7 +43,7 @@ onPage("back-to-top", () => { } button.addEventListener("click", onClick) - if (supportsScrollTimeline()) { + if (cssDrivesVisibility(button)) { return () => button.removeEventListener("click", onClick) } diff --git a/src/styles/global.css b/src/styles/global.css index f420d71..2f9b235 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1142,6 +1142,18 @@ a.card-github:hover .github-logo { @supports (animation-timeline: scroll()) { .reading-progress-bar { 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); } } @@ -1229,8 +1241,28 @@ a.card-github:hover .github-logo { /* 显隐交给滚动时间线,这里只留下颜色类过渡 */ transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; 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 目前不接受简写里的 分量(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); - /* 滚过 320px 开始淡入,620px 完全就位(与 back-to-top.ts 的 480px 阈值呼应) */ + /* 滚过 320px 开始淡入,620px 完全就位(与 back-to-top.ts 的降级阈值呼应) */ animation-range: 320px 620px; } }