# 设计说明

`dsh-chat-locator` 为什么长成这样：三条实现约束、长度渐变的调参记录、验证状态，以及你在依赖它之前应该知道的边界。

<div align="center">
  <p><strong>语言：</strong> <a href="../../docs/design-notes.md">English</a> | <a href="design-notes.md">简体中文</a></p>
</div>

---

## 📋 目录

- [问题所在](#-问题所在)
- [认领样式，而不是重画](#-认领样式而不是重画)
- [设置住在浏览器里，不住在宿主半侧](#-设置住在浏览器里不住在宿主半侧)
- [只做样式，不做正文](#-只做样式不做正文)
- [长度渐变曲线](#-长度渐变曲线)
- [验证状态](#-验证状态)
- [已知边界](#-已知边界)
- [版本记录](#-版本记录)

---

## 🔍 问题所在

DSH 会沿着对话区右缘画一条轮次轨道 —— 每轮对话一枚刻度。它由 `@deepseek-ai/dsh-client-ui-chat` 的 `ChatView` 直接渲染进对话滚动容器（`TurnNavigator`），Slot 系统没有给它留座位，也没有对外暴露「粗细 / 轨道侧」这类配置。

摆在面前的路有两条：自己重画一条，或者认领已有的那条的样式。本插件走第二条。重画一条就得重新实现内置的跳转轮次、未加载轮分页与活跃轮跟随 —— 这三个功能很容易做错一点点，而用户立刻就会发现。叠加样式则把它们全部原样保住。

代价是耦合。插件必须知道轨道当前用的是哪个类名，而这个类名由构建器生成、随版本变化。所以插件在运行时发现它，而不是写死。

## 🔧 认领样式，而不是重画

`discoverRail()` 在活动 DOM 上按模式发现 CSS Module 的真实类名前缀：找 `[class*="_frame"]`，把它的 `class` 属性按空白切成令牌，保留以 `_frame` 结尾、前缀以字母开头的那个，并要求它含 `.<前缀>_mark` 子节点。DSH 0.2.1-rc.2 里的结构是 `nav.<前缀>_frame > div.<前缀>_scroller > div.<前缀>_marks > button.<前缀>_mark`，预览是 scroller 的兄弟；该构建上的实测前缀是 `xpvNua`，它会随构建变化，所以没有任何东西依赖这个具体值。

`railStyleText(prefix, config)` 再按用户配置生成覆盖规则。每条声明都带 `!important`，这是刻意的：DSH 0.2.1-rc.2 把插件的样式表注入在 head 索引 16，**早于**内置 `ui-chat` TurnNavigator 样式表的索引 115 —— 正是最坏顺序 —— 所以规则不能靠插入顺序取胜。同样的顺序也是进场动画不再重声明内置 `@keyframes` 的原因（同名重声明会输给后注入的那张表）：它改用自有 keyframes 名，并用 `!important` 覆盖 `animation`，于是镜像后的动画与顺序无关，还带自己的一层 `prefers-reduced-motion` 保护。

轨道还没渲染时 —— 不足两轮、其它会话视图 —— 插件不抛错，也不密集轮询。一个挂在 `document.documentElement` 上的 `MutationObserver` 会在每个变更批次醒来，但代价一直很低：重新发现轨道被节流到 400ms，覆盖规则文本则按它的两个输入（发现的前缀与配置快照）做了记忆化，只有这两者真的变化时才重拼那串 CSS —— 绝不会每个变更批次都拼一遍。若某个变更批次正好落在 400ms 门限内，插件会排一次补扫，让在这段窗口里挂上的轨道仍能被发现；定时器在清理时取消，重新挂载则替换旧样式标签而不是叠第二份。

## 💾 设置住在浏览器里，不住在宿主半侧

1.3.0 及以前插件有两个活着的半侧：宿主半侧注册 `chat-locator` schema，浏览器半侧绑定上去，于是设置住在 DSH 设置文档里、换机器也跟着走。DSH 0.1.7 移除了客户端 `settingsScope` 服务，宿主半侧唯一的职责随之消失；1.4.0 起设置由浏览器半侧自己管。

**代价一句话：** 设置撑得过浏览器重启，但不再跨浏览器、跨 profile、跨设备同步；旧版本写进设置文档的值会被忽略 —— 升级后首次打开看到的是出厂值。

1.5.0 起这个 store 收敛成一个很小的策略（`createSettingsPolicy()`），底层是 `@deepseek-ai/dsh-client-store` 的 `createSnapshotStore`，持久化在 `dsh.chat-locator.settings`。有两个行为值得知道，因为它们最初都是 bug：

* **用户层只存与出厂值不同的字段。** 把某项调回出厂值时会删掉那个键。于是「按钮置灰＝各项都已是出厂值」与「本地没有可清的东西」永远等价 —— 否则一个恰好等于默认值的显式键，会被本该清掉它的按钮永远清不掉。
* **每次写入都先复制记录再改。** `set()` 会深冻结入参并交给 zustand，而后者在新旧 state 是同一个引用时直接短路。把 `getSnapshot()` 返回的对象就地改完再 set 回去，既不通知也不落盘 —— 设置看着生效，刷新就回滚。测试桩现在按这个契约建模（冻结 + 同引用短路）并断言它，套件不会悄悄退回成宽松假桩。

默认值、区间、步进、归一化、设置页每一行的控件类型与文案键，全都集中在 `FIELDS` 一张表里；`DEFAULTS`、`normalizeSettings()` 与渲染出的各行都由它派生，加字段只改一处。`reset()` 一次写入清空整份用户层，历史版本留下的键也一并清掉。

### `index.js` 为什么还在

`cordis.patch.yml` 把插件行解析到包入口 `main: ./index.js`，删掉它整行都会挂不上。1.4.0 起它就是一个写明了理由的空桩。只有浏览器半侧是活的，页面刷新即生效。

## 💬 只做样式，不做正文

悬停预览是内置那一张。它的正文完全来自内置的轮次大纲 —— `turnOutline` 投影加上已加载窗口的导航项 —— 只取文本块、折叠空白、按预算加省略号截断，这些都是上游实现的事，不是本插件。**本插件不读轮次正文**：它的 CSS 只决定这份预览显示几行、字号与宽度多大、卡片多高、往哪一侧展开。

卡片高度由字号与行数算出：`26 + 提示词行高 + 正文行高 × 行数`，其中 `正文行高 = round(字号 × 1.5)`、`提示词行高 = round((字号 + 1) × 1.5)`，26px 是上下内边距 10 + 10px、提示词间距 4px 与 2px 余量。默认 12px / 3 行仍是内置的 100px；16px / 4 行 148px，18px / 6 行 217px，10px / 1 行 58px。1.5.0 之前的高度没有计入提示词行高，字号放大后卡片最后一行会被裁掉 —— 实测 18px / 6 行裁掉 7px。

`white-space: normal` 与 `overflow-wrap: anywhere` 才是插件自己出的力：把两个本会复发的失效方式钉死 —— 换行产生的空行，以及横向溢出。

## 📐 长度渐变曲线

指针下的那根刻度最长，两侧依次收回内置宽度：

| 距悬停处 | 0 | ±1 | ±2 | ≥3 |
| :--- | :--- | :--- | :--- | :--- |
| 宽度 | **32px** | 21px | 14px | 12px |
| 每格递减 | — | 11px | 7px | 2px |

这张表出自一条幂曲线：`12 + 20 * (1 - d/3)^2`。两个旋钮各管一件事：`GRADIENT_REACH` 是档数，同时是曲线的分母 —— 少一档会让中间几档一起重算，而不是简单砍掉最后一档；`GRADIENT_CURVE_EXPONENT` 管弯曲程度，`1` 是直线、`2.5` 像断崖。当前是 `3` 与 `2`。

| 幂次 | 邻居宽度（±1、±2） | 结论 |
| :--- | :--- | :--- |
| 2.5 | 24、17 | 首格掉 15px —— 像断崖，不像曲线。 |
| 1.5 | 23、16 | 平缓，但邻居太长，悬停那根不够突出。 |
| **2.0** | **21、14** | 当前这版。首格 11px，落在「有弯但不陡」的 9–12px 区间，悬停那根是邻居的 1.52 倍。 |

### 两个最初的错误

**锚点漏掉了当前轮。** 内置 `TurnMark` 给一根刻度加的是 `markActive` **或** `markPreview` —— 是 `if`/`else`，不会同时有。所以只认 `_markPreview` 的锚点，在你悬停当前轮时什么都匹配不到，整段渐变随之消失。现在锚点是 `:is(.<前缀>_markPreview, .<前缀>_mark:hover)`；邻居选择器仍带 `:not(.<前缀>_markActive)`，所以当前轮**作为邻居**不会被改短 —— 无头 Chrome 实测：悬停它得到 32 / 21 / 14，排除规则完好。

**框架把尖端裁掉了。** 内置框架宽 28px，而里面的滚动容器带 `overflow-y: auto`；按规范另一轴会折算成 `auto`，于是**框架就是刻度的裁剪盒**。超过 28px 的部分被直接裁掉，曲线退化成一截短斜线。插件把框架加宽到「最长刻度 + 4px」（36px）。刻度仍右对齐，位置一点没动，多出来的宽度纯是余量。框架同时也是轨道的点击面，这是唯一的可见副作用 —— 见[已知边界](#-已知边界)。

### 左侧模式：不只是镜像

框架的定位祖先是定位条槽的 padding box，它就等于整个对话区，所以内置的 `right:12px` 可以干净地镜像成 `left:12px`。1.5.0 之前的旧规则反而去减 composer-side-clearance，实测 `left:-20px`，把框架推到对话区之外，在 sticky 布局里被完全裁掉。

除了框架，还有四件事必须一起动：`::before` 移到 `left:0` **并且**把 `transform-origin` 改成 `0 50%`（内置原点是 `100%`，不镜像的话普通刻度会从左边缩进 8px、未加载刻度 12px）；键盘焦点环移到左端；按钮保留整帧宽度作为悬停 / 点击面，所以只移动 `::before`；预览改到轨道右侧展开。进场动画用插件自有的 keyframes 名，配 `animation !important` 与 `prefers-reduced-motion` 保护，因此不依赖样式表顺序。

### 渐变开关

渐变是装饰性的 —— 没有它轨道照样完整工作 —— 所以它有独立一行，不像 1.3.0 删掉的悬停预览开关。关掉后整族宽度规则消失，**框架加宽也一起撤掉**（没有 32px 尖端要画，那条隐形点击带也随之消失）。

关掉后刻度回到**内置**长度，而这并不统一：普通 12px、未加载 8px、当前轮 20px、悬停那根 18px（内置悬停态是 `20px × scaleX(.9)`）—— 所以设置页小样里悬停那根是 18px，而不是把一排刻度压平成 12px。

## ✅ 验证状态

`node test/verify-client.mjs` 跑 **151 项断言**，一秒以内跑完 —— 不需要联网、不需要浏览器、不需要安装依赖。`npm test` 会先对两个半侧做语法检查，发布工作流在发布前跑同一条命令。除几何外，全部对着桩验证。

套件**证明不了**什么、以及另外怎么补的：

| 范围 | 怎么覆盖的 |
| :--- | :--- |
| CSS 生成、配置归一化、设置页 | 151 条 Node 断言 + 桩。精确字符串与数字是刻意钉死的：故意改行为就必须同时更新它们。 |
| 渲染出的几何 | 在无头 Chrome 里对着字节级内置 CSS/DOM 实测，sticky 与 absolute 两种布局都测。左侧模式落在 `chatFrame.x + 12`；刻度对齐；悬停当前轮是 32 / 21 / 14。 |
| 活的 DSH 页面 | 在 DSH 0.2.1-rc.2 上确认了布局变体、样式表顺序（插件在内置 `ui-chat` 之前）与实测类名前缀。 |
| 观感，以及「把 CSS 注入真实会话」这最后一步 | **没有机器检查。** 曲线的弧度好不好看、1.5.0 的左侧 CSS 注入真实会话是否干净，请你用眼睛判断 —— 无头客户端没能重新打开一个多轮会话做这项最终确认。 |

Node 套件只是拿文本钉住生成的 CSS，不渲染页面，所以抓不到级联、悬停或像素级回归 —— 那正是上面几次无头实测要补的。

## 🚧 已知边界

* **与上游的耦合。** 唯一的结构约定是：一个以 `_frame` 结尾的 class 令牌，里面有互为相邻兄弟的 `<前缀>_mark` 按钮；加上刻度自身的内置状态类 `_markPreview`（悬停）与 `_markActive`。插件还会用刻度原生的 `:hover` 当锚点，因为内置标记加的是 `_markPreview` **或** `_markActive`，不会同时有。0.1.7 之前距离选择器依赖的 `_markPosition` 包裹层已被上游删除，不会再回来。若上游改掉这些名字，长度渐变会静默失效 —— 不报错，也不影响对话本身。
* **长度渐变的距离链依赖 CSS `:has()`**（现代 Chromium、Firefox、Safari 都已支持）。32px 峰值那条规则与「下方」链本身都不用 `:has()`，所以没有它时悬停那根照样会变长、指针下方的邻居也照样收拢；只有依赖 `:has()` 的「上方」链会失效。
* **容器宽度 ≤ 900px 时轨道自己隐藏**，这是 shipped 的 `@container` 规则。本插件不强行夺回。
* **粗细上限 8px**，因为刻度行高固定 10px，再粗会互相压住。
* **加宽框架有副作用。** 为了让 32px 的尖端不被裁掉，框架从 28px 变成 36px —— 但仅限渐变开着的时候。框架本身就是轨道的悬停/点击面，所以对话区边缘那块**隐形交互带**跟着宽了 8px：在那一带里悬停会浮出预览卡，点击会跳到对应轮次。关掉渐变会一并撤掉加宽，交互带回到内置宽度。
* **悬停那根永远是锚点。** 当前轮的刻度被排除在邻居规则之外，但指针指着它时它就是锚点本身，会变长到 32px；两侧邻居仍按 21 / 14 收拢。
* **预览字号/宽度只作用在预览卡上。** 字体不是内置轨道的字体，宽度也不改轨道本身占用的 28px 布局空间，所以调大不会挤到正文。
* **距悬停处第 3 格起不改长度。** 保持内置宽度 —— 普通轮 12px、未加载轮 8px、当前轮 20px —— 这样轨道整体观感不变。注意邻居规则没有排除 `_markUnloaded`：距离 1–2 格内的未加载刻度会被加宽到 21 / 14px，第 3 格起才回到内置的 8px。
* **安装或卸载 bundle 需要重启宿主。** 插件树在启动时合成，所以安装或卸载都要等 `dsh web` 重启一次才生效 —— 只刷新浏览器不是可靠路径。当前的 DSH 版本能热合成客户端条目，所以重新启用一个已装好的 bundle 可能无需重启就生效；重启仍是稳妥答案。（设置本身不再等任何东西：1.4.0 起它就是浏览器本地的。）

## 📚 版本记录

每个版本的完整细节在 git log 与 GitHub Releases 里。下面是简版 —— 对使用者意味着什么、该升到哪一版。

| 版本 | 对你的意义 |
| :--- | :--- |
| **1.5.1** | 当前版本。行为无变化：纯文档版本。README 改为默认中文（英文版移到 `README.en.md`），补了设置页与长度渐变两张截图，徽章行加了 Awesome DSH Plugin 与 powered-by-dsh。标题、平台徽章与 `package.json` 描述里去掉了「Web」这个限定 —— DSH 有浏览器版也有桌面版，本插件两边都能用。仓库根目录新增 `screenshots.json`，把这两张截图交给 Awesome DSH Plugin 列表展示。发这一版是为了让 npm 包页面显示同一份 README —— npm 渲染的是已发布 tarball 里的副本，发布后无法再改。 |
| 1.5.0 | 四个能感觉到的修复：悬停当前轮时渐变也会收拢（以前整段消失）；左侧模式真的看得见、刻度也对齐（以前轨道被推到对话区之外并被裁掉）；预览字号放大不再裁掉最后一行；设置现在真的能扛过刷新（之前写入路径有 bug 会静默丢失）。此外：三套并行字段清单收敛成一张表，重新挂载不再叠第二份样式表，存量的损坏值回落默认而不是抛错。 |
| 1.4.3 | 行为无变化 —— 第一个由 CI 通过 npm provenance 发布的版本。 |
| 1.4.2 | 行为无变化 —— Trusted Publisher 配置待完成期间本地发布，因此不带 provenance。 |
| 1.4.1 | 文档改成描述插件真正做的事；发布包里带上自己的文档；当前轮的刻度被排除在邻居规则之外。 |
| 1.4.0 | 设置搬进浏览器（不再写 DSH 设置文档），宿主半侧变成空桩；渐变选择器按 DSH 0.1.7 的 DOM 重写。 |
| 1.3.0 | 去掉悬停预览的开关：轨道的存在意义就是被预览。 |
| 1.2.0 | 加长成曲线的渐变与加宽的裁剪盒；预览字号与卡片宽度；「恢复默认」。 |
| 1.1.0 | 预览行数真的生效了；渐变锚点改到悬停那根。 |

**从 1.3.x 或更早升级：** 设置现在只存浏览器本地，所以 DSH 设置文档里残留的 `chat-locator:` 段会被忽略，首次打开看到的是出厂值。在设置页里再设一次即可。

## 📄 许可证

[MIT](../../LICENSE)
