把一天半的时间,标上价格出售

(一次被 AI 带偏、又自己爬回来的建站复盘)

这个博客上线那天,我在封面上写了一句:《三日间的幸福》里那句「如果把余生的日子,标上价格出售」。

我想要的只是一个安静的开场——一屏全幅的海报,楷体,落款,雪在背后落。

接下来一天半,我几乎没再写内容,全在改「这一屏该怎么退场」。

代码绝大部分不是我敲的,是让一个 agent 照着github上面开源博客Firefly的文档敲的。CSS 我几乎没动过手,JS 只零星改过几行,剩下的全是我说要什么、它来写、我看一眼、说不对、再说一遍。

这篇记的就是这个过程:它怎么越来越偏,我怎么被它带着走,最后又怎么靠读源码爬回来。

后来我才想明白一件事:正因为我不写这些代码,我才必须会另一件事——验收。这篇从头到尾都在讲这个。


一、地基:能跑,但处处差一口气

我一开始就把技术栈压到最低,理由是「以后自己还看得懂」:

  • Hugo v0.168 extended(C:\Hugo\bin\hugo.exe),没有前端框架、没有打包器
  • 一份 CSS,assets/css/main.css,现在 2150 行——它生成的,我只改过几个数字
  • 一个 IIFE 包住的原生 JS,assets/js/main.js,855 行,按横幅切成八块——同样是它写的
  • 四个模板:baseof.html、index.html、list.html、single.html,外加五个功能页 layout
  • 布局偷了 Firefly(Astro 主题,Fuwari 那一支):固定毛玻璃导航条、左资料栏、右主列,容器 1120px

它能跑,但每一处都差一口气。我把这些「差一口气」一条条丢给 agent,它改得很快,快到我有种错觉:这个站我其实已经懂了。


二、四个小毛病(和一次被我忽略的伏笔)

1. 一篇文章只算 106 个字

文章页写着「約 106 字」,可那篇两千多字。

agent 的判断很准:Hugo 默认按空格分词,中文没空格,一段只算一个词。

# hugo.toml 顶层
hasCJKLanguage = true

一行解决,106 变 1735。顺带修好了站点总字数和阅读时长。

2. 音符点了没反应

右上角那个音符从建站起就没绑过点击事件,只做了「播放中变红脉动」的样子货。

补静音开关:切 audio.muted,图标上叠一条 rotate(45deg) 的斜线,状态存 hb_muted。第一版agnet把角度写反了——rotate(45deg) 画出来是 /,我要的是 \,得写 -45deg。我盯着那条斜线看了半天才确认不是自己眼花。

3. 卡片上印着 0001.01.01

五个功能页(关于 / 友链 / 留言 / 链接 / 我的)front matter 里没有 date,.Date 拿到零值时间。

补上建站日期就干净了。但顺手牵出一个更大的问题:首页列表用 .Site.RegularPages,把它们也当文章列出来了。

4. 文章数把「关于」「友链」也数进去了

口径收窄成只算 posts 分区:

{{ len (where .Site.RegularPages "Section" "posts") }}

站点统计卡、资料卡、关于页——我让它改了前两处。

这一处漏掉的代价,是后面一整晚。而它当时说的是「三处统计都已同步」,我信了,因为前面六件事它都做对了。

这是第一个伏笔:它会说「已修复」,但那是一次声明,不是一次验证。


三、封面:九个版本,全是被逼出来的

我对首页只有一个要求:打开像拆一封信。剩下所有版本,都是为这句话付的代价。

v0|固定层 + margin 补偿(它的第一个方案)

.site-cover { position: fixed; inset: 0; }
.layout-below-cover { margin-top: 100vh; }

封面用 fixed 钉在视口上,正文用 margin-top: 100vh 往下推一屏,滚动时 JS 给封面加 .site-cover-hidden 让它淡出。

问题是它根本没「滑走」,是「淡掉」——封面一动不动,正文自己在下面往上爬,中间必然留一段什么都没发生的空白带。因为背景和封面同图,看着不像空,但你一滚就知道:那段什么都没在发生。

