# dsh-approval-chime 变更记录 版本戳在「通知提醒」分区页右上角显示,也可在控制台读 `window.__DSH_APPROVAL_CHIME__.revision`。 包版本(`package.json` 的 `version`)现为 **`0.3.0`** —— rev-10/rev-11 那次升到 `0.2.0` 之后一直没动, rev-12…rev-24 累积到这里(其中 rev-15 是一次数据丢失缺陷修复)。它与上面的 `rev-N` 是**两套编号**: `rev-N` 记每一次改动,`version` 是发布里程碑。 > ⚠️ **哈希提醒**:`docs/验证报告.md` 与 t4 评审记录里锁定的实现哈希对应 **rev-1**。 > rev-2/rev-3 是纯外观改动,**rev-4 是功能新增**,**rev-5 是两轮独立验证与需求审查后的修正**, > **rev-6 是 N3 收尾**,**rev-7 把设置入口从「插件」迁到独立的「通知提醒」分区**, > **rev-8 把「启用提示音」换成苹果式拨动开关**,**rev-9 按真机反馈把它改瘦、开启色统一成音量条的蓝**, > **rev-10 加「按会话独立」(会话头部小铃铛 + 每会话音色/音量覆盖 + 各响各的)**, > **rev-11 修 rev-10 的应答乱序窗口(会话写入落定后重读一次表)**, > **rev-12 把会话弹层里的音色下拉列表改成与设置页那张同一套样式**, > **rev-13 按真机反馈把会话头部的小铃铛/caret 放大**, > **rev-14 把「会响」状态的铃铛改成设置页开关同款的蓝色实心**, > **rev-15 修 rev-11 遗留的「收敛重读失败会把本地覆盖表清空、从而丢掉用户静音」缺陷(OBS-A)**, > **rev-16 按真机反馈把会话头部的铃铛与它右边的箭头分开(原先贴在一起,被读成同一个控件)**, > **rev-17 按真机反馈让那个箭头在弹层打开时以过渡动画下转 90°、关闭时转回(旋转作用于 svg 字形而非按钮盒, > 常量驱动并暴露在控制台面;rev-17 当时那条 prefers-reduced-motion 降级已由 rev-20 删除)**, > **rev-18 按真机反馈把那个转动放慢(160ms → 300ms),并新增顶层诊断 `reduceMotion()`, > 让「时长太短」与「环境要求减少动效」这两种看起来一样的原因可被区分**, > **rev-19 再放到 400ms**, > **rev-20 把转动定回 160ms,并删掉那两条 reduced-motion 覆盖(此后所有动画在所有环境下都播放,没有例外)**, > **rev-21 把分区页右上角的构建戳从描述整串改成只印版本 id**, > **rev-22 给会话头部的铃铛加一次「响」(点击切换本会话提示音时摇一下)**, > **rev-23 把摇动换成「倾一次就落定」(换掉四次摆动,并点名拒绝 ease-in-out)**, > **rev-24 把铃铛的动作整段删除 —— 静音改成「把斜杠从左上画到右下」、蓝底同一时钟变暗(240ms)**。 > 每次改动后都复跑了全部 harness:现为 4 个文件 / **663 项断言全绿(124 + 442 + 22 + 75)**,各 exit 0; > 另有**独立验证层**(`verify-independent/`,另一套探针与变异表,与上面四套不共享代码)—— > 早先的读数是 6 个探针 / **402 项**,加**重基线后全绿的 10 个遗留探针 / 820 项**; > **逐轮的当值读数与残留红项见 `docs/变异覆盖与残留红.md`**,本节每个 rev 小节也各自记下当轮的结论。 ## rev-24 · 铃铛不动了,改画那一道斜杠(静音 = 把斜杠从左上拉到右下) 来源:rev-23 交付后用户看真机 ——「**不要晃动,静音时把斜杠重左上拉到右下的动画**」,随后四轮补充: 「关闭静音的时候斜杠从左上到右下动画,**两个动画时长一样**」/「斜杠的图层是在铃铛上面」/ 「**蓝色的部分也弄个逐渐变暗到消失的动画**」/ 以及一串只调时长的反馈(见下)。 - **铃铛本体彻底不动**:rev-23 的倾斜规则**整段删除**,不是调小 —— 字形与 28px 按钮在**任何状态**下都 没有 `transform`、没有 `animation`、没有 `transform-origin`。「铃铛不晃」这类性质正是后来者"顺手加个 keyframes"会破坏的东西,所以测试把它写成**四处缺席断言**(声明、样式表、渲染树、**源码里的死标识符**—— 注释里留一个已删除的常量名,就是下一个读代码的人以为它还在的原因)。 - **动作改成"画"**:静音时把斜杠的**笔画**从对角线左上端画到右下端 —— 动画 `stroke-dashoffset` 从对角线自身长度(**15.27**,由两端点 2.6/13.4 推导,不手抄)到 0;取消静音时反向扫走。 蓝底变暗/回蓝走**同一时钟**(用户明确要求等长)。 - **一个常量、两条曲线**:`BELL_MUTE_MS = 240`(四条规则全部由它拼出,不存在只改了一半的可能); `dacSlashDraw`/`dacBellGlow` 用 `cubic-bezier(0.22,1,0.36,1)`(长尾 ease-out), `dacSlashSweep`/`dacBellDim` 用 `cubic-bezier(0.42,0,0.58,1)`(`ease-in-out`,**它自己的时间反演**)。 两个方向的曲线是**交叉**的:一个在缓出时另一个在缓入缓出。 - **时长是被你调出来的,不是量出来的**(历史原样记在常量旁边):240(第一版,只有笔画,读作"有点快") → 420(仍是笔画,"出来那下有点慢")→ 420 + 蓝底("太慢了"——**同一个时长一旦覆盖更多视觉变化就更慢**, 这是整条反馈里唯一有信息量的一点)→ 300(仍偏慢)→ 360(**只活了一轮**:那是我把你的错字 「在慢点」当成了「再慢点」,你随后说明是「再快点」——360 因此**从未被你真正评判过**,注释里明确写了 "不得被后人引用为被否决的值")→ **240**。回到 240 不是重试:当年那个 240 只管一道笔画, 现在 240 要同时驱动笔画与一大片蓝底,覆盖的视觉变化更多,**读起来比当年慢** —— 这才是理由。 - **"不画墨"用的是 `opacity`,不是 dash 偏移**:这一步走过两条死路,而且**两条你都看见了**—— `stroke-dasharray` 单值每 `2·LEN` 重复一次,所以 `dashoffset = LEN` 会把一个圆头端点停在路径末端 (右下角那个**点**,你问「为什么右下角有个点」),再往前推一个长度又会把下一段重复拉进另一端 (左上角那条**短线**,「现在是左上角有个线」)。**任何 dash 几何都清不掉一条长度恰等于 dash 的路径的两端**, 所以静止态改用 `opacity:0` 隐藏,两个关键帧也各自带动 `opacity` 轨道 —— 这也让每次动画**结束在一个真正看不见的 状态**上,而不是"恰好没有墨"的状态上。 - **图层:SVG 没有 z-index,绘制顺序就是这个 paths 数组**。斜杠改为**先** push,铃铛与铃舌在后, 于是笔画落在铃铛轮廓**后面**(用户报告「斜杠的图层是在铃铛上面的」)。副作用是动作变好看了: 墨从左下侧出现、隐入铃铛、再从另一侧出来。 - **首次绘制仍静默**:`data-draw` 在点击前是 `false`;首次点击后计数器 `drawCount > 0` 会让这个节点 **在会响状态下也保留**(否则取消静音时没有元素可动画)——但**点击之前**会响状态仍然只渲染两个 path, rev-10 的图标契约与 probe-18 记录的 `{pathCount:2,hasSlash:false}` 依旧成立。 - **几何、配色 token、音频路径、host 半一律未动**;**仍不加 `prefers-reduced-motion` 降级** —— 样式表里该字符串仍是 **0 次**(probe-20 量它,不假设它)。 - **锚定字节(rev-24)**:`lib/client.js` **179451 B / sha256 0BDAC98C5F9AB06F687A9856238EA7C7302A5E7F6CDEDD9CBBA6CDEBCEA49958**; `verify/client-half.test.mjs` **121975 B / sha256 1BCC6FAF5C40E64D9023E7EF19B97A548EA422CB9ADD4C943068400706916895** (**442** 项:rev-23 的 20 条换成 22 条,且这一节的主体是**缺席断言**与**静止态的零墨**); `verify/custom-audio.test.mjs` **20263 B / sha256 D2DE24C11CA2699738E975476C9659976FC44C201C9F0174567BCE1551F4CD8C**(仍是 `:267` 那 1 个字节)。 - **harness**:四套作者 harness 合计 **663 = 124 + 442 + 22 + 75**,各 exit 0;独立层 probe-20 **58/58**, 第 7 组**重写为"四态级联模拟"**(会响/静音 × 点过/没点过,问浏览器在这种情况下会算出哪条规则赢: 谁赢、用哪条曲线、留多少墨),它的 **26 个突变仍然一个没加、一个没改名**,`--mutate=all` 逐条命中。 - **本轮最值得记的一课:一条检查"因为新理由而变绿",不算通过。** 变异表(45 行)在本轮抓到一处**我自己造成的 可证伪性流失**:probe-18 的 `B4.icon-two-states` 原本靠"静音才画斜杠"来区分两个状态,而 rev-24 让斜杠 在**会响时**也可能存在(为了能播放取消静音的动画),于是 `mute-ignored` 突变下它**仍然是绿的**—— 图标确实不同了,但**不再是因为静音标志不同**,而那才是它声称在测的东西。修法是让这条检查同时断言 **状态与画面这一对**(`data-muted` + path 数),于是该突变重新精确命中它声明的 14 条。 若无变异表,这条会安静地退化成装饰。 - **本轮我自己写错的验证代码(4 处,全部由运行结果暴露)**:① 新探针的选择器用 `indexOf` 命中**更长的** `.dacSlash` 规则(前缀同名,静默取错块,报出 6 条假红);② 曲线时间反演用浮点比较,`1 − 0.58` 得到 `0.42000000000000004`;③ 抓关键帧的正则 `\}\}` 把 `to{}` 的收尾括号一起吃掉,看起来像产品坏了; ④ 把关键帧步骤的名字当成 `conditions`,而解析器放在 `prelude` 里。**四分之四都在验证侧**, 与之前几轮同一条教训一致:**先怀疑验证器。** - 另一处同源误报:把 JS 字符串长度(中文按 1 计)当成**字节数**印出来,一度让文件"看起来小了 1.7 KB"; 真字节数与 sha 由磁盘与探针各自独立读取并一致。 ## rev-23 · 铃铛改成「倾一次就落定」(换掉 rev-22 的抖动) 来源:rev-22 交付后用户看真机 ——「再换一个要有高级感」。rev-22 的动作是**四次递减摆动** (0 → +14° → −11.06° → +7° → −4.06° → 0,420 ms,`ease-in-out`),读起来像**抖**;用户要的是收得住的那版。 - **改的是「形状」,不是幅度**:帧表从 6 帧、**四次过零**,变成 4 帧、**只过零一次** —— `BELL_TILT_FRAMES = [[0,0,1],[30,1,1.055],[66,-0.22,1.012],[100,0,1]]`(角度 0 → +9° → −1.98° → 0), 并新增 **scale 轨**(1 → 1.055 → 1.012 → 1)。一句话:**晃四下的铃铛是玩具,倾一下就落定的铃铛是机构。** - **缓动是另一半**:`cubic-bezier(0.22,1,0.36,1)`(长尾 ease-out),不再是 rev-22 对称的 `ease-in-out` —— 快速离开、把大部分预算花在「到达」上,这是这种尺寸的动作里「高级感」的主要来源;测试里有一条**点名拒绝 `ease-in-out`**,防止以后被「简化」回去。 - **峰值 14° → 9°,时长 420 ms → 560 ms**;支点仍是**字形顶边** `transform-origin:50% 0`(铃铛挂在冠上), 「动的是字形、28px 按钮不动」的分工与 rev-22 一致;`data-ring` → **`data-tilt`**, `@keyframes dacBellRing` → **`dacBellTilt`**,控制台面 `bellRingMs/Deg` → **`bellTiltMs/Deg`**(560 / 9)。 字形 key 仍是 `glyph`,首次绘制仍**静默**(点击后才上膛)。 - **几何、配色、音频路径、host 半一律未动**;**仍不加 `prefers-reduced-motion` 降级** —— 样式表里该字符串仍是 **0 次**(probe-20 量它,不假设它)。 - **锚定字节(rev-23)**:`lib/client.js` **168920 B / sha256 DA25EB01F21156A294DDAA5DD098BE97129101434A6E03EE106AC442F6B8263E**; `verify/client-half.test.mjs` **112635 B / sha256 9BA8B5776B968D0C3346A97F12AB2796C36DD0C00BA61FA8DE84C01FD2C1C78C** (**424** 项:rev-22 的 17 条换成 20 条,其中两条量的正是用户要的「形状」—— **过零次数** 与 **膨胀上界**); `verify/custom-audio.test.mjs` **20263 B / sha256 2D712A6DE50DAA755D5C70A656BD42CAEB35CA48E36D4FBA744B63F6FA8268CF**(仍是 `:267` 那 1 个字节)。 - **harness**:四套作者 harness 合计 **645 = 124 + 424 + 22 + 75**,各 exit 0;独立层 probe-20 **49/49** (第 7 组重写为 12 条),它的 **26 个突变仍然一个没加、一个没改名**。 - **本轮补的课(登记,这是我上一轮的漏项)**:r22/t1 的重锚**只搬了 `rev-` 修订前缀、漏了 `r-` 轮次前缀**, 于是 `r15t6` 仍在往 `_raw/r21-evidence/` 里写,一次运行把 **r21 canonical 的变异表与约 40 份逐条日志覆盖**了。 已做两件事:① 新增 `r22-t2-evidence-paths.mjs`,把它的输出路径搬出 r21 目录(6 行、逐行声明命中数、改前副本归档); ② 本轮重锚把**轮次前缀与修订前缀一起搬**,并实测它现在确实写进 `_raw/r23-evidence/` (45 份 `r23-mut-*.txt` + `r23-t1-mutation-table.json/.md`)。**r21 那批文件不可恢复**,仅存的一手记录是 `_raw/r21-evidence/r21-t1-mutation-table-console.txt`。教训写在这里:**只数行数的检查,读着错轮次的字节 也会绿 —— 计数不是溯源测试。** ## rev-22 · 铃铛会响(切换本会话提示音时摇一下) 来源:用户看着会话头部那个蓝铃铛说 ——「这个铃铛也要有动画」(旁边的箭头从 rev-17 起就会转)。 - **改法**:被摇的是 `` 字形,**不是**那个 28px 按钮(按钮本身就是 hover 底板,摇它等于摇指针脚下的背景); 支点是字形的**顶边** `transform-origin:50% 0` —— 铃铛挂在它的冠上,绕中心转看起来是「打转」而不是「响」。 动画在**点击切换**时播放一次(不是载入时、也不是 hover 时):`data-ring` 只在第一次点击后置为 `"true"`, 字形带一个随点击递增的 React `key`,key 变了节点就被替换,替换就重放动画。 - **数值**:`BELL_RING_MS = 420`、`BELL_RING_DEG = 14`,都挂在控制台面 (`sessionIcon.bellRingMs` / `sessionIcon.bellRingDeg`)。420ms 比箭头那 160ms **故意长**,而且是**另一个旋钮**: 箭头报告的是一个离散状态(一次 90° 转),铃铛模仿的是衰减振荡,两者互不推导、也不许互相抄。 - **关键帧是生成的**:`BELL_RING_FRAMES = [[0,0],[15,1],[35,-0.79],[55,0.5],[75,-0.29],[100,0]]`, `bellRingKeyframes()` 由这张表和 `BELL_RING_DEG` 生成 `@keyframes dacBellRing`(角度为 `0,14,-11.06,7,-4.06,0`), 所以样式表与控制台面**不可能**对峰值角度各说各话。 - **没有加 `prefers-reduced-motion` 降级**(与 rev-20 对箭头的处置一致):一条规则、所有环境,代价写在常量旁边 而不是包装成无障碍最佳实践。样式表里 `prefers-reduced-motion` 仍是 **0 次**(probe-20 量它,不假设它)。 - **锚定字节(rev-22)**:`lib/client.js` **166986 B / sha256 04376143BDD0EBC5AAB9F67910DFFC10997C8CC3D55D08A7FE6383152CB8207E**; `verify/client-half.test.mjs` **111255 B / sha256 75218CE8462FFDABC5590E735FC9BAAB5D096DDFF5CF74105F32B25CBCD026E5** (+17 条:常量、生成的关键帧及其衰减、支点、按钮未被摇、首帧静默、点击后上膛并换 key); `verify/custom-audio.test.mjs` **20263 B / sha256 11DAF28B703C43DCC2F3845A05ECCF278772AD48F6E06F2764705058ED8D6F05**(仍是 `:267` 那 1 个字节)。 - **harness**:四套作者 harness 合计 **642** 项(client-half **421** / host-half **124** / waterfall **22** / custom-audio **75**),各 exit 0;独立层 probe-20 **47/47**(新增第 7 组共 10 条,含「点击后才上膛」), 它的 **26 个突变一个没加、一个没改名**,`--mutate=all` 复测全部按各自声明的红集命中。 - **本轮补档(登记,本可不发生)**:本轮先用编辑工具改了 `lib/client.js`、**之后**才建重锚工具,而重锚工具只归档 **它自己碰过**的文件,于是 rev-21 那份产品字节(159172 B / `AE391909…`)一度**在盘上没有任何副本**,连 r21 的 证据里也没有一处写着这一对。已用**逆向重建**补回:把本轮的 10 处改动逐条求逆作用回 rev-22 文件,重建结果 **159172 B 且 sha256 命中 `AE391909…`(256 位摘要相等)**,即 rev-21 原件本身,已归档为 `_raw/r22-evidence/archive/lib__client.js.ae3919098ccc11c9.txt`;重建脚本 `_raw/r22-evidence/reconstruct-rev21.mjs` 为每一条逆操作声明命中数,任一条不符即拒绝写盘。 (第一次重建正好差 **2 字节** —— 多留了一个空行 —— 是摘要把它拦住的;第二次报「NOT RECOVERED」则是**我脚本里的 大小写比较**写错了,64 位十六进制其实完全相同,已修并在脚本注释里登记这次误报。) ## rev-21 · 构建戳只显示版本号(badge 从整串改成版本 id) 来源:用户看设置页截图指出 ——「这里只显示版本号就行了」。原先「通知提醒」分区页右上角显示的是整串 `rev-20 · the caret turn takes 160 ms`。 - **改法**:`.dacRev` 改渲染 `snapshot.bundleRevisionId`,它**由 `REVISION` 推导** (`REVISION.split(' · ')[0]`),所以「版本 id」与「描述半句」不可能各说各话;描述半句留在控制台面 (`diagnostics.revision` 仍是整串),并新增 `diagnostics.revisionId`(= badge 的文本,也可读 `window.__DSH_APPROVAL_CHIME__.revisionId`)。**行为、样式、几何、音频路径与 host 半一律未动** —— 这是一次纯渲染改动,也是本轮的**全部**改动。 - **锚定字节(rev-21)**:`lib/client.js` **159172 B / sha256 AE3919098CCC11C9E5016AC0B72A12C8A97C0D13DA6009318D4801FCBA37A2D4**; `verify/client-half.test.mjs` **104904 B / sha256 5FAA8FEF5C6259875F981F23376BBE282F8E82B9A3F3D2839B0B453165CE50DA** (+4 条断言:badge 必须**恰好**是 `rev-`、不带 `·` 分隔符、整串**不再出现在页面上**、控制台半句仍在); `verify/custom-audio.test.mjs` **20263 B / sha256 49B0493EB031D576BC4AD02D0559B31357A8ADD5D74DD4BC08FBDBEF119DA0CE** (仍是 `:267` 那 1 个字节)。 - **harness**:四套作者 harness 合计 **625** 项(client-half **404** / host-half **124** / waterfall **22** / custom-audio **75**),各 exit 0;独立层 probe-17 **95/95**(+1 条 = badge 无散文半句)。 - **本轮开始时发现的事实(登记,不当没看见)**:r20 的 canonical 跑在 2026-09-19 23:06,之后 2026-09-20 00:38 的提交 `b0ad1eb`(术语统一 556 处 + 新版 README)把产品字节从 **158549 B / `4B6C8B91…`(r20 全部指纹钉的那一对)** 改成 **158546 B / `5DE1F30C…`**, 同时改了 `verify/client-half.test.mjs`、`README.md`、`docs/**`、`package.json`。 后果:**在本次重锚之前,runner 第 0 节(冻结清单)在已提交的树上已经是红的**, 文档机检也有 **9 条声明值**因此失配(`artifact:lib/client.js`、`artifact:verify/client-half.test.mjs`、 `artifact:verify-independent/probe-17-r7-section.mjs`、`artifact:README.md`、`artifact:docs/挂载与验收.md`、 `changelog-anchor:lib/client.js(rev-20)`、`visibility-scan-files`、`policy:old-semantics-scan-files`、 `fingerprint-restatements:lib/client.js(all mentions)`)。本轮把这些声明值一并重锚(逐条见 `docs/变异覆盖与残留红.md` §20),并**没有**改写 §15–§18 的冻结历史。 - **独立验证**:canonical runner 本轮重跑(exit 0 / `FAILURES` 0 / 冻结 9/9 / legacy 10/10 / 变异表 45/45),当值 console 为 `verify-independent/_raw/r21-run-console.txt` **75978 B / sha256 5C61EDB5B0160296F66C91BCD956CDE39E7A03889EF508CD71F50400485496FE**。 这一轮跑了三次,前两次都红,**两份红色 console 都作为证据留着、没有被覆盖**;两次的 `FAILURES` 行都只点名 rev-7 评审探针 `probe-r7-reqcheck.mjs` 的**登记**(探针本身没改): - **首跑**(`verify-independent/_raw/r21-evidence/r21-run1-red-console.txt` **75007 B / `92145633…`**): 当时 CHANGELOG 还没有锚定本版 `lib/client.js` 的 sha256,那条探针实测 **10 条**红 —— 比平时多两条:术语提交 `b0ad1eb` 改了 intro 文案 1 条、本轮 badge 改成只印版本 id 1 条。 而 runner 登记的 marker 还是 `9 failed`、登记的名单里也没有「the intro line is present」, 于是同时判红两条:**登记 marker 漂移** + **登记红集漂移**。 - **第二跑**(`.../r21-run2-red-console.txt` **75216 B / `0CE2DF28…`**):CHANGELOG 锚定之后探针实测 **8 条**红、**恰好等于**登记的名单,但 marker 还写着 `9 failed` —— 只判红一条:**登记 marker 漂移**。 - **第三跑(当值,全绿)**:登记改成现场实测的数值。登记红集:锚定状态 **8 条**、未锚定状态 **10 条** (= 8 + 2:CHANGELOG 未锚定 live 哈希时多红 `lib/client.js` 的字节数与 sha256 两条)。 名单逐字写进 runner 的 `$reviewerRedSet` / `$reviewerRedSetExtra`,marker 期望值在未锚定状态由 `8 failed` 改成 `10 failed`;**两份红色 console 的 `FAILURES` 行本身就是这两次失败的记录**。 - **未证**:badge 只显示版本号是否就是你要的观感、真机像素与刷新后是否真的加载了新 bundle —— 只有你看页面才能判定。 ## rev-20 · the caret turn takes 160 ms in every environment(减少动效不再有例外) 来源:用户真机反馈 —— rev-19 把 caret 转动由 300ms 加到 **400ms** 后**依然看不到转动**。定位结果: 用户的系统处于 `prefers-reduced-motion: reduce`,而 rev-17 起的那条媒体块在 reduce 环境下把 caret 的 过渡整条去掉(`transition:none`)、只留 90° 终态 —— **时长怎么加都不会有动画**。所以本轮不调时长,改策略。 - **改法**:顶部常量 `CARET_ROTATE_MS` 由 400 改成 **160**、构建戳 `REVISION` 改成 `'rev-20 · the caret turn takes 160 ms'`;**删掉两条 `@media (prefers-reduced-motion: reduce)` 块** —— caret 的那条,以及**设置页开关**(`.dacSwitch` 的 `transition:background-color .18s ease,border-color .18s ease`) 的同款覆盖。两条都删 ⇒ 现在产品里 `@media (prefers-reduced-motion` 出现 **0** 次、`transition:none` **0** 次, **开关的过渡也不再分环境**。caret 的过渡规则仍**恰好一条**、时长仍由常量拼出 (`.dacCaret svg{transition:transform ms ease}`),没有手抄副本。 - **新行为(当值)**:**任何环境下都是同一段 160ms 转动,没有例外**。顶层诊断 `reduceMotion()` 保留,但 **降级为纯环境报告** —— 它只回答"本页是否命中 `(prefers-reduced-motion: reduce)`",**不改变任何行为**、 也不在 `sessionIcon` 里。角度仍 90、曲线仍 `ease`、`.dacCaret` 在任何状态下仍无 `transform`。 - **这是有意的取舍,照实写**:在要求减少动效的环境里,本插件**不再尊重**系统偏好(caret 与开关都一样)。 用户原话是"开不开都要有动画",本轮的验收也把"产品里 `@media (prefers-reduced-motion` × 0"写成了硬断言, 所以不是疏忽、也不是漏删。**"这段 160ms 是否真的能被察觉"只有用户能在真机上判定**(见 `README.md` §9 H20)。 - **沿革(照实)**:160ms(rev-17 初值)→ 300(rev-18)→ 400(rev-19)→ **160(rev-20)**。 前两次是"时长太短"的诊断;本轮是**归因纠正**:用户在 reduce 环境下看到的瞬跳来自那段被删掉的过渡,与时长无关。 - **锚定字节(rev-20)**:`lib/client.js` **158549 B / sha256 4B6C8B91F0C294A0E2C561934C8ED627C7D3F937CFF33904A8FACA651A5949F3** —— 改前 rev-19 是 **157296 B / sha256 1C75C8B59634F2B44988056A2A343B4E46241D86C122C35B13EF1FAE2F3B6B35**, 改前字节归档在 `_raw/r20-t1-archive/lib__client.js.1c75c8b59634f2b4.txt`(写在第一次编辑**之前**)。 **DSH 侧 `lib/index.js` 零改动** —— 46638 B / sha256 03778391E15163487BC0F26082A73CBA15FAAF44CDC2CF93B0C185D75FB0B938(仍是 rev-11 字节)。 - **自测(rev-20 实测)**:四套作者 harness 合计 **621** 项(client-half **400** / host-half **124** / waterfall **22** / custom-audio **75**),各 exit 0。`verify/client-half.test.mjs` **103459 B / sha256 C156BBB2BC7C12EA30237CF13AAF0308B0A152C26B7F72D9A2411787C2948622**(比 rev-19 多 1 条断言 = 新守卫"**任何环境下都不给 caret 加 reduced-motion 覆盖**")、`verify/custom-audio.test.mjs` **20263 B / sha256 D4B610DF4ACFDB5156EC14EA4DF8BB95D19D7099E097FB998EACDB92311E8686**(仍是全文件恰好 1 个字节不同)。 - **重锚(工具 `verify-independent/r20-reanchor.mjs`)**:**111 行 / 115 处替换**、覆盖 **11 个活文件** (三个数取自工具自己写的 `_raw/r20-evidence/r20-t1-reanchor.json`,**不手数**);改前字节逐个归档在 `_raw/r20-evidence/archive/`。变异表在 rev-20 字节上重跑 **45/45**,逐条日志 `_raw/r20-evidence/r20-mut-*.txt`。canonical runner 的前缀(`r19-` → `r20-`)与冻结清单同批重锚, 但**本轮没有重跑 canonical**:上一轮的权威运行仍是 `_raw/r19-run-console.txt`。 - **文档面(r20/t3)**:`README.md` §3.2/§7.1 与 `docs/挂载与验收.md` 的 B2/B5/B6 按**新语义**重写 (任何环境都是同一段 160ms;`reduceMotion()` 只报告环境、不改变行为),rev-18/rev-19 的历史记录逐字节保留; 本轮每个被改文件的**改前字节**归档在 `_raw/r20-t3-archive/`(5 个副本,命名带改前 sha16)。 ## rev-19 · the caret turn takes 400 ms(把 caret 转动由 300ms 再放到 400ms) 来源:本轮的指派是在 rev-18 的 300ms 之上再放慢 —— **`CARET_ROTATE_MS` 由 300 改成 400**(rev-19)。 **这一轮只是把 300 ms 加到 400 ms,行为其余部分零改动**:角度仍 **90**、曲线仍 **`ease`**、 reduced-motion 仍**只关过渡并保留 90° 终态**、`reduceMotion()` 仍**每次调用实时读**且仍在 `sessionIcon` 之外、 CSS 的过渡时长**仍由常量拼出**(`.dacCaret svg{transition:transform ms ease}`), 没有一处手抄数字。**快慢是否合适、转动是否真的能被察觉,只有你能在真机上判定**(见本节末)。 - **改法(共 6 行,全是等长的行内替换)**:顶部常量 `var CARET_ROTATE_MS = 400;`(唯一一处声明, `CARET_ROTATE_MS = 300` 出现 **0** 次);构建戳 `REVISION` 由 `'rev-18 · the caret turn takes 300 ms'` 改成 `'rev-19 · the caret turn takes 400 ms'`(可测事实:自测断言它含 `rev-19` 与 `400 ms`, 且不含 see/visible 一类词);常量注释里的帧数算术随之由工具重算 —— **400 ms ≈ 60Hz 下 24 帧** (rev-18 的 300 ms ≈ 18 帧、rev-17 的 160 ms ≈ 10 帧)。 - **锚定字节(rev-19)**:`lib/client.js` **157296 B / sha256 1C75C8B59634F2B44988056A2A343B4E46241D86C122C35B13EF1FAE2F3B6B35** —— **字节数与 rev-18 相同**(六处改动全部等长,实测而非假定);改前字节 **157296 B / sha256 7FE150A0C1A89D6797ED3491F8CC74251CD7FD64DD8FA708621BA33FB9F49C45** 归档在 `_raw/r19-t1-archive/lib__client.js.7FE150A0C1A89D67.txt`(写在第一次编辑**之前**); 逐行对照只有 **6 行**不同(:151 的戳、:241/:252/:257 的 300→400、:253 的帧数、:268 的常量声明), 行数 3186 / CRLF 3185 / 无 BOM 全部不变。**DSH 侧 `lib/index.js` 零改动** —— 46638 B / 03778391E15163487BC0F26082A73CBA15FAAF44CDC2CF93B0C185D75FB0B938(仍是 rev-11 字节)。 - **自测计数(rev-19,本轮实测)**:**620 项全绿(124 + 399 + 22 + 75)**,四套 exit 0。 自测文件:`verify/client-half.test.mjs` **101901 B / sha256 6DBDF454151EC92CDC476B520516BE29F2EA8196C015F8AAC9595A4C1E3C6389**、 `verify/custom-audio.test.mjs` **20263 B / sha256 B2C825016A2ED7965488FDFA8CE10EDEF65DEB01A74235228D4FBBFCB451D913** (仍是**全文件恰好 1 个字节**不同:版本戳那一行)。 - **重锚(工具 `verify-independent/r19-reanchor.mjs`)**:**95 行 / 100 处替换**、覆盖 **11 个活文件**, 三个数取自工具自己写的 `_raw/r19-t2-reanchor.json`(**不手数**);每一行都要求精确出现次数, 任一行不匹配就整轮拒绝写入;冻结清单里 `lib/client.js` / `verify/client-half.test.mjs` / `verify/custom-audio.test.mjs` 的新字节数与 sha256 由脚本**当场从磁盘量出**再写入 `run-r13.ps1`。 改前字节按文件逐个归档在 `_raw/r19-evidence/archive/`(**11 个副本**,副本名带自己的 sha256 前缀, 机检逐个重哈希核对)。canonical 日志前缀同批改成 **`r19-`**:`r18-*`、`r18b-*`、`r18c-*`(以及更早各轮) 的证据一个字节都没被覆盖。 - **过程记录(如实登记,不许省)**:第一次 `--write` 用的工具版本有两个缺陷 —— `r18c-`→`r19-` 被误写成 **`r19c-`**,以及预测 post-edit 状态对**插入行**不幂等。执行者**没有**用补丁批次掩盖:先用 `_raw/r19-evidence/restore-pre-edit-bytes.mjs` 把 11 个文件**逐字节还原**到 pre-edit sha(**11/11 相符**), 修好工具后**从真 pre-state 重跑**(dry → write → post-state 全绿);复核:全仓 `r19c-` 出现 **0** 次。 另一件:`_raw/r15-t2-mutant-copy-lib-client.js.txt` 被重写过 —— 那是 r15t2 `--mutant` 的既有 byproduct (r18c 轮同样如此),**不是证据丢失**,但写在这里而不是藏起来。 - **记账缺口(如实登记)**:`_raw/r19-t2-reanchor.json`、`_raw/r19-t1-mutation-table.json`/`.md`、 `_raw/r19-mut-*.txt` 落在任务声明的 `_raw/r19-evidence/` **之外**,scope 校验器因此不许它们进 `changedPaths`(t1 的归档副本同例)。这是**记账**缺陷,不是行为缺陷。 - **独立层与 canonical**:变异表已在 rev-19 的产品字节上重跑 —— **45 行 / 45 ok** (`_raw/r19-t1-mutation-table.json`,`lib/client.js` 157296 B / `1C75C8B5…`,逐条日志 `_raw/r19-mut-*.txt` 45 条); 6 个计数探针、10 个遗留探针与四套 harness 的**整轮汇总**由 canonical 重跑一次产出,权威数字在 `_raw/r19-run-console.txt`(`r19-*` runner 日志)—— **本节不替那次运行写数字**。 - **未证(不许当已证)**:**本轮改的是一个时长常量与随之而来的戳/注释/指纹,不是"让你看得见"**。 400ms 在你屏幕上快慢是否合适、转动是否真的能被察觉、像素与主题观感、`transform-origin` 是否视觉居中、 过渡插值与合成层、真机上 `prefers-reduced-motion` 是否真的命中 —— 全部**只有你能判定**(沙箱没有浏览器引擎)。 能证的只到上面那些:时长常量、样式表由它拼出、戳是可测事实、字节/哈希/归档一致。完整清单见 `docs/变异覆盖与残留红.md` §18.6。 - **只改客户端** → **刷新页面即可**;DSH 侧与 rev-18 逐字节相同。 ## rev-18 · the caret turn is slow enough to see(把转动放慢,并给两种"看不到转"一个区分手段) 来源:真机反馈「**要有过渡动画能看到在转动的箭头**」—— rev-17 的 **160ms** 转动在你机器上**看不出来**。 rev-18 只做两件事,并且**不替用户下「现在看得见」的结论**(只有你能判定,见本节末)。 - **时长 160ms → 300ms**:常量 `CARET_ROTATE_MS` 换值,CSS 的过渡时长**仍由常量拼出** (`.dacCaret svg{transition:transform ms ease}`),没有一处手抄数字; 自测里因此多了一条"样式表必须由常量拼出"的断言。旧注释的依据("本文件既有过渡是 `.18s`")是**判断不是度量**, 已被真机反馈直接推翻,注释已重写为诚实版本(引用你的原话 + 写明该依据被推翻)。300ms ≈ 60Hz 下 18 帧, 中间角度确实会被绘制;弹层没有入场动画且已在屏上,箭头转完时视线刚到第一行,所以**判断**它不至于显得拖沓 —— 但这是判断,不是本机实测的观感。 - **新增顶层诊断 `__DSH_APPROVAL_CHIME__.reduceMotion()`**(**不在 `sessionIcon` 里** —— 那是几何快照): **每次调用实时**读 `window.matchMedia('(prefers-reduced-motion: reduce)')` 并返回布尔;平台没有 `matchMedia` 时返回 `false` 而不是抛错;`matchMedia` 自身抛错也返回 `false`。 **它为什么存在**:箭头"瞬跳"有**两个**在真机上长得一模一样的原因 —— **时长太短**(本轮改的就是它)与 **环境要求减少动效**(此时 `prefers-reduced-motion:reduce` 媒体块**有意**关掉过渡,瞬跳是**设计行为**, 90° 终态规则照常生效)。控制台里 `reduceMotion() === true` 就说明"看不到转动"是设计行为而不是缺陷; 这条诊断的存在就是为了让这两种情况**不必再猜**。 - **可证伪性**:`verify/client-half.test.mjs` **388 → 399 条**:**11 条新增**(时长常量 == 300、样式表由常量拼出、 恰好只存在一条 caret transition 规则且**没有残留的 160ms 副本**、`reduceMotion` 是函数而非布尔快照、 它不在 `sessionIcon` 里、无 `matchMedia` 时不抛且答 `false`、`matches:false` → `false`、`matches:true` → `true`、 所问的查询串恰为 `(prefers-reduced-motion: reduce)`、同一实例上把桩翻转后**实时**重读) —— 并把已被真机推翻的那条("时长落在 120–200ms 带内,读起来是转动而不是跳变")**替换**掉; **净删除 0 条**,是「1 条被更强的替换 + 11 条新增」。 独立探针 `probe-20` 由 30 → **37/37** 条、声明变异由 20 → **26**(新增 6 个 reduceMotion 变异: `reduce-motion-boolean-snapshot`(红 1,2,3,4,5)/ `reduce-motion-always-false`(2,5,6)/ `reduce-motion-always-true`(3,4,5,6)/ `reduce-motion-cached-after-first-call`(仅 5)/ `reduce-motion-asks-the-wrong-query`(仅 6)/ `reduce-motion-snapshotted-into-sessionIcon`(仅 7)), `--mutate=all` **26/26** 恰好命中、两两红集不同、每个锚串实测恰好命中 1 次。 另有一个既有变异 `caret-turn-ms-250` 的锚串与声明名随之更新(250 现在只是**错的时长**, 而不再是"越出带宽";它的红集仍是 1 条)。 - **锚定字节(rev-18)**:`lib/client.js` **156937 B / sha256 1C8DB24CA492CE2A27B6BEC10A3366612F2F5AD7294D39E8B9D4D78C2FC8B54D** (rev-17 为 154457 B / `B4A0A1B3…`,其归档副本见 `_raw/r18-t1-archive/lib__client.js.B4A0A1B3.txt`); **DSH 侧 `lib/index.js` 零改动** —— 46638 B / 03778391E15163487BC0F26082A73CBA15FAAF44CDC2CF93B0C185D75FB0B938(仍是 rev-11 字节)。 自测文件:`verify/client-half.test.mjs` **100983 B / sha256 AB6F7E4833EBD15488BB3214007462540186A2893DE7553922ABCD232FBD8731**(388 → 399 条); `verify/custom-audio.test.mjs` **20263 B / sha256 6EF2F162F0E92CC5904297219DF98D7FF6113ED97138C6A9AF47480C3F89D87F**(仍是**全文件恰好 1 个字节**不同:`:267` 的版本字面量)。 - **自测计数(rev-18)**:**620 项全绿(124 + 399 + 22 + 75)**,四套 exit 0。 - **独立层**:probe-20 30 → **37/37**、声明变异 20 → **26**;6 个计数探针 32 / 94 / 131 / 66 / **37** / 42 = **402**; 变异表 39 → **45 行全 ok**;10 个遗留探针重锚后**仍 10/10 全绿**。 - **重锚(`r18-t1-reanchor.mjs`)**:**76 行 / 84 处替换**,**每一行都要求精确出现次数**,任一行不匹配就整轮拒绝写入; 冻结清单里 `lib/client.js` / `verify/client-half.test.mjs` / `verify/custom-audio.test.mjs` 的新字节数与 sha256 由脚本**当场从磁盘量出**再写入 `run-r13.ps1`,不手抄。拒绝逻辑**先被证伪过**:控制组 exit 0, 而"次数写高 / 次数写低 / 前后两个字面量都指向工作区里不存在的文本"三个坏副本**都 exit 1、点名该行、 8 个目标文件 0 字节改动**。旧字节归档在 `_raw/r18-t1-archive/`(**17 个副本** + `PROVENANCE.md`, 每个副本名带自己的 sha256 前 8 位、逐个重哈希核对,并分成「当场快照 / 手工编辑快照 / 哈希可证的旧基线副本」三组)。 - **canonical runner 的日志前缀同批改成 `r18-`**:前缀行与其它行**在同一次原子写入里**改掉 (rev-16 覆盖 rev-15 日志那次事故的补课),所以本轮的 `r18-*` 日志与 `r17-*`(68 条)、`r16-*`(61 条)、 `r15-*` 证据并存,一个字节都没被覆盖。 - **未证(不许当已证)**:**本轮改的是时长常量与一条诊断,不是"让你看得见"** —— 转动在你的屏幕上是否真的 "看得见"、快慢是否合适、像素与主题观感、`transform-origin` 是否视觉居中、过渡插值与合成层、 真机上 `prefers-reduced-motion` 是否真的命中,全部**只有真机能定**(沙箱没有浏览器引擎)。 完整清单见 `docs/变异覆盖与残留红.md` §15.8。 - **只改客户端** → **刷新页面即可**;DSH 侧与 rev-17 逐字节相同。 ## rev-17 · the caret turns to point down(箭头打开时下转) 来源:真机反馈「打开的时候箭头向下转一下」。**纯外观改动**:动效不改变任何行为、DOM 形状、请求或音频路径, 但它动了 `lib/client.js`,因此所有锁定产品字节的断言都按纪律重锚了一遍(见本节末)。 - **改法**:新增两个常量 —— `CARET_OPEN_ROTATE_DEG = 90`(一个直角,是"指向下"的唯一自洽角度)与 `CARET_ROTATE_MS = 160`(120ms 以下眼睛看到的是跳变而不是转动,200ms 以上箭头明显滞后于面板; 本文件既有的过渡是 `.18s` 的开关轨道与滑钮,面板本身没有入场动画、唯一在动的就是字形,160ms 落在 区间中段略低于 `.18s`)。两者都在 `lib/client.js` 顶部,CSS 由它们拼出,**没有一处手抄数字**。 - **旋转作用于字形而不是按钮盒**:`.dacCaret svg` 拿 `transition:transform ms ease` 与 `transform-origin:center`,终态是 `.dacCaret[data-open="true"] svg{transform:rotate(deg);}`。 按钮自己(`.dacCaret`)在任何状态下都**没有** transform,仍是 `16px × 28px`;11×16 的字形转过 90° 后 足迹 16×11,仍装得下。**打开态复用的是现成的 `[data-open="true"]` 钩子** —— 没有新增 state, 渲染树里也没有任何 JavaScript 在算角度。 - **`prefers-reduced-motion:reduce` 降级**:块内**只有** `transition:none`,90° 终态规则在媒体块**外面** ——"少动效"不该等于"少一个状态提示"(降级掉的是动画,不是终态)。 - **可证伪性**:`verify/client-half.test.mjs` 新增 15 条断言(open 态 transform == `rotate(常量deg)`、 closed 态无 transform、transition 字符串由常量拼出、角度字面 90、时长落在 120–200ms 带内、 `transform-origin` 居中、按钮两态都没有 transform、转过后的字形足迹装得下、reduced-motion 规则逐字、 该块只关 transition 不要 transform、终态规则在媒体块之外、Escape 后回到未变换的 `[data-open="false"]`)。 独立探针 `probe-20` 由 23 → **30/30** 条,并**为每条新检查各声明一个变异**(7 个): `caret-turn-transition-dropped` / `caret-turn-rule-dropped` / `caret-turn-angle-45-deg` / `caret-turn-ms-250` / `caret-turn-reduced-motion-dropped` / `caret-turn-moved-to-button`(红 2 条)/ `caret-turn-also-when-closed`;`--mutate=all` **20/20** 恰好命中且两两红集不同。 其中一个既有变异(`caret-css-hardcoded-12`)的**声明红集由 1 名加宽到 2 名**,这是新检查真的钉住了 caret 盒 16×28 的**实测结果**,不是放宽。 - **锚定字节(rev-17)**:`lib/client.js` **154457 B / sha256 B4A0A1B323A9BC9A99803EDBE28E770DE1097162165B16000995DD8E84A4A2F4** (rev-16 为 150663 B / 76B4D4E7…,其归档副本见 `_raw/r17-t1-archive/lib__client.js.76B4D4E7.txt`); **DSH 侧 `lib/index.js` 零改动** —— 46638 B / 03778391E15163487BC0F26082A73CBA15FAAF44CDC2CF93B0C185D75FB0B938(仍是 rev-11 字节)。 自测文件:`verify/client-half.test.mjs` **95803 B / 97EEB2D05C5277C6AF6DB1B015E9A0C6AEF3CB717945E58D25823E77BF7588F1**(373 → 388 条); `verify/custom-audio.test.mjs` **20263 B / 523572EA2ADC896FD1BD38B450600D7E2F021FD8C55DB1CF523DF0C03B9FF8EE**(仍是**全文件恰好 1 个字节**不同:版本戳那一行)。 - **自测计数(rev-17)**:**609 项全绿(124 + 388 + 22 + 75)**,四套 exit 0。 - **独立层**:probe-20 由 23 → **30/30**、声明变异由 13 → **20**;6 个计数探针 32 / 94 / 131 / 66 / **30** / 42 = **395**;变异表由 32 → **39 行全 ok**;10 个遗留探针重锚后**仍 10/10 全绿**。 - **重锚(`r17-t1-reanchor.mjs`)**:**76 行 / 84 处替换**,**每一行都要求精确出现次数**,任一行不匹配就整轮拒绝写入; 冻结清单里 `lib/client.js` / `verify/client-half.test.mjs` / `verify/custom-audio.test.mjs` 的新字节数与 sha256 由脚本**当场从磁盘量出**再写入 `run-r13.ps1`,不手抄。拒绝逻辑本身**先被证伪过**:把某行的声明次数从 9 改成 10、 改成 8,以及把一个 `to` 字面量指向工作区里根本不存在的 `rev-99`,**三次都由脚本自己拒绝**(exit 1、点名该行、 **未写入任何字节**)。 旧字节归档在 `_raw/r17-t1-archive/`(**15 个副本** + `PROVENANCE.md`,每个副本的文件名都带它自己的 sha256 前 8 位, 且 `PROVENANCE.md` 逐个重哈希核对过)。 - **canonical runner 的日志前缀同批改成 `r17-`**:这是 rev-16 踩过的坑(见 `docs/变异覆盖与残留红.md` §13.8)—— 前缀行与其它行**在同一次原子写入里**改掉,所以本轮的 `r17-*` 日志与上一轮的 `r16-*`(61 条)、`r15-*` 证据并存, 一个字节都没被覆盖。 - **只改客户端** → **刷新页面即可**;DSH 侧与 rev-16 逐字节相同。 ## rev-16 · the bell and the caret stand apart(铃铛与箭头分开) 来源:真机截图 + 一句话「这个铃铛和箭头分开点」。**纯外观改动**,但它是 rev-14 以来第一次动 `lib/client.js`,因此所有锁定产品字节的断言都按纪律重锚了一遍(见本节末)。 - **问题**:铃铛与 caret 同在一个 `inline-flex` 包裹器(`.dacBellWrap`)里且**没有 gap**; caret 盒子 16px 宽而其中的箭头只画 11px,于是蓝方块右缘到箭头左缘只剩 **2.5px**(`(16-11)/2`), 两个**本来就是独立按钮**的控件被读成一个。 - **改法**:新增常量 `BELL_GAP_PX = 6`,`.dacBellWrap` 的 `gap` 由它拼出。6px 的依据是 DSH 自己的节奏 (`dsh-client-ui-conversation` 的 `.headerActions{gap:8px}`):加上箭头自带的 2.5px 内缩,墨迹间距 ≈8.5px。 同一个常量上了控制台面(`sessionIcon.bellGapPx`),CSS 与诊断面因此**不可能各说一套**。 - **可证伪性**:`verify/client-half.test.mjs` 新增 2 条几何断言(包裹器规则带上了报告的 gap;该 gap 就是 6px)。 独立探针 `probe-20` 的几何检查升级为**合取**(CSS 必须带报告值 **且** 报告值必须是 6),并为**每一半各声明一个变异**: `bell-caret-gap-removed`(CSS 退回贴死、诊断面仍报 6)→ 红 1 条; `bell-caret-gap-constant-zeroed`(常量归零、CSS 与诊断面**一致地**退回贴死)→ 红 2 条。 两个红集**不同**(探针自身强制两两不同),锚串各命中 1 次,均 exit 0。 - **锚定字节(rev-16)**:`lib/client.js` **150663 B / sha256 76B4D4E73FFAE5CA47838B2798EE74AE39AB60ADEF2699CF4562D4A89AAAD6FE** (rev-15 为 149196 B / 32F0E31F…);**DSH 侧 `lib/index.js` 零改动** —— 46638 B / 03778391E15163487BC0F26082A73CBA15FAAF44CDC2CF93B0C185D75FB0B938(仍是 rev-11 字节)。 自测文件:`verify/client-half.test.mjs` **88441 B / B5FC3935EA5CB81814C2501C811B15C5C2EF5EB88624808AA3B03A618BCC0D9D**(371 → 373 条); `verify/custom-audio.test.mjs` **20263 B / 5AD07459C62401FB8DFC16079AD59A9FA2D5BBAD04591DF48FF12623ECBC387C**(仍是**全文件恰好 1 个字节**不同:版本戳那一行)。 - **自测计数(rev-16)**:**594 项全绿(124 + 373 + 22 + 75)**,四套 exit 0。 - **独立层**:probe-20 由 22 → **23/23**、声明变异由 11 → **13**(`--mutate=all`:13/13 恰好命中、两两红集不同); 变异表由 30 → **32 行全 ok**;6 个计数探针 32 / 94 / 131 / 66 / **23** / 42 = **388**; 10 个遗留探针**重锚后重新 10/10 全绿**(其中 probe-8 的版本戳字面量是真的过期,已重锚)。 - **重锚(`r16-t1-reanchor.mjs`)**:66 行 / 69 处替换,**每一行都要求精确出现次数**,任一行不匹配就整轮拒绝写入 —— 实测第一次 dry-run 因 1 行的引号写错而拒绝、**未写入任何字节**;旧字节归档在 `_raw/r16-t1-archive/`(**13 个文件** + `PROVENANCE.md`, 其中 9 个是编辑前当场抓的,4 个是取自 t8 影子副本、**先哈希等于 rev-15 记录值才允许复制**)。 冻结清单里 `lib/client.js` / `verify/client-half.test.mjs` / `verify/custom-audio.test.mjs` 的新字节数与 sha256 由脚本**当场从磁盘量出**再写入 `run-r13.ps1`,不手抄。 - **本轮的一次自身失误(已登记,见 `docs/变异覆盖与残留红.md` §13.8)**:第一版重锚清单漏了 runner 的日志前缀, 于是**第一次 canonical 运行把 rev-15 的 59 个逐命令日志覆盖成了 rev-16 内容**。rev-15 的汇总证据 (`_raw/r15-run-console.txt`)、变异表与 30 个变异日志、以及该轮全部任务证据都未受影响;被覆盖的那 61 个文件 整组隔离在 `_raw/r16-t1-mislabelled-r15-logs/`(目录名说明它们是什么),并补做 `_raw/r16-t1-rename-runner-logs.mjs` 让这类碰撞不再可能;**第二次 canonical 运行(`r16-*` 前缀)才是权威集**。 - **只改客户端** → **刷新页面即可**;DSH 侧与 rev-15 逐字节相同。 ## rev-15 · a failed re-read keeps the mutes(修 OBS-A) 来源:用户批准的三件事之一 ——「① 修 OBS-A」。**这是该轮唯一的产品改动。** - **缺陷(rev-11 引入的残留)**:会话写入落定后(整表在飞计数归零)会重读一次 DSH 表以求收敛; 这次重读**若失败**,旧实现会 `ready=false` 并把本地覆盖表清空成 `Object.create(null)`(旧码 `lib/client.js:1145`)。 后果是**用户设过的静音被一次瞬时网络失败换掉** —— 该会话重新跟随全局,全局开着就会响。 方向是错的:失败应当更安静,而不是更吵。 - **修法**:新增 `sessionsReadFailed(message)`,四条失败路径(无 `fetch` / 同步抛出 / 应答非 ok / promise reject) 一律只写 `sessions.error` + `publish()`,**不再动表、不再降 `ready`**;只有真正落地的读才允许移动表。 顺带把三处会因此变成假话的注释(模块头 "degrades to no overrides"、`sessions()` 与 `sessionWrites()` 的收敛点说明) 改成诚实表述。 - **实测危害(在逐字节 rev-14 字节上跑当前自测)**:**359/371、恰好 12 条红**(版本戳 + 5j 失败路径 11 条), 现场量到 `triggers 1→2`(被静音的会话真的响了)、`oscillators 2→4`、表→`{}`、`ready→false`、 `suppressedSession 1→0`、`data-muted` `true→false`。 - **独立验证(另一个 agent,不是写这行代码的人)**:`verify-independent/r15t2-independent-probe.mjs` —— 自建 `node:vm` 跑真 bundle + 自建 DOM / 录音 WebAudio / HTTP 桩 + DSH 文件模型, **未 import 作者 harness**、断言文本在 `verify/` 里 0 命中。shipped **42/42**、exit 0; **同一探针跑在真实 rev-14 字节上 → exit 1、31/42、8 条语义红**(证明它抓的是产品行为,而不只是自造变异); 自造回退变异 → **红集恰好 10 条 = 运行前预先声明**、锚串在出厂字节恰好 1 次、 变异体 `C742FBE6…`/149540 B 与出厂 `32F0E31F…`/149196 B **只在锚串区间不同**(A5:前后缀逐字节相同)。 - **质量门(第三个人)verdict = pass**:5 条产品验收 + 5 条收口验收逐条自跑判定;反向替换独立复现; 遗留探针重基线经机检 `removed=15 / added=28 / unpaired=0`(15 对 1:1,`rev-N` 归一化后 0 孤儿)、 `.only/.skip/todo/if(false)` 0 命中、45 行删除逐行核过。 - **锚定字节(rev-15)**:`lib/client.js` **149196 B / sha256 32F0E31FB5ABE1BE7B5C14746213C29EA75896CD54A88401FD70925D6D67BD55** (rev-14 为 147062 B / 730D1C2F…);**DSH 侧 `lib/index.js` 零改动** —— 46638 B / 03778391E15163487BC0F26082A73CBA15FAAF44CDC2CF93B0C185D75FB0B938(与 rev-14 逐字节相同)。 自测文件:`verify/client-half.test.mjs` 88005 B / BB2C1A34049C9F24FAF2D147043C465083A39B403A183D997041FFC3DC54943C (rev-14 为 77626 B,净增 35 条 5j 断言);`verify/custom-audio.test.mjs` 20263 B / D13A8D63F073FA1748070996839F09DBD9E760F2448F555ABD865AC45218E822(**全文件恰好 1 个字节不同**: offset 11636 `0x34('4')→0x35('5')`,即版本戳那一行)。 - **自测计数(rev-15)**:**592 项全绿(124 + 371 + 22 + 75)**,四套 exit 0;client-half 由 336 → 371(净增 35 条)。 - **只改客户端** → **刷新页面即可**;DSH 侧与 rev-14 逐字节相同。 - **未证实(如实列出)**:re-read 失败后的**真机表现**(本探针是 headless vm + 桩服务,不覆盖真浏览器 / 真音频输出 / 真 Host 路由);**"永不回答的超时读"这条路抓不住「失败即清空」型变异**(没有失败落地), 探针只断言其表不动;**恢复块在修复前后都绿** —— 危害窗口只在"失败读 → 下一次成功读"之间。 - **配套(非产品)**:10 个 rev-1…rev-3 遗留探针重基线到 rev-15(37 条过期期望,真缺陷 0), 其豁免表退役为 `$legacyHistory`(非零即 FAILURE);`verify-independent` 全部冻结指纹重锚到 rev-15。 ## 变异覆盖与残留红(验证侧收口 · **不是 rev**:`lib/**` 零改动) 来源:用户「把这变异报红全修了」。先把范围讲清 —— 上一轮报告里那 **6 个"报红"是设计使然的假阳性** (变异 = 故意植入缺陷,报红恰好证明自测抓得住),**不是缺陷**。真正修的是三类**真红**: - **(1) 过期指纹**:rev-13/rev-14 改过 `lib/client.js` 后,三个独立探针的冻结哈希还停在旧字节 (实测 `probe-17` 91/94、`probe-18` 130/1、`probe-19` 63/66),canonical 运行器 `run-r12` 的冻结清单同样过期。 修法是**只改字面量**重新锚定到 rev-14,并用"反向替换后逐字节回到改动前的哈希"证明改的只有这些字面量。 - **(2) 变异自身的健全性**:存在死变异(锚串不存在 → 空转)、红集与声明不符、以及"抓住了却 exit 1"的驱动缺陷。 修法是每个变异加「真改写」sha256 自检 + 锚串唯一性度量(必须恰好 1 次),并自测证明死变异/声明漂移必然非零退出。 - **(3) `.scratch/` 里 reviewer 的遗留探针**长期非零:能修的修绿,不能修的**逐条**给出与当前事实对照的理由。 **结果**:canonical `run-r13.ps1` → `exit 0`、**0 条 `FAILURES`**、唯一非零 = `probe-13`(沙箱无浏览器引擎,11/14); **29 个声明变异**(18 + 11)逐条满足"真改写 + 红集恰好等于声明 + 抓住时 exit 0";独立探针 32/94/131/66/**22**。 **零产品改动**:`lib/client.js` **147062 B / `730D1C2F…`**、`lib/index.js` **46638 B / `03778391…`** —— 与 rev-14 冻结值逐字节相同。 - **新增独立探针 `probe-20-r14-bell-appearance.mjs`**(22 断言 / 11 声明变异):把 rev-13/rev-14 的**铃铛外观** 纳入独立覆盖。它自建 `vm` 沙箱跑真 bundle、读真注入的 `

