Skip to content

dsh-mobile-ui

Verified

@jianghuifr/dsh-mobile-ui · v0.1.0 · MIT · Web UI

Mobile layout, touch interaction and decluttered chrome for the DeepSeek Harness web GUI, plus Material Icon Theme file icons for its file explorer. Scoped to viewports <= 1023px; the desktop UI is untouched.

Install

dsh plugin add @jianghuifr/dsh-mobile-ui

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Tags

Readme

@jianghuifr/dsh-mobile-ui

CI npm license

不带 scope 的 dsh-mobile-ui 在 npm 上已被他人占用(wbaifan,功能也高度重合), 所以这里用自己的 scope 发布,避免混淆。

给 DeepSeek Harness 网页端加一层移动端布局、样式与触控交互的客户端插件, 外加文件浏览器的 Material Icon Theme 图标

面向的是「在手机浏览器里打开 dsh web」这个场景。桌面端不受任何影响:移动端规则全部挂在 html[data-dsh-mobile="true"] 下,而这个属性只在视口宽度 ≤ 1023px 时才出现——和 AppFrame 自己的 SIDEBAR_AUTO_COLLAPSE = 1024 用同一个阈值,两边对「窄屏」的判断一致。 (文件图标是例外:它跟屏幕宽度无关,桌面端同样生效。)


文件图标(Material Icon Theme)

文件浏览器里原本所有文件和文件夹都是同一个通用字形。现在按名字解析成 Material Icon Theme 的彩色图标:index.ts 是蓝色 TS、App.tsx 是 React 原子、Dockerfile 是鲸鱼、 .gitignore 是 Git 标、src/ 展开时换成打开的文件夹。

icon grid

为什么图标放在宿主侧

上游是 ~1250 个 SVG(1 MB)加一张 398 KB 的映射表。把整张表发到浏览器只为了决定屏幕上 那十几个图标画什么,是每个页面都要付一次的代价。

所以反过来:浏览器按文件名问,宿主查表、读盘、回 SVG:

GET /material-icons/index.ts      -> typescript.svg
GET /material-icons/src?d=1&o=0   -> folder-src.svg
GET /material-icons/src?d=1&o=1   -> folder-src-open.svg

客户端因此不需要任何映射数据,只设一个 background-image;每个 URL 都是 (文件名, 标志位, 版本) 的纯函数,所以响应带 immutable 缓存——同一个图标一辈子只请求一次, 一个目录列表总共也就几个小请求(实测每个 215 B – 1.3 KB)。

解析顺序照着 VS Code 来,因为上游的表就是给它生成的:全名 → 忽略大小写全名 → 逐级变短的扩展名链(a.spec.tsspec.tsts)→ 主题默认。文件夹多一个展开态。 test/resolve.test.mjs 把这套契约固定下来了——注意里面的期望值是上游的真实行为而不是 直觉:package.json 是 Node 六边形,App.tsx 映射到 react_ts,而 Cargo.toml 上游压根 没有条目,会落到 toml

更新图标集

图标已经拷贝进插件assets/),运行时完全不读 VS Code 的扩展目录,所以升级 VS Code 不会把它弄坏。要跟进上游版本:

node scripts/vendor-icons.mjs              # 自动找已装的 material-icon-theme
node scripts/vendor-icons.mjs --from <dir> # 或指定目录

图标没出现?

这一项需要重启 dsh web,和插件的其它部分不一样:路由注册在宿主半边上,而宿主半边 不会热重载(客户端半边会)。重启前宿主对 /material-icons/... 回 404,此时客户端会退回 App 自带的图标而不是把图标列变空——这条降级路径是特意做的,也有测试覆盖。


为什么需要它(移动端)

0.1.5-rc.2 的 Web 客户端没有任何按视口宽度生效的样式。静态 shell 的 CSS 里只有 3 条 @media,全是 prefers-reduced-motion;全部 8 个宽度断点都在设置页、引导页、轨迹表这类叶子 面板里。外壳唯一的「移动适配」是 JS:AppFrame 在 1024px 以下把侧边栏折叠成 rail,而 computeColumns 里有一条 400px 的中间列下限 + 恒定 56px 的 rail

结果是在 390px 的手机上:

现象 实测
侧边栏折叠后仍占 56px(14% 屏宽),中间列只剩 334px sidebarCol {0,0,56,844}
--dsh-chat-content-width 下限是硬编码的 680px,在 334px 的列里横向溢出 JS CONTENT_MIN = 640
展开侧边栏时是挤压而非覆盖,中间列被压到 110px,输入框 64px centerCol w=110composer w=64
设置面板是 164px 导航 + 内容的固定两列,正文挤成每行一两个字 docs/before-settings.png
输入区字号 14px / 搜索框 13px → iOS 聚焦时整页缩放 composerFont: 14px
发送键 34×34,附件/模型/权限 28×28,会话行 32px,行内操作图标 16×16 实测 hit target
20 处控件只在 :hover 出现,触屏永远够不到 rowActionschevroninspectButton
全站 :active 反馈只有 2 个元素;touch-action: manipulation 0 处 112 个样式表里
viewport-fit=cover、无 env(safe-area-inset-*)、无 dvh 刘海/小白条区域不可用
右侧面板在 rightbarCol 宽度为 0 时只能整屏 overhang panel {0,0,390,844} fixed

安装

# 从 npm 安装(推荐)
dsh plugin --profile web add @jianghuifr/dsh-mobile-ui
# 装完重启一次 dsh web —— profile 的 bundles 变了

发版流程(staged publishing + trusted publishing,无需长期 token)见 docs/release.md

也可以从本仓库用 link: 装,或者手工加 Loader 行(见下)。

插件分成两半,两边的刷新方式不一样:

  • lib/client.js —— 手写的、不需要构建的客户端 bundle,装下全部移动端布局与样式。 它在浏览器里热重载,改完刷不刷新都行。
  • lib/index.js —— 宿主半边。它只做一件事:注册 /material-icons 图标路由(外加 assets/ 里 vendored 的图标集)。它不会热重载,改到它就必须重启 dsh web

客户端半边不能单独存在:@deepseek-ai/dsh-client-modules 的扫描以 Loader 行为入口, 所以哪怕只有客户端逻辑,也需要这一行。

方式 A:dsh plugin(推荐,需要重启一次)

dsh plugin --profile web add link:$HOME/.dsh/plugins/dsh-mobile-ui   # 本地源码
dsh plugin --profile web add @jianghuifr/dsh-mobile-ui              # 或从 npm
# dsh.profile.bundles 变了,需要重启 dsh web 才生效

link: 而不是 file:file: 是硬链接拷贝,之后改代码不会同步;link: 是符号链接, 编辑立即生效(配合客户端 HMR,改 lib/client.js 不用刷新页面)。

方式 B:手工加行(不用重启,刷新页面即可)

patchReload: live 的 profile 会监听 cordis.patch.yml,新增 Loader 行可以热生效:

mkdir -p ~/.dsh/profiles/web/node_modules/@jianghuifr
ln -sfn ~/.dsh/plugins/dsh-mobile-ui ~/.dsh/profiles/web/node_modules/@jianghuifr/dsh-mobile-ui

然后往 ~/.dsh/profiles/web/cordis.patch.yml 追加(该文件必须是单个顶层 YAML 数组, 不要追加到已有的 [] 之后):

- insert:
    - id: mobile-ui
      name: "@jianghuifr/dsh-mobile-ui"

@ 是 YAML 的保留起始字符,名字必须加引号,否则整个 patch 文件解析失败。

浏览器按一次刷新(F5)即可——新行不会触发 HMR 帧。这条路径不需要重启,代价是宿主半边 (也就是文件图标路由)要等下一次重启才生效;在那之前文件浏览器保持 App 自带的图标。

两种方式不要同时用:同一个包名被两个 Loader 源解析会直接报错 (package X resolves from multiple active Loader sources)。

卸载

删掉上面加的行(或 dsh plugin --profile web remove dsh-mobile-ui),刷新即可。插件在 apply 的 disposer 里移除了自己的 <style>、导航按钮和遮罩,也清掉了根元素上的全部属性。


它做了什么

外壳

  • 三列栅格收成一列AppFramegrid-template-columns 写成行内样式,所以这里必须 !important;同时给 frame 打上稳定的 data-dsh-part 标记——CSS Module 的哈希每个版本 都会变,插件的样式只能挂在自己打的结构标记上。
  • 侧边栏变成真正的抽屉:固定定位、离屏、带遮罩、可滑动。data-sidebar-collapsed 的 有无是「展开」的唯一信号,而「不存在」没法用选择器表达,所以运行时把它镜像成 html[data-dsh-drawer]
  • 关闭是两段式的:先让抽屉滑出去,280ms 后再点 App 自己的开关。否则 App 会立刻把宽侧栏 换成 56px 的图标 rail,于是「会话列表滑出屏幕、图标 rail 滑进来」。
  • 注入一个导航按钮,因为 rail 一旦隐藏,手机上就再没有打开会话列表的入口了。
  • 右栏整屏化,并在隐藏时关掉命中测试(见下面的坑)。
  • 去掉两根 8px 的拖拽分隔条:触屏上够不到,只会吞掉横向手势。