我调了半天 margin、加了吸附、试了各种 px,全治不好。

v1|把海报放回文档流(我否掉了它那套)

.site-cover {
  position: relative;
  width: 100%;
  height: 100vh;
}

它变成文档流里一个普通的一屏高元素,跟着页面 1:1 滚走,滚完正文自然接上。没有补偿、没有吸附、没有淡出补丁。

这一版让我确认一件事:结构错了,参数怎么调都是错的。

v2|底部羽化(它做得很漂亮,我否掉了)

我又嫌封面和正文之间那条硬边生硬,让它做底部羽化——两层背景各挂一条 mask-image,让海报底边化进后面的壁纸。

它做得很好,连 inset: -3% 导致基准高度多 6% 这种细节都算进去了,两层百分比分开写。

但我看十秒就难受:首页背景是动态下雪。羽化出来的透明带会一直透出流动的雪,跟静止的信笺拼在一起,割裂得厉害。

这是第一条真正属于我的判断:局部羽化只适合静态背景。背景会动时,透明区域等于给动态开了一扇窗,它和静止内容的落差比硬边更刺眼。

v3|滚动时间轴(它最坚持、也最不该用的一版)

我真正想要的是「滑下去,字一点点长出来」。它给了 CSS 原生方案:

.hero {
  animation: heroRise linear both;
  animation-timeline: view();
  animation-range: entry 20% entry 60%;
}

opacity、translateY、blur 一起动,观感确实像一句话慢慢凝聚成形。为了给它跑道,它还在封面和正文之间塞了一个 40vh 的空 div。

它坚持这套的理由很漂亮:原生、零 JS 开销、跑在合成器上、性能最好。

这套东西有两个病:

  1. 整个元素共用一个进度,标题和引言只能一起浮,分不出层次
  2. 进度跑在合成器上,getComputedStyle 永远只读到起始值

第二条对我是致命的。我能看见它在动,但没法证明它动到了哪,也没法量出「停在半透明」到底是我的错觉还是真的。

后来我把这句话写进了自己的规矩里:一个测不出来的效果,等于一个交付不了的效果。

但当时我还不这么想。当时我觉得「看着挺好」,只是偶尔觉得哪里不对。

它在这里犯的错不是写错代码,是选了一个更漂亮、但不可验收的方案。漂亮是它的偏好,可验收是我的需求。

v4|把进度算在 JS 里

同一件事,我让它算在 JS 里:

var rect = hero.getBoundingClientRect();
var t = (rect.top < from) ? (from - rect.top) / (from - to) : 0;
t = Math.max(0, Math.min(1, t));
heroEl.style.setProperty('--hp-title', t.toFixed(4));

CSS 只负责消费:

.hero.hero-reveal .hero-title {
  opacity: var(--hp-title, 1);
  transform: translateY(calc((1 - var(--hp-title, 1)) * 26px));
}

它只读滚动位置,不吸附、不接管滚轮、不改速度,所以手感还是原生的。但它能被读出来:

getComputedStyle(heroEl).getPropertyValue('--hp-title')   // 真的有数

占位 div 也删了。没有跑道,靠两条曲线的时序去接。

v5|丝滑和分层,是两个参数

v4 做完它贪心了,拆成三段错开:标题先现 → 引言跟上 → 副题最后。它说这叫层次。

我自己滑了几遍,感觉是「一顿一顿」:标题已经清楚了,引言还虚着,读起来像卡带。

我让它加开关:

var HERO_SPLIT = false;   // false = 整块丝滑;true = 三段分层

同一个进度引擎,两种消费方式。

这件事改了我一个认知:观感是参数,不是架构。 想「更丝滑」不一定要换技术,可能只是把分段收成一段。反过来,为了换感觉去推翻架构,代价大得多。

v6|整块淡出、不留白、导航条反向淡入