`「通知提醒」+ 右上角 bundleRevision 徽标 → 一行 intro (旧的页级标题「审批提示音」不再出现,避免两个互相竞争的大标题)→ 原封不动的控件面板: 启用勾选、音量滑杆 0..100、音色下拉(导入项在前)、导入按钮 + 隐藏 file input + 选中自定义音色时的「移除」、 试听、恢复默认、抑制原因行、只读/已覆盖徽标、错误行、统计行。标题 16px/500/24、intro 14px/22 + `--dsw-alias-label-tertiary`,对齐 DSH `dsh-client-ui-settings-models` 的 `.title/.intro` (`lib/client.js:58` 的 `.zGbnIq_*` 规则)。 - **导航文案**:字典新增 `nav`(zh 通知提醒 / en Notifications)并与 `title` 同文;`label` 是 **thunk**(每次投影重读,`dsh-cordis-client-runner/lib/client.js:3894`),内部解析 `ctx.locale.bind(NS)` 返回的翻译器(`dsh-client-locale/lib/client.js:1283-1304`),因此切换语言后 导航行立即跟随——测试断言 zh→en→zh。id `approval-chime` 为自用 id,不会顶替 general/models/plugins/agent-presets 任何一行;order 16 紧跟「插件」15、在「Agent 预设」20 之前。 - **此前修好的缺陷原样保留**(未触碰):`::picker(select)` 的 `box-sizing:content-box;max-height:84px` (= 3×28px 行高,第 4 项起滚动)、`border-radius:10px`、导入上限 50、空白名回退 id、 超限 413 先读干再应答。断言仍覆盖这些常量(client-half 断言 CSS 两条;custom-audio 断言 84px/50/413/空白名)。 - **自测**:`client-half` 由 103 → **143** 条(新增设置注册形态/id/order/label thunk + 语言切换、 「从未注册或提及插件页槽」、唯一 `