内容区

  • --dsh-chat-content-width 改成 100%,并清掉输入区的左右留白(原值 16px,一屏两侧 共 64px)——消息终于用满整列宽。
  • 顶部栏压缩并右对齐,为导航按钮让出左侧 56px。
  • 转录区、代码块、表格、图片都保证在列内滚动,不会撑宽页面。
  • 两个滚动容器加 overscroll-behavior: contain,避免下拉刷新把会话刷没。

输入区

  • 所有可聚焦输入面字号 ≥ 16px(输入区、搜索框、textarea)——这是 iOS 聚焦缩放的唯一 修法。代价是手机上字号设置里 12–15px 会被抬到 16px:不缩放比严格跟设置更重要。

  • 发送键 34 → 44px,附件/模式/模型 chip 28 → 36–38px,行距收紧,保证 375px 宽仍是一行。

  • 模型 chip 限宽 104px 并省略号截断:它在容器宽度超过 ~360px 时会展开显示完整模型名,正是 它把 412px 以上的机型顶成两行。

  • visualViewport 监听键盘高度,写进 --dshm-kb 并撑开 frame——iOS 不会因为键盘改变布局 视口,不处理的话输入框永远在键盘下面。

  • 手机上回车 = 换行,只有点发送按钮才发消息。 软键盘只有一个回车键、没有 Shift, 「回车即发送」会把每一次想换行的尝试都变成提前发送。桌面端不变,回车仍然发送。 实现上没有自己插入换行,而是借用 App 已有的手势:它的输入框键位表第一行就是 if (event?.shiftKey === true) return false;,交给 Lexical 插入换行。所以这里在捕获阶段 吞掉普通回车,再派发一个 Shift+Enter —— 走 App 自己的路径,Lexical 的状态天然同步。

    三件必须照旧的事都有守卫:输入法(composing / keyCode === 229 时回车是确认候选词, 拦了会毁掉所有中日韩输入)、/@ 菜单(回车是选中项,用 data-trigger-menu 识别)、修饰键组合(Cmd/Ctrl+Enter 仍然发送,外接键盘行为不变)。

触控

  • 会话行 32 → 44px,项目行 34 → 44px,工具调用行 24 → 40px,设置导航 40 → 46px。
  • touch-action: manipulation(去掉双击缩放的 300ms 延迟)、去掉点击灰色高亮。
  • 全站 :active 按压反馈。
  • (hover: none) / (pointer: coarse) 下把 20 处 hover-only 控件显出来,并隐藏会在触屏上 卡住的 tooltip。
  • 恢复会话行的长按选中(App 对这两处设了 user-select: none)。

覆盖层

  • 设置面板整屏化:把两列 body 改成纵向堆叠,164px 的导航列变成横向可滚动的标签条,内容列 拿到整宽。
  • 对话框改成底部弹出、菜单限高、toast 避开刘海、代码块内部滚动。
  • 右侧面板整屏 + 安全区内边距。

效果对照

docs/ 下是同一台 390×844 视口的 before / after:

before after
侧边栏展开 before-sidebar.png(中间列被压到 110px) after-sidebar.png(覆盖式抽屉 + 遮罩)
会话视图 before-conversation.png(334px 内容列) after-conversation.png(整宽 + 单行输入区)
设置 before-settings.png(每行一两个字) after-settings.png(横向标签条 + 整宽内容)

精简

手机屏幕放不下桌面才需要的装饰,默认收掉三组:

  • 顶栏在 访达/终端 中打开工作目录 那对分裂按钮,以及「更多操作」菜单——它唯一的 条目就是下载 Session 日志;
  • 输入框下方的一整排:指令、附件、访问模式、模型、上下文圆环,只留下一个输入区和最右侧 的发送键,也就是微信/飞书在手机上的形状;
  • 输入框下面那行统计(轮次/步数/token/缓存命中)。

decluttered conversation

输入框那排是折叠而不是删掉:左侧留了一个很轻的 ,点开就恢复完整工具排。所谓 「默认折叠」就是这个意思——那几个控件桌面端有用、拇指很少用,但不该彻底够不着。

