# 踩坑记录 / PITFALLS 这个插件从 0 到能用,一共返工了 12 个提交。这份文档把**过程中真正犯过的错误**记下来 —— 包括平台层的坑,也包括我自己的判断错误。写下来是因为其中大部分**不是能力问题,是流程问题**, 下次可以直接避开。 每一条都给:**现象 → 原因 → 代价 → 现在遵守的规则**。 --- ## 一、平台 / API 层的坑(DSH 客户端插件) ### 1. 三处名字必须完全一致,否则插件静默不出现 **现象**:`package.json` / `cordis.patch.yml` / 注册 id 任何一处不一致,宿主不报错, 只是插件列表里没有它。 **原因**:宿主的补丁机制按名字找包。 **规则**:`package.json` 的 `name`、`cordis.patch.yml` 里 `insert[].name`、注册时的 `id` 三者逐字符相同;再加 `dsh.client.platform === "web"`、`exports["./client"]`、 `dsh.bundle.patch` 三个字段齐全,才是一个可用的客户端插件。 ### 2. 只有 9 个模块可以 `require` **现象**:`require('react-dom/client')` 之外的任何第三方包都会在加载时抛错。 **原因**:客户端插件的 `factory(require)` 只喂平台种子: `react`、`react/jsx-runtime`、`react-dom`、`react-dom/client`、`@deepseek-ai/cordis`、 `dsh-client-store`、`dsh-client-ui-slots`、`dsh-client-ui-primitives`、`dsh-client-ui-dockkit`。 **规则**:需要图标就手写 SVG,需要状态就用 `ctx.sessions` / `ctx.remote`,**不要引依赖**。 也因此这个插件是**零构建**的手写经典脚本 —— 反而省掉了 tsdown/rollup 一整条链路。 ### 3. portal 会丢 CSS 变量(最隐蔽的一个) **现象**:卡片弹层里拖柄渲染成灰色、卡片背景消失,但同一套 CSS 在别处正常。 **原因**:浮层用 `createPortal` 挂到 `document.body`,此时它**不是** `.dshcps-root` 的后代, 祖先上定义的自定义属性继承不过去。 **规则**:把 `--dshcps-*` 变量同时声明在 `.dshcps-root, .dshcps-pop, .dshcps-tip` 三个选择器上 (亮色主题的覆盖也一样要三份)。 ### 4. `position: fixed` 的浮层会被祖先的 `transform` 拽走 **现象**:浮层定位飘到别处。 **原因**:祖先只要有 `transform`(哪怕 `translateZ(0)`),就会成为 `fixed` 的包含块。 **规则**:浮层 portal 到 `body`;连预览页的居中布局都**不能用 `transform`**, 要用 `left:50% + margin-left`。 ### 5. `useLayoutEffect` 的依赖里塞了每次渲染都新建的函数 → 渲染死循环 **现象**:页面卡死、风扇狂转。 **原因**:依赖数组写成 `[hovering => 0]`,每次渲染都是新函数,effect 每次都跑, effect 里又 setState。 **规则**:依赖只放**原始值**(`[open, place, view, total]`);定位函数内部比较前后状态, 没变就 `return prev` 原对象,避免无意义的重渲染。 ### 6. `mask-image` 的渐变断点插值不可靠,`clip-path` 又会出现硬边 **现象**:想做"紫色从右往左扫过",用 `mask` 的渐变断点做插值,不同版本表现不一致; 改用 `clip-path` 后扫描前沿变成一刀切的硬边。 **原因**:渐变断点在插值时的行为不稳定;裁切是硬边界。 **规则**:最后用的是**两倍宽的材质 + `background-position` 平移**,两半渐变在基础蓝 `#46 7ac6` 处**颜色接续**,所以前沿天然是柔和过渡。`background-position` 的插值在任何 Chromium 上都一定平滑。**(Sol 推荐的"钉住材质 + 反向平移"方案也试过,同样出硬边,已否掉。)** ### 7. 逐关键帧的 `animation-timing-function` 是"分段缓动"的正解 **现象**:想要"起手快、后面慢",用一个 `cubic-bezier` 做不到。 **规则**:在关键帧**内部**写 `animation-timing-function`,它作用于**从该关键帧开始**的那一段。 两个关键帧各配一条曲线,就是两段独立缓动。 ### 8. 无头截图不可复现,会误导判断 **现象**:同一个 HTML、同一个 `--virtual-time-budget`,两次截图的动画进度不同; 还遇到过一次"卡片半透明"的假象(其实是抓到了入场动画中间帧),我一度以为 UI 坏了。 **原因**:虚拟时间预算不保证落在同一帧;同一个循环里连开两个 Edge 实例还会互相抢。 **规则**: - 要**确定性**就"暂停法":`animation-delay: -s` + `animation-play-state: paused`; - 截图一定**一台一次**、独立 `--user-data-dir`、独立进程; - 动画类验证优先用**逐像素 A/B**(有/无某个动画层),而不是肉眼比不同时间的截图。 ### 9. 静态 HTML 里计时器不会跑 **现象**:渲染出来的 `data-notice` 永远是 `true`,没法验证"1.6s 后消失"。 **原因**:离线 harness 产出的是**没有 JS 运行时**的静态 HTML,`setTimeout` 不会执行。 **规则**:在 harness 的 Node 侧**真的 `await` 到计时器触发**,再渲染第二帧来对比 —— 这样验证的是**同一份逻辑**,不是另写一套。 --- ## 二、我自己的判断错误(更值得记的) ### 10. 用**一张静态截图**推断**行为**——本任务最大的一次误判 **现象**:我量了参考图,得到"8 颗墨点、成环、距柄心 25–33px、深蓝紫", 于是判断原版是"溅开后**停在那里**的一圈"。Sol 也据此选了方案,我做完上线。 用户一句话推翻:**"那个是飞溅出来的,然后消失。"** **代价**:一轮 Sol 咨询(12,242 tk)+ 一次实现 + 一次上线,全部作废。 **原因**:我量到的那一帧,其实是**飞行途中**的一帧。静态测量只能约束"某一瞬间长什么样", **不能约束生命周期**。而我把"某瞬间的外观"当成了"终态"。 **规则**: - 静态图只能回答"长什么样",**回答不了"什么时候出现、持续多久、怎么消失"**; - 碰到"动效"需求,**先问行为**(一次性还是循环?多久?停在原地吗?),再谈参数; - 参数可以量,行为必须问人。 ### 11. 触发条件收得太窄 —— 功能"做完了"但用户一次都没看到 **现象**:额度提醒上线后,用户说"**现在不会弹出更快消耗那个字了**"。 **原因**:我把触发条件写成"**提交态**从 false 变 true"。这要求: ① 用户本来不在最高档;② 且 `selectModel` 的 RPC 已经回来。 用户如果本来就在最高档、或只是重新打开卡片,就**永远不触发**。 **代价**:连续两轮返工(`fecce78` → `2db6964`),最后用户直接给了明确要求 "**划到那个档位就显示,不用松开鼠标确认**"。 **规则**: - 交互触发的验证,要**列全路径**:拖动预览 / 松手提交 / 键盘 / 重新打开 / 外部(原生菜单)改变; 逐条问"这条路上会触发吗"; - **UI 反馈跟着"用户看见了什么"走**(`isUltra` 含拖动预览), **不要跟着"数据写入成功"走**(提交态)—— 后者有网络往返,还有失败可能; - 别把动效挂在异步结果上。 ### 12b. 位移「撞在一起」—— 用预算约束代替反复调参 **现象**:用户连提三次「位移再大一些」之后,第四次说 「**颤动效果和位移并不好,位移可能会重叠**」。 **原因**(这次我先把数读回来才动手,所以结论是硬的): ``` 静止时最近的两颗:中心距 2.0px,半径和 1.3px —— 只剩 0.67px 间隙(几乎贴着) 漂移单轴最大 8.3px,而相邻条带只有 9.9px 宽 -> 最坏情况下 7 对会重叠;加上抖动则 11 对会重叠(共 276 对) ``` 锚点用的是「等分条带 + 条带内随机」,**只保证了排序,没有保证间距**: 前一颗落在条带最右、后一颗落在下一条带最左,就贴上了。而位移幅度又是各自独立给的, 两边一叠加必然穿过去。 **代价**:三轮调参(每次只改幅度)全部白做,最后要请 Sol 重新给方案。 **规则**: - **幅度不是可以单独调的旋钮,它是「空间预算」的函数。** 固定的空间里, 「每颗都动得明显」和「彼此不撞」是零和的; - 正确顺序是:先定锚点 → 算每颗到最近邻的**边缘间隙** → 按比例反推允许的位移包络 → 让目标幅度被预算**按比例缩小**(绝不放大)。空间不够的粒子就该少动; - 让「运动方向正交分解」(一个主方向 + 垂直方向的扰动),最坏距离才能写成 `sqrt(D²+J²)`, **保证才是可证明的**,而不是靠抽样几帧看不出来; - 写一条**全相位断言**(遍历所有对:中心距 − 两半径 − 两包络 ≥ 间隙), 在渲染管线里就能验完 —— 比截图比对可靠得多; - 反过来看:**如果用户连续三次要求「再大一点」,说明方案本身有问题,不是参数不够大。** 第三次就该停下来问「是不是该重新设计」,而不是第四次把数字再乘 1.4。 ### 12c. 「证明了不重叠」不等于「看起来在动」—— 少了一个速度检查 **现象**:12b 那版修完之后,我给出了一份很硬的证明(全相位最小间隙 3.35px、276 对全达标、 PASS),用户第一句话是「**现在根本不动啊**」。 **原因**:我只检查了「会不会撞」(幅度上限),没检查「动不动得起来」(速度下限)。 那版的漂移是 0.90–1.52px / 周期 17–26s —— 峰值速度约 `2πA/T ≈ 0.3px/s`, 也就是**三秒移动一个像素**,肉眼就是一个静止画面。约束全对,观感为零。 根因是我把 Sol 的「漂移约 1px」当成了幅度指标直接实现:那个 1px 是配着它自己 17–29s 的周期给的,**两者是一组**,我却没有回过头算速度。 **代价**:两次上线 + 两次用户否定(「现在根本不动啊」→「基本不动啊」)。 **第二次为什么还是不动**:我把幅度抬到预算上限(包络 1.88–3.48px)之后, **周期还是 8.7–13.8s** —— 幅度对了,但一轮 10 秒,盯一秒当然看不出变化。 我当时的验证只比了「漂移两极的静态 transform 差 716 像素」, 那只证明**数值对**,没证明**动画在跑、且跑得够快**; 补上「把漂移动画暂停在两个相位」的检查才确认动画确实在运行(差 377 像素)—— 所以问题纯粹是周期。改到 3.4–5.9s 后峰值速度从 1.3px/s 提到 4.3–7.2px/s。 **规则**: - 动效的验收至少要有**三个量**:**会不会撞**(幅度上限,用包络断言证明)、 **动不动得起来**(动画真的在跑,用「暂停在两个相位」验证)、 **看不看得出来**(速度下限)。少任何一个都可能交付一个「正确但看不见」的东西; - 用**峰值速度**做感知下限的粗筛:`2π × 幅度 / 周期`。经验值 —— 低于 **0.5px/s** 基本等于静止;**1px/s 上下**盯着看才勉强察觉; **4–7px/s** 才是「一眼看出在漂浮」;超过 **10px/s** 开始像在跑; - **幅度被外部约束卡住时,周期就是唯一的可调量**:幅度定死在 2–3px 之后, 想让动效被看见只能缩周期,别再回去抬幅度(那样只会撞); - 验证要分清「数值对不对」和「动没动」:**静态设死 transform 做对照,只能验证前者**。 要验证后者必须让**动画本身**停在两个不同相位上比; - **幅度和周期要一起调**:把幅度抬 N 倍而周期不动,速度就抬 N 倍;反过来把周期拉长 也会把动效「冻住」; - 拿到专家给的「某个数」时,要问清楚**它是配着哪组参数给的**(这里是 1px 配 17–29s), 不能只搬数字; - 有了「空间预算」这类机制之后,**阈值本身应该交给空间决定**:把目标定得比预算大, 让空间成为唯一约束 —— 这才不会同时犯「撞」和「不动」两个错。 ### 12d. 两套 opacity 动画会互相抵消观感 —— 以及验证流光时的两个坑 **背景**:最高档铺满之后要加「光效流动」。Sol 的方案里有一条容易被忽略的指令: **把原来那层 11.7s 呼吸的柔光改成静态**,新加的流光只做一次淡入 + 持续位移。 **原因**:两个独立的 opacity 动画**不会数学上抵消**,但会在**感知上互相掩盖** —— 一片区域正在移动变亮时,另一层刚好整体变暗,空间变化就被抹平了。 所以「一个负责呼吸、一个负责流动」看起来像两个都在动,实际读起来是「什么都没动」。 **规则**: - **同一个视觉维度(亮度)上只允许一条长期动画**。要加新的运动,先把旧的退成静态, 让「动」的来源唯一; - 分层的原则是:**入场用 opacity,稳态用 transform**,这样两层动画不会争同一个属性。 **验证流光时踩的两个坑**: 1. **不能直接比真实 t=0 与半周期**。流光有 1.8s 延迟 + 1.2s 淡入,t=0 时它还是透明的, 那样比出来的差异是「变亮」而不是「位移」,会把方向判反。正确做法是固定 「扫描=终态、入场=完成、粒子全部暂停」,**只改 drift 的相位**。 2. **冻结时间要注意 delay 的语义**。要冻结在**全局**时间 T,有 `delay` 的动画得写成 `animation-delay: calc(delay - T)`:T 小于 delay 时是正延迟(还没开始,保持 0% 状态), T 大于 delay 才是负延迟。我第一版直接写 `-Ts`,等于把延迟抹掉了, 于是「铺满前不可见」这条根本测不出来(三个时间点的亮度完全一样)。 **验收口径**:光效这类「宽而柔」的变化,不要用「最大像素差」或「每个像素都变」来判, 要看**两侧区域均值的方向**(左暗右亮 = 光向右迁移)和变化是否呈宽分布 (本次 91% 的轨道像素都有 1–10 码值变化,最大 30)。 ### 12e. 「流动」被理解成「亮度迁移」—— 需求和参考图对不上就白做一轮 **现象**:用户第一次说「让紫色的条在延伸满以后,有一些光效流动」,我请 Sol 给了方案、 实现、验证全部通过(方向 ±5 码值、91% 像素有变化、时机正确)。用户看完**真实 Codex 截图**后说: 「**实现类似这样的蓝紫流动吧**」—— 他要的是**颜色在流动**,我做的是**亮度在流动**。 **原因**:我手上没有任何视觉参考,就把「流动」默认理解成「光在材质里移动」, 而参考图里其实是**一条蓝→紫的水平色带**。同一个词指向两种完全不同的做法: 亮度迁移(叠一层透明光) vs 色相迁移(移动一条不透明色带)。 **代价**:一整轮方案 + 实现 + 验证作废(Sol 的 token 也白花了)。 **规则**: - 用户说「像某个东西那样」时,**先要参考图**。没有参考图就先用一句话把两种可能解释都写出来确认, 不要自己挑一个最"高级"的; - **「颜色在动」和「亮度在动」是两个不同的机制**:前者必须让**颜色本身**承载运动 (移动不透明色带 / 周期性色场),后者只能叠透明层 —— 后者无论怎么调都不会被认成前者; - 色带的实现要点(这轮学到):`background-position: 0%→100%` 在 `background-size:200%` 下 只移动 **W**(不是 2W),所以「周期渐变走 0%→100%」其实只走了半个周期,循环会跳; 要无缝就得让可见窗口整体相等(`C(x+P)=C(x)` 且位移恰为一个周期), 否则老老实实做**往返**(端点速度为零的缓动),保住单峰配色; - **两个图层要同色派生**:扫描终态和"流动接管"必须从**同一张色标表**算出来, 否则接管那一帧会有一次配色跳变。本次实测接管前后通道差 ≤ 2/255。 **验证这类颜色动画的正确口径**(Sol 给的,比"看着像在动"硬得多): 固定住其他一切,只改一个相位,然后要求 **`I_{t2}(x) ≈ I_{t1}(x-Δ)`** —— 即整帧应当是**刚性平移**。本次互相关峰在 64 设备像素(=32 CSS px,与设计值一致), 该位移下平均通道差 **0.01**,不位移时 **18.22**。这比"截图看起来变了"强得多。 **结局:这一版被回退了。** 用户看到后说「原来那个一个高光的白色条飘过去的效果,我很喜欢这个保留, 感觉现在不好,紫色渐变效果不好,而且滑出很生硬」。 回退后的对照非常直接(同一扫描进度下放大截帧): | | 色带版(被否) | 回退后(被肯定) | |---|---|---| | 扫描 50% | 左蓝、中段一片**发白的宽亮区**、右端偏暗 | 左蓝,**右侧一道明确的白亮高光**,右端仍饱和 | | 扫描 85% | 整条**偏白扁平** | 左侧更深的蓝 + **中右一道亮高光** + 深紫 | | 终态 | 平淡、层次少 | 蓝→亮高光→深紫,**层次饱满** | **根因**:我为了"精确复刻采样色",把扫描渐变的**后半段直接换成参考色带**, 于是扫入的前沿从「亮高光 `#9298ff`」变成了「深靛紫 `#2f4ea9`」—— 高光没了、滑入变生硬、 整体扁平。**参考图的静态终态截图里没有"扫入过程",所以逐像素采样只能对齐终态, 不能用来生成扫描过程色**;把终态色反推成过程色,就会把用户喜欢的动态特征抹掉。 **规则**: - **静态参考图只能定"终态",定不了"过程"**。任何扫入/过渡的中间色必须单独设计, 不能把终态色标机械地搬到过程层; - **用户明确说过喜欢的东西,优先级高于任何"更准确"的重构**。这次我拿"逐像素对齐参考图" 去换掉了用户喜欢的亮高光,是典型的以准确度压观感; - 改材质类的东西前,先做**同相位 A/B 截帧对照**(同一扫描进度、同一位置), 一眼就能看出"平淡/生硬"在哪,比事后猜快得多。 ### 12. 过度克制 —— 一次没到位的"审美交付"**现象**:Sol 给的抖动参数(12/24 颗、0.18–0.72px 亚像素),我照做上线, 用户反馈"**没有达到我的预期,感觉可以稍微进步**"。 **原因**:Sol 的克制线是对的(防噪点),但**我把它当成了上限**,没有在"可见"和"克制"之间 再往前试一档。A/B 差异 1,595 像素、最大差 21/255 —— 数学上"在动",观感上"几乎看不见"。 **规则**: - 拿到"保守参数"时,**同时问一句"还能往上推多少才会俗"**,把区间问出来而不是只问一个值; - 可以用**逐像素 A/B** 客观量化"可见度"(差异像素数 + 最大差),不要只凭"代码写了动画"就交付; - 加强后是 2,366 像素 / 最大差 80 —— 依然没有越过噪点线。 ### 13. **用 PowerShell 读写 UTF-8 源码 —— 同一个错误犯了两次** **现象**:`Get-Content -Raw` + `Set-Content -Encoding UTF8` 往返之后, 中文变成 `娣卞叆` 这种双重编码,换行被吞掉,文件直接语法错误。 **代价**: - 第一次毁掉 `tools/render-preview.mjs`(不可读、只能删); - 第二次(就在本轮)又毁掉一次,而且更严重 —— 十几行注释的换行被吞, 注释把后面的代码整行吃掉。最后**重写整个 harness**(`tools/preview.mjs`)才恢复。 **原因**:Windows PowerShell 5.1 的 `Get-Content` 默认按 ANSI(GBK) 解码, `Set-Content -Encoding UTF8` 再按 UTF-8 写 → 双重编码。 **规则(硬性)**: - **永远不用 PowerShell 读写 UTF-8 源码文件**,只用 read / write / edit 工具; - 必须用 shell 改文件时,走 `[System.IO.File]::ReadAllText/WriteAllBytes` 显式指定编码; - 我在第一轮就写下了这条规则,**然后在同一轮又犯了一次** —— 所以它不只是"知道", 要变成"默认不这么做"。 ### 14. 把"服务端下发的 JS 源码"当成"运行结果"读 **现象**:验证线上是否生效时,我在 bundle 里找 `${CARD_W}`,找不到就以为失败。 **原因**:那是 `lib/client.js` 的**源码文本**(模板字面量还没被求值), 不是运行后的 CSS。 **规则**:验证"是否生效"要找**真实产物**的痕迹(类名、常量名、关键帧名、 响应里的 DOM),或者干脆跑一个真实渲染。**别在源码里找运行时的东西。** ### 15. 早期两轮咨询没有记录 token 用量 **现象**:用户要"告诉我他用了多少 tk",我只能给后几轮的精确值,前两轮只能估算。 **原因**:`usage` 字段一开始没落盘,是后加的。 **规则**:**外部调用在第一轮就把用量写进日志**(现在落 `ref/sol-usage.jsonl`)。 没记的东西只能估,估出来的数字必须**明确标注是估算**,不能混在精确值里。 --- ## 三、代码层已经修掉的具体 bug(备查) | # | 问题 | 修法 | |---|---|---| | 1 | 渲染死循环(`useLayoutEffect` 依赖含新函数) | 依赖改原始值 + `place()` 无变化时返回原对象 | | 2 | tooltip portal 用数组渲染但没 key | 包一层 `React.Fragment` | | 3 | portal 丢 CSS 变量(拖柄变灰) | `--dshcps-*` 同时声明在 root/pop/tip | | 4 | `clip-path` 扫描前沿是硬边 | 换 2 倍宽材质 + `background-position` 平移 | | 5 | 无头截图抓到入场中间帧 | `--virtual-time-budget`(后用暂停法彻底解决) | | 6 | 拖柄飞溅粒子颜色/尺寸/生命周期全错 | 按量到的规格重做(深蓝紫、等比缩放、一次性) | | 7 | 额度提醒不可见 | 触发从"提交态"改为"`isUltra` 含拖动预览" | | 8 | 左上角模型入口多余 | 按 Sol 复审撤掉,回到原版单一入口 | | 9 | **点别的模型没反应**(`selectModel` 从未发出) | 模型列表是独立 portal,「点外部关闭」的 pointerdown 捕获监听没排除它 → 在 pointerdown 阶段就把面板卸载了,`click` 永远不到。修:把列表节点一起排除(后来升级成几何矩形判定) | | 10 | **悬停看模型说明时「框框异动」** | 气泡位置每次 render 都用「当前行的 right」重算是否翻到左侧 → 行宽差一点就左右跳;行间 2px 空隙还会让气泡闪断。修:位置在 `onMouseEnter` 时按**整列的左右边界**算好并冻结;离开延迟 90ms 再隐藏 | | 11 | **使用中仍「抽动」** | ① 松手用 `Math.round` 重算档位,指针停在边界另一侧时松手会跳到相邻档;② 迟滞阈值 7px 偏小。修:松手**沿用拖动中最后显示的那一档**、阈值提到 11px | | 12 | **选择框异常关闭** | 纯 `ref.contains` 判定:portal 重挂载瞬间 ref 可能为 null、隐藏卡片区域点击又会穿透 → 被误判成「点外部」。修:改成**几何矩形判定**(点击坐标落在任一浮层矩形内都不关)+ 打开后 **120ms 宽限** | | 13 | 模型列表面板跟着 hover 上下动 | `panelStyle` 的 `bottom` 每次 render 都读 `popRef.current.offsetHeight`。修:进入模型视图时**量一次并冻结**到 ref | | 14 | 列表内部滚动让浮层跳一下 | `window` 的 `scroll` 捕获监听把「面板内部滚动」也当成窗口滚动。修:`event.target` 在浮层内部时直接 return | **这轮的教训(比单个 bug 更重要)**: - **静态结构断言测不出交互序列 bug**。#9 之所以能活好几轮,是因为 harness 直接调用 `onClick`, 根本不存在「document 级 pointerdown 先触发」这一步。凡是「点了没反应 / 莫名关闭 / 抖动」 这类问题,必须模拟**真实的 pointerdown → pointermove → pointerup → click 序列**, 并且要有**反向对照**(「点真正的内部不该关」+「点真正的外部应该关」两条都要有)。 - **假 DOM 的 `contains: () => true` 是个陷阱**:它让「点外部关闭」这条路径在测试里永远走不到。 假节点必须能给「不命中」的答案,几何判定才有意义。 --- ## 四、这次沉淀下来的验证手段(可复用) 1. **离线 harness**:`tools/preview.mjs` —— 最小 React 运行时 + 假 DSH 宿主 + HTML 序列化,把真实 `lib/client.js` 渲染成静态 HTML,不依赖网络与真宿主。 2. **暂停法定帧**:`animation-delay: -s` + `animation-play-state: paused` → 确定性的一帧,可逐像素测量。 3. **逐像素 A/B**:只关掉**一个**动画层,其余不动,比较差异像素数与最大差值 → 客观量化"这个动效到底有没有、有多强"。 4. **真实计时器验证**:在 Node 侧 `await` 到计时器触发再渲染第二帧 → 验证的是一份逻辑,不是两套实现。 5. **带签名 cookie 的线上检查**:用 `~/.dsh/.credentials.yaml` 里的 `client-connection/browser-session` 密钥签一个 `dsh-auth-` cookie,直接请求宿主的 `plugins/??…` 组合包,确认改动真的被下发。 --- ## 五、还没完全解决的问题 - **额度提醒在"重新打开卡片"时是否该重播**:目前按 Sol 的建议**不重播**(避免挡住模型入口), 但如果用户希望"每次看到最高档都提醒一次",改一行即可。 - **墨点环的半径**:现在按拖柄直径等比缩放(29px 柄 → 半径 18–20px), 紧贴白圆;想更"散"可以调基准半径,卡片右侧还有约 6–7px 余量。 - **抖动强度**:已按 gpt-6.1-sol 的「空间预算」重做(见 12b、12c)。当前漂移 1.74–3.32px、 颤动 0.47–1.11px、包络 1.88–3.48px,全相位最小间隙 1.22px(要求 ≥ 0.8),276 对全部达标。 **目标幅度故意定得比预算大**,让空间成为唯一约束;周期必须配得上幅度(见 12c)。 --- ## 六、一句话总结 这个任务里最贵的三个错误,没有一个是"不会写代码": 1. **用静态截图推断动态行为** → 整轮返工; 2. **触发条件只覆盖了自以为的路径** → 功能上线但用户看不见; 3. **知道规则却没有变成默认动作**(PowerShell 改源码,犯了两次)。 三次都是**流程问题**:先问清行为、先列全路径、把规则变成肌肉记忆。 --- ## 附录二:入场扫光的「方向」为什么会被填充层吃掉 用户要的是"调到最高档时白光条从右边向左边过来"。查下来发现**材质本身早就是右→左**的, 但观感相反,原因在**三个层叠的机制不同步**: | 机制 | 实际方向 | 说明 | |---|---|---| | `.dshcps-ultra` 的 `background-position` 0%→100% | 右→左 | 2 倍宽图里取景窗右移,画面上材质向左滑 | | `.dshcps-stars` 的 `clip-path` 96%→0 | 右→左 | 粒子跟着材质铺开 | | **`.dshcps-fill` 的 `width`(0.18s 过渡)** | **左→右** | 材质被装在填充层里,填充层从左往右长 → **整块材质被"从左掀开"** | **结论**:视觉方向由**最后揭开的那个容器**决定。填充层尽管只是"宽度动画", 它决定了材质以什么顺序进入视野,把另外两个右→左的机制全盖掉了。 **修法**(三条缺一不可): - 进 Ultra 时去掉 fill 的宽度过渡(`.dshcps-fill.is-ultra{transition:none}`),让它瞬间到满; - 揭示改由材质自己的 `clip-path: inset(0 0 0 X%)` 承担,X 从 100% 收到 0(右→左); - 前沿白光条必须是 `.dshcps-ultra` 的**兄弟**——放进被 clip 的那层里,会被一起裁掉。 **规则**: - **"出现顺序"由容器决定,不是由内容决定。** 内容里怎么动,都盖不过容器的显露方向。 - 验证方向不要靠"看动画",要**冻结揭示进度**(20/50/85%)后量材质左边界: 实测 0.802 → 0.501 → 0.151,单调左移才算通过。 - 终态必须**逐像素比对**(git stash 出改前版本各渲一张,`tools/diff-png.py`): 本例最大差 2 级、>2 级差异 0 个 = 只是抗锯齿。 ## 附录三:无头 Edge 默认 `prefers-reduced-motion: reduce` 排查白光条"为什么不渲染"时踩到的:**headless Edge 把 reduced-motion 报成 reduce**, 于是截图里跑的全是插件的**减弱动效分支** —— 白光条被 `display:none`、星尘不闪、极光不漂。 这会让"截图看着没问题/有问题"的判断系统性失真。 **修法**:harness 的 `page()` 里显式把该分支还原成"有动效"的取值,例如 ```css @media (prefers-reduced-motion: reduce){ .dshcps-ultra-front{display:block !important} .dshcps-ultra{clip-path:none} .dshcps-aurora>i{opacity:1 !important;transform:none} } ``` **规则**: - 只要组件有 `prefers-reduced-motion` 分支,**离线截图必须先确认跑的是哪一支**; - 冻结帧自己会把元素 `display:none` 时,覆盖它的规则要带**同等 `!important`**(后写的赢); - 判断"某元素有没有渲染",用**开关式差分**(`.cls{display:none !important}` 各截一张、比像素), 比盯着一整张图找它快得多。 --- ## 附录:气泡贴边 bug —— 用「最大宽」估算翻转,把放得下的情况判成放不下 **现象**:用户报「模型介绍气泡离菜单太远很丑」,并且怀疑"间距改成 5px 没生效"。 **实测**:代码里 GAP 确实是 5,但气泡被定位到 x=8(屏幕最左),与面板(右边缘 562)间距 **292px**。 **根因**: `js const TIP_W = 360 // 这是 max-width const flip = base.right + GAP + TIP_W > window.innerWidth - 8 tipLeft = flip ? Math.max(8, base.left - TIP_W - GAP) : base.right + GAP ` 气泡内容实际只有两百来像素,但翻转判定拿 **360(最大宽)** 去估 → 562 + 5 + 360 = 927 > 892 成立 → 被判成"右边放不下" → 翻到左侧 (300 - 360 - 5 = -65)→ 再被 Math.max(8, …) 夹到**屏幕最左**。 于是"5px 间距"变成了 292px。 **规则**: - **凡是用尺寸做几何判定,必须用「实际会占用的尺寸」,不能用 max/上限值。** max-width 是排版约束,不是布局输入。 - 对"先定位、后渲染才知道真实尺寸"的元素,最稳的是**给固定宽/高**(本例改成 width: 300px), 几何变成可精确预测,判据也能写死(实测间距 = 5px,可断言)。 - 定位类 bug 先**量**再改:本例先算出 tipLeft 的实际值(567 vs 期望 8), 一眼就看出是翻转分支被误触发,而不是继续调 GAP 数字。 - 测量时要让**假 DOM 矩形与真实布局一致**,否则像素测量会失真(本例假矩形 300..562、 真实渲染 343..624,导致第一版量出负间距)。做法:测量页把面板钉到假矩形位置。 ## 附录四:入场瞬间「左边凭空冒出一条白条」—— 多动画共用一个 fill-mode **现象**:滑到最高档的瞬间,轨道**左端**突然出现一条白色亮条,用户连续三轮报同一句话。 **实测定位**(探针页把扫描冻结在 t=0,逐层关闭后量轨道"左起 10–25%"段亮度): `all` 128.52 vs `no-a1` 109.12 —— 差值 **19.4/255** 只出现在这一分段,其余分段完全相同; `no-a1` 与 `no-aurora` 完全相等 → 元凶唯一:aurora 的 **a1 白光层**。 **根因**(三段叠加): ```css .dshcps-aurora > i{ animation-fill-mode:both; } /* 基类:作用于该元素「全部」动画 */ .dshcps-aurora > .a1{ animation-name: dshcps-aurora-in, dshcps-aurora-x1, dshcps-aurora-breathe; animation-delay: 2.4s, .7s, 2.4s; } @keyframes dshcps-aurora-breathe{ from{opacity:.65} to{opacity:1} } ``` 1. `breathe` 排在最后 → 同一属性(opacity)由**最后声明的动画**胜出; 2. `fill-mode:both` 让它在 **2.4s 延迟阶段就持有 from 值 .65**; 3. 于是 `aurora-in` 的 `opacity:0` 被完全盖掉 → 挂载瞬间白光层就以 .65 可见; 4. 而 `x1` 起点是 `translateX(-16%)`(元素宽 200% → 位移 = 轨道宽 32%), 白光团正好落在**左侧**。 **修法**: - 给 a1 **按动画顺序单独声明 fill-mode**:`both, both, none`(让"呼吸"在延迟阶段不产出 opacity); - "呼吸"延后到淡入结束(2.4s + .9s = 3.3s)才启动,且关键帧 `from{opacity:1}` 与淡入终值接续 —— 否则它会从 .65 起手、出现"暗一下"的跳变。 **规则**: - **不要用一条 `animation-fill-mode` 同时管多个动画**。只要某个动画在 delay 阶段会产出 与"淡入"同名的属性(opacity / transform),它就会把淡入盖掉,表现为"凭空出现"。 - 谁生效看的是 **animation-name 列表顺序(后面的胜)**,不是时长、也不是 delay。 - 视觉 bug 的最快归因法:冻结到出问题的那一帧,**逐层关闭**再量差异 —— 只有一处变化就能唯一归因(本例 19.4/255 只出现在左 10–25%)。 ## 附录五:图层「挂载」那一帧的硬跳,要单独淡入 **现象**:即使白条修好,材质本身在挂载瞬间仍是硬跳 —— 用户读成「一个蓝色的东西突然出现」。 **实测**:把 t=0 帧与"完全没有 Ultra 层"的基线逐段对比: `L0-10 +0.7 / L10-25 +5.3 / M25-50 +15.9 / R50-75 +29.8 / R75-88 +25.9` (右段在 0ms 内跳 29.8/255,肉眼必然可见)。 **修法**:给 `.dshcps-ultra` 增加第二个并行动画 `dshcps-ultra-in .2s ease-out both` (只淡入 opacity),与 `background-position` 扫描并行。改后 t=0 与基线**逐段差 0.00**。 **规则**: - 任何"条件挂载"的视觉图层(`isX ? : null`)在挂载帧都会瞬变; 材质越亮、面积越大,硬跳越明显。 - 给它 120–200ms 的 opacity 淡入,底下留一层**颜色接续**的底色, 观感就从"弹出"变成"浮现"。 - 怎么证明是"平滑移动"而不是"突跳":用不同步长采样同一段动画。 dt 从 0.30s 缩到 0.05s,若最大单帧变化同步缩小到约 1/6、且序列无孤立尖峰,即为平滑移动; 本插件实测细采样最大 0.037(归一化),无尖峰。