# dsh-composer-markdown

[English](./README.md) · [npm](https://www.npmjs.com/package/dsh-composer-markdown) · [GitHub](https://github.com/chendefine/dsh-composer-markdown)

![npm](https://img.shields.io/npm/v/dsh-composer-markdown) ![license](https://img.shields.io/npm/l/dsh-composer-markdown) ![node](https://img.shields.io/node/v/dsh-composer-markdown) ![CI](https://img.shields.io/github/actions/workflow/status/chendefine/dsh-composer-markdown/ci.yml) ![stars](https://img.shields.io/github/stars/chendefine/dsh-composer-markdown)

> **一句话简介**：DSH（DeepSeek Harness）Web 纯客户端插件，为对话输入框（composer）提供 Markdown 编辑增强 —— 列表自动续行与序号规整、行内代码样式（反引号渲染后隐藏）、代码块围栏自动闭合与原子化交互。所有编辑手势做在 **Shift+Enter** 上；**Enter 保持 DSH 原生「直接发送」语义，从不拦截**。除「有序列表序号规整」外一切只作用于编辑态视觉与按键，**发送文本始终保持字面 Markdown、逐字节保真**。

- 平台：web（DSH Web GUI，`dsh plugin --profile web`）
- 形态：纯客户端插件 —— host 半为空操作载体，零运行时依赖，可在「设置 → 插件」中随时开关
- 测试：475 个断言全部通过（零依赖测试器：产物新鲜度 + 纯逻辑单测 + bundle 形状断言 + 可选宿主契约冒烟）
- 许可：MIT

## 目录

- [用途与适用场景](#用途与适用场景)
- [功能一览](#功能一览)
- [安装](#安装)
- [使用](#使用)
- [技术架构](#技术架构)
- [兼容性](#兼容性)
- [已知限制](#已知限制)
- [开发须知与规范](#开发须知与规范)

## 用途与适用场景

DSH 的 composer 是一个 Lexical 纯文本编辑器，原生只提供「Enter 发送 / Shift+Enter 软换行」——写 Markdown 全靠手敲字面字符。本插件把输入框的 Markdown 编辑体验补齐到成熟聊天产品的水平：

- **在输入框里写结构化内容**：列表、步骤、代码片段、代码块，不用离开键盘去点工具栏；
- **让字面 Markdown 变得可读**：`• ` 圆点标记、等宽序号、行内代码底色、成对的 ```` ``` ```` 渲染成一个真正的代码块 —— 所见接近发送后消息的渲染效果；
- **保住「纯文本」的契约**：一切增强都是编辑态视觉层，发出的就是你敲的字面 Markdown，逐字节一致 —— 模型侧、消息渲染侧、剪贴板投影完全不受影响；
- **不改变任何原生习惯**：Enter 照常发送，`/`、`@` 菜单、`@xxx` 引用 chip、图像粘贴、IME 中文输入全部照旧。

不适用：需要富文本（非 Markdown）编辑、或希望在发送阶段改写/美化 Markdown 语法的场景 —— 本插件刻意不做任何发送态变换。

## 功能一览

````
  你输入                     编辑态呈现                        发送文本
─────────────────────────────────────────────────────────────────────
  - 苹果                   • 苹果   (圆点标记)                - 苹果
      ⇧↵ (Shift+Enter)    - ␣      (下一行自动带前缀)          …
  1. 第一步                1. 第一步 (等宽序号)                1. 第一步
      ⇧↵                   2. ␣     (自动递增,组内恒连续)       …
  `code`                   code     (等宽+底色,反引号隐藏)      `code`
  ```ts ⇧↵                ┌─────────────────┐                 ```
                          │ ts 徽标          │                 (空行)
                          │ ␣ (光标在此)     │                 ```
                          └─────────────────┘
````

按功能域：

| 域 | 能力 |
|---|---|
| **列表** | 无序/有序续行（任意视觉行与任意嵌套深度，含软换行与多行粘贴行）、光标处截断续行、空项退出、组内序号恒连续（删行接上/隔断重起/合并累加/嵌套感知）、中间插入整体顺延、**层级缩进阶梯（Tab 降级 / Shift+Tab 或前缀后 Backspace 升级，整棵子树递归联动，顶层取消列表回归普通文本；降级上限 = 父级下一级，不可无限降级）**、前缀原子删除（缩进并入原子）、方向键原子跨越（含缩进）、原子内部不可停留、标记差异化渲染（`• ` 圆点 / 等宽序号） |
| **行内代码** | 成对反引号内部文本呈行内代码样式、反引号闭合瞬间隐藏（零步进隐形，保留字体度量）、选区覆盖时显形（所选即所见） |
| **代码块** | ```` ``` ```` / ```` ```ts ```` + Shift+Enter 自动闭合骨架、行首敲 ```` ``` ```` 的保留内容流、成对提交制渲染（整块代码观感 + 语言徽标浮层 + 标记行零高度）、块边界一次解包（去围栏保正文）、↑/↓ 跳过标记行、围栏内 Shift+Enter 插入新段、任意行行首开块（软行提升——引用 chip 内嵌换行只阻断其自身边界，其余边界照常提升，v0.1.3）、孤儿闭合标记自动清理 |

## 安装

### 前提

- DSH `0.1.2-alpha.x`（composer 为 Lexical 纯文本编辑器 + `@lexical/plain-text`，lexical `0.49`）；
- Node.js `^22.19.0 || >=24.0.0`（与 DSH 本体的支持窗口一致，包的 `engines` 字段与其对齐）；
- 现代桌面浏览器（Chrome / Edge / Firefox / Safari）。

### 安装插件

包内声明了 `dsh.bundle.patch`（`cordis.patch.yml`：一条 insert 行，挂载 host 半空操作 entry，使插件出现在「设置 → 插件」中）。

本地检出（开发常用）：

```sh
dsh plugin --profile web add link:/absolute/path/to/dsh-composer-markdown
```

从 npm registry：

```sh
dsh plugin --profile web add dsh-composer-markdown
```

从 GitHub（构建产物 `client.js` 随仓库提交，即使 pnpm 跳过 `prepare` 构建脚本插件也能正常加载；建议用 `#<sha>` 固定 commit，避免后续 push 改变实际运行的代码）：

```sh
dsh plugin --profile web add github:chendefine/dsh-composer-markdown
```

> pnpm ≥ 10 会在用户显式放行前阻塞 git 依赖的 `prepare` 脚本。若希望安装时从 `src/client/` 重新构建 `client.js`，请把 pnpm 打印的键（如 `dsh-composer-markdown: true`）加进 profile 的 `pnpm-workspace.yaml` 的 `allowBuilds` 下再重新 `add` —— 这一步等于授权该包在你机器上于安装期执行代码，请仅放行信任的源。

或经 DSH 插件市场（设置 → DSH 插件市场）—— 给仓库打上 `dsh-plugin` topic 即被自动收录。

安装后**重启 `dsh web` 服务**并刷新浏览器页面生效。本地 link 方式安装后，修改 `src/client/` 源码并执行 `node scripts/build.mjs` 重新生成 `client.js`，开发态下 HMR 会自动热载重新生成的产物（无需刷新页面；若 HMR 未运行则刷新一次）。

### 开关与卸载

- **开关**：设置 → 插件 → `composer-markdown`，禁用即恢复全部原生行为（刷新后生效）；
- **卸载**：`dsh plugin --profile web remove dsh-composer-markdown`。

禁用/卸载时自动清理：document 监听器、注入的样式标签，并清除编辑器内所有代码样式标志位（草稿文本内容不变）。

## 使用

所有编辑手势在 **Shift+Enter** 上；**Enter = 原生发送，从不拦截**；未命中任何场景的 Shift+Enter = 原生软换行。**Tab / Shift+Tab 仅当光标行是列表项时被认领**（层级阶梯），其余场景保持原生（焦点移动等）。普通 Backspace / Delete / 方向键仅在恰好命中下述边界时被认领，其余保持原生逐字符/逐字符移动。

### 列表

| 操作 | 触发 | 行为 |
|---|---|---|
| 无序/有序续行 | 行首 `- ` / `* ` / `N. `（**任意缩进深度**）后输入内容，按 **Shift+Enter** | 下方新行自动带 `{indent}- ` / `{n+1}. ` 前缀（嵌套层级原样保留），光标停在前缀后；段落首行、软换行行、多行粘贴行**任意视觉行**均生效 |
| 行中间截断 | 光标在列表项**内容中间**按 **Shift+Enter** | 从光标处截断：光标后的内容移到新 item、接在新前缀之后（字节保真）；光标在行首/前缀内部/行尾时维持「下方新增整行」 |
| 中间插入顺延 | 在有序组中间 Shift+Enter 插入新项 | 新行拿到 `n+1`，其下同组所有成员依次 +1（`1. 2. 3.` 在 1 与 2 之间插入 → `1. 2. 3. 4.`），整组保持连续且不重复；插入与顺延同属一个撤销步 |
| 序号规整（不变式） | 任意改动之后（删除/插入行、空项退出、粘贴、撤销/重做） | 每个有序组（连续有序行、同缩进、围栏外）**恒为 `1. … n.` 从首成员连续编号**：删行接上；组被普通行/空行/无序行隔断 → 后一组从 `1.` 重起；隔断行被删、两组相连 → 合并累加；**更深缩进只挂起外层组**（嵌套子列表不打断外层，外层缩进回归时续接原计数），嵌套组自身独立规整。有意取舍：组不能保留非 1 起始号或手动跳号——手改序号立即回弹（连续性即契约） |
| 空项退出 | 只有前缀的空列表项上按 **Shift+Enter** | 移除前缀，保留空段（软行上保留空行）—— 连按两次即可干净退出列表；**光标留在清空后的本行**（v0.1.2）——空项在尾部、段中或块首行均如此，既不回上一项行尾、也不跳下一行行首 |
| 层级缩进（降级） | 光标在列表项**那一行的任意一处**（含内容中间）按 **Tab** | 该项连同**下方全部更深层级**（嵌套子列表、更深缩进的续行文本）每行缩进 +2 空格；同层兄弟项与其后内容不动；光标在行内相对位置随行平移；整棵子树一次按键、一个撤销步；嵌套有序组随后按不变式独立规整（如 `2.` 挂到上层后重排为嵌套组的 `1.`）。**降级有上限（v2.9）**：任何项最多只能降到其**父级**（上方最近的列表项，普通行跳过、空行/围栏隔断）的**下一级**——已处于父级下一级的项、以及列表**首项**（上方无可嵌套的父级）按 Tab 为**认领但不动**的空操作（焦点不会因此跳出输入框），不会无限降级 |
| 层级缩进（升级/取消） | 同上位置按 **Shift+Tab**，或折叠光标恰在**前缀原子之后**（内容首）按 **Backspace** | 嵌套项（缩进 ≥1 空格）→ 该项及全部子层级各 −2 空格（奇数缩进按 `min(2, 缩进)` 取整到 0）；**顶层项 → 取消列表**：删除整个前缀原子，只留正文成普通文本行（光标留在该行，v0.1.2），子层级仍各升一级。取消列表留下普通行会隔断有序组，其后同缩进成员按不变式从 `1.` 重起。长按 Tab 降一级后停在层级上限（认领但不动）；长按 Shift+Tab 每次重复升一级——两键焦点都不会从列表行跳走 |
| 前缀原子删除 | 折叠光标恰在列表项**行首**（整个原子之前）按 **Delete** | **缩进 + 前缀**整体一次删净（如 `␣␣1. ` 六字符一并消失），该行回归顶层普通文本，独立撤销步；缩进无法被逐字符选中删除 |
| 前缀方向键原子跨越 | 折叠光标贴在任意列表原子边界按 **← / →** | `→` 在行首一次跨过整个 `␣␣1. ` / `␣␣- `（**缩进含在内**）落到内容首字符左侧；`←` 在前缀后一次跨回行首；光标已落入原子内部（含缩进空格内，点击/纵向移动）时按方向键直接跳出 —— 键盘永不逐字符穿过缩进或前缀内部。**Shift+方向键保持原生**（选区仍可精确覆盖序号字符手动改号） |
| 标记差异化渲染 | 草稿中任意视觉行行首的 `- ` / `* ` / `N. `（围栏外，含空前缀） | 有序序号换用等宽字体；无序横线/星号渲染为 `• `（圆点 + 空格为一个整体标记单元）；标记字节**原样保留**在草稿/复制/发送文本中；**选区真正覆盖标记字符时显形原始字符** |
| 原子内部不可停留 | ↑/↓、鼠标点击等任意途径把折叠光标落入原子内部（**含缩进空格之间**） | 光标被移到最近的原子边界（平局取行首边界）；边界本身（行首/内容首）是合法停留点；选区不受影响 |

### 行内代码

- 段内出现成对反引号 `` `非空内容` ``，且内容**不跨行**、**首尾紧邻定界的字符非空白**（含全角空格/NBSP）时：内部文本呈行内代码样式（等宽字体、浅底、圆角，复用 DSH 主题 token）；
- 配对反引号**闭合瞬间即隐藏**（不可见、不占宽，仍逐字节保留在草稿与发送文本中）；光标移入配对内部或紧邻两侧时**保持隐藏**；
- 只有**选区真正覆盖到某个反引号字符**时（Shift+方向键、全选等）该配对才临时显形（所选即所见），选区收起后重新隐藏；跨段落选区（Ctrl+A）按各段分别判定，段边界不漏显；
- 不满足条件的配对完全惰性（反引号保持可见，不影响后续配对）；`` `` `` 等多重反引号定界不识别（朴素两两配对）。

### 代码块

| 操作 | 触发 | 行为 |
|---|---|---|
| 骨架闭合 | 光标所在行恰为 ```` ``` ````（可带语言标识 ```` ```ts ````）且光标位于标记串之内/之后，按 **Shift+Enter** | 插入三行骨架 ```` ``` / 空行 / ``` ````，光标落在空行行首；**任意一行**的行首输入都生效（软换行/多行粘贴产生的行同样识别）；光标在标记串**之前**（裸 ```` ``` ```` 行 offset 0）为普通文本，保持原生软换行 |
| 保留内容流 | 在**已有内容的行**行首输入 ```` ``` ```` 后（光标仍紧随其后）按 **Shift+Enter** | 从 ```` ``` ```` 之后截断：标记行保留 ```` ``` ````，插入空行 + 闭合骨架，**原行 ```` ``` ```` 之后的全部内容移到闭合标记下方**（块外、逐字节保留）；语言标识流不受影响（```` ```ts ```` 行尾仍得到带徽标的骨架） |
| 成对渲染 | 草稿中出现成对 ```` ```…``` ```` 包裹区域 | 整块呈**一个代码块**：内部代码底色 + 等宽字体（与消息渲染侧 CodeBlock 同一套主题 token 与几何）；首行标记**零高度**，语言标识以**浮层徽标**显示在块内右上角；```` ``` ```` 标记完全不可感知（不可见、光标不停留）；**未闭合的 ```` ``` ```` 一律普通文本** —— 行中键入 ```` ``` ```` 不会把下方内容吞进代码块 |
| 一次解包 | 光标在块边界按 **Backspace / Delete**（内容首行行首/块后一段行首 ← 退格；块前一段行尾/内容末行行尾 → 删除键） | **一次按键去掉一对 ```` ``` ```` 标记行**：代码块解除渲染，块内正文逐字节保留为普通段落，光标原地不动，独立撤销步 |
| 退出块 | 内容末行行尾按 **↓**（块为草稿末尾时自动生长一个空段） | 光标落到块下方新段，继续输入普通文本；↑/↓ 在块边界垂直移动时直接跨过标记行；标记行驱逐光标时落点为**文本锚点**（v0.1.3），被送回正文边缘后 ↑/↓ 依然可用——块顶/块底绝不成为键盘死角 |
| 围栏内换段 | 光标在围栏内（含闭合行）按 **Shift+Enter** | 插入新段（而非软换行），从光标处截断；普通 **Enter** 在围栏内也照常发送 |

### 与原生行为的共存

- **Enter**：保持 DSH 原生「直接发送」，本插件从不拦截（列表行上、围栏内也一样）；
- **Ctrl/Cmd+Enter**：保持原生「加速提交」，不拦截；
- **Shift+Enter（未命中场景）**：保持原生软换行（`<br>`）；
- **Tab / Shift+Tab（非列表行、围栏内、选区状态、带修饰键）**：完全保持原生（浏览器焦点移动等），从不拦截；触发菜单打开时同样让位；列表行上的长按重复与单击同样重新规划（绝不落到原生焦点移动）；
- **`/`、`@` 触发菜单打开时**：对 Enter 系按键完全让位；
- **中文输入法（IME）**：组合中的按键不触发任何本插件行为（三信号防护：`isComposing` / `keyCode 229` / `compositionend` 后 10ms 窗口，与 DSH 输入机自身防护一致）；
- 斜杠命令认领、`@` 引用 chip、图像拖放/粘贴、busy/locked 状态、问答卡片与审批卡片均不受影响；
- 与其他 composer 插件共存：捕获阶段先到先得 —— 已被 `preventDefault` 的按键本插件让位；本插件消费的按键 `stopImmediatePropagation`。

## 技术架构

### 双端结构

DSH 插件分 host（node）半与 browser 半，本插件是**极端的纯客户端**形态：

```
┌─ host 半 (node) ──────────────────────────────────────────────┐
│ index.js          空操作载体：给 bundle 一个可导入的 entry 行，  │
│                   使插件出现在「设置 → 插件」中可开关            │
└───────────────────────────────────────────────────────────────┘
┌─ browser 半 (web) ─────────────────────────────────────────────┐
│ client.js         单文件装载产物（DSH 装载器只认单文件 bundle），│
│                   由 src/client/ 15 个模块经 scripts/build.mjs  │
│                   拼装生成并入库；经                             │
│                   window.__ModuleLoader__.load({id, factory})   │
│                   注册，内部自带 CommonJS 风格模块注册表          │
└───────────────────────────────────────────────────────────────┘
```

### 源码分层（src/client/，自上而下单向依赖）

```text
constants.js     常量 + 行文法正则 + CSS 类名（样式表选择器由它拼装，杜绝漂移）
editor.js        宿主契约缝：唯一触碰宿主下划线内部
                 （__lexicalEditor/_nodes/_nodeMap/_selection/_compositionKey）的模块
grammar.js       行文法基底：视觉行、围栏区间 + 提交制覆盖、共享光标行解析、
                 选区覆盖显形规则 —— 每次读取只推导一次
doc.js           文档读取：块/叶子扁平几何 + 光标/选区映射
                 （committed/live 两读法）+ 空壳块查询
fence-plan.js    纯围栏投影与决策（行/块角色、软行提升、围栏对、孤儿清理、
                 原子键、光标归位）
code-plan.js     纯行内代码核 + 每块 span/定界投影
enter-plan.js    纯 Shift+Enter 仲裁（先围栏后列表的一棵决策树）
list-plan.js     纯列表规划（状态驱动重编号、标记字形、原子删/跨越/归位、
                 合并/脱离、顺延、重锚）
analysis.js      一次 analyzeDraft(texts)：模型 + 全部域投影
edits.js         编辑代数：全部活节点变更的唯一所在（平坦区间切除/软行提升/
                 段落切分/数字改写/格式形状）
style-sheet.js   CSS 文本（由类名常量拼装）+ 样式标签生命周期
present.js       呈现层：块标记（围栏类/徽标）+ 唯一的字形标记引擎
                 （{at, class, reveal} 表）+ stripDom
gestures.js      键盘面：守卫 + 手势策略表（prepare/apply/history 三步）
                 + 唯一 claim 路径
restyle.js       收敛引擎：有序 STAGES 表（规范化 → DOM 着色 → 修复 →
                 光标归位 → 代码/标记形状），每轮一次分析、至多一次结构提交
                 + 跨扫描记忆 + 环路守卫，实例状态非模块全局
index.js         组合根：生命周期 + __internals 组装
```

### 五条贯穿性设计

所有功能不是一条条 feature 各自铺管线，而是同一组正交抽象上的行：

1. **一条行文法**：视觉行切分（段落按 `\n` 切行，软换行 `\n` 与粘贴 `\n` 等价）、围栏配对与提交制覆盖、光标行解析只在 `grammar.js` 推导一次；列表/行内代码/围栏/Enter 仲裁全部消费同一模型，构造上不可能互相漂移。
2. **一次草稿分析**：`analyzeDraft(texts)` 把围栏角色、软行提升、行内代码 span/定界、列表标记字形、重编号不变式组装为单一对象 —— 各投影看见同一些行、同一份围栏覆盖。
3. **一套编辑代数**：plan（纯数据，单测覆盖）与 apply（活节点操作，`edits.js` 独占）彻底分离；手势策略与收敛引擎都不直接碰节点。
4. **一个呈现引擎**：编辑态视觉只有两种形状 —— 块标记（围栏段落类/徽标）与字形标记（单字符叶 + 类 + 显形规则）；隐藏反引号与列表标记样式共用同一引擎与同一显形谓词，新增一种字形是加一行表项，不是加一个 pass。
5. **一张表驱动的双驱动**：键盘面是策略表（每个键族一条 prepare/apply/history），收敛面是阶段表（每阶段一个纯 plan 函数）；新增行为是向表里加行，而不是再铺一条管线。

### 关键技术细节

**键盘路径（为什么是 document 捕获阶段）**：DSH 的 Enter 命令处理器以 CRITICAL 优先级吞掉一切非 Shift Enter（提交），命令层无插入口；而 Lexical 的 Enter 派发源自根元素 keydown 监听。因此插件在 `document` **捕获阶段**监听 keydown —— 必然先于 Lexical —— 仅在命中编辑场景时 `preventDefault()` + `stopImmediatePropagation()` 阻断原生软换行，改用 `editor.update()` 完成插入。普通 Enter 从不进入该路径。

**隐藏字形技巧（零步进隐形）**：Lexical 每个文本节点渲染为独立元素。样式写入先把反引号/标记字符两侧切开（`splitText`）并把该单字符叶子标记为 **unmergeable**（Lexical 归一化会把同格式相邻叶子合并回去，只有这个 detail 位能保住隔离），随后的 DOM 扫描用 `editor.getElementByKey()` 给这些叶子挂类：等宽字体 + `letter-spacing: -1ch` 恰好抵消字符步进、`color: transparent` 隐去墨迹 —— **保留真实字体度量**。不能用 `display: none`（无盒子，浏览器无法在其旁锚定光标），也不能用 `font-size: 0`（光标高度取自其锚定文本节点的字体，会得到零高度不可见光标）。无序圆点由 `::after` 伪元素渲染（排在零步进横线之后，行首光标因此渲染在整个 `• ` 单元之前）。

**围栏成对提交制**：只有**闭合**的围栏才渲染；未闭合区间在覆盖判定中只占标记行自身 —— 行中键入 ```` ``` ```` 既不把下方内容渲染进代码块，也不关掉下方行的列表续行/行内代码配对。

**软行提升（任意行开块）**：围栏文法以段落为单位，而软换行是段内 `<br>`、多行粘贴把字面 `\n` 拼进文本节点。restyle 第 1 阶段把与围栏相邻的软行边界提升为真正的段落边界（文本投影恒等，草稿/发送文本逐字节不变），使 ```` ``` ```` 在**任意一行**行首输入都能提交成块。

**收敛引擎**：restyle 由编辑器 update 监听器驱动，每轮读一次文档、跑一遍阶段表、**至多提交一次结构更新**；提交再次触发监听器，下一轮发现无差异即停（写只在有差异时发生，幂等收敛）。自触发写入限速（滚动窗口 16 次/秒）兜底环路；IME 组合中直接让路。格式/样式写入带 `history-merge` 标签，不污染撤销栈；结构性手势（插入+顺延、合并/脱离+改号）是离散撤销步，一次 Ctrl+Z 整体还原。

**宿主契约与安全降级**：lexical 并非模块表的共享模块，插件也不能打包第二份 lexical（双副本会分裂模块态与节点类）。所有节点操作都通过**宿主编辑器实例**完成：`editor._nodes` 取真实节点类、`editorState._nodeMap`/`_selection` 读取与定位、全部使用宿主节点自身的方法（`insertAfter/append/select/splitText/spliceText/setFormat`）。这些内部访问点与 `__lexicalEditor` 同属事实契约，集中在 `editor.js`；升级破坏契约时插件安全降级为无操作（console.warn 一次）。

**发送保真**：除「有序列表序号规整」（它本身就把序号改写为连续后的字面值，所见即所发）外，其余各条线都不触碰文本内容 —— 发送文本 = composer 的 clipboard 投影 = 逐文本节点 `getTextContent()`，样式标志位与 DOM 类不影响序列化结果。

## 兼容性

- DSH `0.1.2-alpha.x`（composer 为 Lexical 纯文本编辑器 + `@lexical/plain-text`，lexical `0.49`）；
- 现代桌面浏览器（Chrome / Edge / Firefox / Safari）；
- 与其他 composer 插件共存：捕获阶段先到先得（见「与原生行为的共存」）。

## 已知限制

- **列表标记样式与反引号隐藏均为编辑态视觉层**：字符本身仍在草稿/复制/发送文本中；选区覆盖时恢复原始字样与字宽。无序圆点与原横线的字宽不完全一致（圆点用等宽字体 `::after`），极端窄列下可能比原 `- ` 略宽，属可接受的排版差异。
- **纵向归位有短暂瞬态**：↑/↓ 落入标记内部后，驱逐发生在随后的 restyle 扫描（微任务级），极端情况下可能见到一帧内部位置；←/→ 按键期仲裁无此瞬态。
- **代码块语言标识不可直接编辑**（标记行不可达）：修改语言 = 一次 Backspace 解包（保留正文）后重敲围栏。
- 未闭合的 ```` ``` ```` 一律不渲染（含粘贴/草稿恢复出的），在标记行上按 Shift+Enter（或补上闭合 ```` ``` ````）即成块。
- `` `` ``/```` ``x`` ```` 等多重反引号定界不识别（朴素两两配对）。
- 有序列表规整按现状重写一切非连续序号：手动跳号/非 1 起始会回弹；更深缩进的行只挂起外层组（空行、围栏、缩进不深于本组的普通行/无序行仍断组）；同一组超过 9 位序号（≥10 亿行）不适用。
- **层级阶梯的子树边界按「更深缩进」判定**：空行、围栏区域、缩进不深于该项的行都终止子树（与规整分组的边界语义一致）；空行后的深层缩进行不算前项的子层级，不会被联动平移。
- **降级上限（v2.9）**：父级 = 上方最近的列表项行（普通文本行跳过不算，空行/围栏区域隔断查找）；项最多处于父级下一级（父级缩进 +2）。已达上限的项与列表首项按 Tab 是「认领但不动」的空操作——不会无限降级，也不会因此让焦点跳出输入框；要再深一级，须先让其父级（或上方兄弟）先降级。升级方向无上限（0 级为自然下限）。
- **取消列表（顶层升级）会隔断有序组**：留下的普通行使下方同缩进成员按不变式从 `1.` 重起——这是既定编号契约的直接后果，非 bug。v2.8 起该手势取代了旧的「有序项退格合并进上一行」行为。
- 缩进/前缀原子的「不可选中、不可删除」为**稳态键盘体验**：折叠光标无法进入原子内部（方向键跨越、越界驱逐、行首 Delete 整体删除）；拖拽/Ctrl+A 等范围选区仍可覆盖原子字符（与序号手动改号的「所选即所见」规则一致），此时原生删除可用。
- 行内代码内容若恰为 lexicon 中的 `/name`、`@name` 文本引用 token，样式可能被 chip 着色覆盖（文本内容不受影响）。
- 引用 chip（`@xxx`）内部含换行时无法在其上提升软行（chip 为原子节点）；这类行的围栏判定退化为整段判定。
- 完全无显形的行内代码方案需 chip 节点（技术文档路线 B，留作后续演进）。

## 开发须知与规范

### 构建与测试

```sh
node scripts/build.mjs                       # 由 src/client/ 重新生成 client.js
node scripts/build.mjs --check               # 仅校验 client.js 是否与源码同步
node tests/run-tests.mjs                     # 产物新鲜度 + 纯逻辑单测 + bundle 形状断言
DSH_CHECKOUT=/path/to/dsh node tests/run-tests.mjs   # 追加宿主契约冒烟（grep 断言）
```

`npm run build` / `npm test` 为等价快捷方式。手工验收清单见 [tests/e2e-recipe.md](tests/e2e-recipe.md)（可用 playwright-cli 驱动，键盘事件需是真实 keydown）。设计与证据文档：仓库根 `dsh-composer-markdown-view-tech.md`（需求、证据索引、已否决方案）。

### 源码规范

- **不要直接改 `client.js`**：它是 `scripts/build.mjs` 生成的产物且入库，测试会校验新鲜度；改 `src/client/` 后重新构建并提交再生的产物。DSH 装载器把每个插件的 `./client` 导出作为**单个** `<script src>` 加载（无多文件插件支持），这是存在构建步骤的唯一原因。
- **模块约定**：每个 `src/client/*.js` 文件是一个 CommonJS 风格的模块体（接收 `module, exports, require`，`require('./xxx')` 解析到 bundle 内部注册表）；构建时按 `MODULES` 表顺序原样拼接，不做转译或重排版。零运行时依赖 —— 不得引入任何外部包。
- **绝不 import/bundle lexical**：lexical 不是模块表的共享模块，打包第二份会分裂模块态与节点类。一切节点操作经宿主编辑器实例（`editor.js` 是唯一契约缝）；新增宿主内部访问点时加进 `editor.js` 并保持「破坏契约 → warn 一次 → 无操作」的降级。
- **扩展方式是向表里加行**：新按键手势 → 在 `gestures.js` 的策略表加一条 `{keys, guard, prepare, apply, history}`；新收敛阶段 → 在 `restyle.js` 的 `STAGES` 加一个纯 plan 函数；新字形样式 → 在 `present.js` 的字形标记表加一行 `{at, class, reveal}`。不铺新管线。
- **plan/apply 分离**：规划器必须是当前草稿的纯函数（无跨扫描记忆，围栏孤儿清理的配对记忆除外），经 `__internals` 暴露给零浏览器测试器单测；活节点变更只写在 `edits.js`。
- **收敛纪律**：写只在有差异时发生；每轮至多一次结构提交；样式/光标写入带 `history-merge`，结构性手势用离散撤销步；尊重 `MAX_WRITES_PER_SECOND` 环路守卫与 IME 让路。
- **样式复用 DSH 主题 token**（`--ds-font-family-code`、`--dsw-alias-markdown-inline-code`、`--dsw-alias-markdown-code-block*`），选择器由 `constants.js` 的类名常量拼装，禁止硬编码颜色或让 CSS 与类名漂移。

## 许可

MIT（见 [LICENSE](LICENSE)）。