最后我把要求说清楚了,三条:

  1. 下滑时整个封面一起变淡,不是只化底边
  2. 导航条出现在留白下方,跟正文对齐
  3. 封面底部不许透明——底下的雪一直在动,太突兀

第 3 条正式给 v2 判了死刑。

删掉所有 mask-image,.site-cover 的 opacity 由 JS 直接写,底图、暗色层、文字、按钮整块同步退场。那个 50vh 占位 div 也删了。

改完立刻冒两个新问题:

A. 导航条横穿信笺。 没有留白之后,滚到 250px 它就在视口里了,一条半透明白带压在信笺中间,脏得像没擦的玻璃。修法是反向淡入:封面在时全透明,封面淡透后由下往上显形,没显形前 pointer-events: none。

B. 两层文字叠在一屏。 它沿用了旧区间(vh*0.95 → vh*0.34),结果封面才淡掉四成,大标题已经浮到屏幕下半部,静止的信笺字和流动的大标题叠在一起。

修法是把两条曲线配平:

var COVER_FADE_END = 0.65;   // 滚过 0.65 屏,封面全透明
// hero 区间:from = vh * 0.62 → to = vh * 0.18

封面淡透那一刻(0.65),文案刚好起步(0.62)。首尾相接,不重叠。

v7|让首页的字跟着色相走

设置球能调主题色相,全站都变,唯独首页三行大字纹丝不动——它们写死了 #fff。

它没有在 JS 里一条条加 setProperty(那样得改两处,漏一处就会闪一下旧色),而是加了个裸数值变量:

:root { --hue: 6; }
.hero-title { color: hsl(var(--hue, 6) 72% 88%); }
.hero-quote { color: hsl(var(--hue, 6) 88% 85%); }
.hero-sub   { color: hsl(var(--hue, 6) 42% 78%); }

JS 只写一次 setProperty('--hue', hue)。第一版参数给的是 92% 亮度 + 62% 饱和,四个色相肉眼分不出——浅色堆在高亮度区间会被压成同一个白。调到 88% / 72% 才看得出,但也不能再往下压,低于 70% 就糊在壁纸上。

这一处它是真的做对了,而且是那种「我提不出更好方案」的对。

v8|最后一个版本不是效果,是行距

全部弄完,我去读了一遍自己写的复盘,读两屏就累。

行距继承 body 的 line-height: 2.2,我 html 字号是 20px,算下来行盒 44px、行间空白 24px。中文在这个尺度上,眼睛每读一行都要重新找下一行的起点。

只改正文容器:

.article-content {
  line-height: 1.7;   /* 20px 字号 → 34px 行盒,比原来少 10px */
}

段距没动,还是 2em。行收紧之后段落依然分得开,大留白的骨架还在,读起来顺了。顺手把对话块 .chat-log .chat-line 也从 2.0 收到 1.7,不然同一篇文章里对话比正文还稀。

排版也是要改的。效果做得再顺,字挤得人读不下去,前面全白费。


四、那一夜:它把我带到了显卡驱动

前面所有版本,它都还算可靠。偏题是从一句「改了没效果」开始的。

4.1 起因

我连着几轮确认同一件事:文章数没变、间距改了没反应。

它开始排查。这是一次真正的滑坡。

4.2 它的怀疑链

轮次 它的判断 怀疑的层级
1 浏览器缓存 浏览器
2 bfcache 恢复了旧 DOM 浏览器
3 你有两个服务器(1313 / 1314),看错了 进程
4 系统代理 网络
5 火绒的 Web 扫描 安全软件
6 Watt Toolkit / Steam++ 改了 hosts 系统文件
7 换端口,换到 1414 端口
8 换浏览器,Chrome 也试 客户端
9 重启电脑 操作系统
10 AMD 显卡驱动把旧页面合成到了显存 硬件
11 关掉硬件加速 硬件

它一开始怀疑的是它刚改过的那份 CSS,最后怀疑的是我显卡里的显存。

中间隔着一个浏览器、两个服务器进程、一个代理、一个杀软、一个加速器、一次重启。

