# dsh-approval-chime — 交付说明与验收手册 > 本文件原为仓库根目录的 `README.md`(面向实现的交付说明 + 挂载/重启验收手册)。 > 仓库现已换成面向使用者的 README,这份手册整体移到这里,**章节编号 §1–§9 原样保留**, > 因此其它文档中「README §N」的引用仍然指向本文档的对应小节。 DSH 审批提示音插件:DSH 向你申请权限的那一刻响一声,音量、音色、开关都在**「设置 → 通知提醒」**里调。 内置风铃、铃铛、蜂鸣三种合成音色,支持试听与恢复默认,还能导入本地音频(≤5MB、最多 50 个)当音色; WebAudio 现场合成、不加载音频文件,配置持久化到 DSH 设置文档,另附已触发次数与上次触发时间, 静音环境下也能自证是否生效。 > 上面这段同时是「插件市场 → 已安装」那一行**灰色介绍位**要显示的文字。市场只对精选目录里的插件 > 自动显示作者简介;本插件是本地 `link:` 安装、未上架,那个位置要用市场自己的「添加备注」粘贴 > (≤200 字符)。原因见 CHANGELOG 的「rev-7 · 市场简介」条。 当 DSH 向用户**申请权限**(审批请求)时播放一声音,并在**「设置 → 通知提醒」**(独立分区,rev-7 起)提供一页设置: 启用开关(苹果式拨动开关 38×22,开启色与音量条同一个令牌;rev-9 起)、音量进度条(0..100)、音色选择(chime / bell / beep)、试听、恢复默认, 以及**已触发次数 / 上次触发时间**等可见指示(无声环境下也能自证)。 - 提示音是 **WebAudio 现场合成**(正弦/三角/方波 + 包络),不加载任何音频文件。 - 设置持久化到**DSH 设置文档**(默认 `$DSH_HOME\settings.yaml` 的 `approval-chime:` 段), 写入走平台既有的 revision-fenced 通路(浏览器 `ctx.settingsScope.bind({namespace}).set/unset` → `remote.settings.mutate` → DSH 文档)。插件自己不碰任何浏览器存储。 - **完全不参与审批瀑布**:不注册任何 `approval/request` 监听,内置审批面板的决策路径一个字节都没变。 - **按会话独立(rev-10)**:会话标题行挂一个**小铃铛**(开=实心铃铛 / 关=带斜杠),点一下只静音**这个会话**; 铃铛旁的 caret 还能给该会话单独指定**音色与音量**(默认跟随全局)。这些覆盖**不进设置文档、不进浏览器存储**, 而是由 DSH 侧写进插件自己的文件 `$DSH_HOME|~/.dsh\approval-chime\sessions.json`(原子写、上限 200 条、按 `updatedAt` 淘汰)。 多个会话同时待审批时**各响各的**(不再合并成一声):同一批按快照顺序逐个响、相邻 180 ms。 - **写入收敛(rev-11)**:同一会话的两次点击是两个 POST、两条 socket,**应答被消费的顺序不保证等于 DSH 写盘的顺序**, 所以"最后一个应答落地"并不足以决定本地表。最后一次在飞的写入落定后,插件会**重读一次 DSH 文件**, 让本地表最终等于文件 —— 因此铃铛显示的状态不会再与 DSH 的记录长期相反(rev-10 的 F-01 已修,见 §9 H17)。 - **两处音色列表同一套样式(rev-12)**:设置页卡片里与会话弹层里的音色下拉**共用同一条 `::picker(select)` 规则**(圆角、token 配色、行高、高亮、字号都由同一份声明给出), 所以两处展开后的列表长得一样是**构造上**成立,而不是两处手抄一致;两者只允许在"显示几行后滚动"上不同 (卡片 3 行 = 84px,弹层 4 行 = 120px)。见 §3.3。 本 README 是 t2 的交付说明,也是 **t5 挂载/重启验收** 的操作手册。 实现依据:`docs/契约调研.md`(t1 的 DSH 契约调研,全部结论带 `文件:行号`;rev-10 的会话槽/待审批表/home 规则见 §L)。 --- ## 1. 包结构 ``` dsh-approval-chime/ package.json name/type=module/exports{./client,./cordis.patch.yml}/ dsh.bundle.patch=./cordis.patch.yml, dsh.client.platform='web' cordis.patch.yml insert 一行 {id: dsh-approval-chime, name: dsh-approval-chime} lib/index.js DSH 侧:注册 approval-chime 设置命名空间(默认 enabled=true, volume=70, tone=chime) + 音频路由 + 按会话覆盖路由与 sessions.json(rev-10) lib/client.js 浏览器侧:审批监听 + WebAudio 合成 + 「通知提醒」设置分区 + 会话头部小铃铛 + 写入落定后的收敛重读(rev-11)+ 两处音色列表共用一套 picker 规则(rev-12) (经典脚本,只有 factory 闭包) verify/_harness.mjs 自测脚手架(vm 经典脚本加载器 / 模拟 React / 模拟 AudioContext / 模拟服务与 locale / 假 HTTP 请求-应答) verify/host-half.test.mjs 自测 1:包结构 + DSH 侧注册与降级 + 按会话存储与路由(rev-10) verify/client-half.test.mjs 自测 2/3:浏览器侧装载、两个槽注册、分区页、铃铛两态与 popover、响铃语义、按会话有效值、 写入收敛(rev-11:应答反序仍与存储一致)、两处 picker 的样式等价(rev-12)、 batch 间隔、自动播放策略 verify/waterfall.test.mjs 自测:审批瀑布零注册(静态 + 运行时 + 真 cordis 对照实验) verify/custom-audio.test.mjs 自测 4/4:导入音频(路由、413、名册、自定义音色播放) node_modules/@deepseek-ai/schemastery 指向上游自愈副本的 junction(见 §2,必须存在) docs/契约调研.md t1 产出(本插件所有 DSH 契约的唯一依据;rev-10 的 §L 见下) docs/挂载与验收.md t5 的挂载/验收手册(§7.1 = rev-10 手工步骤;§7.2 = rev-12 看什么) ``` ## 2. 必须存在的 junction(t5 预检项) 工作区插件是以 `link:` 依赖挂载的,Node 会把链接解析到**真实路径** (`D:\...\dsh-approval-chime\lib`),因此它的裸导入**不会**去 profile 目录找包。 本包自带一个 junction,指向上游每次启动自愈的依赖闭包: ```powershell # 已由 t2 创建;预检/重建用 $ns = '\node_modules\@deepseek-ai' New-Item -ItemType Directory -Force -Path $ns | Out-Null New-Item -ItemType Junction -Path "$ns\schemastery" ` -Target '$DSH_HOME\profiles\node_modules\@deepseek-ai\schemastery' ``` 预检命令(应输出 `True` 且两个 realpath 相同): ```powershell Test-Path '\node_modules\@deepseek-ai\schemastery\package.json' (Get-Item '\node_modules\@deepseek-ai\schemastery').LinkType # Junction ``` `lib/index.js` **故意不用顶层静态 `import '@deepseek-ai/schemastery'`**:缺链接时静态导入会让整行 loader entry 导入失败。它按 `import.meta.url`(junction)→ `ctx.baseUrl`(profile 目录)→ `$DSH_HOME/profiles`(DSH 自愈镜像)的顺序尝试解析,再退到动态 `import()`,全失败就打印警告并保持惰性—— **绝不抛出、绝不阻断 `dsh web` 启动**。自测 1 用子进程 + 空 `DSH_HOME` + 包外副本实测了这条降级路径。 ## 3. 设置项 | 字段 | 类型/范围 | 默认值 | 含义 | | --- | --- | --- | --- | | `enabled` | boolean | `true` | 关闭后**任何**声音都不产生(包括试听,且会计入 `suppressedDisabled`);审批触发路径连 AudioContext 都不创建 | | `volume` | number 0..100 | `70` | 音量。主增益 = `volume / 100 × 0.6`(`MASTER_GAIN`),`volume = 0` 时不发声 | | `tone` | `chime` \| `bell` \| `beep` \| `custom:` | `chime` | 音色。前三个为现场合成(bell 为三角波+双分音,beep 为两声方波短鸣);`custom:` 前缀指向一个导入文件 | | `custom` | `[{ id, name }]` | `[]` | 导入的音色名册。**顺序即含义**:按导入先后排列,渲染时排在三个内置音色**之前** | - 命名空间:`approval-chime`(匹配 `/^[a-z][a-z0-9-]*$/`,`dsh-settings` 强制校验)。 - **入口(rev-7)**:设置 → **通知提醒**(`settings.section` 的独立分区,id `approval-chime`、order 16)。插件页不再出现本插件的行。 - 落盘:`$DSH_HOME/settings.yaml` → `approval-chime:` 段;`applies: 'live'`,改完立即对插件生效。 - 「恢复默认」= 对 `enabled/volume/tone` 依次 `scope.unset()`(**不动** `custom`:导入的文件是用户的素材库,不是一项偏好)。 - 音量/开关与「试听」的边界(rev-5 更正措辞):**审批触发**在 `volume = 0` 或关闭时既不建音频节点也不建 AudioContext;但**「试听」是显式用户动作**,会先解锁音频上下文(刻意设计:让你第一次点击就能解除浏览器自动播放限制),只是同样不发声。原文档"不创建任何音频节点"的说法过宽,已按实测更正。 - 非 loopback 页面(远程浏览器)平台只把写入留在内存(`persistence = isLoopback ? 'host' : 'memory'`)。**该模式下分区页渲染为空**(导航行仍在):命名空间拿不到 `status === 'ready'`,分区组件返回 `null`,因此不会出现"内存保留"之类的提示行(该提示分支不可达,属历史措辞)。验收请用 `http://127.0.0.1:3080`。 ### 3.1 导入自定义音频(rev-4) - 操作:分区页「音色」右侧的 **导入音频** → 选择本地文件 → 浏览器把字节 `POST` 到 `/api/approval-chime/audio` → DSH 侧写入插件目录的 `audio/.`,并把 `{id, name}` **追加**进 `custom` 名册,随后自动选中并试听。 - **两处分开存**是刻意的:名册(顺序、显示名)随设置文档走,音频字节落在插件目录 —— 设置文档要保持小、可读、可合并,而浏览器存储是按浏览器且会被清理的。 - 上限 **5 MB**;允许扩展名 `mp3 / wav / ogg / oga / opus / m4a / aac / flac / webm`。超限 → `413`(**先把剩余请求体读干再应答**,否则 Node 会在请求未读完时销毁 socket,413 根本到不了客户端),扩展名不支持 → `415`,**拒绝而非截断**(被截断的音频会解码成怪声,比明确失败更糟)。 - 名册上限 **50 个**:满了以后「导入音频」直接拒绝并说明原因,不会先把文件传上去再把它显示成"(文件缺失)"。 - 文件名处理:先 `trim`,扩展名按**最后一个点**判断;没有扩展名时按 MIME 兜底(`audio/mpeg` → `.mp3`)。id 是**大小写敏感**的小写 uuid。 - 选中导入音色时右侧出现 **移除**:删除文件并从名册去掉该项;「恢复默认」不会删文件。 - 音量对导入音色同样生效:样本经与合成音**同一个主增益**播放,`lastGain` 仍然是 `volume/100 × 0.6`。每个文件只 fetch+decode 一次,之后复用解码后的 buffer。 - 弹出列表显示 **3 行**,第 4 项起滚动(`select::picker(select)` 的 `max-height`,依赖 `appearance:base-select`,见 §8 的浏览器前提)。 - ⚠️ **改 `lib/index.js`(DSH 侧)必须重启 `dsh web`**:音频路由和 `custom` 字段的 schema 都活在 DSH 进程里;只改 `lib/client.js` 时刷新页面即可。 (rev-10 起 DSH 侧还多了按会话覆盖的路由与 `sessions.json`,所以 **rev-10 及之后的验收必须先重启 DSH**,见 §3.2。) ### 3.2 按会话独立(rev-10,写入收敛 rev-11,图标放大 rev-13,会响态蓝色 rev-14,间距 rev-16,箭头转动 rev-17,转动放慢 rev-18,再放慢 rev-19,**不分环境 rev-20**) - **入口**:会话标题行(`conversation.session.header.actions`,本插件 id `approval-chime`、order 30,已取证的官方占用是 -10/10/20) 里的一个小铃铛 + 更小的 caret。**尺寸(rev-13 按真机反馈"太小了"放大)**:铃铛按钮 **28×28**(与出厂 header chip 的 `min-height:28px` 同高)、铃铛图标 **22px**(绘制出来约 15px 高,是 rev-12 的 **1.57 倍**)、caret 按钮 **16×28**、 chevron **11×16**,悬停胶囊圆角 8px。**两个控件之间的间隔(rev-16 按真机反馈"这个铃铛和箭头分开点"加)**: 包裹器 `.dacBellWrap` 带 `gap:6px` —— caret 盒子 16px 宽而箭头只画 11px,所以贴死时墨迹间距只有 2.5px, 两个独立按钮会被读成一个;6px 加上那 2.5px 内缩 ≈8.5px,正是 DSH `.headerActions{gap:8px}` 自己的节奏。 **箭头转动(rev-17 按真机反馈"打开的时候箭头向下转一下"加)**:弹层打开时字形**下转 90°**、关闭时转回, 动画是 `transition:transform 160ms ease`(时长由 `CARET_ROTATE_MS` 拼出; **rev-20 起在任何环境下都是这一条**,见下)、 旋转中心 `transform-origin:center`。**转的是 `` 字形、不是按钮盒**: `.dacCaret` 在任何状态下都没有 `transform`,盒子仍是 16×28;11×16 的字形转过 90° 后足迹 16×11,装得下。 打开态复用现成的 `[data-open="true"]` 钩子(**没有新增 state,也没有 JavaScript 在算角度**)。 **rev-20 的策略(当值,应真机要求的有意取舍)**:产品里**没有** `@media (prefers-reduced-motion: reduce)` 块、 `transition:none` 出现 **0** 次 —— caret 的转动**不分环境**,要求减少动效的环境里**也照常播放同一段 160ms**; 设置页那个苹果式开关的 `.18s` 过渡**同样不再分环境**(原先那条覆盖一起删了)。代价照实说:这是**有意为之**, 在 reduce 环境下本插件不再尊重该偏好(用户原话是"开不开都要有动画",验收也把媒体块 ×0 写成了硬断言)。 **沿革(历史)**:rev-17 是 160ms、rev-18 放慢到 300ms("要有过渡动画能看到在转动的箭头")、rev-19 再放到 400ms、 **rev-20 回到 160ms 并去掉环境例外** —— rev-18/rev-19 那两条媒体块只在那些版本里存在过,当时 reduce 环境下看到的是 瞬跳,那是**已退役的旧策略**,不是现在的行为。 控制台 `__DSH_APPROVAL_CHIME__.reduceMotion()`(rev-18 新增)现在**只报告环境**(见 §7.1):读 `true` 只说明本页 命中那句媒体查询,**不改变转动、也不改变任何别的行为**。 **配色(rev-14)**:**会响**的会话是**实心蓝底 + 白色铃铛**,蓝就是设置页开关 `[data-on="true"]` 与音量条 `accent-color` 用的同一个令牌 `--dsw-alias-state-business-primary`(一处常量 `BELL_ON_BG`,三处规则比对); **被静音**的会话保持"透明底 + caption 灰 + 带斜杠" —— 沿用「开=蓝、关=灰」,且颜色从不是唯一信号。这些数字只在 `lib/client.js` 顶部的常量里写一次 (`BELL_BOX_PX`/`CARET_BOX_PX`/`BELL_GLYPH_PX`/`CARET_GLYPH_W`/`CARET_GLYPH_H`/`BELL_GAP_PX`/ `CARET_OPEN_ROTATE_DEG`/`CARET_ROTATE_MS`),CSS 与两个 `` 的属性由它们拼出来, 控制台可读 `__DSH_APPROVAL_CHIME__.sessionIcon` 核对。铃铛 **开=实心铃铛 / 关=带斜杠铃铛**, `title` 与 `aria-label` **同时**给中英两种文案 (`本会话审批提示音:开 · Approval chime for this session: on` / `…:关 · … off`)。 - **铃铛语义(①A:只可能更安静,不可能更响)**:点击 = 切换该会话的 `enabled` 覆盖 —— 当前会响 → 写 `enabled:false`(只静音这个会话);当前被静音 → **清除** `enabled` 覆盖(回到跟随全局)。 **不提供**「全局关时给单个会话强制打开」。全局关时每个未覆盖会话都静默,铃铛也显示为「关」。 - **有效值**:`enabled = 会话覆盖 ?? 全局`、`volume = 会话覆盖 ?? 全局`、`tone = 会话覆盖 ?? 全局`(逐字段)。 会话覆盖的音色是 `custom:` 而名册里已经没有这个文件时,**回退到全局音色**并在 popover 里说明(不报错、不静音)。 - **caret popover**(自绘、只有 react 依赖):音色(**跟随全局** / 导入的音色 / 风铃 / 铃铛 / 蜂鸣)、 音量(勾选「跟随全局音量」= 清除覆盖,取消勾选后 0..100 滑杆即为该会话的音量)、**恢复跟随全局**。 `position:fixed` + `getBoundingClientRect` 自定位(贴到视口底部时向上翻),**外部 pointerdown 与 Escape 关闭**。 写入是**乐观更新**:先本地生效,DSH 拒绝则回滚并在 popover 里显示错误行。 - **写入收敛(rev-11)**:每次写入 +1 一个"在飞写入"计数;**计数归零时重读一次 DSH 文件**, 让本地表最终等于文件 —— 因此两次点击的应答即使被反序投递(rev-10 的 F-01),铃铛也不会与 DSH 的记录长期相反。 被拒绝的写入仍然回滚并保留错误行(这次重读只重读表、不覆盖该次写入的结论)。 代价是每次写入多一个 `GET`(连点两次只多一次,因为它是"归零才读");重读本身失败时退化为"无覆盖"并保留原因。 - **落盘**:`$DSH_HOME\approval-chime\sessions.json`(未设 `DSH_HOME` 时 `~\.dsh\approval-chime\sessions.json`, 规则与 DSH `@deepseek-ai/dsh-home-paths` 一致:**纯空白的 `DSH_HOME` 视为未设置**)。 形状:`{"version":1,"sessions":{"":{"enabled"?:bool,"volume"?:0..100,"tone"?:string,"updatedAt":number}}}`。 三个字段都可缺省;`POST` 里字段值 `null` = **清除该字段**(回到跟随全局);三个字段全缺省的记录**不落盘**。 - **两个 HTTP 路由**(沿用既有 `webServer` 注册与「没有 web 服务器就只是不做路由」的降级姿势): `GET /api/approval-chime/sessions` → `{ok:true, revision:, sessions:{…}}`; `POST /api/approval-chime/sessions`,体 `{sessionId, patch}` → 应用后返回**同样的结构**。 非法 `sessionId`(空/非字符串/超长 >200)、非法 `volume`(非 0..100 整数)、非法 `tone`(非 `chime|bell|beep|custom:<小写 uuid>`)、 未知 patch 字段一律 **400 且不落盘**;未知路径 **404**;非 GET/POST/HEAD 方法 **405**。 - **上限与淘汰**:最多 `MAX_SESSIONS = 200` 条,按每条记录的 `updatedAt` 淘汰最旧;文件缺失/损坏/非对象时**退化为空表并告警**, 绝不抛错中断插件(下一次成功写入会把文件重写成合法内容)。 - **原子写**:同目录临时文件(`.sessions...tmp`)+ `rename()` 覆盖目标;写失败会清掉临时文件并回 `500`。 - **多会话各响各的**:同一批快照里 N 个**可响**会话各自响一次,按快照顺序、相邻 **180 ms**(`BATCH_GAP_MS`); 被本会话静音的**不响**并计入 `suppressedSession`(设置页抑制行显示「因本会话提示音关闭而静音 ×N」)。 全局关导致的静默仍计 `suppressedDisabled`(语义不变)。 - **跨机器说明(②B 的固有属性,不修)**:覆盖表在**DSH 所在的机器**上,因此换机器/换 profile 不会跟着走; 设置文档里的全局值照旧按平台通路同步。详见 §9。 - ⚠️ **rev-10 改了两半**:新路由与新文件都在 DSH 侧,**必须先重启 `dsh web`**;只刷新页面会出现「铃铛在、写入报错」。 **手工验收判据(摘要;逐步操作见 `docs/挂载与验收.md` §7.1)**: | 判据 | 通过的样子 | | --- | --- | | 铃铛存在且两态 | 会话标题行有 **28×28** 的铃铛按钮(22px 图标)+ **16×28** 的 caret;**会响=蓝底白铃铛(rev-14,与设置页开关同色)、静音=透明底灰斜杠铃铛**;**点开 caret 时箭头下转 90°、关闭时转回(rev-17,转的是字形不是按钮盒;**rev-20 起过渡 160ms 且不分环境**,rev-19 曾 400ms、rev-18 曾 300ms)**;hover 同时显示中英文案(rev-13 起图标比原来大 1.57 倍) | | 只静音一个会话 | 把 A 的铃铛点成「关」后 A 不响、B 照响;设置页出现「因本会话提示音关闭而静音 ×N」;全局开关不变 | | 「开回来」= 清除覆盖 | 再点一次铃铛,`sessions.json` 里 A 的 `enabled` 字段**消失**(不是写入 `true`) | | 多会话各响各的 | 两个未静音的会话同时待审批 → `triggers` 连续 +2(相邻约 180 ms),不是一声 | | 每会话音色/音量 | caret → 选 bell、音量 30 后,该会话 `stats().lastTone === 'bell'`、`lastGain === 0.18`,其它会话与设置页不变 | | 数据落点 | `$DSH_HOME\approval-chime\sessions.json` 出现该会话记录;`settings.yaml` 无会话数据;浏览器无任何存储项 | | 上限 | 最多 200 条,最旧的 `updatedAt` 先被淘汰(自测用 205 条种子文件断言) | | 写入收敛(rev-11) | 同一会话连点两次后,`sessionWrites().outstanding === 0` 且本地表与 `sessions.json` 逐字段一致(应答顺序无关) | | 零回退 | 设置页与本轮之前逐项一致;审批面板按钮行为不变 | ### 3.3 两处音色列表是同一套样式(rev-12) 同一张"展开的音色列表"在本插件里出现两次:**设置 → 通知提醒**卡片里的那张,和**会话弹层 caret** 里的那张 (后者多一行 `跟随全局`)。rev-3/rev-5 只给卡片那张写了自定义渲染(`appearance:base-select` + `::picker(select)`), 弹层那张只拿到 `option` 的颜色,于是它落回浏览器默认渲染:**方角、灰底高亮、没有 ✓**。rev-12 把两者并到同一条规则上。 - **一份声明,两个选择器**:`.dacCard select::picker(select),.dacPop select::picker(select){…}`(以及 `select`、`option`、`option:hover,:checked` 三条同理)。圆角 10px、行圆角 7px、`padding:4px 9px`、 行高钉在 20px、高亮用 `color-mix(in srgb,currentColor 14%,transparent)`、配色走 `--dsw-alias-*` 令牌 —— 全部只写一次。 - **唯一允许的差异是行数上限**:卡片 3 行(`3×28 = 84px`,rev-5 R5-1 的验收点不变);弹层是它的默认 4 行 (`跟随全局` + 风铃/铃铛/蜂鸣)→ `4×28 + 8 = 120px`。`max-height` 只**夹紧**、不会把短列表撑高, 所以那 8px 只是给浏览器自己的 picker chrome 留的余量(卡片那张在"恰好 3 行"时就是可滚动的), 同时仍远小于第 5 行(140px)——语义依旧是"4 行后滚动"。 - **列表字号自己声明**:`font-size:13px`(`.dacCard` 自己的字号)。会话弹层面板是 12px, 若让列表继承会比卡片那张小一号,与"改成一样"冲突,因此两张列表共用这一个显式数字。 - **没改的**:弹层里"合上的" `