展开后是两行,而且排布是刻意的一行放不下:七个控件在 358px 里塞不下(实测约 404px), 所以工具排留在第一行左侧、模型/圆环/发送作为一组换到第二行右对齐。这里顺带修掉了一个 真实的观感问题——App 原本用 space-between 排这一行,只有两个子项时没问题,多出开关变成 三个之后,换行会把开关甩到最左、工具甩到最右,看起来像两簇互不相干的东西。改成 justify-content: flex-start 后开关和它的工具就贴在一起了,而 .trailing 靠自己的 margin-left: auto 依旧靠右。

实现上还有个 React 层面的取舍:这个开关是追加到那一行的末尾、再用 flex order: -1 挪到 左边的,而不是插到开头。那一行的子节点归 React 管,往前插一个外来节点会让它用来比对的 每个下标错位;追加则完全不动已有的节点顺序。每次同步还会补一次——万一 React 重渲染把它 丢掉,下一帧就回来了。

全部规则都挂在 html[data-dsh-mobile='true'] 下,桌面端一样都没少(有用例逐项核对桌面 顶栏仍然保有那三个按钮)。

安全区

运行时给 viewport meta 补上 viewport-fit=cover,否则 env(safe-area-inset-*) 恒为 0。 补上之后抽屉、右侧面板、输入区、设置面板都会吃到刘海和 home indicator 的 inset。


维护这个插件时要小心的坑