它走得越远,离我那行 CSS 就越远。

这件事最诡异的地方在于:每一步都真实存在。缓存真的会有,bfcache 真的存在,Steam++ 真的会改 hosts,AMD 驱动真的有贴图问题,服务器真的起过两个。

它没有编造任何一个技术名词。它只是把这些真实存在的东西,当成了眼前这个现象的原因。

4.3 我照做了

我关掉了整台机器的硬件加速。

为了一个不存在的 bug。

我还重启了电脑,换了浏览器,杀掉了一个服务器进程,看了眼系统代理是不是开着的,检查了 hosts 文件里那几十行 GitHub 重定向。

每一步都不贵,加起来是好几个小时。

4.4 我怎么爬出来的

它给的所有解释都有一个共同点:没有一个能在我的屏幕上被验证。

于是我做了三件它虽然最后才建议我做、但没让我做到底的事。

第一件,Ctrl+U 看源代码。

页面的 HTML 里,CSS 引的是新指纹文件,色相公式也是新的。这说明浏览器手里的字节是新的——不是缓存,也不是什么中间层在转发旧内容。

这一步它其实也让我做过,做完它更确信了:「字节是新的,但画面是旧的,所以是渲染层。」

我当时也觉得这个推理无懈可击。

第二件,它让我在页面上搜「文章数」三个字。

不是搜代码,是搜页面上印出来的字。

Ctrl+F 输入「文章数」,页面只高亮了一处。

亮一处是站点统计卡,出问题的是资料卡统计。我让它改过的那一处已经变了,另一处还是旧数字。

第三件,去 baseof.html 里搜 stat-num。

同样是两个块:

<!-- 站点统计卡 -->
<div class="stats-card"> … <span class="stat-num">{{ len (where .Site.RegularPages "Section" "posts") }}</span> … </div>

<!-- 资料卡统计 -->
<div class="profile-stats"> … <span class="stat-num">{{ len .Site.RegularPages }}</span> … </div>

第二块写的是 .Site.RegularPages,没带 where。

真相就这么简单:agent把同一条信息改了一处,漏了另一处。

浏览器一直在老实渲染新页面。它没有缓存、没有 bfcache、没有代理、没有杀软、没有显卡问题。

是我把「渲染正确」当成了「渲染异常」。

4.5 它为什么会这样

我不觉得它撒谎。它每一条判断都有理由,而且理由听起来比我的直觉专业得多。

但它给的是最像样的解释,不是最能被验证的解释。

这两者的差别,就是数小时甚至一整晚。

它有三条惯性,我后来总结出来了:

① 它总在「解释」我,而不是「确认」我。

我说「还是老样子」,它说「这是 bfcache 恢复旧 DOM」;我说「我已经用无痕了」,它说「那是 GPU 合成层」。

每一步它都在把我的观察翻译成它的假设,而不是先把我看到的东西量一遍。

② 它的证据都是间接的。

curl 拿到新内容、响应头字节相同、源码视图是新指纹——这些都只能证明「服务器是对的」,不能证明「我的改动覆盖了所有该改的地方」。

而它从「服务器对」直接跳到「所以是渲染层」,中间漏掉的那个可能性是:我改得不够全。

③ 沉没成本是它最好的帮凶。(最重点)

查了两小时之后,我也开始不愿回头。回头意味着前面两个小时白费。它每提出一个新的怀疑对象,都像是在往前推进,而「再搜一遍自己的代码」听起来像原地踏步。

这一晚我真正学到的不是「AI 会胡说」,而是:

它有能力生成一个自洽的故事。而故事永远比测量便宜。


五、其它几次「方向越来越怪」的实现

那一晚是极端案例,但不是唯一一次。下面这些更日常,也更能说明它的工作方式。

1. 它会把 bug 解释成我的错觉

我把 padding-top 从 150px 改到 1000px,页面没反应。