`「通知提醒」(与导航行文案一致)、一行 intro,以及 range(0..100)/checkbox(rev-8 起 `role=switch`,绘制为苹果式拨动开关)/select(3 音色)/导入+隐藏 file input/试听/恢复默认/计数器文本/抑制原因行/ 只读与已覆盖徽标/错误行/**构建戳徽标(rev-21 起 badge 印的是 `snapshot.bundleRevisionId` = 版本 id 本身**;条件行各有一条断言, 另加 4 条:戳**恰好**匹配 `/^rev-\d+$/`、**不带** `· ` 分隔符、等于控制台 `.revisionId`、整串描述**不出现在页面上**); 拖动在释放时写 `set('volume',…)`(一次鼠标拖拽会先后触发 `pointerup` 与 `mouseup`, 因此同一值**可能写两次**;平台的写队列串行化并携带 `pendingRevision`,语义等价于一次), 开关/音色立即写,恢复默认走 `unset`; 一次审批 = 一个连到 destination 的主增益且其值 **等于 `volume × MASTER_GAIN`**, 同 key 重复发布不再响、替换请求(新 key)再响一次、非 approval 条目不响, `volume=0` / `enabled=false` 时**音频节点数为 0**; 首个用户手势解锁 AudioContext 并释放监听、解锁前/不支持的浏览器只计数不抛异常, 全程无 unhandled rejection。 3. **waterfall**:静态断言 `lib/client.js`/`lib/index.js` 里没有审批事件名、没有 `$on`、没有 `.on(`、 没有 `waterfall(`;运行时断言 apply 后 remote 订阅为 0; 用 DSH**真实 cordis** 复跑 t1 的实测:不调用 `next()` 的监听器切断整条链、其后注册者永不执行、 决策返回值原样返回、`{prepend:true}` 在 `ctx.on` 上确实生效(所以问题只在 `$on` 只转发两个参数, 并由 `dsh-api-gateway/lib/client.js` 的 `$on(event, listener)` 源码签名佐证); 最后在这种"敌对环境"里证明本插件照样响铃。 > 自测会在 `%TEMP%` 下建一个临时目录(包外副本 + 空 DSH_HOME + 子进程脚本), > 结束时删除;不写工作区其它位置,不碰 profile,不装任何包,不联网。 ## 5. 挂载 + 重启验收步骤(交给 t5) 前置:§2 的 junction 存在;自测全绿。 1. **改 profile(唯一需要动 profile 的一步)** — `$DSH_HOME\profiles\web\package.json`: - `dependencies` 增加 `"dsh-approval-chime": "link:"`; - `dsh.profile.bundles` **追加** `"dsh-approval-chime"`(追加即最后一层;本插件对行序无要求)。 2. **让 profile 的 `node_modules` 出现该 junction**: 推荐(t5 实测路径,等价于手改):`node dsh-approval-chime/deploy/mount.mjs` —— 它先备份、再幂等地写入上面两处 JSON 改动并建 junction;`--dry-run` 只看不改。 > ⚠️ **CLI 语义更正(t5 实测源码,`@deepseek-ai/dsh/lib/plugin-Ddi42qoW.js`)**: > `dsh plugin --profile ` 只是**把参数转发给 profile 目录里的 pnpm** > (`spawnSync('pnpm', args, { cwd: profileDir })`),成功后再按「已安装依赖」重算 `dsh.profile.bundles`。 > 因此 **`dsh plugin --profile web install` 只是 `pnpm install`**:它本身**不会新增依赖**; > 只有当 `package.json` 已声明该 `link:` 依赖时,它才会建立 junction(并补上缺失的 bundles 项)。 > 想用一条命令完成「依赖 + junction + bundles」,正确形式是 > `dsh plugin --profile web add "link:"`; > **该形式本任务未实测**(会跑 pnpm、改写 `pnpm-lock.yaml` 并动 `node_modules`,风险大于收益), > 本插件采用的是上一条的等价手工/脚本路径。详见 `docs/挂载与验收.md` §8。 退化路径(连脚本也不想跑时): `New-Item -ItemType Junction -Path "$DSH_HOME\profiles\web\node_modules\dsh-approval-chime" -Target ""`。 ⚠️ **不要**再往 `profiles/web/cordis.patch.yml` 里写同 id 的 `insert`:所有层的 patch 会合并成一个数组, 重复 id 直接抛 `duplicate loader entry id` → 启动失败。临时停用请用 id 定向覆盖: `- id: dsh-approval-chime` + `disabled: true`。 3. **重启 DSH**:DSH 侧不会热加载(DSH 把 bundle 字节读进内存)。 停掉当前 `dsh web` 进程,重新 `dsh web`,然后浏览器**刷新一次**页面。 4. **验收信号(可观测)**: - DSH 日志无 `failed to import loader entry dsh-approval-chime`、无 `client-modules: … failed to compose`; - 浏览器控制台:`window.__DSH_BOOT__.entries` 里出现 `{id: 'dsh-approval-chime'}`; - 控制台:`window.__DSH_APPROVAL_CHIME__` 存在(见 §7 自证接口); - 设置导航里出现独立一行 **「通知提醒」**(在「插件」之后、「Agent 预设」之前),进入后是标题「通知提醒」+ 一行说明 + 本插件的全部控件,右上角带构建戳 —— **rev-21 起只印版本 id**(当前 `rev-24`;整串描述 `rev-24 · muting draws the slash instead of moving the bell` 只在控制台 `.revision` 可读,戳的文本 = `.revisionId`),**且「启用提示音」是苹果式拨动开关(开启=与音量条同色的蓝底白钮、关闭=灰底)**; - **反向判据**:设置 → 插件 → 插件配置里**不再**出现本插件的行(rev-7 起不再注册那张卡片); - `$DSH_HOME\settings.yaml` 出现 `approval-chime:` 段(点一次试听/改一次音量后)。 - **rev-10 的新信号(rev-11 起写入收敛)**:任意会话标题行出现**小铃铛 + caret**(开=实心 / 关=带斜杠,hover 中英双语); 控制台 `await fetch('/api/approval-chime/sessions').then(r => r.json())` 返回 `{ok:true, revision:…, sessions:{…}}`; `$DSH_HOME\approval-chime\sessions.json` 在点过铃铛/caret 后出现;连点两次后 `__DSH_APPROVAL_CHIME__.sessionWrites().outstanding === 0`(写入已落定并重读过)。 手工步骤见 `docs/挂载与验收.md` §7.1。 - **分区页空白时先看 DSH 日志**:搜 `dsh-approval-chime`。若是 `could not load @deepseek-ai/schemastery; settings namespace "approval-chime" is not registered`, 就是 §2 的 junction 缺失(重建后重启即可,导航行仍在但页面为空);若是 `settings service unavailable`,说明 profile 里没有 `dsh-settings`(本插件对它是硬依赖,只影响本插件)。 5. **功能验收(不依赖听力)**: - 分区页「已触发」计数在审批出现时 +1,「上次触发」显示时间;没有声音时下面的提示行会说明原因 (被开关关闭 / 音量为 0 / 浏览器自动播放策略 / 不支持 WebAudio); - 先点一次页面任意处或「试听」解锁 AudioContext,再触发一次审批(让 DSH 申请一次权限), 应能听到提示音;把音量拖到 0 或关掉开关后,计数只能看到"静音原因",不应有任何声音; - 刷新页面后音量/音色/开关保持(值来自 DSH 设置文档)。 6. **回滚**:删掉 `dsh.profile.bundles` 里那一行与 `dependencies` 里那一行 → 重启; 可选删除 profile 里的 junction 与 `settings.yaml` 里的 `approval-chime:` 段。三层独立可回滚。 ## 6. 触发设计(为什么不用审批瀑布) t1 的实测结论(`docs/契约调研.md` §A): - `ctx.remote.$on(event, listener)` **只有两个形参**,第三个 `{prepend:true}` 被静默丢弃 (`dsh-api-gateway/lib/client.js:1472-1474` → `remote-events.js:32-35`)。 - 内置 `ui-approval` 的行由 `dsh-web-app` 的 patch 插在我们前面,且它的正常路径是 `return await pending.result` —— **不调用 `next()`**,cordis 的事件链到此为止,其后注册者永不执行。 - 因此加入这条链只有两种结果:不响,或者把用户的审批面板吞掉。**本插件一行都不碰它。** 实际做法:订阅 `ctx.uiSession.pendingInteractions`(`getSnapshot()` / `subscribe() => disposer`), 按快照里**新出现的** `kind === 'approval'` 条目的 `key` 去重后响铃: - 同一 `key` 永远只响一次(快照因无关原因重发不重复响);替换请求是新 `key`,会再响一次; - **一批新审批(多个会话同时等待)各响各的**(rev-10):快照里每个可响的会话各响一次,按快照顺序、相邻 180 ms; 被该会话自己静音的条目不响并计入 `suppressedSession`(旧行为「一批只响一声」已按用户要求改掉); - 每条审批用的是**它自己那个会话**的有效值(`会话覆盖 ?? 全局`:音色/音量/开关逐字段); - 页面加载时若已有待审批条目,会补响一次(同样是逐会话); - 已见 key 保留最近 256 个(FIFO 淘汰)。 **已知 DSH 限制(记录,不绕过)**:每个 session 只暴露一个 pending interaction(按 precedence 取大者), 同会话同时有待审批与待回答问题时审批可能被遮蔽 → 那一次会漏响。见 `docs/契约调研.md` §H3。 ## 7. 无声环境自证接口 浏览器控制台: ```js window.__DSH_APPROVAL_CHIME__.revision // 'rev-24 · muting draws the slash instead of moving the bell'(整串描述;判断页面是否旧 bundle) window.__DSH_APPROVAL_CHIME__.revisionId // 'rev-24'(rev-21 新增:= 分区页那个构建戳的文本,由 revision 推导,两者不可能各说各话) window.__DSH_APPROVAL_CHIME__.slot // 'settings.section'(分区页注册的槽) window.__DSH_APPROVAL_CHIME__.sessionSlot // 'conversation.session.header.actions'(铃铛注册的槽,rev-10) window.__DSH_APPROVAL_CHIME__.sessionAction // {id:'approval-chime', order:30}(铃铛的格子与排序,rev-10) window.__DSH_APPROVAL_CHIME__.sessionIcon // rev-13/14/16/17/20:{bellBoxPx:28,caretBoxPx:16,bellGlyphPx:22,caretGlyph:'11x16',bellGapPx:6, // caretOpenRotateDeg:90,caretRotateMs:160, // onBackground:'var(--dsw-alias-state-business-primary,#2563eb)',onForeground:'#fff',onBackgroundHover:'color-mix(…86%,#000)'} // CSS 与两个 的尺寸都由这组常量拼出;想看"是不是够大 / 是不是分开了 / // 转多少度、转多久"就比这里(caretRotateMs 是 rev-20 的 160;rev-19 曾是 400) window.__DSH_APPROVAL_CHIME__.reduceMotion() // rev-18 新增、rev-20 起只报告环境:实时回答"本页是否处于 (prefers-reduced-motion: reduce)",不改变行为(见 §7.1) window.__DSH_APPROVAL_CHIME__.batchGapMs // 180(同一批相邻两声的间隔,rev-10) window.__DSH_APPROVAL_CHIME__.stats() // {triggers, previews, approvalsSeen, lastAt, lastTone, lastVolume, lastGain, suppressedXxx…, lastBatchSize, lastBatchPlayed} window.__DSH_APPROVAL_CHIME__.audio() // {state:'idle|running|suspended|unsupported|error', unlocked, lastError} window.__DSH_APPROVAL_CHIME__.settings() // 当前生效的全局 {enabled, volume, tone, custom} window.__DSH_APPROVAL_CHIME__.preview() // 按当前设置响一次(开关关闭时返回 false,不发声) window.__DSH_APPROVAL_CHIME__.unlock() // 手动解锁 AudioContext(等价于一次用户手势) window.__DSH_APPROVAL_CHIME__.snapshot() // 分区页读到的完整快照 window.__DSH_APPROVAL_CHIME__.sessions() // rev-10:{ready, revision, error, sessions:{:{…}}} window.__DSH_APPROVAL_CHIME__.sessionSettings('') // rev-10:该会话的有效值 {enabled, volume, tone, overridden:{enabled,volume,tone}, customMissing, globals:{enabled,volume,tone}} window.__DSH_APPROVAL_CHIME__.toggleSession('') // rev-10:等价于点一次铃铛(乐观更新,失败回滚) window.__DSH_APPROVAL_CHIME__.refreshSessions() // rev-10:重新从 DSH 读覆盖表 window.__DSH_APPROVAL_CHIME__.sessionWrites() // rev-11:{outstanding:} 还在飞的会话写入数;0 = 已重读、本地表=DSH 文件 window.__DSH_APPROVAL_CHIME__.toneRows // 3:设置卡片那张音色列表显示几行后滚动(rev-5 起 84px) window.__DSH_APPROVAL_CHIME__.sessionToneRows // rev-12:4(多一行「跟随全局」),弹层列表显示几行后滚动 window.__DSH_APPROVAL_CHIME__.pickerMetrics // rev-12:{rowPx:28,textPx:13,slackPx:8,cardMaxPx:84,sessionMaxPx:120} // 两张列表共用的行高/字号,以及各自的上限;自测直接读这组数字 ``` `stats().lastGain` 就是那一枪实际挂到 destination 上的主增益值(`volume/100 × 0.6`), 所以「响没响、以多大声响、为什么没响」都能在不听声音的情况下判定; `stats().suppressedSession` 是「被本会话静音」的次数(与全局关闭的 `suppressedDisabled` 分开计数,rev-10)。 ### 7.1 `reduceMotion()`:现在只报告环境(rev-18 新增,**rev-20 改语义**) ```js window.__DSH_APPROVAL_CHIME__.reduceMotion() // true = 本页处于 (prefers-reduced-motion: reduce);它不改变行为 ``` **rev-20 起这个顶层诊断只有一个用途:报告环境。** 产品里已经**没有** `@media (prefers-reduced-motion: reduce)` 媒体块(`transition:none` 出现 **0** 次),所以用户报告「**看不到箭头在转动**」在 rev-20 只剩一个可能的原因: 1. **时长/观感** —— 转动就是 `CARET_ROTATE_MS = 160ms` 的**一段**过渡;**任何环境下都是这一段,没有例外** (CSS 的过渡时长由该常量拼出,`sessionIcon.caretRotateMs` 报告同一个数)。嫌快/嫌慢只改这一个常量。 `reduceMotion()` 读 `true` 只说明本页命中那句媒体查询;**它不改变转动,也不改变任何别的行为**。 **旧语义(历史,已退役)**:rev-18/rev-19 曾用它区分"时长太短"与"环境要减少动效" —— 那些版本在 reduce 环境下 **有意**把过渡整条去掉、只留 90° 终态,于是屏幕上是一个瞬跳。那是**当时**的设计,**不是现在的行为**: rev-20 把这个例外删掉了(应真机要求,见 `CHANGELOG.md` 的 rev-20 节),现在只剩**时长/观感**这一个原因。 所以排查顺序是:先在控制台读 `.revision`(应为 `rev-24 …`)确认拿到的是本版 bundle;看不到就**先硬刷新**; 再确认 `sessionIcon.caretRotateMs` 是 160。 实现上的四条纪律(**一条没少**,都由 `verify/client-half.test.mjs` 与 `probe-20` 钉住,各含一个变异): - **实时读**,不是安装时的快照:用户在页面开着时切换系统设置,下一次调用就要给出新答案 (同一个实例上把 `matches` 由 `false` 翻到 `true` 再读,必须得到 `true`)。 - **问的必须是那一句查询**:`(prefers-reduced-motion: reduce)`(问反了的变异会让答案在真机上整体颠倒)。 - **不抛错**:平台没有 `matchMedia`(本仓的无头桩就是)或 `matchMedia` 自己抛错,都答 `false`。 - **不在 `sessionIcon` 里**:那个对象是会话头控件的**几何快照**,媒体查询的答案不是几何。 **它证不了的事**:真机上系统开关到底有没有让浏览器命中这条媒体查询,只有你在真机上判定(见 §9 H20)—— 注意 **rev-20 起它与"看没看到转动"无关**(转动不分环境)。**观感(快慢、是否被察觉、会不会太抢眼)依旧只有真机能定**。 ## 8. 浏览器端依赖边界(实测结论) 可以 `require` 的种子模块(shell 产物 `staticModules` 实测): `react`、`react/jsx-runtime`、`react-dom`、`react-dom/client`、`@deepseek-ai/cordis`、 `@deepseek-ai/dsh-client-store`、`@deepseek-ai/dsh-client-ui-slots`、 `@deepseek-ai/dsh-client-ui-primitives`、`@deepseek-ai/dsh-client-ui-dockkit` (外加模块图上存在的行 id)。 **不能** `require` 的:平台卡片工具 `CardForm` / `numberField` / `ValueField` / `PluginCard` —— `dsh-client-ui-settings-plugins` 的浏览器 bundle 只导出 `apply`/`inject`(`lib/client.js:1813-1814`)。 所以本插件的分区页是**自绘**的(原生 `input[type=range]`、`input[type=checkbox]`(rev-8 起被 CSS 画成 iOS 拨动开关,原生控件仍在、仍可 Tab 聚焦与空格切换)、`select`、`button`, 样式走 DSH CSS 变量 `var(--dsw-alias-*)`,分区/标题/intro 的排版照抄 DSH `dsh-client-ui-settings-models` 的 `.section/.title/.intro` 规则),但**写入仍走平台契约**: `ctx.settingsScope.bind({namespace:'approval-chime'})` 的 `set`/`unset`,落到 DSH 设置文档。 rev-10 的会话 popover 同样是自绘的:**内联 SVG 图标 + `position:fixed` 自定位 + 自己实现的 「外部 pointerdown / Escape 关闭」**(平台提供的 `useDismissOnOutsidePointer` 来自 `dsh-client-ui-primitives`,而该包不在可 require 的种子里);它的写入**不走设置文档**, 走 DSH 侧自己的 `POST /api/approval-chime/sessions`(§3.2)。 本 bundle 只用 `require('react')`(自测里断言"只有 react 一个 require")。 ## 9. 已知风险 / 未证实项 | # | 事项 | 处置 | | --- | --- | --- | | H2 | `uiSession.pendingInteractions` 是否被官方列为第三方扩展点(**未证实**) | 取不到时打印明确警告并停用提示音,分区照常注册(自测覆盖) | | H3 | 每 session 只暴露一个 pending | 记录为已知限制(§6) | | H4 | 改 `lib/client.js` 后是否真的热替换(**未证实**) | 验收按"重启 DSH + 刷新页面 + 核对分区页上的 `REVISION`"执行,不依赖自动热替换 | | H5 | 自动播放策略 | 全局首个手势解锁 + 分区页「试听」手势 + 每次被策略拦下都计数并在页面上提示 | | H12 | 非 loopback 页面写入只在内存 | 该模式下 `status` 不会变成 `ready` → **分区页整体渲染为空**(导航行仍在,无提示行);验收须用 `http://127.0.0.1:3080` | | H13 | `settings.section` 是否被官方列为第三方扩展点(**未证实**;rev-7 新依赖) | 该槽由设置外壳声明(`dsh-client-ui-settings-general/lib/client.js:621-624`),DSH 契约调研文档见 `docs/契约调研.md` §K;注册本身包在 try/catch 里,失败只丢导航行、不影响提示音 | | H14 | `conversation.session.header.actions` 是否被官方列为第三方扩展点(**未证实**;rev-10 新依赖) | 该槽由会话头部组件声明(`dsh-cordis-client-runner/lib/client.js:3102-3157`,占位者可查 `:3149-3154`;官方占用者确实从 props 取 `sessionId`,`dsh-client-ui-jobs/lib/client.js:117`),见 `docs/契约调研.md` §L.1;注册包在 try/catch 里,失败只丢铃铛、不影响提示音与设置页 | | H15 | **覆盖表不跨机器**(②B 的固有属性,设计如此) | 覆盖存在 DSH 机器的 `$DSH_HOME\approval-chime\sessions.json`,换机器/换 profile 不跟随;全局设置仍按平台通路走。**不提供同步**:跨端同步需要平台级存储,超出插件边界 | | H16 | popover 的 `position:fixed` 若被带 `transform` 的祖先裁剪 | 头部行本身不裁剪(`dsh-client-ui-conversation/lib/client.js:14652` 的 `.headerActions` 无 overflow/transform);若未来 DSH 给祖先加 `transform`,popover 可能被裁 → 目前按契约上溯不到该情形,**未在真机验证** | | H17 | 同一会话铃铛连点两次的写序(**rev-11 已修**) | rev-10 的窗口:两次 POST 的应答被反序投递时,**后到**的应答会覆盖**更新**的那一次,本地表与 DSH 文件相反且没有收敛路径(审查者 F-01)。rev-11 起:每次写入 +1 一个在飞计数,**计数归零时重读一次 DSH 文件**,本地表最终等于文件(与应答顺序、与 DSH 两次 `rename` 的顺序都无关)。**残留窗口(如实记录)**:重读完成前的一瞬,本地表可能短暂停在被反序覆盖的状态(一次 `GET` 的往返,本机 median 3.33 ms);重读失败时退化为"无覆盖"并在 popover 保留原因为错误行。未加请求序号/串行化(审查者给的选项①):本实现选的是选项② | | H18 | 「全局关时给单个会话强制打开」**故意不做**(用户选 ①A) | 会话覆盖只可能比全局更安静:铃铛只写 `enabled:false` 或清除覆盖。若手工把 `enabled:true` 写进 `sessions.json`,有效值公式仍会取它(`覆盖 ?? 全局`)——那是文件的语义,不是 UI 的入口 | | H21 | 铃铛的**蓝色观感**(rev-14) | "同款蓝"这件事是**构造上**成立的:`BELL_ON_BG` 一个常量同时供给铃铛填充、开关 `[data-on="true"]` 与音量条 `accent-color`,自测逐条比对这三处(换一个蓝、手抄十六进制、静音也填充、删掉会响态的 `:hover`、静音图标染色——五个变异各自报红,另有"填充挂到基类"报 3 条)。**不能证**的是观感:白铃铛在蓝底上的对比度(`#fff` on `#2563eb` ≈ 4.8:1,图形元素够用)、两种主题下的实际效果、以及"实心蓝块会不会太抢眼"。要换色只改 `BELL_ON_BG` 一处。**rev-14 之后独立层也补齐了**:`verify-independent/probe-20-r14-bell-appearance.mjs` 用它自己的 11 个声明变异逐条钉住外观(静音也填蓝 → 3 红 / 删掉会响态 `:hover` → 4 红 / 蓝底写死十六进制 → 2 红 / 静音图标染色 → 1 红);而这四个正是审查者当初自造、**独立层一条都没抓住**的对抗性变异(当时只有自测层抓得住)—— 现已双方覆盖,闭环证据是"变异体 sha256 逐条相同、红集 0 → 3/4/2/1" | | H20 | 铃铛/caret 放大后的**观感**(rev-13;间隔 rev-16;箭头转动 rev-17;转动放慢 rev-18;**不分环境 rev-20**) | "够不够大"只能在真机上看,沙箱渲染不了。自测能证的只是几何:按钮 28px(= 出厂 header chip 的 `min-height:28px`)、图标 22px(≈15px 绘制,rev-12 的 1.57 倍)、caret 16×28、chevron 11×16、**两控件间 `gap:6px`(rev-16:贴死时墨迹间距只有 2.5px)**,且"≥20px / >1.4×"是**绝对下限**(缩回旧尺寸会报红)。若你觉得还要更大/更小:改 `BELL_BOX_PX` 与 `BELL_GLYPH_PX`;**要更开/更紧:改 `BELL_GAP_PX`**(都在 `lib/client.js` 顶部常量),自测数字从 `sessionIcon` 读、自动跟随。**6px 是"像不像两个按钮"的判断,只有真机能定** —— 这一轮就是你自己看出来的。**rev-17/rev-18 的箭头转动同理**:能证的是"打开态解析出的 `transform` 就是 `rotate(deg)`、过渡时长由 `caretRotateMs` 拼出(**rev-20 起 = 160**;rev-19 是 400、rev-18 是 300、rev-17 是 160)、样式表里**恰好只有一条** caret transition 规则且时长没有手抄副本(只由常量拼出)、按钮盒在任何状态都没有 `transform`、**产品里没有 reduced-motion 媒体块(任何环境下都是同一段 160ms)**、转过 90° 的字形足迹 16×11 仍装在 16×28 里、`reduceMotion()` 的三种分支各自正确";**"看不看得见 / 快不快 / 顺不顺 / 会不会太抢眼"只有真机能定** —— 沙箱没有浏览器引擎,`getComputedStyle` 的过渡插值、合成层、`transform-origin` 是否视觉居中、`prefers-reduced-motion:reduce` 在**真机上**是否真的命中、以及两种主题下的观感一律**未证**。要改角度或时长只动 `CARET_OPEN_ROTATE_DEG` / `CARET_ROTATE_MS` 两个常量,CSS 与诊断面自动跟随;若"看不到转动":**rev-20 只剩时长/观感一个原因** —— 先在控制台读 `.revision` 确认拿到的是 `rev-22 …` 的 bundle(看不到就硬刷新),`reduceMotion()` 只报告环境、**不改变行为**(§7.1)。同类改动的观感判定一律由真机完成(rev-8/rev-9 的开关也一样) | | H19 | 会话弹层音色列表的**真实像素**(rev-12) | 沙箱没有浏览器引擎,量不到展开后的实际高度。按"行高钉死 20px + `4×28` 行 + 8px 余量"给出上限 120px:多出的余量是为了应付浏览器自己的 picker chrome(卡片那张在恰好 3 行时就是可滚动的,说明 chrome 会吃掉几个像素)。**它最多换来一条几像素的滚动条,不会藏掉一行**(`max-height` 只夹紧、不定高)。**5 行场景**:会话存了一个名册里已不存在的自定义音色时,列表会多插一行「(文件缺失)」→ 5 行撞上限、必然可滚动(卡片那张同样如此:4 行撞 84px)——这是设计,不是缺陷。真机上若第 4 行被裁或出现滚动条,改 `TONE_PICKER_SLACK_PX` 一个数字即可(自测随之自动跟随)。**你的浏览器确实支持 `appearance:base-select`**:设置页那张列表在你给的第二张截图里就是这套渲染(圆角 + ✓ + 滚动条);本插件卡片侧的 CSS 本轮未改,弹层用的是同一条规则 |