# 踩坑记录 / 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), 不能只搬数字; - 有了「空间预算」这类机制之后,**阈值本身应该交给空间决定**:把目标定得比预算大, 让空间成为唯一约束 —— 这才不会同时犯「撞」和「不动」两个错。 ### 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 复审撤掉,回到原版单一入口 | --- ## 四、这次沉淀下来的验证手段(可复用) 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 改源码,犯了两次)。 三次都是**流程问题**:先问清行为、先列全路径、把规则变成肌肉记忆。