这些都是实现过程中真实踩到、并且会被「看起来正常」的截图掩盖的问题。

  1. transform 会制造 fixed 的包含块。 设置对话框渲染在 sidebarCol 内部。抽屉最初用 transform: translateX() 滑动,于是 对话框的 position: fixed 相对抽屉解析,被压在 320px 宽的抽屉里,永远盖不满屏幕。 现在抽屉用 left 滑动,并且闭合态不能visibility: hidden——那同样会把内部的 fixed 后代一起隐藏。闭合态改用 left: -(w+16px) + pointer-events: none

  2. App 全局是 content-box 任何 width: 100% 加上 padding 都会溢出。设置内容列因此溢出 48px(每行两个字),顶部栏 溢出 16px(把右侧面板按钮推出屏幕、点不到)。凡是本插件钉了宽度的元素都显式 box-sizing: border-box

  3. 负 margin 是桌面版的视觉补偿。 顶部栏右侧按钮带 margin-right: -16px,在桌面被 28px 的 padding 吸收;手机上会把按钮 推到屏幕外。按视口给 padding-right 之后,必须同时中和这层负 margin(包括里面的 _1kL45W_button)。

  4. 固定定位的整屏列会吞掉所有点击 —— 而且它的交互性必须由 CSS 决定,不能由 JS 采样决定。 rightbarCol 在两种状态下都铺满视口,所以它永远不能参与命中测试 (常驻 pointer-events: none),由里面的 panel 自己收回(pointer-events: auto)——面板 关闭时是 visibility: hidden,本来就点不到,所以这样是安全的。 这里踩过两次坑,都是因为把交互性挂在了运行时采样的标志位上:

    • 标志位滞后:面板打开时标志还是 closed,于是列是 none、面板继承了 none—— 文件管理器打开后点不动;
    • 标志位回正得太晚:等它变成 open 时列又成了 auto,而面板已经滑走,于是那块 看不见的 z-index 45 的盒子把整个界面锁死。

    面板的最终 transform 是在滑动开始时一次性写好的,之后不再产生属性变更——任何 「变化时采样一次」的做法都会采到动画中间态并把它永久锁住。现在除了改成 CSS 静态判定, 运行时还补了一次动画结束后的复核(SETTLE_MS),并且这条复核不会再排下一次, 避免退化成轮询。

  5. pointer-events: none 是继承的,而 position: fixed 不是。 设置对话框挂载在 sidebarCol 内部。抽屉收起时是 pointer-events: none(为了不让 离屏内容可交互),而对话框虽然靠 fixed 逃出了抽屉的裁剪、正常铺满全屏,却继承none——于是它画得好好的,但每一个点都穿透到下面的会话上:点不动、滑不动,看起来就是 「打开设置后整个页面卡死」。 这里用了两套彼此独立的机制,因为单点采样失效一次就足以冻住整个屏幕:

    • 一条纯结构、零运行时状态的规则:抽屉内任何包含 navCell / dialog / mask 的 overlay 自己收回交互权;
    • 运行时标志 html[data-dsh-modal] 兜住规则没覆盖到的层。

    和上一条同源:覆盖层的可交互性必须单独断言,几何和截图都证明不了它。

  6. 列向 flex 子项默认 min-height: auto 设置内容列因此不肯收缩到内容高度以下,整块被顶出面板底部 38px:既看不到底部,也不会 滚动。min-height: 0 之后滚动容器才真正开始滚。

  7. 客户端 bundle 注册的 id 必须等于 package.json 的 name。 client-modules包名作为客户端条目的 id(table.set(packageName, graphRow(packageName, …))), 浏览器端会拒绝注册成别的名字的 bundle(loaded without registering ... via __ModuleLoader__.load)。 所以改包名要同时改三处:package.jsonnamecordis.patch.yml 里的行 name、 以及 lib/client.jswindow.__ModuleLoader__.load({ id })。三处不一致插件会直接不加载。 另外 profile 里的符号链接目录名也要跟着变成 node_modules/@scope/name

  8. 空闲时绝不能写 DOM —— 写一次就可能变成一个反馈环。 键盘 inset 最初用 innerHeight - vv.height - vv.offsetTop > 80 判断,并且无条件写入。 iOS 上地址栏的收起/展开本身就会让 vv.height 变化 50–90px,正好骑在 80px 这个阈值上; 而缩小 frame 又会影响浏览器「要不要显示地址栏」的判断——于是 frame 变矮 → 地址栏弹出 → vv.height 变小 → inset 变大 → …… 自持的高频循环,表现为窗口底部一条白色区域不停闪; 而且与主题无关,所以切回深色也不会停(深色下它只是不那么显眼)。 现在三条一起用:只有可编辑元素获得焦点时才算键盘、140px 进入 / 70px 退出的滞回、以及 值没变就一个字节都不写。同时 setAttribute 即使值相同也会产生 mutation 记录,所以 插件里所有属性写入统一走 setAttr / toggleAttr,值相同就跳过。 这条已由 quiet(空闲 3 秒内插件写入必须为 0)和 vv(用桩 visualViewport 重放地址栏 抖动,断言 0 次写入;同时真实键盘仍被正确跟踪)两组用例守住。

    修完闪烁之后这里又踩了第二个坑,两个坑恰好是一对:inset 该跟谁比。 当时为了躲开地址栏噪声,基准换成了「见过的最大 vv.height」,结果 Android 上 输入框被顶到屏幕最上方。原因是 Chrome 的 interactive-widget=resizes-content (正是这个插件自己写进 viewport meta 的)会连布局视口一起缩——浏览器已经把页面缩好了, 我再加上一份键盘高度的 padding,就变成缩了两次,输入框被推到差不多屏幕顶端。

    正确的参照物是布局视口 window.innerHeight,不是视觉视口自己的历史:

    平台 innerHeight vv.height 差值 该不该补 inset
    iOS(键盘浮在页面上) 不变 −键盘 键盘高度 要补
    Android resizes-content −键盘 −键盘 ≈0 不补(浏览器已缩)
    Android resizes-visual 不变 −键盘 键盘高度 要补

    地址栏是另一回事:它让两个数一起变,差值照样 ≈0,所以不需要任何特例——原来想用基准 躲开的噪声,用 innerHeight 天然就没有了。 vv 用例现在把三种平台都跑一遍;composer-kb 再用 setDeviceMetricsOverride 缩布局 视口来模拟 resizes-content,断言输入框底边正好落在可见区底边、且 inset 保持 0。

  9. 一个 bundle 的语法错误会打挂整批插件。 客户端 bundle 是普通 script,会被拼进同一个 combo batch。CSS 模板字符串里出现一个反引号 (在注释里写 `left` 这种)就会提前结束字符串,整个 batch 连同其它插件一起挂掉。 改完先跑 node scripts/check.mjs

  10. CSS Module 哈希每个版本都会变。 样式选择器要么挂在运行时打的 data-dsh-part 上,要么用类名后缀 + 元素类型 (button[class*='_toBottom']——注意 _toBottomSlot 也含 _toBottom,漏掉元素类型会 同时命中槽位和按钮)。App 自己提供的 data-*data-conversation-scrolldata-composer-seatdata-rightbar-col)最稳定,优先用。 另外 data-sidebar-right-panel模式标记,面板隐藏时依然存在,判断开合只能用 计算后的可见性或几何位置。


验证

