# 缺陷复盘 · dsh-codex-model-effort-selector
每个已修缺陷写成 **现象 → 根因 → 修法 → 断言** 四段。编号沿用 [README](../README.md) 的原始编号(因此不连续,缺 12+ 是因为那些号从未使用)。架构背景见 [ARCHITECTURE.md](./ARCHITECTURE.md),像素测量见 [SPEC.md](./SPEC.md)。每个断言都能在 `test/*.mjs` 里找到对应的那一行。
| # | 现象 | 根因一句话 | 断言位置 |
|---|---|---|---|
| 1 | 点一个分组,全部一起展开 | 同一个 `Set` 被当成两种含义读 | `collapse-regression.mjs` |
| 2 | 从模型列表关闭后再打开推理条,界面「从上面收回」 | `close()` 没有重置 `pane` | `verify.mjs` |
| 3 | 只有 Ultra 有特殊颜色 | 填充/标题写死单色 | `palette.mjs` + `verify.mjs` |
| 4 | ⚡ 的说明浮层被遮挡 | 浮层是弹层子元素,被 `overflow:hidden` 裁掉 | 预览 + 源码断言 |
| 5 | 拖动不平滑、不跟手 | 拖动时直接吸附到离散档位 | `verify.mjs` |
| 6 | 有时无法选中 | 三个独立缺陷叠加 | `verify.mjs` |
| 7 | 看不出当前模型在哪个分组 | 第一级没有活动标记 | `verify.mjs` |
| 8 | 最底下的分组被遮挡、滚轮没反应 | flex 链失去确定高度 + 第一级没有滚动容器 | `verify.mjs` |
| 9 | 滑条能停在任意位置 | React 因状态未变而跳过重渲染,吸附值从未写回 | `verify.mjs` |
| 10 | 第一级每行右侧的灰色模型名是冗余的 | 第二级已经列出并标勾选 | `verify.mjs` |
| 11 | 蓝点在 provider 名左侧,与「状态在右」的阅读习惯相反 | 渲染顺序是 name → dot → chevron | `verify.mjs` |
| 4b | ⚡ 浮层**悬停完全不显示**(4 的修法没生效) | 弹层的 `backdrop-filter` 使弹层成为 fixed 子元素的包含块,视口坐标被当成相对弹层解释后裁掉 | `verify.mjs` ×3 |
| 13 | 滑条在最右端时**按住拖不动** | 旋钮 30px / 轨道 26px,旋钮上下 2px 悬空且不可命中,按下落到面板上 | `verify.mjs` |
| 14 | 无坐标的释放把旋钮甩出轨道 | `nearestIndex(NaN)` → `--pos: NaN` 使 `left:calc()` 非法 | `verify.mjs` |
| 15 | 悬停刻度点不高亮 | 把**事件对象**当 `clientX` 传入,映射恒为 `NaN` | `verify.mjs` |
| 16 | 升级 0.2.1 后模型名上的「1017」徽标消失 | 子槽位 `conversation.input.model.label` 只在声明它的条目挂载期间存在 | `slots-integration.mjs` |
| 17 | 拖动到一半「变成无法选中」 | 手势依赖指针捕获,捕获一丢就再也收不到 `pointermove`;旋钮可命中又让事件目标一直变 | `verify.mjs` |
| 18 | 按住时的柔光环(按需求去掉) | 设计变更:按压只保留放大 | `verify.mjs` |
---
## 第 4b 条 · ⚡ 浮层悬停完全不显示(第 4 条并未真正修好)
### 现象
悬停 ⚡ 按钮,**什么也不出现**。不是「被裁掉一部分」,而是**整块看不到**。
### 根因
第 4 条把浮层改成 `position:fixed`,假设这样就能逃出弹层的 `overflow:hidden`。**这个假设不成立**,
因为弹层还画了官方菜单材质:
```css
.${P}-popover{ ... backdrop-filter:var(--dsw-menu-backdrop-filter); overflow:hidden }
/* 主题令牌实测值: blur(40px) saturate(150%) */
```
按 CSS 规范,**非 `none` 的 `backdrop-filter`(以及 `transform` / `filter` / `contain` / `will-change`)
会让该元素成为其后代的包含块**。于是浮层的 `position:fixed` 不再是相对视口,而是相对弹层的 padding box;
`positionTip()` 算出的视口坐标被原样当作「相对弹层」的坐标使用。真实浏览器实测:
```
想放在 top = 479 (= 按钮 top 551 − 浮层高 66 − 6)
实际落在 top = 1023 (= 弹层 top 544 + 479)
```
随后 `overflow:hidden` 把它整块裁掉。悬停其实**成功**把 `opacity` 置成了 1、`visibility` 置成了 `visible` ——
只是没有任何东西被画出来,所以从「看不出 hover 生效」推断成「hover 没触发」是错的。
### 修法
把浮层从弹层里**搬出去**,渲染成弹层的**兄弟节点**(仍在 `.cmes-root` 内):
```
… ← 在这里
```
`.cmes-root` 上没有任何 transform / filter / backdrop-filter,`position:fixed` 重新相对视口生效
(探针实测:同一个 fixed 盒子放在弹层里报 `12,544`,放在 root 里报 `0,0`)。
因为它不再是按钮的后代,原来的 `.cmes-fast:hover [role=tooltip]` **本来就无法命中**,
所以显隐改为 `pointerenter/leave` + `focus/blur` 驱动的 `.show` 类。
顺带修掉第二个隐患:定位原本在 hover 回调里做,**首次悬停时浮层还没参与过布局**,
`offsetWidth/offsetHeight` 走的是占位值,于是它按错尺寸被摆放。改为 `useLayoutEffect` 在布局后定位。
### 断言
- 根因的两半仍在源码里(弹层确实有 `backdrop-filter` 与 `overflow:hidden`),而旧的 `:hover` 后代规则**必须消失**
- 浮层节点必须渲染在 `h('section'` **之后**,且全文件只有一个 `role: 'tooltip'`
- 显隐是 `.show` 状态类;定位在布局效应里做且读取真实 `offsetHeight`
- 浮层随弹层关闭、随面板切换收起
---
## 第 13 条 · 滑条在最右端时按住拖不动
### 现象
滑条已经在最右端时,按住旋钮往左拖,**偶尔完全不动**;换个位置按又能拖。最右端最容易复现。
### 根因
旋钮可见直径 **30px**,轨道高度只有 **26px**,所以旋钮上下各有 **2px** 悬在轨道外;
而旋钮当时是 `pointer-events:none`。在悬出来的那 2px 上按下,命中测试落在**面板**上,
`pointerdown` 从未到达轨道 —— 手势根本没开始。真实浏览器逐点实测(右端、按住后左移 60px):
```
STUCK knob top edge pos 1 -> 1 hit=cmes-pane cmes-main
STUCK knob bottom edge pos 1 -> 1 hit=cmes-pane cmes-main
SLIDES knob centre pos 1 -> 0.33 hit=cmes-rail
```
右端之所以最容易中招:轨道右端的填充已经铺满,旋钮就是那一段**唯一的抓手**,指尖稍偏就落在死区。
### 修法
1.1.0 先试过「让旋钮可命中 + 加抓取带」两层。**前者是错的**,见第 17 条:旋钮一旦可命中,
拖动时它会从光标下面移走,事件目标就跟着变,反而制造了新的中断。
最终形态只有一条:把命中范围**长在轨道自己身上**。
```
.cmes-rail::before{content:"";position:absolute;inset:-6px -3px;border-radius:20px}
```
抓取带是轨道的盒,命中测试返回轨道本身;它上下各覆盖 6px,把旋钮悬出的 2px 完全包住。
旋钮保持 `pointer-events:none` —— 于是**整段手势始终落在同一个目标(轨道)上**。
### 断言
抓取带存在、`pointer-events:none` 仍在旋钮上、`nearestIndex` 对非有限数有保护。
浏览器逐点复测右端 8 个抓取点(旋钮中心 / 上下边缘 / 轨道上下悬出 / 轨道右边缘 / 旋钮右边缘 /
轨道左侧)全部可拖,且命中目标全部是 `cmes-rail`。
---
## 第 14 条 · 无坐标的释放把旋钮甩出轨道
### 现象
(潜在缺陷)某些释放路径下旋钮会瞬间跳到轨道外面。
### 根因
`finish()` 用 `nearestIndex(event ? event.clientX : NaN)`。没有坐标时 `NaN` 一路穿过
`fractionAt` → `nearestIndex` → `stopFraction` → `--${P}-pos: NaN`,
而 `left:calc((100% - 30px)*NaN)` 是**非法值**,声明被丢弃,旋钮退回 `left:auto`。
第 13 条的窗口级兜底正好会调用「无事件」的 `finish()`,所以这条必须先修。
### 修法
无坐标时解析到**当前档位**(`known ? nearestIndex(event.clientX) : index`),
`nearestIndex` 也加了 `Number.isFinite` 保护。
### 断言
`finish` 里有 `Number.isFinite(event.clientX)`、回落表达式、且**去掉注释后**函数体里不再出现 `NaN`。
---
## 第 15 条 · 悬停刻度点不高亮
### 现象
(潜在缺陷)指针掠过某个刻度点时,本该放大的刻度点毫无反应。
### 根因
`pointerMove` 的非拖动分支把**事件对象**传给了只接受坐标的函数:
```js
const i = nearestIndex(event); // 应该是 event.clientX
```
`(eventObj - box.left - 15)` 是 `NaN`,于是 `tickX` 是 `NaN`、比较恒假,`setHovered(-1)` 永远成立。
### 修法
改传 `event.clientX`。
### 断言
源码里存在 `nearestIndex(event.clientX)`,且**不存在** `nearestIndex(event)`。
---
## 第 16 条 · 升级 0.2.1 后模型名上的徽标消失
### 现象
DSH 从 `0.2.0-rc.2` 升到 `0.2.1-rc.2` 后,被装饰过的模型名(如内置「1017」徽标)不再出现,
**控制台没有任何报错**。
### 根因
0.2.1 的席位条目要**声明自己的子槽位**,而且子槽位只在**声明它的那一条挂载期间**存在:
```js
// 官方 dsh-client-ui-model-selection:
scope.slots.register({
name: 'conversation.input.model',
children: { 'conversation.input.model.label': { kind:'single', scope:'session', inline:true } },
inject: …
}, ModelSelect);
```
内置 `dsh-client-ui-deepseek-model-label-1017` 通过 `ctx.slots.inject('conversation.input.model.label', …)`
往这个子槽位注册。本插件以 `priority:-1` 遮蔽了父席位却没声明子槽位 → 子槽位不存在 →
`slots.inject` 的回调**永远不会被调用**,装饰静默消失。
### 修法
遮蔽时**声明同样的子槽位**(`{kind:'single', scope:'session', inline:true}`),并把它**渲染出来**
(trigger 用 `placement:'trigger'`、模型行用 `placement:'option'`,无贡献时回退到目录模型名)。
声明 children 后渲染器才会把 `renderSlot` 注入组件;0.2.0 没有这个绑定,调用处做了条件化。
### 断言
`test/slots-integration.mjs` 从随包发布的官方源码里**读出**官方声明的子槽位集合,
与本插件声明的集合逐一比对,并核对 `kind/scope/inline` 三元组与两处 `renderSlot` 调用;
0.2.1 不再单独发布 `@deepseek-ai/dsh-client-ui-slots`,因此「实例化真实 SlotCore」的两条断言自动 skip,
只有在仍能读到该包的环境里才执行。
---
## 第 17 条 · 拖动到一半「变成无法选中」
### 现象
按住旋钮拖动,拖到中途滑条突然不再跟手、松手也不提交,看起来像「从按住状态掉成了无法选中」。
1.1.0 修完第 13 条后**反而更频繁**,而且**任意位置**都可能出现。
### 根因
两个原因叠加,第二个是 1.1.0 自己引入的。
1. **手势的所有权押在了指针捕获上。** 代码在 `pointerdown` 里 `setPointerCapture`,之后靠轨道上的
`onPointerMove` 跟手。可捕获并不牢靠:捕获元素被替换、按下触发**原生拖拽或文字选择**、
指针移出窗口、浏览器决定接管手势 —— 都会派发 `pointercancel` / `lostpointercapture`。
一旦如此,轨道再也收不到 `pointermove`,拖动**当场卡死**;而 `lostpointercapture`
当时被显式忽略("capture loss is not a cancel"),于是既不结算也不跟手。
2. **1.1.0 把旋钮改成了可命中元素。** 拖动时旋钮会跟着数值**从光标下面移走**,
于是指针下的元素在「旋钮 / 填充 / 轨道 / 面板」之间不断切换。捕获正常时看不出问题,
一旦捕获丢失(原因见上),事件就落到别的元素上 —— 这正是「任意位置都会断」的来源。
### 修法
把手势的**所有权从「轨道 + 指针捕获」搬到 document**,并让事件目标固定:
```js
// 拖动期间:document 捕获阶段监听,捕获丢了也照样跟手
document.addEventListener('pointermove', move, true);
document.addEventListener('pointerup', up, true);
document.addEventListener('pointercancel', cancel, true);
window.addEventListener('blur', blur); // 窗口外松手也能结算
```
- 按下时 `event.preventDefault()`,掐掉会引发 `pointercancel` 的原生拖拽/选择默认行为;
- 只认发起手势的 `pointerId`(`owns(e)`),多指不会互相打断;
- 轨道的 `onPointerMove` 退化为**只做悬停高亮**(`if (dragRef.current) return`),
拖动分支只由 document 那一处执行 —— 每个 `pointermove` 恰好应用一次;
- 指针捕获降级为「顺手取一下」,不再承担正确性;
- 旋钮恢复 `pointer-events:none`,抓取带(第 13 条)继续覆盖它悬出的部分 ——
整段手势的目标恒定是轨道。
### 断言
document 三个监听 + `blur` 兜底存在、`pointerId` 所有权判断存在、`preventDefault()` 存在、
轨道 handler 在拖动时早退、旋钮仍不可命中。
浏览器实测(伪造最坏情况):拖动中途强制 `releasePointerCapture` 后再移动,
滑条仍逐像素跟手,拖到轨道下方 220px 再拖回来也正常,松手正确提交。
---
## 第 18 条 · 按住时的柔光环(设计变更)
### 现象
按住旋钮时,除了放大,还有一圈向外扩散的柔光环。
### 处理
按需求**去掉光环、只保留放大**:删除 `box-shadow: 0 0 0 7px …` 那层外扩,
按压只剩 `transform:scale(1.16)`,松手沿回弹曲线回位。
### 断言
按住时的 `box-shadow` 必须与静止时**逐像素相同**(实测:两串计算值完全一致),
且源码里不得再出现 `pressRing` 或按压态的 `box-shadow`。
---
## 第 1 条 · 点一个分组,全部一起展开
### 现象
点一个**折叠着**的 provider 分组,**所有**分组同时展开;再点一次又全部收起。
### 根因
旧实现用一个 `Set` 保存折叠状态,但两半的读法**互相矛盾**:
```js
// 写入侧:把 Set 当作「已展开的 id」集合
if (cur) next.add(gid); // 展开 -> 加入
else next.delete(gid); // 收起 -> 移除
// 读取侧:把同一个 Set 当作「已收起的 id」集合
return !collapsedIds.has(gid); // 不在集合里 == 展开
```
第一次点击一个折叠分组时:写入侧算出「当前是折叠 → 应展开」,执行 `next.add(gid)`;读取侧把「在集合里」解释成「已收起」,于是对刚加进去的那个 id 返回 `false`(收起),对其余所有 id 返回 `true`(展开)。集合里只有一个 id,读取侧就认为「只有一个分组是收起的,其他都是展开的」—— **全部展开**。
这个 bug 的形状不是「状态错了」,而是**同一个状态被两种语义读取**。任何「再补一个字段」「再加一次同步」的修法都只会加深矛盾。
### 修法
不再用折叠集合,改为参照 [Bai-2008/a-better-dsh-codex-effort-slider](https://github.com/Bai-2008/a-better-dsh-codex-effort-slider) 的**两级下钻**:一个 `view` 页码 + 一个 `subProvider` id。
```js
const [view, setView] = useState(1); // 1 = provider 列表,2 = 某 provider 的模型
const [subProvider, setSubProvider] = useState(null);
function enterProvider(gid) { setSubProvider(gid); setQuery(''); setHighlight(0); setView(2); }
function goBack() { setView(1); setSubProvider(null); setQuery(''); setHighlight(0); }
```
「哪个分组是打开的」现在由 `view === 2 && subProvider === gid` 单点定义,而不是由某个集合的成员关系**推断**出来。两半不可能再对同一个状态有两种解释。
### 断言
`test/collapse-regression.mjs` 里**同时实现了有 bug 与修好后的两个状态机**:
```js
check('the buggy model really does expand everything on one click', () => {
const m = buggy('A');
assert.equal(m.isExpanded('B'), false, 'B starts collapsed');
m.toggle('B'); // 用户点击 COLLAPSED 分组
const opened = m.groups.filter(m.isExpanded);
assert.deepEqual(opened, ['A', 'B', 'C'], 'buggy model opens all three');
});
check('the fixed model opens exactly one provider', () => {
const m = fixed('A');
assert.deepEqual(m.groups.filter(m.isOpen), [], 'nothing is drilled in initially');
m.enter('B');
assert.deepEqual(m.groups.filter(m.isOpen), ['B'], 'exactly B, not all three');
});
```
先证明有 bug 的版本确实会「一次全开」,再证明修好后的版本不会 —— 这样这个 bug 的**形状**不可能再悄悄回来。另外四条断言源码里反转读法与旧标识符已彻底消失:`!collapsedIds\.has\(`、`collapsedIds`、`toggleGroup`、`next\.add\(gid\)`。`verify.mjs` 从另一侧钉住同一个不变量:`assert.doesNotMatch(src, /!collapsedIds\.has/)` 与 `assert.match(src, /if \(view !== 2 \|\| !activeProvider\)/)`。
---
## 第 2 条 · 界面「从上面收回」
### 现象
打开弹层 → 切到模型列表(面板变高)→ 关闭 → 重新打开。弹层高度从**列表高度**动画回 104px,视觉上是「界面从上面收回」。同时下钻状态残留:重开后还在某个 provider 的模型列表里。
### 根因
`close()` 只关了 `open`,没有重置 `pane`:
```js
function close() {
cancelDrag();
setOpen(false);
setQuery('');
/* 这里少了 pane 与下钻状态的重置 */
}
```
popover 高度由 `--cmes-height` 驱动,值是 `pane === 'models' ? cappedHeight : 104`。`pane` 停留在 `'models'`,重开时 CSS 的 `transition:height .2s cubic-bezier(.2,.8,.2,1)` 就把高度从列表高度动画到 104px —— 一段用户没要求过的动画。
### 修法
`close()` 复位全部视野状态,与官方行为一致(官方总是从强度面板打开):
```js
setPane('effort'); setView(1); setSubProvider(null); setHighlight(0);
triggerRef.current?.focus({ preventScroll: true });
```
`setPane('effort')` 让 `--cmes-height` 在下一次打开时**已经是** 104px,没有可动画的差值;`setView(1)` / `setSubProvider(null)` 清掉下钻残留。
### 断言
`verify.mjs` 从 `close()` 的函数体里取出源码再断言:
```js
const closeFn = src.match(/function close\(\) \{([\s\S]*?)\n \}/);
assert.match(closeFn[1], /setView\(1\)/, 'view reset');
assert.match(closeFn[1], /setSubProvider\(null\)/, 'sub-provider reset');
```
高度公式本身也被断言:`assert.match(src, /\$\{pane === 'models' \? cappedHeight : 104\}px/)`。
---
## 第 3 条 · 只有 Ultra 有特殊颜色
### 现象
滑条在轻度 / 中 / 高 / 极高四档外观完全相同;只有 Ultra 换色。收起态的席按钮同样只有 Ultra 才变色,因此点开菜单前看不出强度。
### 根因
填充与标题是**写死的单色**(`#3a83f7`),Ultra 走一条独立的 `.peak` 分支。颜色不是从档位派生的,所以档位本身没有视觉区分度。
### 修法
引入按档位派生的色板,填充 / 轨道底色 / 标题 ink / 刻度点 / 收起态按钮**全部**从当前档位派生:
```js
style: {
[`--${P}-fill`]: fillFor(displayed),
[`--${P}-track`]: mixHex('#e8e7e8', fillFor(displayed), 0.22),
[`--${P}-ink`]: inkFor(displayed),
[`--${P}-dot`]: 'var(--dsw-alias-label-caption,#b6b6b8)',
[`--${P}-pos`]: fraction
}
```
轨道底色用 `mixHex` 把未填充段朝当前档位的填充色混 22%,因此轨道本身也读得出「现在是哪一档」。排序轴是**色相**(蓝 `216.6°` → 紫罗兰 `263.9°` 单调推进),而不是亮度 —— 见 [ARCHITECTURE.md 的色彩一节](./ARCHITECTURE.md#色彩)。
### 断言
`palette.mjs` 从源码里读出真实色值再算:
```js
check('every fill is visible against the unfilled rail (>= 2.5)', () => {
for (const k of LEVELS) assert.ok(cr(FILL[k], RAIL) >= 2.5, `${k} vs rail < 2.5`);
});
check('hue advances monotonically blue -> violet (the real ordering axis)', () => { /* ... */ });
check('every level has a DISTINCT fill (no accidental duplicates)', () => { /* ... */ });
```
`verify.mjs` 断言五个实测色值仍在源码里:
```js
for (const c of ['#3a83f7', '#3b83f6', '#a779fe', '#7448ee', '#8b46ee']) {
assert.ok(src.includes(c), `missing ${c}`);
}
```
---
## 第 4 条 · ⚡ 的说明浮层被遮挡
### 现象
悬停 ⚡ 按钮时,说明浮层(「1.5 倍速 / 用量更多 / (仅视觉效果)」)被弹层裁掉一部分。
### 根因
浮层是弹层的**子元素**,而弹层有 `overflow:hidden`(为了圆角与面板滑入动画必须保留)。浮层定位在按钮上方、超出弹层边界,于是被裁。
### 修法
改为 `position:fixed` 的**视口层**,由 JS 动态定位到按钮上方并夹进视口:
```css
.cmes-tip{position:fixed;z-index:1400;padding:6px 9px;white-space:nowrap;
background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2));
box-shadow:var(--dsw-elevation-panel);border-radius:8px;
font-size:12px;line-height:18px;text-align:center;
color:var(--dsw-alias-label-primary);
opacity:0;visibility:hidden;pointer-events:none;
transition:opacity .12s ease,visibility 0s .12s}
```
```js
function positionTip(event) {
const tip = tipRef.current;
const btn = event?.currentTarget ?? fastBtnRef.current;
if (!tip || !btn) return;
const r = btn.getBoundingClientRect();
const tw = tip.offsetWidth || 160;
const left = Math.max(8, Math.min(r.left + r.width / 2 - tw / 2, window.innerWidth - tw - 8));
tip.style.left = `${left}px`;
tip.style.top = `${Math.max(8, r.top - (tip.offsetHeight || 56) - 6)}px`;
}
```
左右都被夹进视口(`Math.max(8, Math.min(..., innerWidth - tw - 8))`),上方同样 `Math.max(8, ...)`,所以即使按钮贴着窗口顶边,浮层也不会跑到屏幕外。
显示/隐藏是**纯 CSS** 驱动的,不需要 JS 悬停状态(`.cmes-fast:hover [role=tooltip]` 与
`.cmes-fast:focus-visible [role=tooltip]` 都置 `opacity:1;visibility:visible`)。
`transition` 里 `visibility 0s .12s` 是刻意的:淡出时延迟 120ms 再切 `visibility`,避免浮层在淡出过程中就消失。
### 断言
`positionTip` 挂在按钮的 `onMouseEnter` 与 `onFocus` 两处,键盘用户同样能看到。`make-preview.mjs` 把浮层强制显示出来做视觉对照:
```js
${effortPane(2).replace('class="' + P + '-tip"',
'class="' + P + '-tip" style="opacity:1;visibility:visible;left:60px;top:-4px"')}
```
---
## 第 5 条 · 拖动不平滑、不跟手
### 现象
按住旋钮拖动时,旋钮一格一格跳,不跟指针走。
### 根因
拖动期间每次 `pointermove` 都直接吸附到最近的**离散档位**,所以 218px 的行程上只有 N−1 个可停位置。指针在两个档位之间移动时,旋钮不动。
### 修法
拖动期间写**原始小数**(连续跟手),松手才吸附:
```js
function paintLive(frac) {
const rail = railRef.current;
if (!rail) return;
rail.style.setProperty(`--${P}-pos`, String(frac));
}
// pointerMove 的拖动分支
const frac = fractionAt(event.clientX);
liveRef.current = frac;
paintLive(frac); // 连续跟手,不吸附
const i = Math.round(frac * (efforts.length - 1));
const id = efforts[i]?.id;
if (id && id !== previewRef.current) showEffort(id);
```
吸附只发生在 `finish` 里(见第 9 条)。拖动期间还要关掉 CSS 过渡,否则会拖出橡皮筋感:
```css
.cmes-rail.dragging .cmes-knob{transition:none}
.cmes-rail.dragging .cmes-fill{transition:none}
.cmes-rail.dragging{cursor:grabbing}
```
### 断言
`verify.mjs` 断言 `paintLive` 写的是原始分数、`pointerMove` 在拖动分支里调它、吸附只在 `finish` 里发生:
```js
assert.match(src, /function paintLive\(frac\)[\s\S]*?setProperty\(`--\$\{P\}-pos`, String\(frac\)\)/);
assert.match(src, /if \(dragRef\.current\) \{[\s\S]*?paintLive\(frac\)/, 'move tracks pointer');
assert.match(src, /function finish\(event\)[\s\S]*?nearestIndex\(event \? event\.clientX : NaN\)/);
```
---
## 第 6 条 · 有时无法选中(三个独立缺陷)
### 现象
滑条**有时候**拖不动、点了没反应;快速拖完松手后,界面显示的档位从未真正提交。间歇性出现,难以稳定复现。
### 根因
三个独立缺陷叠加,任何一个都足以造成「有时」:
**6a · `setPointerCapture` 抛错中断了 `pointerdown`。** `dragRef` 在它**之后**才设置,所以一旦抛错就完全拖不动:
```js
// 旧代码
event.currentTarget.setPointerCapture(event.pointerId); // 可能抛
dragRef.current = true; // 永远到不了这里
```
**6b · `onLostPointerCapture` 误调 `cancelDrag`。** 指针捕获丢失被当成取消,于是回滚而非提交 —— 用户明明选好了,结果弹回原位。
**6c · 在途提交被 `busyRef` 静默丢弃。** 快速拖完松手时,若上一次提交还在跑,这一次就被丢掉:
```js
// 旧代码
if (busyRef.current) return; // 这一行把「最后一次选择」吃掉了
```
界面显示的是 `preview`,但 `select()` 从未收到那个档位。
### 修法
**6a** 用 `try/catch` 兜住捕获,无论成败都继续:
```js
/* BUG FIX (issue 6): capture can throw if the pointer is already gone
* (or the element was re-rendered); an uncaught throw here aborted the
* handler *before* dragRef was set, which is exactly the "sometimes I
* cannot grab it" symptom. Guard it and keep going. */
try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* non-fatal */ }
dragRef.current = true;
```
**6b** 把捕获丢失处理成空操作,注释直接写明语义:
```js
onLostPointerCapture: () => { /* capture loss is not a cancel */ },
```
真正的取消只有两个来源:`onPointerCancel` → `cancelDrag`,以及 `close()` 的第一句。
**6c** 改为**合并**:记住最新意图,当前请求结束后继续提交,因此「最后一次选择必定落地」:
```js
pendingRef.current = next;
if (busyRef.current) return; // 只有第一个请求跑循环
busyRef.current = true;
try {
while (pendingRef.current) { // 排队的意图一定会被排空
const want = pendingRef.current;
pendingRef.current = null;
const result = await select(want);
if (result && !result.ok) throw new Error(result.error?.message || t('failed'));
if (result === undefined) throw new Error(t('unavailable'));
}
} catch (cause) { /* ... */ }
```
### 断言
```js
check('in-flight commits coalesce instead of being dropped', () => {
assert.match(src, /pendingRef\.current = next;/, 'newest intent remembered');
assert.match(src, /if \(busyRef\.current\) return;/, 'only the first runs the loop');
assert.match(src, /while \(pendingRef\.current\)/, 'queued intent is drained');
});
```
6b 由源码文本直接核对(`onLostPointerCapture: () => { /* capture loss is not a cancel */ }`)。
### 附带 · 6d 与 6e(自查发现,与 6 号同源)
**6d · `pointerDown` 用 `busyRef` 拦了一道。** 提交期间整条轨道变成不可抓,这正是「有时候拖不动」的另一半原因。提交既然会合并,就没有理由拦住新的拖动。现改为只以「真正不可交互」为条件设闸:
```js
function pointerDown(event) {
/* Deliberately NOT gated on `busyRef`. An in-flight commit must never
* make the rail ungrabbable — that was a large part of the "sometimes
* I cannot grab it" symptom. Commits coalesce, so a drag started during
* one simply supersedes it. Only a genuinely non-interactive rail
* (disabled session / single level) refuses the gesture. */
if (disabled || efforts.length < 2) return;
/* ... */
}
```
断言方式比较特别 —— 它从源码里**取出 `pointerDown` 的函数体**再断言里面不含 `busyRef.current`:
```js
const pd = src.match(/function pointerDown\(event\) \{([\s\S]*?)\n \}/);
assert.doesNotMatch(pd[1], /busyRef\.current/, 'pointerDown must not gate on busyRef');
```
注意这是**局部**断言:整个文件里 `busyRef.current` 当然存在(`commit()` 用它),只有 `pointerDown` 里不能有。全局断言做不到这一点。
**6e · 拖动时写像素值,React 写 `calc()`。** 旧实现把**像素值**写进两个变量(`--cmes-knob-x`、`--cmes-w`),而 React 渲染时写的是 `calc()`。两者是同一个属性,React 可能不会重新写回,**旋钮会卡在拖到的位置不吸附**。现改为只驱动**一个无单位的 `--cmes-pos` 分数**(0..1):
```js
/* one unitless fraction drives both knob and fill */
[`--${P}-pos`]: fraction
```
```js
assert.doesNotMatch(src, /--\$\{P\}-knob-x/, 'knob-x pixel var must be gone');
assert.doesNotMatch(src, /--\$\{P\}-w,/, 'fill width pixel var must be gone');
```
---
## 第 7 条 · 看不出当前模型在哪个分组
### 现象
模型列表第一级是纯 provider 名,看不出当前模型住在哪一组,用户得逐个点进去找。
### 根因
第一级没有活动标记。而在两级下钻里,**下钻到哪个 provider** 与**当前模型属于哪个 provider** 是两件不同的事 —— 前者是临时导航状态,后者是持久选择状态。
### 修法
加一个蓝色小圆点,**按 `selection.provider` 判定**(不是按下钻状态):
```js
const isCurrent = selection?.provider === g.id;
h('span', { className: `${P}-rowDot`, hidden: !isCurrent, 'aria-hidden': true })
```
```css
.cmes-rowDot{flex:none;width:6px;height:6px;border-radius:50%;background:#3a83f7;
box-shadow:0 0 0 3px rgba(58,131,247,.16)}
```
用 `hidden` 属性而不是条件渲染,行高不会因为标记有无而变化。蓝点 `aria-hidden` —— 它表达的信息在第二级的勾选标记里已经有了,屏幕阅读器不必读两遍。
### 断言
```js
check('ISSUE 1: the active provider is marked with a blue dot', () => {
assert.match(src, /\$\{P\}-rowDot\{[^}]*border-radius:50%;background:#3a83f7/, 'dot style');
assert.match(src, /className: `\$\{P\}-rowDot`, hidden: !isCurrent/, 'dot only on the current provider');
// keyed off the SELECTION, not off which provider is drilled into
assert.match(src, /const isCurrent = selection\?\.provider === g\.id;/, 'keyed on selection');
});
```
「按 selection 判定」被单独断言,因为按下钻状态判定看起来也「能用」—— 用户下钻时那个点会跟着走,直到他发现关掉再打开时点跑到了错的地方。位置断言见第 11 条。
---
## 第 8 条 · 最底下的分组被遮挡、滚轮没反应
### 现象
provider 多到装不下时,**最后一行被裁掉**,且滚轮没有任何反应 —— 那个分组永远点不到。
### 根因
三个问题叠在一起:
**8a · `.cmes-pane` 用 `bottom:0` 定位但没有 `top`。** 高度是内容撑的:
```css
/* 旧代码 */
.cmes-pane{position:absolute;left:0;right:0;bottom:0;/* 没有 top */}
```
于是 `.cmes-models{height:100%}` 解析成 `auto`,**整条 flex 链失去确定高度**,`min-height:0` 无从生效,内部滚动容器分不到可用空间。
**8b · 第一级(provider 列表)根本没有滚动容器。** 页面被 `overflow:hidden` 裁掉后滚不回来。
**8c · 弹层高度按内容算**,长列表会超过窗口可用空间。
### 修法
给 pane 钉 `top:0` 拿到确定高度:
```css
/* Issue 2 fix: the pane is anchored with bottom:0 but no top, so its height is
content-driven. That made the models pane's height:100% resolve to auto, the
whole flex chain lost its definite height, and a long provider list simply
overflowed the popover and got clipped with nothing to scroll. Pinning top:0
gives the pane a definite height so min-height:0 on the descendants can
actually hand the leftover space to the row list. */
.cmes-models{top:0;height:100%;padding:10px;overflow:hidden;
display:flex;flex-direction:column}
```
每一页做成 `height:100%` 的 flex 列,行列表加 `flex:1 1 auto; min-height:0; overflow-y:auto`:
```css
.cmes-viewport{position:relative;overflow:hidden;
transition:height .3s cubic-bezier(.32,.72,.28,1);flex:1 1 auto;min-height:0}
.cmes-page{position:absolute;top:0;left:0;width:100%;height:100%;
display:flex;flex-direction:column;overflow:hidden;
transition:transform .3s cubic-bezier(.32,.72,.28,1)}
.cmes-rows{flex:1 1 auto;min-height:0;overflow-y:auto;
overscroll-behavior:contain;padding:0 2px 4px}
```
`overscroll-behavior:contain` 防止滚到边界后把滚动传给宿主页面。弹层高度夹到触发器上方的可用空间,保证滚动容器**始终有高度可分**:
```js
const roomAbove = typeof window === 'undefined'
? Infinity
: Math.max(120, window.innerHeight - bottomVar - 24);
const cappedHeight = Math.min(menuHeight, roomAbove);
```
`Math.max(120, ...)` 是下限:即使窗口很矮,也留 120px 而不是压到 0。
### 断言
```js
check('ISSUE 2: every page list is its own scroller', () => {
assert.match(src, /\$\{P\}-models\{top:0;height:100%/, 'pane pinned top:0 for a definite height');
assert.match(src, /\$\{P\}-viewport\{[^}]*min-height:0/, 'viewport min-height:0');
assert.match(src, /\$\{P\}-page\{[^}]*height:100%;display:flex;flex-direction:column;overflow:hidden/);
assert.match(src, /\$\{P\}-rows\{flex:1 1 auto;min-height:0;overflow-y:auto/, 'page-1 scroller');
assert.match(src, /h\('div', \{ className: `\$\{P\}-rows` \}, \.\.\.providerNodes, \.\.\.noticeNodes\)/);
assert.match(src, /const cappedHeight = Math\.min\(menuHeight, roomAbove\)/, 'height is capped');
assert.match(src, /\$\{pane === 'models' \? cappedHeight : 104\}px/, 'capped height applied');
});
```
**高度常量也在这个 bug 的因果链里**:行高常量比真实值小 3px 时,第一级最后一行会被 `overflow:hidden` 裁掉。所以 `verify.mjs` 断言常量与 CSS 声明必须一致:
```js
assert.match(src, /const ROW_H = 41, MODEL_H = 39, LIST_MAX_H = 252, LIST_PAD_B = 4;/, 'row constants');
assert.match(src, /\$\{P\}-row\{[^}]*padding:10px;border-radius:10px/, 'row padding 10 (=> 41 high)');
assert.match(src, /\$\{P\}-model\{[^}]*padding:9px 10px;border-radius:9px/, 'model padding 9 (=> 39 high)');
assert.match(src, /\$\{P\}-list\{[^}]*max-height:252px/, 'list cap matches');
```
---
## 第 9 条 · 滑条能停在任意位置
### 现象
松手后旋钮**停在手指松开的位置**,不回落到最近的档位点。不是每次都出现 —— 只在「松手落到的档位 == 拖动时最后预览的档位」时出现。
### 根因
拖动时 `paintLive` 把 `--cmes-pos` 写成**原始小数**。松手落到的那一档如果**恰好等于拖动时最后预览的那一档**:
1. `chooseEffort(id)` 里 `if (id !== savedEffort) void commit(...)` —— `preview` 已经是这个 id,`showEffort(id)` 里 `setPreview(id)` 的值没变;
2. React 对**相同的 state 值**会 **bail out**(跳过重渲染);
3. 于是没有任何代码把 `--cmes-pos` 从原始小数改回吸附分数;
4. 旋钮就停在手指松开的位置。
这是最容易漏的一类 bug:**逻辑是对的,视觉是错的**,因为「视觉更新」被委托给了「React 会重渲染」这个并不成立的前提。
### 修法
`snapLive(i)` 在**释放路径**里显式写回吸附分数,不依赖 React 一定会重渲染:
```js
/** Force the knob onto a stop, bypassing React's bail-out. */
function snapLive(i) {
const rail = railRef.current;
if (!rail) return;
rail.style.setProperty(`--${P}-pos`, String(stopFraction(i)));
}
```
三条释放路径都调用它:
```js
function finish(event) {
if (!dragRef.current) return;
dragRef.current = false; setDragging(false); liveRef.current = null;
const i = nearestIndex(event ? event.clientX : NaN);
snapLive(i); // 先钉住旋钮
const target = efforts[i] ?? efforts[index];
if (target) chooseEffort(target.id);
}
function cancelDrag() {
if (!dragRef.current) return;
/* ... */
snapLive(index); // 回滚到「已提交」的档位,不是原始像素位置
}
function keyDown(event) {
/* ...算出 i... */
snapLive(i); // 键盘移动本来就落在档位上,同样显式写回
const target = efforts[i];
if (target) chooseEffort(target.id);
}
```
`cancelDrag` 回滚到的是**已提交的档位** `index`,而不是拖动开始前的原始像素位置 —— 取消应该是「什么都没发生」,而不是「回到半个档位」。`--cmes-pos` 是单一的无单位分数变量(见第 6e 条),旋钮与填充都从它派生,所以一次写回同时修正两者。
### 断言
```js
check('ISSUE 3: the knob always settles on a stop on release', () => {
assert.match(src, /function snapLive\(i\)[\s\S]*?setProperty\(`--\$\{P\}-pos`, String\(stopFraction\(i\)\)\)/);
assert.match(src.match(/function finish\(event\) \{([\s\S]*?)\n \}/)[1], /snapLive\(i\)/, 'finish snaps');
assert.match(src.match(/function cancelDrag\(\) \{([\s\S]*?)\n \}/)[1], /snapLive\(index\)/, 'cancel rolls back');
assert.match(src.match(/function keyDown\(event\) \{([\s\S]*?)\n \}/)[1], /snapLive\(i\)/, 'keyboard snaps');
assert.match(src, /function stopFraction\(i\)/, 'stop fraction helper');
});
```
三条路径**分别**断言,因为修好其中一条并不能保证另外两条也修好了 —— 而键盘路径最容易被忘掉(它本来就在档位上,看起来不需要吸附)。
---
## 第 10 条 · 第一级每行右侧的灰色模型名是冗余的
### 现象
第一级 provider 行的右侧有一列灰色文字,显示「该 provider 当前选中的模型名」。但它和雪弗龙挤在一起,读起来像第二个标签,而且信息重复。
### 根因
第二级已经完整列出该 provider 的模型并标勾选,第一级再显示一遍是**信息冗余**。冗余本身还不是最大的问题:它占据的正是行右侧那个「状态位」,与蓝点争位置。
### 修法
**移除**右侧灰色文字。第一级只留 provider 名 + 蓝点 + 雪弗龙:
```js
h('span', { className: `${P}-rowName` }, g.name),
h('span', { className: `${P}-rowDot`, hidden: !isCurrent, 'aria-hidden': true }),
icon('chevron', `${P}-rowChev`)
```
### 断言
删除的是一整个 CSS 类(`.cmes-rowCur`)与它的渲染节点,所以断言方式是**断言它不存在**:
```js
check('page 1 rows show the provider name + chevron (no gray model text)', () => {
assert.doesNotMatch(src, /\$\{P\}-rowCur/, 'current-model column removed');
assert.doesNotMatch(src, /\$\{P\}-rowCur\{/, 'current-model style removed');
assert.match(src, /\$\{P\}-rowChev/, 'chevron');
});
```
「名字吸收剩余空间」是这次移除的**前提**:只有名字 `flex:1`,蓝点和雪弗龙才会被一起推到右边缘。
```js
assert.match(src, /\$\{P\}-rowName\{flex:1 1 auto;min-width:0;overflow:hidden/, 'name fills the row');
assert.match(src, /aria-expanded': view === 2 && subProvider === g\.id/, 'expanded state');
```
---
## 第 11 条 · 蓝点在 provider 名左侧
### 现象
蓝点渲染在 provider 名的**左侧**,与「状态在右」的阅读习惯相反 —— 眼睛在行的右侧找状态,结果状态在最左边。
### 根因
渲染顺序决定了位置。旧顺序把标记放在名字之前:
```js
// 旧顺序
h('span', { className: `${P}-rowDot`, ... }),
h('span', { className: `${P}-rowName` }, g.name),
icon('chevron', `${P}-rowChev`)
```
### 修法
蓝点移到行**右侧**:名字之后、雪弗龙之前。渲染顺序就是第 10 条修法里给出的那一份(`rowName` → `rowDot` → `rowChev`),名字 `flex:1` 把蓝点和雪弗龙一起推到右边缘。
### 断言
这条断言**不读 CSS,读的是渲染顺序** —— 它从源码里取出 `providerNodes` 的构造块,再比较三个类名第一次出现的下标:
```js
const row = src.match(/const providerNodes = groups\.map\(g => \{[\s\S]*?\n \}\);/);
const nameAt = row[0].indexOf('${P}-rowName');
const dotAt = row[0].indexOf('${P}-rowDot');
const chevAt = row[0].indexOf('${P}-rowChev');
assert.ok(nameAt < dotAt, 'dot comes AFTER the name (right side)');
assert.ok(dotAt < chevAt, 'dot comes BEFORE the chevron');
```
顺序断言是必要的:CSS 只描述样式,**位置由 JSX 顺序决定**,读 CSS 永远查不出这个问题。而 `.cmes-row` 是 `justify-content:space-between`,即使顺序错了外观也「像那么回事」—— 只是点跑到了左边。
---
## 关于回归测试的一条设计说明
`test/collapse-regression.mjs` **同时实现了有 bug 与修好后的两个状态机**:
```js
/* ---------- the BUGGY model, kept to prove the failure is real ---------- */
function buggy(selected = 'A') {
const groups = ['A', 'B', 'C'];
let expanded = null; // null = default; else a Set
const isExpanded = gid => expanded === null ? gid === selected : !expanded.has(gid);
const toggle = gid => {
const base = expanded ? new Set(expanded) : null;
const cur = base ? !base.has(gid) : (gid === selected);
const next = new Set(base ?? []);
if (cur) next.add(gid); else next.delete(gid);
expanded = next;
};
return { groups, isExpanded, toggle };
}
/* ---------- the FIXED model (what ships) ---------- */
function fixed(selected = 'A') {
const groups = ['A', 'B', 'C'];
let view = 1; // 1 = provider list, 2 = models of subProvider
let subProvider = null;
const isOpen = gid => view === 2 && subProvider === gid;
const enter = gid => { subProvider = gid; view = 2; };
const back = () => { view = 1; subProvider = null; };
return { groups, isOpen, enter, back };
}
```
**为什么要有 bug 版本。** 只测「修好后的版本不会全开」无法区分两种情形:
| 情形 | 只测好版本的结论 | 同时测 bug 版本的结论 |
|---|---|---|
| 状态机确实修好了 | 通过 | 通过(好版本通过、bug 版本失败) |
| 测试根本没走到那条路径 | **通过**(假阳性) | bug 版本也「通过」→ 暴露测试无效 |
换句话说,bug 版本是**测试自身的对照组**:它必须失败,才能证明这个测试真的能捕获那个失败。这也让「这个 bug 的形状」以可执行的形式留在了仓库里 —— 任何人重构状态机时,只要重新引入「同一个集合被两种语义读取」,第一条断言就会响。配套的四条断言(`fixed` 只开一个、切换 provider 时仍只开一个、`back` 回到列表、源码里反转读法已消失)覆盖了修好后的行为与源码形状两个层面。完整列表见 [ARCHITECTURE.md 的测试台策略](./ARCHITECTURE.md#测试台策略)。