它的解释是:「留白比一屏还宽,滚轮一滑就跳过 hero 只剩个尾巴——不是没生效,是数值大了看着像没生效。」

我当时信了,还自己反省了一下「是不是我太着急」。

后来我打开 getComputedStyle 读了一下:

getComputedStyle(document.querySelector('.layout')).paddingTop   // "32px"

改 150 是 32px,改 1000 还是 32px。

真因在 CSS 的书写顺序上:

/* 我以为生效的 */
.layout.layout-below-cover {
  padding-top: calc(var(--navbar-h) + 150px);
}

/* 实际生效的(写在后面) */
.layout {
  padding: 32px 24px 64px;
}

特异性相同时,后写的赢。padding 是简写,把 padding-top 整条吃掉。

它不是在骗我,它是在替我解释一个它没测量过的现象。 而这比骗更危险——因为它给的解释让我停止怀疑。

从这天起我给自己定了一条:任何「看起来没生效」,先读计算值,再听解释。

2. 关掉动画,整块文案消失

站里的入场动画是这么写的:

.fade-in-up {
  opacity: 0;
  animation: fadeInUp 1.2s forwards;
}

opacity: 0 是基础样式,靠 forwards 才升到 1。

它为了接管 hero 的动画写了 animation: none,动画没了,opacity: 0 就永久生效。

当时画面整个空白,我一度怀疑逻辑全错。它给的诊断是「变量没写进去」,还打了日志证明 --hp-title 是 1.0000、子元素 opacity 也是 1——然后它也懵了。

真因是容器自己是 0。它在看子元素,没看容器。

.hero.hero-reveal {
  animation: none;
  opacity: 1;   /* 必须补,不是冗余 */
}

这个坑的形状很典型:它证明了「我以为错的那个东西」是对的,然后卡住。 因为它只验证了它想到的那个假设。

3. 它会给你一把漂亮的锁,然后忘了钥匙

进度重算的标准写法是「布尔锁 + rAF 合并」,它写得很规范:

var ticking = false;
function onScroll() {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(paint);
}
function paint() {
  ticking = false;
  // ...
}

坑在这里:后台标签页里 requestAnimationFrame 会停摆。一旦 ticking = true 之后 rAF 不回调,锁就永远锁着,后面所有滚动都被 return 掉,文案永远停在透明状态,切回来也不恢复。

它没想过「如果我这个回调永远不来」。

ticking = true;
requestAnimationFrame(paint);
heroFallbackTimer = setTimeout(paint, 120);   // paint 里 clearTimeout

这类问题的共性是:它的代码按「正常路径」写得很干净,异常路径靠你自己撞出来。

4. 它把状态绑在了错误的节点上

悬浮球是「长按 0.2s 后才能拖」。第一版 pointerup 绑在元素自己身上:

按下 → 鼠标滑出元素 → 在元素外松手 → 元素收不到 pointerup → pressing 永久为真 → 之后任何超过 6px 的移动都触发拖拽。

用起来像「悬停即拖」,实际是状态泄漏。

// ❌ 元素级
el.addEventListener('pointerup', end);

// ✅ 取消类事件挂 document
document.addEventListener('pointerup', end);
document.addEventListener('pointercancel', end);

只要状态机依赖「松手」,取消监听就必须落在 document 上。

5. 它相信原生 API,哪怕 API 不适合这个场景

我让它做「滑过封面后轻柔吸到正文」,它用了 CSS:

html { scroll-snap-type: y proximity; }
.layout-below-cover { scroll-snap-align: start; }

结果我被吸到了文章区,hero 被切掉半截。

因为 snap 对齐的是盒子边界,而盒子包含 padding。.layout 有 padding-top,start 对的是 padding 外沿,不是内容起点。

它后来改成 JS 精确补正,再后来我干脆让它全删了——那个「黏」的手感我自己也不喜欢。

scroll-snap 适合全屏分页式幻灯片,不适合「封面 + 长正文」。

6. 它不知道 wheel 不算手势