scripts/check.mjs 是提交前的静态检查(语法、CSS 里的游离反引号、意外的模板插值、顶层 ESM)。 开发时的浏览器验证覆盖了 7 种视口(320/375/393/412/430/768,以及 852×393 横屏),断言:

  • 中间列 = 视口宽度,文档不横向滚动,内容不溢出;
  • 抽屉开合到位,闭合后完全离屏;
  • 输入区字号 ≥ 16px,发送键 44×44,控制行不换行(≤ 60px);
  • 导航按钮 / 发送键 / 输入框在 elementFromPoint 下确实可点;
  • 设置面板真的能用:导航 / 首个控件 / 关闭按钮 / 三张外观卡片都可点,标签页切换真的生效, 内容溢出时能滚动、且不会顶出面板底部。

另有一组覆盖层生命周期用例(cycles),逐个走完「进去再出来」并断言主界面恢复可交互:

  1. 右侧文件管理器:开 → 关;
  2. 文件管理器 → 打开一个文件(文件详情)→ 退出;
  3. 设置面板:开 → 切标签 → 关;
  4. 抽屉与右侧面板嵌套,两种顺序。

还有两组针对「静默」的用例,专治闪烁这一类问题:

  • quiet:空闲 3 秒内,插件自身属性写入必须为 0,且不能有仍在运行的动画——分别在 对话页和切到浅色之后各测一次;
  • vv:用桩 visualViewport innerHeight 把三种平台各跑一遍——地址栏抖动 (30 次 0↔65px 往复)断言 0 次写入;iOS(只缩视觉视口)断言 inset = 300px; Android resizes-content(两个都缩)断言 inset = 0,即不重复扣一次键盘;失焦后释放;
  • composer-kb:用 setDeviceMetricsOverride 缩布局视口模拟键盘弹出,断言输入框底边 正好贴在可见区底边(既没被键盘盖住,也没漂到屏幕上方),键盘收起后回到原位;
  • simple:精简后的移动端——工具排/模型/统计默认收掉、发送键在最右、开关在最左, 点开关真的展开、再点真的收起;然后发一条消息让会话不再空白(顶栏在空会话里是 display:none,不这样做顶栏断言会全部空过),核对顶栏只剩标题 / 右侧边栏 / 标签页, 访达入口和下载菜单都不在了;最后切到 1280 宽,确认桌面端一个都没少
  • enter:六条回车契约各用全新页面跑一遍(提交会让智能体跑起来,运行中的智能体会改变 输入框行为并自己增加消息,共用页面会让「消息数不变」的断言失去意义)—— 普通回车换行且不发送、Shift+Enter 换行、Cmd/Ctrl+Enter 发送、发送按钮发送、 /re + 回车由菜单选中命令(并用一个「注册在插件之后」的捕获监听器证明插件没有拦截它)、 桌面宽度下普通回车仍然发送。

图标部分另外两组:

  • node test/resolve.test.mjs:29 条解析契约(全名 / 大小写 / 复合扩展名 / 点文件 / 文件夹展开态 / 兜底);
  • icons:真浏览器里逐行核对文件浏览器——每行都画上了图标、App 自带字形被隐藏、 展开文件夹时 URL 从 o=0 变成 o=1、无网络失败; 另外验过降级路径:把客户端指向一个不存在的路由时,所有行回到 painted=false / glyphHidden=false,即保留 App 原图标而不是变空。
SMOKE PASS — all invariants hold
CYCLES PASS — every overlay returns an interactive main surface
QUIET PASS — the plugin is silent when the app is idle
VV PASS — the inset tracks a real keyboard and ignores browser chrome
COMPOSER-KB PASS — the composer stays above the keyboard, exactly once
ENTER PASS — newline on mobile, submit everywhere it should
SIMPLE PASS — decluttered on mobile, desktop untouched
ICONS PASS
ALL RESOLVE OK

后两类断言都是补上来的。它们缺失的那段时间里,点/滑/几何断言全部通过、截图也完全正常, 但实际存在过两次「进去就出不来」的冻结(见上面第 4、5 条坑)。覆盖层的可交互性必须单独 断言——几何和截图都证明不了它。


兼容性

针对 dsh 0.1.5-rc.1 / @deepseek-ai/dsh-web-frontend 0.1.5-rc.2 实测。插件只依赖 DOM 结构、data-* 钩子和 CSS 自定义属性,不注入任何 Cordis 服务(exports.inject = []), 所以客户端其它插件升级不会影响它;但上游如果改了上面第 10 条列出的结构标记,需要同步调整。