` 与导航文案一致、intro、条件行(只读徽标/错误行/抑制原因行)、 分区排版 CSS、rev-7 戳),旧的插件页槽断言全部替换;`custom-audio` 由 70 → **74** 条 (修订戳与槽断言更新,新增唯一 `

` 与「已覆盖」徽标)。现为 **56 + 143 + 20 + 74 = 293 项全绿**(4 套 exit 0)。 - 锚定字节:`lib/client.js` **80889 B / sha256 6B9C38CE738859C4D0007EE027B994353242D4C8C974B1E39A420CF48D54F5D1** (DSH 侧未改,仍 27592 B / 75188B4C…)。 - 文档:README §3 入口路径改为 设置 → 通知提醒、§4 计数/描述与 §7 自证接口同步; `docs/挂载与验收.md` §7 导航路径更新;`docs/契约调研.md` 新增 §K(`settings.section` 合同,带 DSH `文件:行号`)。 - **本机验收(交给 t5/用户执行;本轮未执行、未证实)**:需重启 `dsh web`(bundle 字节在启动时读入内存)→ 刷新页面 → **应**在设置面板左侧看到独立一行「通知提醒」(在「插件」之后)、进入后是分区页; **应**在插件页看不到本插件。**这一步本轮没有做**:真实 `dsh web` 端到端与浏览器渲染在本沙箱内未证实 (沙箱无浏览器引擎、不允许起/替换 Web 服务器);未证实清单见 `docs/rev7-独立验证.md` §8(5 项)与 `docs/rev7-需求符合性审查.md` §10。请勿把本条读作已完成的真机验收(与 rev-1 条目里确实执行过的 「真机验收」不同)。 - **未证实项(rev-7 新增)**:`settings.section` 未被官方文档写明为「第三方扩展点」(它是 DSH 设置外壳 自己声明的槽,契约依据见 `docs/契约调研.md` §K);真实浏览器里导航行的插入位置与图标未实测 (未知 id 由 DSH 回退到齿轮图标,`dsh-client-ui-settings-general/lib/client.js:76-93`)。 注册整体包在 try/catch 里:失败只丢导航行,不影响提示音。 ### rev-7 · 文档修复(t4 repair-round-2:代码字节未动) 来源:`docs/rev7-需求符合性审查.md`(t3,verdict=needs_revision)的 DOC-R7-1/2/3 与观察项 OBS-R7-1。 - **DOC-R7-1(medium,已修)**:`docs/契约调研.md` 里仍把旧入口写成现行做法的两处已改写—— §J「给下游的一页摘要」第 4 条改为 `settings.section` 注册形态 (`{name:'settings.section', id:'approval-chime', order:16, label:, locale:NS}`,指向 §K.2); §E.3 挂载步骤第 5 步的验收信号改为「设置导航出现独立一行『通知提醒』;设置 → 插件 → 插件配置里不再出现本插件」。 §K 的取代横幅**扩写到覆盖 §C / §E.3 / §G.3 / §J**,并声明 `docs/rev4-*`、`docs/rev5-*`、`docs/rev6-*`、`docs/验证报告.md` 同属历史轮次记录(描述当时形态);§G.3 的标题与代码块上方标注「**历史骨架:入口形态以 §K 为准**」。 - **DOC-R7-2(low,已修)**:本条目上方的「本机验收」已改写为「**交给 t5/用户执行;本轮未执行、未证实**」口径, 并指向 `docs/rev7-独立验证.md` §8 的 5 项未证实清单(真实浏览器渲染 / 导航行外观 / 真实 `dsh web` 端到端 / 真实文件对话框 / 真实音频输出)。 - **DOC-R7-3(low,已修)**:`docs/验证报告.md` 头部加 rev-7 横幅(入口路径已迁移 → `docs/契约调研.md` §K、 `README.md` §3),其 §8 的人工清单标注为 **rev-1 形态**(第 1 项同时给出 rev-7 的等价步骤)。 - **OBS-R7-1(low,引用修正)**:agent-presets 的 `order: 20` 引用改为 `dsh-client-ui-agent-preset/lib/client.js:1520-1522`(`docs/契约调研.md` §K.1 已改;occupants 清单 `dsh-cordis-client-runner/lib/client.js:3911-3916` 保留为 **id 的第二来源**,因为它不含 order)。 `lib/client.js:58` 与 `:1768` 携带**同一处**过期指针,但该文件是已验证并冻结的字节锚 (t2 的独立验证 §1 与 t3 的审查都锚在这份 sha256 上),**按 captain 边界本轮有意不改**—— 取舍与正确引用已显式记在 `docs/契约调研.md` §K.1 表下方(「一处『有意不改』的过期指针」)。 - **本轮未动代码**:`lib/**`、`verify/**`、`verify-independent/**`、`README.md` 的 sha256 与 t2 收工基线 `verify-independent/_raw/r7-baseline-after.txt` 逐行一致(本轮只改文档:本文 + `docs/契约调研.md` + `docs/验证报告.md`), 因此上面的锚定字节 `lib/client.js 80889 B / 6B9C38CE…` 仍然有效(已与磁盘实测复核)。 - 四套 harness 只作回归复核(不承担验证职责):56/143/20/74,四条 exit 0。 ### rev-7 · 市场简介(package.json / README 文案;运行时代码零改动) 需求(用户):「插件市场 → 已安装」里 `deepseek-harness-background` 那一行显示了一段作者简介, 而 `dsh-approval-chime` 那一行的灰色文字只有 `link:D:/…` 源路径 —— 给本插件也写一段详细介绍。 - 那段灰色文字是**依赖 spec**(`dshmarket:src/client/MarketSection.tsx:4606-4653`):`link:` / `file:` / `github:` 这类 spec 是这一行**唯一**说明「插件从哪来」的地方,市场明确选择保留;只有 `version` 存在 且 spec 是纯版本区间时才隐藏(`specRedundant`,`:4611`)。所以那行路径**不是**能替换成介绍文字的位置。 - 介绍文字在 spec 下方**独立一行**,来源只有两个(`:4681-4708`):精选目录条目的 `description` (`:4683`,`entry?.description[lang] || entry.description.en`)或**用户自己的备注** (`notes[name]`,≤200 字符,`MAX_NOTE` 见 `dshmarket:src/hot.ts:308`,存 `/.dsh-market/state.json`)。 本插件是本地 `link:` 安装、未上架精选目录(收录走 awesome-dsh-plugin 的 PR,见该插件 `README.zh.md:81`), 目录里查不到它 ⇒ 该位置为空。**这不是我们的 bug,是市场的设计。** - 本次落地:把详细介绍写进**两处我们自己的权威位置** —— `package.json` 的 `description`(169 字符, 写在备注 200 字符上限之内,可原样粘贴)与 `README.md` 首段(同一文案)。想在市场卡片上看到它, 点那颗「添加备注」把同一段贴进去即可 —— 备注位就是那个介绍位。 - 锚定字节:`lib/client.js` 80889 B / 6B9C38CE…(**未动**)、`lib/index.js` 27592 B / 75188B4C…(**未动**); `package.json` 973 B / D483E65F094EC41299DD03894A840146FB9ADB4D42E3B55BB0B955A409097B4D、 `README.md` 22131 B / A82F40CBA2C31252F6B49EFA3644AB49123FEF8F736D0F3BA2AC8613D508C7DD。 README 因此在 t2 基线 `r7-baseline-after.txt` 之上有差异 —— **已验代码字节不受影响**(那条基线锚的是 `lib/**`)。 - 四套 harness 复跑 56/143/20/74,四条 exit 0(`package.json` 只改 `description`,`host-half` 的包结构断言不涉及该字段)。 - 说明性取舍:`description` 由英文改为中文(对齐参照插件的做法);若将来上架 npm 需要英文元数据再补。 ## rev-6 · case-closed ids(N3 收尾 + 文档校订) 来源:`docs/rev5-复验.md`(t3 复验)的 N3/R-RESID 与 `docs/rev5-需求复审.md`(t4 复审,verdict=pass)的 DOC-1。 - **N3(low,端到端可达)**:rev-4 的 D2 只修好了 `tone` 一侧 —— `custom[].id` 仍是裸字符串、 浏览器侧的 `CUSTOM_ID` 仍带 `/i`,而路由是大小写敏感的 ⇒ 手改设置文档塞入大写 id 会得到一个 **"能选、但试听必然 404"的死选项**。现在两侧一致收紧为小写 uuid(schema `custom[].id` 加 pattern、 浏览器侧去掉 `/i`),大写 id 既不可存也不可渲染。 - **R-RESID(low)**:纯空白的显示名会被 trim 成空 → 回落到 id,不再渲染成一行"看起来空"的选项。 - **DOC-1(文档)**:`docs/rev4-独立验证.md` 追加 §8 校订,更正该报告 §5 U1 里"`::picker` 保持 content-box、92px 正好 3 行"的推断(UA 实际给的是 border-box),并记录 rev-5 的修法与 rev-6 的收尾。 - 自测:`host-half` 新增两条正向断言(拒绝非小写 uuid 的 roster id)后为 **56/56**,其余不变 (103/20/70),合计 **249 项全绿**。 - **残余 low(记录在案、不阻塞)**: - N1 未超上限的停滞上传没有插件级时间上界 —— 载体(Node http)默认 300 s 有界,加插件定时器反而会 引入"慢但合法的上传被拒"的新失败模式;本地单人 GUI 下选择不加,若将来把 GUI 暴露到本机之外需重新评估。 - N2 流式超过约 2× 上限会被 RST 而非 413 —— `refuseOversized` 注释里声明的宽限取舍;插件自身 UI 走不到 (客户端先做体积预检)。 - O4 弹层外框在 content-box 后为 94px,若 UA 以内容盒锚定宽度可能比选择框宽约 10px —— **未证实**, 需要真实浏览器;真人验收时留意(若明显偏宽,改成 `border-box` + `94px` 即可,不影响判定)。 **验证状态(t5,独立窄复核,attempt 2)**:N3 ①②③④ 与 R-RESID **全部 PASS,未发现新缺陷**; 全量回归 4 套 harness 56/103/20/70 全绿 + 该成员 474 项探针断言 + reviewer 78 项,全部通过。 报告 `docs/rev6-复验.md`,一键复跑 `verify-independent/run-r6.ps1`。 锚定字节:`lib/client.js` 72730 B / sha256 5A925E1E…、`lib/index.js` 27592 B / 75188B4C…。 - **O-1(测试卫生,已修)**:`verify/custom-audio.test.mjs` 原先的"audio/ 目录为空"断言会被**任何**外部 遗留文件误伤 —— 实跑时曾报出 2 个 13 B、无法归属来源的孤儿 `.mp3`(并发跑探针期间产生),使结果看起来像 产品回归。现改为**与本次运行开始时的快照比较**;实测在 `audio/` 中故意放入外来文件后仍 70/70, 且该外来文件原样保留(测试不再删别人的文件)。 ## rev-5 · review fixes(修正两轮独立验证与需求审查发现的缺陷) 来源:`docs/rev4-独立验证.md`(DSH 路由,455 断言 / 451 通过)、`docs/rev4-浏览器侧独立验证.md`(262 断言)、`docs/rev4-需求符合性审查.md`(R5-1 等)。 ### medium - **R5-1 弹层第 3 行被裁、默认就有滚动条**:`::picker(select)` 的 `box-sizing` 由 UA 样式表定为 `border-box`,原 `max-height:92px` 的实际内容区只有 `92-8-2=82px`,而 3 行需要 `3×28=84px` ⇒ **一个文件都没导入时打开下拉就已出现滚动条并裁掉第 3 行 2px**,与需求相反。 修:该规则显式 `box-sizing:content-box` + `max-height:84px`。 - **D1 超限 413 永远到不了客户端**:原实现先 `reject` 再 `req.destroy()`,413 被写进已销毁的 socket(客户端 0 字节或 `ECONNRESET`)。三次实测后定为:**先把剩余请求体读干(丢弃)再应答 413**(`Connection: close`)—— 因为 Node 在「响应已结束但请求未读完」时会立刻销毁 socket, 只有等发送方写完,413 才真的能到达。Content-Length 与 chunked 流式两种写法均已验过。 - **F1 同步抛错穿透 DSH 监听循环**:`fetch` 同步抛错时异常从 `playSample` → `chime()` 一路抛进 DSH 的 `pendingInteractions` 通知循环;导入路径同根因把按钮永久卡在「导入中…」。 修:`loadSample`/`uploadAudio` 把同步抛错转成 rejection,`chime()` 与导入路径各加一层兜底 (计数与 `lastError` 照常记录,不再逃逸)。 ### low - **D2** id 正则去掉 `i`:大写 uuid 曾被 schema 接受却永远 404,渲染成死选项。 - **D3** `findAudioFile` 改为**精确基名 + 已知扩展名**匹配,不再前缀扫描(`.aaa` 不会顶替 或错删 `.mp3`)。 - **D4/F4** 浏览器侧的显示名按**码点**收敛到 120 并剥离控制字节,与 DSH 同界(原可把 20 万字符 的名字渲染成一个 20 万字符的 option)。 - **F5** DSH 120 字截断改为**码点**截断,不再切断代理对(emoji 会变成替换字符)。 - **F6** 上传名先 `trim`,扩展名按**最后一个点**取;无扩展名时按 MIME 兜底 —— 原 `'audio'` 兜底 与尾随空格名都必然被 415 拒绝,而文件其实完全合法。 - **F2(更正文档措辞)**:**审批触发**在 `volume=0` / 关闭时不创建 AudioContext;**「试听」是 显式用户动作,会有意解锁音频上下文**(首次点击即解锁是刻意设计),但同样不发声。 - **F3** `enabled=false` 时的试听也计入 `suppressedDisabled`(此前只有审批路径计数,卡片上 "为什么没响"的一行会随入口不同而不同)。 - **R4-CAP** 名册满 50 时**先拒绝导入**(竞态兜底里会删掉刚上传的文件),不再把第 51 个显示成 「(文件缺失)」——那是对一个真实存在的文件说谎。 - **R4-RACE** 导入成功后在**写入时刻**重读名册再追加(多标签页并发的残留窗口记为已知限制)。 ### 残留 FAIL 说明(复跑独立探针时可见) 两份探针的期望表里有多条编码的是 **rev-4 的旧行为**,rev-5 有意改变,故仍显示 FAIL: `.mp3` 与 `payload.mp3 ` 的 415(现按 F6 接受)、大写 uuid 被接受(现按 D2 拒绝)、 `rev-4` 版本戳(现为 rev-5)、20 万字符名不被截断(现按 D4 截断)、`max-height:92px`(现为 `content-box;84px`)、`enabled=false` 试听不计数(现按 F3 计数)、以及 probe-8 那三条 "同步抛错会逃逸"(现按 F1 不再逃逸 —— 这三条由 FAIL 转为"nothing was thrown"正是修复证据)。 ## rev-4 · custom audio(导入自定义音频) - 新增「导入音频」按钮(在音色选择框右侧):选择本地音频 → 浏览器 `POST` 到 DSH 侧新增的 `/api/approval-chime/audio` → DSH 写入插件目录 `audio/.` → 把 `{id, name}` 追加进 设置文档的 `custom` 名册并立即选中播放。 - 名册**按导入先后**排列,渲染在三个内置音色之前:第一个导入的排第一。 - 弹出列表固定显示 **3 行**,第 4 项起滚动(`::picker(select)` 的 `max-height`)。 - 选中导入音色时出现「移除」:删文件 + 出名册;「恢复默认」不删文件。 - schema:`tone` 从「三个 const 的 union」放宽为「三个 const ∪ `custom:` 模式」—— **仍然拒绝**任意字符串,只是精确开了这一个口子;新增 `custom` 数组字段(默认 `[]`)。 - 安全/边界:id 必须是 uuid(路径形状的 id 到不了文件系统);扩展名白名单(否则 415); 5 MB 上限且**拒绝而非截断**(413);GET/HEAD/DELETE 齐备,缺失文件删除幂等。 - 降级:`webServer` 走**可选注入**(`ctx.get` + `ctx.inject`),不是硬依赖 —— 没有 web server 时只打一行警告,内置音色照常工作,绝不把 profile 的启动拖下水。 - 新增 `verify/custom-audio.test.mjs`(70 项):真实 fs 上跑完整 上传→取回→删除 与全部拒绝路径、 schema 仍然拒绝垃圾音色、渲染顺序(含丢弃畸形/重复名册项)、3 行滚动 CSS、以及自定义音色 真正播放(fetch 一次、decode 一次、增益 = `volume/100 × MASTER_GAIN`、二次触发不重取)。 - ⚠️ 改的是 DSH 侧 → **必须重启 `dsh web`** 才生效(路由与 schema 在 DSH 进程内)。 ## rev-3 · rounded picker - 音色下拉的**弹层**改为可定制渲染:`appearance:base-select` + `::picker(select)`。 弹层四角 10px 圆角、与卡片同源的底色与文字色、选项 7px 圆角并带 hover/选中反馈。 - 动机:原生 `