自动播放被浏览器拦了,要等一次「用户手势」才解锁。它把手势链里加了 wheel,注释还写着「下拉封面第一瞬间即可开始音乐」。

Chromium 的硬规则:只有 click / keydown / pointerdown / touchend 算 transient activation,wheel 和 scroll 都不算。

桌面上「滚一下就开始放歌」物理上做不到,必须点一下。它写的那行注释,是一个听起来很合理、但浏览器不认的假设。


六、我怎么验收它的活(重点回顾)

这一夜大半时间不是花在写代码上,是花在证明代码真的生效上。下面这几条,现在是我每次让它改完之后的固定动作。

1. 先要「复现步骤」,不要「原因说明」

我问「为什么没生效」,它能给我三个原因,全都很合理。

我问「我该在哪里看到什么」,它只能给一个具体位置。

原因可以被编,位置不能。 从这天起我改问法:不问「为什么会这样」,问「我怎么确认它变了」。

2. 用页面上的原文搜,不用代码里的命名

它叫 stat-num,页面上印的是「文章」。

搜「文章」会命中两处,搜 stat-num 会命中两个块——但如果你搜的是「文章数」这个中文词, 只能命中一次,因为模板里只出现过一次。

所以:搜用户能看见的字,不搜开发者起的名字。

3. Ctrl+U 这个动作要留着

它是区分「字节旧」和「渲染旧」的决定性实验。响应头能被中间层仿造,源码视图不能。

但也要记住:源码是新的,只能证明服务器没事,不能证明你改全了。这两件事我以前当成一件。

4. 读计算值,不读我以为的那个值

getComputedStyle(el).paddingTop        // 到底是多少
getComputedStyle(el).getPropertyValue('--hp-title')   // 到底到哪了

「CSS 里写的值」和「浏览器算出来的值」,是两个不同的数字。中间隔着一整个级联。

5. 红色调试法

想确认「这层到底有没有透出后层」,别盯着半透明画面猜:

.site-cover { background-color: #ff0000 !important; }

底部真的浮出红色渐变,遮罩就是生效的。三十秒给出确定答案,比截十张图可靠。验完立刻删。

6. 一次只让它改一个变量

它很擅长一次改五处,然后说「都优化了」。出了问题你就不知道是谁的错。

我自己现在会拆:先改结构,量一次;再改数值,量一次。改两处一起动,等于把两个未知数塞进一个方程。

7. 探针页:唯一能看到「中途」的办法

无头截图只能截首屏。要看滚到一半长什么样,写一个探针:

<iframe id="f" src="/"></iframe>
<script>
f.addEventListener('load', function () {
  setTimeout(function () {
    var w = f.contentWindow;
    w.scrollTo(0, 700);
    w.dispatchEvent(new Event('scroll'));   // 无头下必须手动补
  }, 1100);
});
</script>

放 static/_p.html,用完立刻删——它在线上是可访问的。

8. 无头浏览器的三条铁律(它踩过,我记住了)

  • iframe 里 scroll 事件根本不派发,必须手动 dispatchEvent;rect.top 却在变,所以用计数判断会误判为「没滚动」
  • CSS 滚动时间轴的进度测不出,getComputedStyle 恒返回起始值——这就是我把 hero 从 CSS 时间轴换掉的真正理由
  • 带锚点的 URL 用 --screenshot 会截出全白,同 URL --dump-dom 却是对的,是渲染侧假象

9. (+x) || 1 会把 0 变成 1

它给我的探针里写过这么一行:

var op = (+el.style.opacity) || 1;   // ❌

opacity 为 0 时,+0 得 0,0 是假值,|| 1 返回 1。「完全透明」被读成「完全不透明」,一小时读数全反。

直接读字符串就没事。

10. hugo server 会静默卡死

改了 CSS 页面不变,日志停在 rebuilding (#N) 没有完成行,touch 也不重建。

处置:taskkill //F //IM hugo.exe,重启。这个在项目里发生过至少五次。

另外 v0.168 默认「Serving pages from disk」,裸路径 /css/main.css 可能残留旧内容——永远以页面引用的指纹 URL 为准。

11. 用内容指纹,别用版本号

{{ $css := resources.Get "css/main.css" | fingerprint }}
<link rel="stylesheet" href="{{ $css.RelPermalink }}">

内容一变,URL 里的 sha256 就变。之前写的是 ?v={{ now.Unix }},模板没重渲染(比如服务器正好卡住)时版本号就不变,缓存照样命中。

这个方案是它提的,而且提得对——这是我保留它最主要的原因:它在「机制层面」的直觉经常比我好,在「我到底要什么」和「到底生效没有」上经常比我差。


七、两份清单

我的失误

# 我犯的错 代价 教训
1 它说「三处都已同步」,我没验 埋下整晚悬案 「已修复」是待验证假设
2 它说「是视觉陷阱」,我信了 掩盖真 bug 数小时 先读计算值,再听解释
3 我跟着它怀疑到了显卡 关掉了整机硬件加速 解释越戏剧化,越该先量最朴素的东西
4 我搜代码命名,没搜页面原文 找不到第二处实例 搜用户看得见的字
5 我接受了一个不可测量的方案(animation-timeline) 最终整体重写 不可测 = 不可交付
6 分段做得太明显,还觉得是优点 我自己读着都卡 观感是参数,别替读者决定
7 删掉留白后没重看整屏 白带横穿信笺 删布局元素后必须重看一遍
8 两条过渡曲线没配平 两层文字叠一屏 耦合数值一起调

它的失误(我事后归纳的模式)

# 模式 典型表现
1 解释优先于测量 没量过 paddingTop 就说「那是视觉陷阱」
2 只验证自己想到的假设 查到子元素 opacity 是 1 就卡住,没想到查容器
3 从「服务器对」跳到「渲染层」 漏掉「我改得不全」这个更便宜的可能
4 证据链自洽但前提未验 十条怀疑每一环都成立,但第一环就错了
5 偏好更漂亮的方案 选原生 CSS 时间轴,而不是可验收的 JS
6 按正常路径写代码 rAF 锁没有超时兜底
7 过度自信的措辞 「已修复」「一定」「就是这个原因」
8 替你解释你的错觉 把「没生效」说成「你看错了」

它没有一次是故意骗我。它只是每一次都选择了更省力的那种正确——给一个解释,比做一次测量省力得多。


八、现在的样子

下滑第一屏:

滚动位置 画面
0 信笺完整,底部实心,不透雪
0 ~ 0.65 屏 信笺整块一起变淡:底图、暗色层、文字、按钮同步退场
0.65 屏 信笺完全溶解,只剩流动的雪
0.65 ~ 0.93 屏 导航条由下往上淡入,吸顶时正好完全显形
之后 大标题、引言、副题浮起,进入正文

实测(1100×620 视口,往上滚完全可逆):

滚到 信笺 导航条 文案
0 1.00 0 0
300 0.26 0 0
420 0 0.10 0
500 0 0.56 0.32
620 0 1.00 0.92
700 0 1.00 1.00

标题颜色跟着色相走:

色相 标题实际色 观感
6(默认) rgb(246,207,202) 暖白偏粉
120 rgb(202,246,202) 淡青绿
210 rgb(202,224,246) 冷冰蓝
280 rgb(232,202,246) 淡紫

能调的旋钮都在 assets/css/main.css 和 assets/js/main.js 的注释里,搜 ★ 就能找到。


九、在 AI 时代,怎么不盲目依赖 AI

这一节是我最想留下来的。不是「AI 不可信」这种话,那没用。具体一点:

1. 让 AI 交「判定标准」,再交「实现方案」

我现在开头就会说:先告诉我改完在哪看、看到什么才算对。

它一旦得先定义「对」,就没法用「应该差不多了」糊过去。判定标准是它的紧箍咒。

2. 「已修复」当成待验证假设,不当结论

它说修好了,我就去看。十次里有八次是对的,但那两次错的,代价是整晚。

这不是不信任,这是流程:它交付的是代码,不是结论。

3. 自己保留三个动作

Ctrl+U 看字节、grep 搜页面原文、getComputedStyle 读计算值。

这三个动作不需要我懂框架,只需要我知道「我该在哪里看到什么」。它们是我对它的最后一道复核,也是唯一一道不依赖它的复核。

4. 它开始「解释我没看到的东西」时,警报拉满

「你看到的是 X,其实那是因为 Y」——这句话如果后面没有一次测量,就是纯叙述。

那一晚最危险的时刻,不是它说显卡,是我差点盲目信任AI给出的结论的那个瞬间:它让我开始怀疑自己的眼睛,而它的依据只是推理。

5. 拒绝不可测量的方案,哪怕它更漂亮

animation-timeline 输给 JS,不是因为 CSS 差,是因为 CSS 那个版本里我拿不到一个数字。

在 AI 时代这条更重要:它很擅长生成「看起来很专业的方案」,而专业性不等于可观测性。你要的东西能不能被验收,比它写的代码优不优雅重要得多。

6. 一次只让它动一个变量

它喜欢一次改五处然后说「都优化了」。出了问题,你连从哪查都不知道。

我现在会明确说:这一轮只改 padding,别动别的。把一个五元方程拆成五个一元方程,是我唯一能控制它的方式。

7. 给它一个「否决记录」

我让它把「试过、被否、为什么否」写进文档。这不是为了它,是为了我自己:再过三天我会忘,然后它会把同一套方案重新端上来,因为它每次都是重新开始的。

它的记忆是有限的,我的判断必须留在纸面上。

8. 沉没成本是最大的陷阱

查了两小时之后,「回头再搜一遍代码」听起来像认输。

可那一晚,正确答案就在最便宜的这一步里。越查越深的时候,先问一句:最便宜的那个可能性我排除了吗?

9. 它擅长机制,你负责意图

它提的内容指纹、hasCJKLanguage、readDir 里没有 .Ext 这种,都比我自己查得快。

但它不知道「我读着累」「这条透明带太突兀」「这个手感黏」。这些只有我知道。

分工应该是:它管怎么做,我管做成什么样。 一旦它开始替你决定「什么样算好」,你就失去验收能力了。

10. 不写代码的人,更得会验收

这个站的 CSS 我几乎没写过一行,JS 也只零星改过几处。这本来是弱点:某条规则在 2150 行的哪个位置,我根本不知道。

但它逼我用另一种方式定位问题——不看它怎么写,只看它有没有生效。

不懂实现,不等于没法判断。你不必会做一道菜,也能尝出咸了。前提是你真的去尝,而不是听厨师说「这个咸度是行业标准」。

那一晚我犯的正是这个错:我尝出了不对,但被它一句「这是 bfcache」说服,把嘴闭上了。

11. 你是验收方,不是甲方

甲方说「我要更好看」,验收方说「这一版通过 / 不通过,因为 X」。

在 AI 时代,会写代码的人不一定稀缺,能判定「这行不行」的人稀缺。

而判定能力不是靠读它的解释长出来的,是靠你自己动手量出来的——读一次源码、搜一次页面、看一眼计算值。就这三次,能救一整晚。


十、最后

一开始我以为这是个「加几个效果」的活。

做完发现,真正的成本几乎全在证明上:证明样式生效了、证明缓存不是凶手、证明动画停在我以为的位置。

而最贵的那次错误,跟技术一点关系都没有。是我搜了 stat-num,没搜「文章」。

《三日间的幸福》里那句话,放在这里意外地合适——「如果把余生的日子,标上价格出售」。

如果把这一天半标上价格:七分在验证,三分在写。

而那一整晚买到的,只是一个我本该自己搜一遍的字符串。

下次开工前,先搜一遍页面上真正的字。


写于 2026 年 10 月 24 日,封面第三版、正文行距、还有这篇复盘本身都改完之后。