Sign in

    dsh-console-frontend

    dsh-console 前端:rabbita + precss

    dsh
    console
    rabbita
    precss
    Download zip
    Version
    0.1.0
    License
    MIT
    Last updated
    4 hours ago
    Downloads
    3

    #dsh-console 前端(rabbita + precss)

    四页全量:左侧导航四项(实例总览 / 日志 / 设置 / 关于),页面状态写进地址栏 (history.pushState + popstate,见 route.mbt),刷新与前进 / 后退都落回原处。 读数全部来自后端五个只读接口,读不到就照实写「未知」/「—」,不编; 读数还没回来之前界面是加载态,不是空态。

    写动作走契约里的两个写接口:概览页详情抽屉里的控制按钮 → POST /api/local/action, 设置页 → GET/PUT /api/local/settings(形状见 ../design/write-api-contract.md)。 「设置」原先在顶栏,这一轮搬进侧栏并做成左索引 + 右条目的设置页,顶栏随之取消。

    依赖只有 rabbita 与 precss:没有组件库、没有图标字体、没有仓库外的模块成员。 组件 DOM 与类名照 Vue 版(已按清单退役,见 git log)逐条对齐,样式与图标都是我们自己的。

    Vue 版留下的对照物:../design/vue-baseline-*.json(几何基线)与 ../../dsh-console-design/(三列对比图与换栈决策)。

    #30 秒上手

    ./check.sh # 类型检查 + 构建(单退出码) ./check.sh --fast # 只做类型检查,几秒 ./build.sh # 只构建:样式 → js → 拷 dist

    跑起来看(后端起在 5390,同时供静态与接口,同源)。自测请显式给 DSH_CONSOLE_DIST: 它不接受「猜」,指错了会直接算失败并写清原因,所以别赌后端上溯能命中。

    DSH_CONSOLE_DIST="$PWD/dsh-console/frontend/dist" \ dsh-console/backend/_build/native/debug/build/dsh-console-backend.exe # http://127.0.0.1:5390/

    MOON_HOME 默认指向 $PWD/.sandbox/moonhome:本机 ~/.moon 挂在只读文件系统上, moon 往里写不了 registry 缓存。要覆盖就 MOON_HOME=... ./build.sh。

    #验收怎么跑

    # 独立验证器:横向溢出 / 控制台报错 / 真数据到位(不依赖任何自证) node ../design/verify-frontend.mjs http://127.0.0.1:5391 # 逐条对账:清单第五节的 12 条几何不变量 + 菜单 / 对话框 / 日志页 / 派生等行为 node ../design/measure-parity.mjs http://127.0.0.1:5391

    measure-parity.mjs 的量法是 DOM 实测(bounding box、computed style、activeElement、 滚动位置),不是「看着一样」。悬停态用 CSS.forcePseudoState 定住再量。

    #目标平台为什么是 js

    moon.mod 里 preferred_target = "js",supported_targets = "js+native"(native 只给 cmd/build-styles 这个构建期步骤用)。理由三条:

    1. 这一侧的运行期就是浏览器。页面要自己 fetch 五个只读接口、自己渲染, native 目标给不了这件事。
    2. native 那一侧已经被后端占了。../backend/(MoonBit + moonback)已经是同端口 服务静态与 JSON 的进程,前端再出一个 native 服务就是两个进程抢同一件事。
    3. 构建期的样式编译(cmd/build-styles)要读文件、写产物,只有 native 给得了; 它由 build.sh 用 --target native 单独跑,不进浏览器产物。

    #依赖:只剩 rabbita 与 precss

    moon.mod 的 import 表就是全部依赖,三个都从 registry 解析:

    依赖谁在用
    moonbit-community/rabbita全部视图与 update 循环
    conglinyizhi/precss只在 cmd/build-styles(构建期把 SCSS 编成 CSS)
    moonbitlang/async只在 cmd/build-styles:async fn main 语法与 @fs 读写都来自它

    没有 moon.work,也没有外部模块成员:上一版把组件库以本地路径写进 moon.work (它没发布到 mooncakes),代价是本工程不自包含。组件库摘掉之后这份工作区文件一起删了, 仓库自包含;icons.mbt 需要的图标路径也是取一次、固化成常量的,不留 Node 依赖。

    想删 moonbitlang/async 之前先看这条:浏览器那一侧确实不用它(app.mbt 里的 async fn 是 MoonBit 的语言语法),但 cmd/build-styles 要,删掉会直接报 「its containing module is not imported by … cannot be imported by its package」。

    #另一个坑:本模块根包的别名要显式给

    模块名 conglinyizhi/dsh-console-frontend 带连字符,cmd/app 里引自己的根包时, 默认别名会从最后一段推导、推出来的标识符不合法。必须写成:

    import { "conglinyizhi/dsh-console-frontend" @frontend, }

    #目录

    . ├── moon.mod / moon.pkg 模块定义(依赖只有 rabbita / precss / async) ├── icons.mbt 26 个图标的内联 SVG:路径常量 + icon() 助手 ├── model.mbt 五个只读接口的数据模型与解析(外壳 → 字段,不做业务推导) ├── settings.mbt 设置键表(9 个键 5 个分组)、取值模型、PUT 请求体 ├── api.mbt 写接口的传输层:fetch + 状态码/响应体,POST action / GET+PUT settings ├── state.mbt Model / Msg / 行模型 / 演示数据 / 工作流文案 ├── app.mbt update 循环:拉取、写动作、设置页状态转移、对话框、日志 ├── clock.mbt 浏览器本地时刻(页脚灯的「检查于 <时刻>」/ 归档时间戳) ├── dom.mbt 浏览器副作用 FFI:焦点、滚动、短时刻、控件值回写、减少动效判定 ├── route.mbt URL 路由:pushState / popstate、地址 ↔ 页面状态、回落说明 ├── view_shell.mbt 外壳:左导航 / 内容区(顶栏已取消)+ 地址回落提示条 ├── view_overview.mbt 实例总览:卡片(三样)、详情抽屉、归档区、三个对话框 ├── view_settings.mbt 设置页:左索引 + 右条目(数字 / 开关 / 下拉) ├── view_logs.mbt 日志页:原生 select 来源、元信息行、日志区 ├── view_about.mbt 关于页:简介 + 版本表 + 「这台的 dsh 版本」(可切版本) ├── view_parts.mbt 共用小件:徽标、分组头、空态、字段行(抽屉里那六格)、对话框外壳 ├── cmd/app/ js 可执行:把 app() 挂到 #dsh-console-root ├── cmd/build-styles/ native 可执行:precss 编 SCSS ├── styles/app.scss 全部样式:reset + 设计 token + 版式 ├── web/index.html 页面外壳 ├── vendor/licenses/mdi-NOTICE.md 图标路径数据的第三方声明(不再分发字体文件) └── dist/ 构建产物,后端直接服务它

    #组件与图标都是自建的

    卡片、按钮、徽标、对话框的 DOM 与类名照 Vue 版逐条对齐,形状全部出自 styles/app.scss (那份表就是从 Vue 的组件样式块机械迁移来的)。没有组件库要对抗,也就不再有 「把 Material 的尺寸档掰回我们的值」那层覆写。

    图标是内联 SVG,不是图标字体:

    • 30 条路径固化成常量放在 icons.mbt,助手 icon(name, size) 产出 <svg class="dsh-icon" viewBox="0 0 24 24" width=… height=…><path d="…"/></svg>;
    • 尺寸在调用点上逐处给(12 / 13 / 14 / 16 / 17 / 20 / 26),与 Vue 版 AppIcon 的 :size 一一对应。别随手改:侧栏图标大一号就会把「从当前实例派生开发副本」 那行挤成两行(实测过);
    • 路径数据取自 @mdi/js 7.4.47 之后固化,Node 依赖没有进仓库; 声明在 vendor/licenses/mdi-NOTICE.md;
    • 所以 dist/ 里没有任何字体或图标资源,只有 app.css、app.js、index.html 和一份许可声明。

    不套现成组件,以及为什么(这些是上一版逐个对比过的结论,仍然成立):

    • 抽屉 / 顶栏类组件把 position: fixed、z-index、side 偏移写成内联样式, 而本外壳是文档流里的 flex 两列;内联样式没法从样式表压过,除非上 !important。
    • 菜单锚在 activator 的包裹层上,位置由内联样式给;我们的不变量是 「锚在按钮组容器上、top = 按钮组底 + 6px、right: 0」。
    • 对话框内容是内联 translate(-50%,-50%),遮罩不给点击回调;我们要「点遮罩关闭」, 而 rabbita 的 div.on_click 收不到事件对象、判断不了 target。 改成遮罩与卡片同级(遮罩 z-index 60、居中层 61),点遮罩天然成立。

    #样式:一份文件 + 自带的 reset

    dist/assets/app.css 由 styles/app.scss 一份编出(cmd/build-styles,precss)。 文件内部按 reset → 设计 token → 版式 排;颜色、圆角、间距一律用 SCSS 变量。

    reset 必须自己带。 这一层以前由组件库的 vendor 层提供,摘库的时候最容易漏: 清单里那些几何量(卡片 padding、字段行高 28px、侧栏 190px…)都是建立在 「所有元素 margin/padding 归零」这个前提上的,少一条整页就位移。 所以 app.scss 开头有一节显式的 reset,表单控件的浏览器默认皮也在那里摘干净 (原生 select 的箭头显式关掉:我们没画替代品,不关的话系统箭头会冒出来)。

    precss 不做颜色函数求值。 rgba($ok, 0.1) 会原样吐成 rgba(#4ec27a, 0.1) —— 浏览器认不了,整条声明被丢掉,背景/描边会静默回落成上一条规则的值(踩过:徽标的 .real 底色没了)。需要透明度时用 RGB 三元组变量:rgba($ok-rgb, 0.1), 那几个变量在 app.scss 的 token 一节。

    app.scss 是机械迁移来的:脚本按类名映射表把 Vue 版散在 18 个组件的 <style scoped> 改名前缀。迁移脚本没进版本库(一次性),映射表与自检在提交说明里有记录。

    #动效

    唯一的动效是启动等待态状态点的 dot-pulse(900ms 呼吸 + 琥珀光晕)。 组件库自带的 transition(按钮背景/阴影、卡片 box-shadow、那条高度为 0 的 loading 进度条)随库一起去掉了,量「还有没有动效」时结果是 0。

    #写接口这一侧(这一轮新增)

    页面上的动作不再是演示,全部落到契约里的两个写接口上:

    • 行内按钮与下拉菜单 → POST /api/local/action,请求带 Content-Type: application/json 与 X-DSH-Console: 1(自定义头就是契约里的跨源闸门,浏览器不允许跨源带它)。 action 与 params 逐条按契约:start-instance / stop-instance / restart-instance / fork-profile / rename-profile / archive-profile / unarchive-profile / delete-profile / set-profile-note。 delete-profile 在 safety.deleteToTrash 不是 true 时带上 confirm:"DELETE"。
    • 设置页 → GET /api/local/settings 取当前值 / 缺省 / 来源,PUT 一次改一个键。 改动即保存,成功失败都有反馈,失败显示后端给的 code: message。
    • 成功率与失败率不一样对待:动作成功之后重读 profiles + instances(刷新读数,不是弹个提示); 失败把后端的原因原样显示在总览页顶部的提示条上,不吞、也不伪装成成功。
    • 目标来自读数:读不到 profile 目录名或端口时,动作在本地说清「没有可用的目标」并不发请求 —— 演示数据里的名字(main / dev / staging)不会被打到真实的东西上。
    • 启动有等待态:回执与展示下限(start_delay_ms)都到齐才收尾;收尾不伪造成「运行中」, 真的起来了的那张卡由重读 instances 给出。

    #profile 备注(第二轮)

    每只 profile 可以写一条备注记它是干什么用的。备注在它自己的目录里 (<profile 目录>/.dsh-note.txt,契约里定的位置),所以改名、派生、搬走都跟着走; 前端只负责读写这条读数与那个动作:

    • 读:GET /api/local/profiles 每个 profile 多一个 note(与 noteSource)。 非空时卡片下面多一行:只有备注本身(左侧一道细竖线当书签边,不带「备注」标 —— 这一行整个就是备注,标签是把同一句话说两遍),整行省略、完整内容与来源在悬停里。 没有备注的卡片不进这一行(note 空串时那个节点根本不在 DOM 里), 所以卡片几何与加这个功能之前逐项一致 —— 这条是量过的,不是估的。
    • 写:行菜单「编辑备注」(与「重命名」并列)→ 同一套对话框外壳 (居中层 .dsh-dialog-wrap、Esc 取消、关闭后焦点回触发元素),内容是自由文本: 预填当前备注、光标落在文末、留空保存就是删掉备注。 保存失败不关框:后端给的原因(bad-params / io-error / not-authorized…)写在框里, 改一改就能再存一次;成功了才关框、重读 profiles、提示条里给出后端那句 note。
    • 删除确认框里也显示这条备注:删之前能看清这一只是干什么用的(有备注才出现那一段)。

    #带令牌的访问地址(第四轮)

    dsh web 的首次握手是 URL 里的 ?token=…,不带就是一个 401 页面。控制台起实例时把 dsh 的 启动横幅(dsh web: http://…/?token=…)留在它的实例日志里,后端因此能按实例给出一个 能直接握手的地址:GET /api/local/instances 每条多 webUrl(读不到是 null)与 webUrlSource(令牌从哪来,或者为什么读不到)。前端这一侧只消费这两个字段,不自己拼地址:

    • 打开 Web UI(卡上)与在新标签页打开(行菜单)用的是同一条 webUrl。开没开成看 window.open 的返回值:被弹窗拦截就写「浏览器把新标签页拦掉了」,不报一句空的「已打开」。
    • webUrl 为 null 时按钮照样可点,但一个标签页都不开:提示条先说清读不到令牌的原因 (后端那句 webUrlSource,例如「外部实例:令牌在启动它的终端里」),再把裸地址给人, 让他从启动它的那个终端里抄横幅自己拼。拿裸地址去开新标签就是一个 401 页面, 那等于给了个点了不能用的链接。
    • 「访问地址」那一行显示的仍是裸地址(人读的),悬停里说清带令牌的那条有没有、 令牌从哪读来、读不到是为什么。令牌原值不进悬停:要看要复制走菜单。
    • 两条复制:「复制访问地址」复制裸地址;「复制带令牌的地址」只在真有 webUrl 时出现, 复制的是 webUrl。剪贴板先走 navigator.clipboard,失败退到 document.execCommand, 两条都失败就如实报失败(提示条上那句「已复制」必须对应真写进去的东西)。

    真跑过的证据在 ../design/evidence/evidence-frontend-weburl/:探针 probe-weburl.mjs 从页面上真点 (Input.dispatchMouseEvent,不是 element.click() —— 没有真手势 window.open 会被弹窗拦截), 抓 window.open 收到的原值、新标签的 target、剪贴板读回的内容,以及新标签自己的文档状态。

    #这两处读数从哪来

    runtime.json(哪些实例是本控制台起的)与 archive.json(归档记录)是两份状态文件, 浏览器读不到文件系统。契约里那五个只读接口的形状一个字段都不动,所以后端另开了一个 只读端点 GET /api/local/console 把它们如实读出去,前端按这份读数办两件事:

    • 卡片上的「由本控制台启动」:按 runtime 里的 port 匹配,且那条登记的 pid 还活着。 只看端口不够 —— 登记过、后来那个 pid 退了、端口被别人接走的情况长得一样。
    • 归档区:按 archive 里的记录标出对应的 profile 行,行上的归档时间就是记录里的 archivedAt(显示成浏览器本地的 MM-DD HH:MM,原值留在悬停里)。磁盘上已经没有那只 profile 的记录另补一行,好让人看见并撤掉它。归档没有「已合并」标记了(第二轮): archive[] 里只有 { profile, archivedAt },旧文件里那个多余的字段读到时直接忽略。

    这一份读数读不到不影响别的页面:卡片上少一个标记,归档区空着,其余照常。

    #页面状态进地址(第五轮)

    地址是页面状态的第二个副本,不是另开一层:route.mbt 里一张表管两个方向 —— parse_route 读、route_url 写,别处不许自己拼地址。

    地址页面
    /实例总览
    /logs?source=… 或 /logs/<来源>日志(来源是页面状态的一部分:不带它,刷新就回默认来源)。两种写法都认:切来源写 query,失败提示条的「查看日志」写路径形式 /logs/actions
    /settings设置(第一个分组)
    /settings/<分组 id>设置(某个分组,如 /settings/safety)
    /about关于

    • 为什么是路径而不是 hash:后端静态服务带 SPA 回退(backend/dist_static 的 history_fallback 指向 /index.html),/settings/process 这种没有对应文件的路径 刷新时拿到的还是同一份 index.html,应用起来之后按 pathname 复原页面。 不用改后端;换成 hash 也能落回,但地址里会永远挂一个 #。
    • 写:用户点出来的切换走 push_route(可后退);由读数推出来的改写走 replace_route (回落、默认来源自动定下来的那一次),它们不是用户走的一步,不该在后退里多占一格。 地址没变就不写 —— 同一页连点两次侧栏不该攒两条一样的历史。
    • 读:启动时读一次,之后每来一次 popstate 读一次当前地址 (不是事件里那份,连点两次后退也只会读到最新的一条)。监听用一次性的 Promise 挂, 回执进 update 再挂下一次,任何时刻只有一个在等。
    • 认不出来的地址不白屏:/nope 回落总览、/settings/某个没了的组 回落设置首页, 并把「为什么」写成一句话显示在内容区顶部那条 .dsh-route-notice 上 (回落这一步同时改写地址栏,页面与地址必须指着同一处)。
    • 日志来源认不出(不是 host / audit / instance:<id>)也走同一套:按默认来源读 + 说一句。

    #读数没到 ≠ 没有数据

    刷新之后那几百毫秒里,读数还在路上。以前这段时间界面直接摆空态 (「当前没有检测到正在运行的 dsh 实例」),把「还没读到」说成了「没有」。 现在分三档,且三档互不混淆:

    档数据源徽标那一格
    请求还在路上读取中(.dsh-badge.pending)、分组计数写 (…)卡片栅格里是 6 块与真条目同形的占位块(第十一轮起最多占一行)+ 一行「正在读取…」(.dsh-loading → .dsh-skeleton-row / .dsh-skeleton / .dsh-loading-note);日志页与回收站页仍是整幅一块(.dsh-loading 面板)
    读到了、确实是空真实数据空态(虚线框 +「当前没有检测到…」)
    读失败演示数据 · 真实读取失败 + 具体原因空态 + 那个徽标(既有行为,不并进加载态)

    • 判据是 readings_pending(model)(built 还没立起来);日志页看的是 log_loading 与 log_source_read:内容不属于当前来源时显示加载态, 不拿上一个来源的行顶着新来源显示。
    • 加载态没有动效:全页的动效留给启动等待态的状态点, 多一个就会打破 design/probe-animation.mjs 的「点击前无可见动效」。
    • 卡片栅格里的加载态与真条目同形(第八轮):6 块占位(图标位 + 名字条)与真卡片共用 同一套列轨与断点,占位块是静态灰块、不是交互元素,带语义的只有那一行 role=status 的说明。 实测与口径见下面「加载骨架屏与卡片同形(第八轮追加)」一节与 evidence-loading-skeleton/。
    • 第十一轮起每个分组最多占一行:占位块装进一层 .dsh-skeleton-row,那一层横跨整幅、 max-height 卡成恰好一行 + overflow: hidden,第 2 行往后裁掉(见下面第十一轮那一节)。
    • 加载块的说明里刻意不写空态那两句话(「当前没有检测到正在运行的 dsh 实例」等): 验收是用整句去认空态的,加载块的说明里带上那半句,就会把「不是空态」这条判成假的。

    路由与加载态的真跑证据在 evidence-routing-loading/(node verify-routing.mjs <baseUrl>): 逐页 / 逐分组贴 URL、Page.reload 前后对照、前进后退、错误 path 的回落, 以及用 Fetch 拦截把接口压后 2 秒拍到的加载态 / 放行后的真实数据 / 空数组响应下的空态。

    #一行自己的 home:停止卡那一格与「复制开发指令」(Phase 3 追加)

    一份档案的身份是 (home, 名字)。卡片上 DSH_HOME 那一格说的是这一行自己的 home, 不是控制台当前在用的那一个 —— 两者可以相同,也可以不同:

    哪一种行这一格的值从哪来认不出时
    运行中那一只实例读出来的 dshHome(实例 environ / fd 前缀,口径见后端 a 那一条)未知,悬停是后端给的来源原文
    已停止profiles 读数就是在 <它>/profiles 下把这一只列出来的那个 home(读数里的 dshHome)未知(不回落到 runtime 读数,更不拿控制台自己的 home 顶上)
    归档孤行归档记录自带的 home(磁盘上没有目录,没有别处可读)—(记录里也没写,就不编一个)

    「复制开发指令」(own_home_of)只用这一行自己的 home 拼 <home>/profiles/<名> 与 DSH_HOME=…; 读不到就一份都不产出,在页面上说清为什么 —— 拿控制台自己的 home 顶上, 粘过去那边就会去动主线那一份 ✗✗。

    profile 的写动作(profile_pairs)同理:认得出来就带上 {profile, home},认不出来只发名字 (由后端按自己的 home 认)。实例与 profile 的配对(running_rows / in_use_by)按 (名字, home) 比: 同名不同 home 是两份档案,home 认不出就不认。

    改前 / 改后对照与三条 fixture 的原始输出:evidence-home-own/。

    #卡片第六格「最近改动」与已停止组的排序(第五轮追加)

    使用者点出来两件事:① 卡片字段区是 2 列 × 3 行 = 6 格,却只填了 5 格,右下空着; ② 派生出来的 profile 排在列表最下面,看不出「刚弄出来一个」。

    #第六格:最近改动,不是创建时间

    那一格取的是 profile 目录的 mtime(GET /api/local/profiles 的 dirMtimeMs, 毫秒时间戳),按浏览器本地时间显示成 10-15 18:50。

    口径是「最近改动」,不是「创建时间」 —— 这条要说清楚,不许含糊:

    • 多数文件系统上可靠的创建时间拿不到。ctime 记的是 inode 变更时间, chmod / 改名都会把它推新,它不是出生时间;birth time 不通用 (不是每个文件系统都存,也不是每个平台都读得到)。
    • 目录 mtime(最近改动)是能拿到、且不会骗人的那一个。
    • 它顺带回答了「哪个是刚弄的」:刚派生出来的 profile 一定是最新的。

    所以:标签写「最近改动」,代码注释里也写「最近改动」。任何地方都不许写成「创建时间」✗

    情况那一格显示悬停里有什么
    有值(停止卡 / 运行卡的 profile 解析得出)10-15 18:50完整 ISO、口径说明、dirMtimeSource
    读了但读不到(字段是 null)读不到后端给的 dirMtimeSource 原因,原样带上
    运行卡解析不出 profile—是「没解析出 profile 名」还是「profiles 读数里没有这一只」,分开说
    响应里根本没有这个字段读不到「profiles 读数里没有 dirMtimeMs 字段(后端这一版还没给)」

    最后一行是前端补的话,与「后端读了但读不到」不是同一件事,所以要说得出区别 —— 缺值不当 0,也不假装有值。运行卡解析不出 profile 写 — 而不是「读不到」, 同理:没有可读的对象,和读了拿不到,不是一件事。

    #已停止组按最近改动降序

    • 已停止那一组按 dirMtimeMs 降序排:新的在最上面,刚派生出来的 profile 一眼就能看到。
    • dirMtimeMs 为 null 的一律沉底(None 不是 0,不参与比较),同一档内按名字升序兜底 —— 顺序必须是确定的,不然同一份读数每次刷新都可能换个样子。
    • 运行中那一组不掺进这条口径:它还是按实例读数自己的顺序走。

    踩过的坑(记在这里免得再踩):兜底的名字升序必须用 String::lexical_compare, 不能用 <。MoonBit 的 Compare 对 String 是 shortlex —— 先比长度、长度相同再逐字符比。 于是 "web-dev" < "headless" 成立(7 < 8),三张卡按「名字升序」排出来是 web-dev / headless / review-dev,即「短的在前」,不是人说的字典序。 evidence-mtime-field/probe-mtime.mjs 的 DOM 实测把这件事抓出来了(那时页面顺序与接口对不上)。

    DOM 实测的证据在 evidence-mtime-field/(真响应一跑、合成 fixture 一跑、缺字段一跑):

    node evidence-mtime-field/probe-mtime.mjs http://127.0.0.1:5390/ real evidence-mtime-field/real node evidence-mtime-field/probe-mtime.mjs http://127.0.0.1:5390/ \ synth:evidence-mtime-field/fixture-synth-profiles.json evidence-mtime-field/synth node evidence-mtime-field/check-order.mjs evidence-mtime-field/synth.json

    check-order.mjs 用 JS 的 sort 独立算一遍期望顺序,再跟页面上卡片的先后顺序对齐。 排序要在合成 fixture 上量:已停止组会排除正被实例用着的 profile,而机器上谁在跑是活的。

    #卡片变条目 + 详情抽屉 + 三色状态灯(第七轮追加)

    使用者原话:「图标右上角来标记一个实心的圆,绿色标记为运行,黄色标记为故障,红色标记为停用」 「隐藏启动等一大堆按钮……首页就只有一点 profile 名称和备注等简单的东西」。

    #卡片只剩三样

    三样各占一处:左上 profile 名、右上 端口号(只在运行中那一条,写成 端口:3080 —— 淡一档的标签加数字,见「右上角那一格加「端口:」标签(第十三轮追加)」)、 下面一行 备注(有才进 DOM)。图标在左边,那颗实心状态灯挂在图标右上角 —— 与条目右上角的端口是两处位置,不是一处(见「第九轮追记」)。 六格读数、按钮组、行尾的 ⋯ 菜单全部撤走 —— 点整张卡是打开详情的唯一入口。 卡片是 article + role="button" + tabindex="0"(不是 <button>:里面还可能放文本节点, 而 button 里嵌块级内容是无效的),鼠标点、Enter、空格都能开抽屉。

    带走的东西一个都没丢,它们搬进了抽屉:今天行菜单里有的一个不少 —— 打开 Web UI / 在新标签页打开 / 停止 / 启动 / 重启 / 查看日志 / 打开工作目录 / 编辑配置 / 重命名 / 备注 / 派生开发副本 / 归档 / 取消归档 / 复制访问地址 / 复制带令牌的地址 / 复制开发指令 / 删除,分四组(主操作 / 拿走 / 改动这只 profile / 运维)。 「由本控制台启动」那一枚也从卡片挪到了抽屉标题旁(它是第四样,卡片放不下)。

    #状态灯:四档 + 一档不是颜色的档(§26.1)

    档位颜色判据
    running绿实例在跑,且这只 profile 没有已知隐患(签名探测通过、缺包 0、cordis.patch.yml 在)
    warn黄有警告:没在跑但有隐患(缺包 / 没有 cordis.patch.yml),或在跑而 profile 有隐患;也接住「端口在听、签名不通」
    fault红上一次启动失败,且失败之后没有成功启动过。判据是 profiles 读数里的 lastStart(后端从 audit.jsonl 里 start-instance 与 restart-instance 两种启动记录取最近一条,见 design-fixes §27.1)
    stopped不画灯没在跑、没有警告、最近一次启动也没失败。状态词「已停止」是第一行唯一的信号
    starting黄(在动)正在起来。同一支琥珀,但文案是「正在启动」,不会被读成故障;它就是原来副标题行上那个等待态的点
    unknown不上色后端的 state 字段没给:空心灰环

    颜色不是唯一信息(契约的硬要求),所以每一档都带两句话,读者不是同一个人:

    • light_reason —— 抽屉「状态」一节里的正文:一行,说人话(它正在 :3080 上应答 / 端口在听,但不应答 dsh 的握手 / 没有在跑)。黄的两种成因因此分得开,两句长得不一样。
    • light_basis —— 悬停里的技术依据:哪一条握手应答命中、探测回来的是什么、profile 那一侧怎么样。 正文一个字都不占:成因编号、应答标识、组件名这些是给改这个工具的人看的,不进正文。

    档位标签只说状态、不写颜色(运行中 / 有警告 / 上次启动失败 / 正在启动 / 已停止 / 状态未知): 颜色由那颗圆自己给,文字里再说一遍就成了在念色卡编号。已停止那一档没有灯, 这个词就是它唯一的信号,所以状态词在任何时候都在场。

    读不到状态时前端不猜颜色:实例卡走 unknown(空心灰环),抽屉正文写「读不到状态,颜色不猜」, 已有的探测结论收在那句悬停里。理由是前端只看得到一半判据:绿的另一半(「端口在听却探测不通过」) 要后端把那些端口列出来才有,现在它们根本不进列表。已停止的 profile 卡不一样:它本来就不是 实例读数,黄/红的判据(缺包、有没有 patch)全在 profiles 读数里现成摆着,那是照判据算出来的事实。

    读数里的 state / stateReason / profileHealth 按契约解析(形状见 model.mbt); 其中 stateReason 不再直接渲染 —— 原文里有成因编号、应答标识这些内部名, 抽屉正文与悬停都由前端按结构化事实重写,原文留给将来给排查面板用。

    #详情抽屉

    • 右侧、覆盖式(position: fixed,不占文档流,卡片不必为它让位);遮罩与面板是兄弟, 与既有对话框同一套做法(rabbita 的 div.on_click 收不到事件对象、判断不了 target)。
    • 层级压在对话框之下(抽屉 50/51 < 对话框 60/61):抽屉里的重命名 / 备注 / 删除会开对话框, 框必须在抽屉上面。开这三个框时抽屉先收掉:不然取消之后焦点会落进一个盖在背后的面板。
    • 点外部 / Esc / 关闭键三条路都发 DrawerClose;打开时焦点进抽屉里的关闭键, 关闭时焦点回触发它的那张卡(卡有稳定 id dsh-card-<row.id>)。 焦点落在被卸载的节点上会掉到 body,键盘用户于是从页首重新 Tab —— 所以这一条是量过的。
    • 窄屏(≤640px)占满宽度、宽屏固定 520px;内容自己滚(.dsh-drawer-body 的 overflow-y: auto)。 窄屏下六格读数同时塌成一列(两列时值全被省略号截掉)。
    • 开合有过渡(第十一轮):从右边滑进滑出 + 一点透明度,出入同一条 220ms / ease-out; 收的那一段里节点多留一会儿、且对指针关掉 —— 焦点不等动画,DrawerClose 那一拍就回卡片了。 prefers-reduced-motion: reduce 下不做任何位移与淡入淡出(收的那一半连节点都不留)。 见下面「开合过渡与骨架屏一行封顶(第十一轮追加)」。
    • 列表重建后抽屉指着的那一行没了(实例停掉变成 profile 行、归档移出分组、profile 被删), 抽屉自动收掉 —— 留一个没有内容的浮层会挡住底下。

    #响应式重算

    卡片瘦成一行之后,原来那套「最小可用宽度 553px」的推导(副标题 181 + 按钮组 278)作废。 新的最小可用宽度 240px = 内边距 32 + 边框 2 + 图标 40 + 间距 10 + 名字那一格的可读宽度 150: 前四项是常量,150 是可读性下限不是溢出下限(名字与备注都带省略号,卡再窄也不溢出, 窄到 150 以下就只剩省略号、认不出是哪一只了)。运行中的条目还要从这 150 里分一点给 右上角的端口(第十三轮起写成「端口:3080」:标签 39 + 数字 24 + 一个 8px 间距, 见「右上角那一格加「端口:」标签(第十三轮追加)」)—— 卡到 240px 时 名字那一截还剩约 99px(加标签之前是 138),仍认得出是哪一只。

    视口断点列数
    < 7261
    ≥ 7262
    ≥ 9783
    ≥ 12304
    ≥ 14825
    ≥ 17346(上限)

    算式:页面这侧可变宽度 = 视口 − 侧栏 190 − 页面内边距 44 = 视口 − 234, n 列要 234 + 12 × (n − 1) + 240n。常见宽屏的对比:1280 从 1 列变 4 列,1920 从 3 列变 6 列。 上限钉死 6 列(1920 上每张卡 271px;2560 上是 377px,不再加列)。 任何宽度都不横向溢出:页头那一行也允许折行(以前 .dsh-head-note 是 flex: none + nowrap, 360px 下会顶穿 .dsh-main,逐档实测抓到的)。

    实测在 evidence-card-drawer/(探针一个都不改 ../design/ 的脚本):

    cd dsh-console/frontend/evidence-card-drawer node probe-states.mjs http://127.0.0.1:5390/ states # 三色 + 黄的两个成因(计算样式,不是看截图) node probe-drawer.mjs http://127.0.0.1:5390/ drawer # 卡片只剩三样 / 抽屉开合与焦点 / 按钮可达 / 窄屏占满 node probe-widths.mjs http://127.0.0.1:5390/ widths # 20 档宽度的列数与溢出

    黄色的成因 b 需要一个「端口在听、cmdline 像 dsh、签名探测不通过」的进程:fixture.mjs 会真起一个 (exec -a 把 argv[0] 写成 dsh 的入口路径),并按契约把那条实例注入 instances 响应。 这一版后端还不列这种端口(state 已经落地,但「签名不过也列出来」这条行为还没有), 所以 b 是照契约造出来的;它的 stateReason 与真实探测结果逐字对齐(HTTP 200 text/plain,没有命中签名)。

    #加载骨架屏与卡片同形(第八轮追加)

    使用者原话:「你正在加载的骨架屏和现在不太符合了,调整一下」。 第七轮把卡片瘦成条目(图标 + 状态灯 / profile 名 / 备注)之后,加载态还是宽卡时代那块 「整幅居中 + 一行字」—— 读数一回来卡片进场,主区宽度、列数、起始位置都要跳一次。

    占位块里没有端口那一格:加载时还不知道哪一条在跑,摆一个假的端口位置只会骗眼睛; 高度仍是 70(端口那一格高 16 < 图标位 40,不进高度),所以放行后不跳。

    现在加载态在同一个栅格里摆 6 个条目形状的占位块(图标位 + 一行名字条),与真卡片共用 同一套列轨与断点:外层 .dsh-loading 在 .dsh-card-grid 下是 display: contents (不生成盒子),里面的块直接成为栅格项。几何逐项对齐 .dsh-case:内边距 14/16、边框 1、 圆角 10、图标位 40×40、图标与名字之间 10 —— 占位块高 70 = 没有备注的真卡片; 窄视口那一档(≤620px)两边一起收成 34 / 12 / 13,收到哪一档都对得上。

    为什么是 6 块:栅格上限就是 6 列,宽屏那一档正好铺满一排,窄屏就是 6 行。 这个数不是读数 —— 真有几张卡要等读数回来才知道,所以不假装知道;分组自己的高度因此 可能变(6 块占位 ≠ 实际 2 张卡),这是「还不知道」绕不开的代价,宽度与列轨不受影响。

    视口列数占位块(加载时)真卡片(放行后)
    19206271×70,列起点 212/496/778/1062/1344/1628271×70;克隆到 6 张后列起点逐列相同
    12804252.5×70,列起点 212/476/742/1006252.5×70,起点 212
    7682261×70,列起点 212/486261×70,起点 212/486
    3601126×60,起点 212126×60,起点 212

    • 放行前后主区宽度、列轨数、第一项的 x 逐像素相同,宽屏下第一项的 y 也相同(96.7)。 360 下第一项 y 从 227.2 变到 161,差的 66px 全部来自加载时那三枚「读取中」徽标 (页头 122.8 → 96.8、分组头 60.4 → 20.1,两项相加 66.3),与占位块无关,改之前同样存在。
    • 占位块是静态的:加载态下全页在跑动画的元素 0 个 (probe-animation.mjs 那条「点击前无可见动效」因此不受影响)。
    • 占位块不是交互元素:.dsh-loading button, .dsh-loading [tabindex] = 0;点它的正中 没有任何变化(URL / 抽屉 / 焦点 / 节点数全同);Tab 走一圈焦点不落在它上面; 12 块全部 aria-hidden,无 role、无 tabindex、无点击处理器,光标也是默认箭头。 带语义的只有说明那一行(role=status + aria-live=polite,每个分组一处,文案只此一份)。
    • 空态(.dsh-empty 虚线框)与「这一版后端没给字段」那两档一个字没动。
    • 日志页与回收站页照旧是整幅一块:那两处的内容本来就不是卡片,条目形状在那里没有对应物。

    实测在 evidence-loading-skeleton/(探针只读页面,一个都不改 ../design/ 的脚本):

    cd dsh-console/frontend/evidence-loading-skeleton node probe-skeleton.mjs http://127.0.0.1:5390/ skeleton # 用 Fetch 拦截把 /api/local/* 压后 2 秒:加载中那一刻量一次、放行后再量一次, # 四种视口(1280 / 1920 / 768 / 360)各一遍,外加点一下与 Tab 一圈的交互性实测。

    #条目改两行与手动拖动排序(第十轮追加)

    #条目:左图标竖跨两行

    第七轮的条目是一行(图标 + 状态灯 / 名字 / 右上角端口)加下面一行备注。第十轮改成左图标 + 右两行: 第一行是 profile 名与端口号,第二行是备注;没有备注就写「上次启动」,两个都没有就空着。

    高度是这一版最要紧的一条约束。两行的行高加起来正好等于图标那一块的高度: 22 (第一行 line-height) + 2 (body 的 gap) + 16 (第二行 line-height) = 40 = 图标, 所以有没有第二行,条目高度都是 70px(14 + 1 + 40 + 1 + 14)—— 与第七轮「没有备注的条目」逐像素一致。 只有一行时整块由 justify-content: center 居中,名字的基线也落在原处。 响应式列数是从这个宽度推出来的,改 .dsh-case 的任何一条常量都要回头看 .dsh-rows.dsh-card-grid 那一段算式。 逐像素对照实测在 evidence-card-layout/ab-old-*.json 与 ab-new-*.json(同一份 fixture、改动前后两个构建)。

    第二行有三档,各有各的记号:

    • 备注:左侧一道 2px 细竖线 + $text-mid —— 那道线是「这是人写的」的记号
    • 上次启动:上次启动 10-15 18:20,不带那道线、颜色再淡一档($text-dim)—— 时间是机器的读数, 给它挂上同一道线就是把两种东西说成一种
    • 空:不产出这一行(DOM 里没有节点),条目落回「只有一行」那副形状

    时刻只从控制台自己的记录里取,取不到就空着,绝不编一个时间:正在跑的那一只用 instances 的 startedAt (后端从进程起始时间推出来),其余按 port: / profile: 去 GET /api/local/console 的 lastStarted 里查 (后端把 runtime.json 的 startedAt 与 audit.jsonl 里成功的 start-instance 合成一份)。 解析不出时刻的串也当没有 —— 写一行「上次启动 —」看着像有读数,其实没有。

    端口那一格的判据是「这一行知不知道端口」,不是「灯是不是绿的」:故障那一档里「端口在听、签名不通」 正是最该一眼看到「它占着哪个端口」的时候,拿颜色当开关会恰好把它藏起来。

    #拖动排序

    全程指针事件(pointerdown / pointermove / pointerup / pointercancel),不用原生 HTML5 拖放: 后者的拖影与放下判定在无头浏览器里既验不了也不好控。

    • 按下与点击共存:按下只记住起点,位移超过 6px 才算拖动;没超过就还是点击(点条目开抽屉那条路一个字都没动)
    • 落位提示:跟着指针的半透明虚影 + 落在将来那一格上的占位块(第十二轮改成这样,见文末那一节)
    • 最后一格与第一格都够得着(第十三轮改,见文末那一节):指针越过最后一块的下沿 = 最后一格、 越过第一块的上沿 = 第一格,多行时最后一格就是最后一行的最后一格。 旧版在列表上下各留 32px 宽容带、出带即作废 —— 而指针要越过那一块才能到那一格,越过就出带, 这两格于是永远够不着。作废只剩两种:横向拖出栅格、拖进另一组(跨组不合并,这条不退)。
    • 松手立刻写回:POST /api/local/action 的 set-row-order,提交的是整份新顺序(不是增量),空数组 = 清空 (成功的回执第十一轮压成「顺序已保存」,落盘路径与条数收进悬停,见文末那一节)
    • 失败回滚:写不进去就把顺序退回提交前那一份并如实报错(io-error),不停在「看着是新的、其实没存下」
    • 窄屏不响应:< 726px(单列那一档)里纵向拖动与页面滚动落在同一个手势上,按下即不启动拖动
    • 键盘也能换位:聚焦某一条之后 Ctrl+↑ / Ctrl+↓,走同一条提交路径。分两拍: 先亮出占位块(190ms),再落位 —— 键盘用户看到的也是「将要落到哪一格」

    指针的移动 / 松开 / 取消挂在外壳(view_shell 的 .dsh-shell)而不是条目或分组上:拖到列表之外松手时, 挂在分组上就收不到那一次抬起,拖动状态会一直挂着。另外移动事件带着按键位掩码,为 0 就当作「其实早就松手了」。

    顺序表存在后端:$XDG_STATE_HOME/dsh-console/order.json,按行 id 存, 不存位置索引(列表本身会变)。行 id 两种:port:<端口号>(端口全机唯一,不带 home)与 profile:<名字>@<home>(多 home 之后一份档案的身份是 home + 名字,同名两份是两个 id,各排各的; home 为空串时才退回老形状 profile:<名字>)。拼法与后端 store/order.mbt 一字不差 —— id 是逐字比对的键。生效规则:没拖过走自动排序;拖过用存下来的;表里没有的行按自动规则排在已排好的之后; 运行中与已停止两组各自排,不合并成一张表。

    实测在 evidence-card-layout/(drag-5533.json 与同名截图:真指针按下一路拖到松开、写回后的 order.json、 刷新后仍在、窄屏不响应、写失败回滚、Ctrl+↓ 换位),探针是 ../design/probe-drag-order.mjs (第十二轮起它读的落位提示换成了占位块)。逐格一致那几条的实测见 evidence-drag-preview/。 第十三轮那两条「拖到最末 / 最前」与改过的作废口径,实测与判定表见 evidence-drag-ends/ (探针 evidence-drag-ends/probe-drag-ends.mjs,自包含、真指针、跑完直接给判定表)。

    #开合过渡与骨架屏一行封顶(第十一轮追加)

    使用者原话:「加一点合理的过度效果:① 面板展开和折叠 ② 骨架屏每个分组最多占用一行空间」。

    #详情抽屉的开合

    • 进:从右边滑进来(translateX(100%) → 0)并淡入;出:往右边滑回去并淡出。 出入同一条时长与缓动(220ms / ease-out)—— 一个快一个慢看着像被弹回去。遮罩跟着淡, 但不参与滑。
    • 时长写在两处、必须是同一个数:样式里 .dsh-drawer 的 animation,与 state.mbt 的 drawer_anim_ms。上限 300:收的那一段走完之后节点才撤,验收探针在 Esc 之后等 700ms 就断言「抽屉没了」,比 300 长就会让那条判定误报。
    • 收的那一段由 Model 里的 drawer_closing 撑着:DrawerClose 把行 id 从 drawer_id 挪到 drawer_closing,节点带着 data-state="closing" 多留 220ms 让滑出看得见,到点由 DrawerClosed(id) 撤掉(号对不上就不撤 —— 这中间又开过抽屉的话,那一枪作废)。
    • 焦点立刻就走,不等动画:三张卡上的老行为一个字没改(点外部 / Esc / 关闭键都发 DrawerClose,焦点回触发它的那张卡)。收的这 220ms 里抽屉还在文档里,但已经 pointer-events: none(半透明的面板不该再吃点击)—— 探针每 40ms 采一格,t=11ms 那一格 就是「抽屉还在(state=closing,x=823.9)、焦点已在 dsh-card-port-3080 上」。
    • prefers-reduced-motion: reduce:不做任何位移与淡入淡出。开的那一半由样式里那条 media query 关掉动画(直接出现);收的那一半还要求连节点都不留(留着的这段时间它还 盖着页面),所以在 DrawerClose 里判一次(dom.mbt 的 prefers_reduced_motion)—— 那个设置下 Esc 是当场撤节点,采样第一格(t=10ms)抽屉就不在了。
    • 新增的对外标记只有 data-state="open|closing"(动画与「对指针关掉」都挂在这个属性上); 可见文案:无。

    #骨架屏每个分组最多一行

    • 6 块占位块照旧渲染,装进一层 .dsh-skeleton-row:一条栅格项(grid-column: 1 / -1), 里面用与真卡片同一套列轨(五条断点写在同一个选择器列表里,不抄第二份)。
    • 这一层 max-height 卡成恰好一行 + overflow: hidden:4 列时那 6 块本来排两行、2 列时三行, 第 2 行往后直接裁掉 —— 不用在 JS 里量列数,骨架屏也不会把下面的分组一路顶下去。
    • 一行的高度就是骨架块自己的高:70(= 14 + 1 + 40 + 1 + 14,与 .dsh-skeleton 逐项对齐); 窄视口那一档(≤620px)两边一起收成 60,两条规则共享同一组数字(改一处得改另一处)。
    • 读数回来之后高度仍会变(真卡片带备注就是两行)—— 这是使用者要的取舍; 列轨与宽度一个字都不变(照上一轮的口径量过:列轨数、grid-template-columns、列起点逐列相同)。
    • 360 那一档如实说一句:骨架块在这一档是 60(占位块的第二行高 8 < 真条目的 16,见第八轮 「占位块永远不会比真条目高」),而带第二行的真卡片是 66。所以 1 列那一档的「一行」是 60, 不是 66 —— 本轮只裁行数,不动块本身的形状。

    视口列数露在外面的占位块骨架区高骨架块高×宽真卡片高度(放行后)
    192066(第 2 行不存在)7070×27170
    128044(另外 2 块被裁)7070×252.570
    76822(另外 4 块被裁)7070×26170
    36011(另外 5 块被裁)6060×12666(带第二行)/ 60(不带)

    #顺序保存那条回执

    正文压成「顺序已保存」。后端那句原文是「顺序表已写入 <落盘路径>(N 条)」: 「顺序表」是内部叫法,落盘路径也不该摊在正文(与删除回执、设置页同一套口径)—— 路径与条数收进悬停(act_notice_hint + act_notice_hint_for),一个字都没丢。 隔离实例里真拖一次读回来的是:正文 顺序已保存、悬停 顺序表已写入 /tmp/dsh-console-fixture/state/dsh-console/order.json(4 条)。

    #实测

    探针都在 evidence-motion-skeleton/(只读页面;写动作只往隔离实例 5533 的状态目录里发, 使用者那一份没碰):

    cd dsh-console/frontend/evidence-motion-skeleton node probe-drawer-motion.mjs http://127.0.0.1:5390/ drawer-motion # 开 / 关每 40ms 采一格(transform / x / opacity / 动画名 / data-state / 焦点); # 再用 CDP 模拟 prefers-reduced-motion: reduce 走一遍;最后两张在途帧(播放速率 0.4,只影响这两张图) node probe-drawer-behavior.mjs http://127.0.0.1:5390/ drawer-behavior # 点卡开 / 点外部关 / Esc / 关闭键 / 关的中途又开 / 窄屏 360 占满整幅:焦点逐条看 node probe-skeleton-rows.mjs http://127.0.0.1:5390/ skeleton-rows # Fetch 拦截把 /api/local/* 压后 2 秒:1920 / 1280 / 768 / 360 各量一遍(加载态 vs 放行后) node probe-order-toast.mjs http://127.0.0.1:5533/ order-toast # 隔离实例(DSH_HOME 与 XDG_STATE_HOME 都在 /tmp 的 fixture 下)里真拖一次,读回执正文与悬停

    #拖动落点预览:预览就是结果(第十二轮追加)

    使用者原话:「调整顺序的时候,使用虚拟元素来提示用户大概会放在哪儿,而不是仅仅使用一根线; 而且,这个线表示的拖动算法也不直观」。

    改之前是目标条目上一条 2px 插入线:一根线看不出会插到第几位,而「指针越过谁的中间线」 那句判定藏在代码里,看不见。改之后:跟着指针的半透明虚影 + 落在将来那一格上的占位块, 其余条目真的让开一格。松手落在占位块那一格。

    #三样东西各是谁

    • 虚影(.dsh-drag-ghost,view_overview 的 drag_ghost_view):手上那一条的半透明副本, 往右下偏 (10, 14) 跟着指针。位置由 js_drag_ghost_move 每动一下改一次 transform —— 不回状态、不重渲染(跟着指针走的不变量只有这一个)。pointer-events: none:它压在 指针下面,不关的话每逢指针落到它身上事件就换了目标。 偏一点而不是正压在指针上:正压着会盖住占位块(实测截图里占位块只剩一层虚线边露出来)。
    • 占位块(.dsh-drop-slot,drop_slot):它就是这块将来真正会占的那一格。 形状与真条目同款(宽高、圆角、内边距、图标位都照 .dsh-case 走),外观另起一副: 虚线边 + 极淡的强调底 + 里面的内容再淡一档;状态灯摘掉(灯说的是现在在不在跑)。 它挂在孩子序列的末尾,摆到第几格由 js_grid_cells 直接写 grid-column / grid-row。
    • 原件(.dsh-case.drag-image):留在原格子里、visibility: hidden —— 仍然占着一格。 位置留着,落点算法才有一份「与没在拖时一致」的坐标可量(js_drop_freeze); 它同时从 Tab 序列里摘掉(tabindex -1),焦点不会落在一个看不见的节点上。

    布局于是是「n 个格子,其中一个空着,占位块在第 k 格」,与「真把它抽走、再插进第 k 格」的 终局逐格相同 —— 预览就是结果。

    #摆格子为什么是渲染之后用 JS 直接写

    试过两条「正规」的路,都被量出来的事实否掉:

    1. 靠孩子序列里的位置:虚拟 DOM 那一层是按位置比对孩子序列的 —— 中间插一个节点, 它会把后面那些节点错位对齐(实测「插在第 0 位的占位块」拿到了第 0 位那个节点的样式与属性, 自己被挤到末尾,预览因此与结果差一格)。所以占位块只追加在末尾,前面一个节点都不动。
    2. 靠 order + 属性选择器:栅格的自动排布碰不得空档 —— order 不成一份完整的 0..n-1 排列时,后一条会自己往后跳一格(实测第 0 格的占位块被摆到了第三列); 补齐成完整排列之后,被拖的那一条又得跟着别人一起重排,而它必须稳稳待在最后一格。

    行内样式是唯一说了算的:它压得过属性选择器,也压得过栅格的自动排布。 js_grid_cells(group, dragged, k) 挂在 after_render,一次算清、一次写完 —— 格号 → 行列(列 = 格号 mod 列数 + 1,行 = 格号 div 列数 + 1),列数从栅格自己身上量 (读 grid-template-columns 有几个轨),所以两列与四列走的是同一份代码,不跟着五条断点改。

    还有一道自检:这一步挂在「渲染之后」,但库的渲染时序并不保证这一拍看到的 DOM 已经是最新那一版 (实测有一拍的滞后)。占位块自己身上写着它该在第几格(data-slot-index),对不上就什么都不做, 等与这一版 DOM 配套的那一拍来做。

    #让位那一段

    走 FLIP(js_drop_flip),不是 CSS transition:让位是节点在序列里换了位置,不是某个属性值 变了,transition 根本不触发。时长 180ms(state.mbt 的 reorder_ms,与 FLIP 里那个数是同一个)。 prefers-reduced-motion: reduce 下这个函数直接返回 0(不作过渡),但占位块留着 —— 它是信息,不是装饰。

    #键盘换位分两拍

    Ctrl+↑ / Ctrl+↓ 挂在抬起那一拍(按一次键浏览器会送好几个键盘事件,挂按下会换两次位), 并且分两拍:先摆占位块(与拖动时同一个块、同一个算法),reorder_preview_ms(190ms)之后才落位。 第二拍带着方向对号:这中间又按了别的方向,出发时那一枪自己作废,不会落在一个看不到的格子上。

    #另外两条

    • 拖动中滚了页面就作废:落点判定用的是视口坐标,页面一滚就全对不上(js_drop_freeze 里挂的 scroll 监听 → js_drop_scrolled → DragCancel)。与其拿过期坐标算一个「看着对、落错格」的落点, 不如当场作废。
    • 读屏播报:aria-live="polite" 的一格,正文形如「web-dev 已移到第 3 位」,只在拖动态存在时 才有一个节点。视觉上裁成一个点(clip-path: inset(50%)),页面上一个字都看不见 —— 这是这一轮唯一的新文案。

    #实测

    逐格一致那几条在 evidence-drag-preview/(drag-preview-5533.json + 截图 + 验收日志): 三个不同落点各拖一趟、每 55ms 采一帧,占位块画在第几格与松手后落在第几格逐趟相同 (2→2 / 1→1 / 0→0),另外还过了窄屏 360 不响应、跨组作废、写回失败回滚、点击不误触发、 reduced-motion 下占位块仍在且让位无过渡。

    cd dsh-console/design && DRAG_STATE_DIR=/tmp/dsh-drag-iso \ node probe-drag-preview.mjs http://127.0.0.1:5391/ /tmp/drag.json # 跑在**隔离后端** 5391 上(自己的 XDG_STATE_HOME):拖动会真写 order.json, # 5390 那台用的是使用者的状态目录,不拿它做写入实测。

    #右上角那一格加「端口:」标签(第十三轮追加)

    使用者原话:「这里加一个 端口: 吧」(圈着条目右上角那个光秃秃的 3080)。

    那一格的 DOM 文本从 3080 变成 端口:3080:.dsh-card-port 里多一个 .dsh-card-port-label(写 端口:,颜色退一档到 $text-dim),数字仍留在 $text-mid。 同一个字号、只让颜色退一步 —— 它是 label,右边那串数字才是内容;不设 font-size 是刻意的: 同一行里混两个字号会把基线错开,而这一格的高(16px)正是「带端口的条目与不带的等高」那一条的支点。 标签不换行(nowrap 从 .dsh-card-port 继承下来)。

    判据一个字没动:还是「这一行知不知道端口」才产出这一格(见第七轮那一节)。 标签挂在这一格里面,所以没有端口的那一行连标签一起不产出 —— 不会剩一个「端口:」孤零零挂在右端。 拖动虚影走的是同一处渲染,跟着一起变。

    #≤420 那一档标签让位

    这一条是量出来的,不是拍的。 卡宽 = 视口 − 234,第一行那一格 = 卡宽 − 70 (内边距 13×2 + 图标 34 + 间隙 8);360 视口下卡宽 126、那一格只有 56px, 而现在的「名字 24 + 间隙 8 + 数字 24」正好把 56 用满(实测 head.scrollWidth == clientWidth)。 「端口:」在这个字号下 39px,硬塞进去只有两种收场:名字被压到 0 宽(连省略号都没有, 等于把名字挤没),或者那一串顶出内容盒右缘(右缘对不齐、窄屏溢出)。两条都是硬要求里 明令不许的 —— 所以这一档由标签让位:名字照旧省略号、条目几何与加标签之前逐像素一致、 任何宽度都不溢出也不折行。

    断点取 420,与页面内边距那一条同档;≥421 起标签回来(卡宽 ≥ 187、那一格 ≥ 117, 名字那一截仍有 46px)。

    #实测

    视口条目 w×h第一行高第一行宽名字左缘名字宽标签宽那一串宽右缘 − 内容盒右缘第一行溢出整页横向滚动
    1280 改前252.5×7022168.550134.5—240falsefalse
    1280 改后252.5×7022168.55095.539630falsefalse
    620 改后386×66223164224539630falsefalse
    474 改后(卡宽 240)240×6622170429939630falsefalse
    421 改后187×6622117424639630falsefalse
    420 改后186×66221164284让位240falsefalse
    360 改前126×6622564224—240falsefalse
    360 改后126×6622564224让位240falsefalse

    无端口那一行逐像素一致(条目宽高 / 第一行那一格 / 名字那一格的位置三项全等), 只有有端口那一行的名字那一格变窄(标签占了 39px)—— 这是这一轮唯一该动的地方。

    名字长了仍是一行省略号:往那一格注入一个 46 字的名字再量,nameLineBoxes = 1、 省略号生效(scrollWidth 368 > clientWidth 96)、那一串的右缘仍与内容盒右缘对齐、 标签不横向越出这一格(labelOutsidePortX = false)、第一行与整页都不溢出。

    cd dsh-console/frontend/evidence-card-port-label PROBE_VIEWPORT_W=1280 PROBE_VIEWPORT_H=900 node probe-port-label.mjs http://127.0.0.1:5390/ after-1280.json after-1280 PROBE_VIEWPORT_W=360 PROBE_VIEWPORT_H=720 node probe-port-label.mjs http://127.0.0.1:5390/ after-360.json after-360 node compare-port-geometry.mjs before-1280.json after-1280.json before-360.json after-360.json # 改前/改后逐像素对照 node dump-dom-proof.mjs > dom-text.txt # DOM 文本 + 没有端口就没有那个节点 cd ../../design && ACC_NO_START=1 node run-acceptance.mjs http://127.0.0.1:5390/ # 判定 31 项:通过 31,失败 0

    #关于页:用哪一版 dsh 起实例(第十四轮追加)

    版本切换(回退)的入口放在关于页,不放总览页:那一块说的是这台机器装了什么, 不是某一只实例的状态。读数来自 GET /api/local/runtime 的 dshCandidates(见后端 README), 前端一行都不推:

    • 每份 dsh 一行:版本号(主文本)+ 「在用」(起实例会用它)/「在跑」(有实例正用它)两枚小标记
      • 「用这一版」按钮;已经是「按你指定的那一版」时,那一行另有「回到自动」。 包目录不进正文(一屏放不下几条长路径),挂在整行的悬停里,连同「从哪找到的」那句话。
    • 列表下面一句说清现在生效的是哪一版:没指定过 / 按你指定的(带版本号)/ 你指定过但那版不在了 (照抄后端 dshPinNote 的原话)。三种说法分开写 —— 把「没指定」写成「按指定」,或者反过来, 都是在这一格上说假话。
    • 点按钮 = 真动作:DshVersionPin(dir, version) → POST pin-dsh; 「回到自动」→ DshVersionUnpin → POST unpin-dsh。回执不猜结果: 成功之后就重读一次 /api/local/runtime,界面按新读数自己变(版本表里的 dsh 版本 也跟着变)。 失败与成功都留一行提示在那一块里 —— 这一页没有总览页那条提示条,不留就没有反馈。
    • 数据钩子:dsh-version-panel / dsh-version-row / dsh-use-version / dsh-use-auto / dsh-pin-line / dsh-pin-notice。 按钮样式沿用抽屉里那副 .dsh-btn,没有新造一套。
    • 「改这一版只影响以后起的实例」写在面板脚注里:已经在跑的那些要重启才会换 —— 这句话是这一块的边界,不写出来,用户会以为点了就把运行中的实例换了。

    抽屉里多一行「还听着 9230」:后端按 pid 归并之后,同一只进程的其余监听端口不再各占一行, 收在 alsoPorts 里。卡片上不显示、也不因此变色(那不是故障);只在详情抽屉里、主端口 (访问地址)那一行下面补一行 .dsh-field,原因进悬停。Row.also_ports 为空时这一行不产出 —— 没有第二个端口的条目与以前逐像素一致。

    实测(1280×900,隔离的 5391):候选两行 0.2.1-rc.2(在用/在跑)与 0.2.0-rc.2; 点 0.2.0 那一行的「用这一版」后,那一行的按钮变禁用并出现「回到自动」、版本表 dsh 版本 变成 0.2.0-rc.2、下面那句变成「起实例用的是你指定的 0.2.0-rc.2」;点「回到自动」之后回到 0.2.1-rc.2 与「没指定过版本,起实例按自动挑的那一份」。

    #页脚改成服务端在线灯 + 原因行补悬停(第三十一轮追加)

    页面左下角那句「本机运行,仅限回环访问」撤掉,换成实时的服务端在线状态 (design/design-fixes.md §31.1):一个 6px 的灯 + 一句状态词,颜色与文字两通道。

    • 三档:灰 正在检查…(刚进页面,还没有第一次结果)/ 绿 服务端在线 / 红 服务端离线。
    • 悬停:在线 最近一次响应 <N> ms,检查于 <HH:MM:SS>;离线 连续失败 <N> 次,最近一次:<原因>。 毫秒是本机量的(performance.now() 差值),不是服务端说的耗时。
    • 轮询:每 5 秒 GET /api/local/console,单次 3 秒超时(AbortController 掐掉, 不掐的话一个不答的请求会把灯停在上一档)。第一条回执到了才排下一次定时器。
    • document.hidden 时不排定时器、不发请求:改成挂一个 visibilitychange 监听, 页面重新可见那一刻立刻补一次(HealthVisible)—— 后台标签页不该在空转。
    • 这一枪不碰任何读数:profiles / instances / console 各有各的请求与错误格, 灯说的只是「服务端答不答」。请求带一个 X-DSH-Health: 1 头,验收要单独拦它时按这个认。
    • DOM 钩子:[data-name="side-foot"](带 data-state="checking|online|offline" 与 role="status"、aria-live="polite")/ side-foot-dot / side-foot-text。

    实测(1280×900,隔离的 5392,CDP 真拦请求):在线时 data-state=online、 灯 rgb(78,194,122)、悬停 最近一次响应 7 ms,检查于 09:30:22;连续三枪的时间戳差值 5112 / 5008 ms;用 Fetch.failRequest 拦掉之后 5 秒内变红(rgb(224,104,95), 连续失败 1 次,最近一次:Failed to fetch),再挂一枪 连续失败 2 次; 放行之后 5 秒内回绿(最近一次响应 14 ms);页面隐藏 11.5 秒期间健康请求 0 条, 恢复可见后 1.2 秒内补了一条。

    维护页「选择配置」、整体替换选源、新建实例框三处的原因行(不可用卡的第三行) 悬停里都是完整句(title 与正文一致):这只配置起的是命令行型,没有网页入口。 这一条在 profile_card 里一直是开着的(第三行那一段就挂着 .title(why)), 本轮只把它写成验收项。

    #失败提示轻量化 + 控制台审计来源(第三十二轮追加)

    动作失败原来把后端整段技术话(pid、argv、日志路径、错误行、建议)原样塞进提示条, 一句几百字。现在(design/design-fixes.md §32.1):

    • 正文只留第一句:按「。」或「;」切第一刀取前段,超过 56 个字再截断补「…」。 完整原文挂在提示条的悬停里(title),也在这次动作写下的审计行里。
    • 右侧一颗低调的「查看日志」:点了跳 /logs/actions,选中「控制台审计」来源、滚到底、 焦点进日志区(#dsh-log-box,tabindex=-1;日志落地之后才送焦点, 因为打开那一刻日志区还不存在)。
    • 只有动作失败带这颗按钮:它走 POST /api/local/action,后端一定写了审计行 (ok:false + code + message)。剪贴板、读数、设置校验那些失败不写审计, 也就不摆一颗点了看不到东西的按钮(Notice.audit 这一格就是这条界线)。
    • 日志页多了一条来源 actions「控制台审计」(后端 logs 的 fixed_sources): 读的是 $XDG_STATE_HOME/dsh-console/audit.jsonl,每行压成 「动作 · 结局 · 错误码 · message」,失败行按 error 上色。它与原来那条 audit 「审计日志」(linxi-guard 的守卫审计)是两个文件。
    • 路由多认一种写法:/logs/<来源>。?source= 照旧认,老链接与书签不会断; 切来源写 query,提示条那颗按钮写路径形式。

    实测(1280×900,隔离 5392;headless profile headless-bad,bundle 缺一个包,起实例必然失败): 提示条正文 57 字(56 + 省略号)「headless-bad 启动失败:HTTP 500 Internal Server Error | spawn…」, 悬停里是 501 字的完整原文;点「查看日志」落到 /logs/actions,来源「控制台审计」, 14 行、最后一行 03:35:01.562 error start-instance · 失败 · spawn-failed · 实例没起来(pid 193120,日志 …/console-headless-bad-8401.log):…,滚到底(scrollTop 3359 + clientHeight 735 = scrollHeight 4094), 焦点在 #dsh-log-box。剪贴板失败(把 navigator.clipboard.writeText 换成 reject)那条提示 没有「查看日志」;390 宽下提示条 195×133、文档 scrollWidth 390 = clientWidth 390,不溢出。

    #别处 home 起的实例:配置 home 按实例认定(第三十三轮追加)

    现象(design/design-fixes.md §33):一只实例跑在 /tmp/… 的 home 里、它的 profile 不在控制台的 DSH_HOME 里时,「配置 home」写「读不到」,紧接着「运行 home」却给出路径,底下一句还说 「这一版读数里没有「配置 home」」。根因是运行行先按控制台的 profiles 读数找这只 profile, 找不到就判 home 未知 —— 而实例自己的 home 明明读得到。

    • 运行行:先按 profiles 读数配(控制台 home 或它的隔离 home);配不上时用实例自己的 home 认定成「共用(<那份 home>)」,home_from_instance = true。悬停第一句是 「按实例的 home 认定的:这只 profile 不在控制台的 DSH_HOME 里,所以按它跑着的那份 home 算」。
    • 「运行 home」那一行只在实例 home 与「配置 home」不同时才出现 —— 按实例认定这一档两者 是同一份,不再单独列一行(以前它俩并排,互相打脸)。
    • 连实例 home 都认不出(dsh_home 读不到)才写「读不到」,悬停 「这只实例的 home 与 profile 都认不出来(原因)」。老后端缺 home 字段时保留原来那句 「这一版读数里没有「配置 home」这一项」。
    • 移进 / 移回:profile 不在控制台的 DSH_HOME 里时按钮禁用(data-movable="false"), 悬停「这只 profile 不在控制台的 DSH_HOME 里,动不了它的 home」。
    • 插件数量 / 最近改动是「—」时,悬停补一句「这只 profile 不在控制台的 DSH_HOME 里, 读不到它的这些读数」(是「读不到」,不是「没有」)。

    实测(1280×900 与 390×780,隔离 5392;probe 起两次,一次在控制台 home、一次用 home=/tmp/dsh-cf2/home2): 别处 home 那一只的配置 home = 共用(/tmp/dsh-cf2/home2),没有「运行 home」行, 抽屉文本里没有「这一版读数里没有「配置 home」」;「移到独立 home」disabled、 data-movable="false"、悬停写清原因;插件数量与最近改动都是「—」且悬停是那句解释。 控制台 home 里那一只一切照旧(共用(/tmp/dsh-cf2/home)、按钮可点)。390 宽下抽屉不横向溢出。

    #通知历史(第三十六轮追加)

    提示条与 toast 是「一条盖一条」:连点几次动作,前面那条就没了。这一轮给它留一个回看窗口 (design/design-fixes.md §36):

    • Model.notice_history:最近 20 条 { at, ok, text, hint },新到旧,每次 notice_put 都追加 (成功、失败都记),超了就丢最旧的。只在内存里,刷新页面就清空 —— 它不是审计, 审计在 /logs/actions。
    • 顶栏右侧一颗铃铛(宽屏与窄屏各一颗):data-unread 是未读条数,角标显示它(满 20 显示 20+), aria-label 同步成「通知:还有 N 条未读」—— 颜色之外还有数字与文字。
    • 点开是右侧面板(z 57/56:盖在抽屉与提示条之上、确认框之下),标题 通知(N); 每行 时刻 + 成功/失败标记(文字通道)+ 正文两行封顶(-webkit-line-clamp: 2),悬停给 hint; 空态一句「还没有通知。动作的结果会在这里留一条。」。打开面板时未读归零,焦点进关闭键; 关闭(关闭键 / 遮罩 / Esc)之后焦点回铃铛。
    • 正文与悬停里不会出现带令牌的地址:notice_put 是提示的唯一出口,那里统一走 redact_tokens,把 token=<值> 整段换成 …(连参数名一起去掉,所以正文与历史里连 token= 这个串都搜不到)。令牌原值仍在内存(notice_url)与实例日志里,只是不上屏。

    实测(1280×900 与 390×780,隔离 5396 —— 5392 上跑着另一个会话的控制台,没动它): 连续两个成功(复制地址、复制开发指令)+ 一个失败(headless-bad 启动)后角标 2 → 3; 再点一次「打开 Web UI」(正文里本来带 ?token=… 的地址)后角标 4,正文里那一段已变成 http://127.0.0.1:5401/?…;开面板:标题 通知(4),四行新到旧(13:30:16 成功 / 13:30:12 失败 / 13:29:39 成功 / 13:29:38 成功),hasToken=false,未读归零、角标消失、焦点在关闭键; Esc 与点遮罩都能收,焦点回 dsh-notice-bell。连做 21 次复制后历史停在 20 条、角标 20+; 390 下面板宽 390(满宽)、scrollWidth 390 = clientWidth 390。

    #缺包自助修复(第三十九轮追加)

    缺 bundles 的 profile 以前只会一直报警(状态灯黄、抽屉里一句「缺 N 个包」),这一轮给它一颗 能按的按钮(design/design-fixes.md §39):

    • 抽屉「状态」组里,缺包时多一行 安装缺的包(N)(次要样式,与启动 / 打开 Web UI 分开)。 悬停写清三件事:在哪个目录跑哪条命令、只动 node_modules、输出落到状态目录的 logs/repair-<名>-<毫秒>.log。命令由后端按 lock 文件选好(repairCommand 一处口径, 前端不自己猜 pnpm / npm)。
    • 点它先开确认框:标题 给 <名> 安装缺的包,正文 将在 <目录> 里执行 <命令>,只补 node_modules。装完会重新读一次这只 profile。,按钮 安装 / 取消(Esc / 遮罩也能关,焦点回那颗按钮)。
    • 确认之后这一行置忙:按钮变 正在安装… 并禁用,抽屉里其余写动作(启动 / 重启 / 改名 / 备注 / 归档 / 删除……)跟着一起禁用。回执到了才收。
    • 成功:提示一句短的(后端那句 repairme 的包已装齐(pnpm install --prefer-offline,0 秒)), 悬停补日志路径与退出码 / 用时;同时重读读数 —— 缺包归零、灯不再是黄、按钮自己消失。
    • 失败:走 §32 那条路 —— 提示条一句话(install-failed 之类),右侧一颗 查看日志, 点它落到 /logs/actions,那一行就是这次 repair-packages,message 里带完整输出路径。

    实测(隔离 5396;两只专门造的 profile):repairme 声明一个 file: 本地包、没有 node_modules (缺 1 个);repairfail 声明一个不存在的包(任何 registry 都装不到,必然失败)。

    • 按钮 安装缺的包(1)、宽 135、悬停照上面三件事;确认框 360×…(1280)、焦点在 安装。
    • 点 安装 之后抓到 正在安装…(disabled=true,state=running),同一拍里其余写按钮 启动 / 重启 / 复制访问地址 / 重命名 / 备注 / 归档 / 删除 全部 disabled。
    • 成功:提示 repairme 的包已装齐(pnpm install --prefer-offline,0 秒),抽屉健康行变成 「声明的包都已安装,cordis.patch.yml 也在」,安装缺的包 那颗按钮消失(缺包 0)。
    • 失败:提示 repairfail 安装缺的包失败:HTTP 500 Internal Server Error | inst…(57 字,§32 的口径), 悬停是完整原文含日志路径;点 查看日志 → /logs/actions,最后一行 06:09:37.687 error repair-packages · 失败 · install-failed · 安装失败(退出码 1):…完整输出在…/logs/repair-repairfail-1792476576983.log;审计那笔的 outcome 里 logPath、exitCode=1、 missingBefore=1、missingAfter=1 都在。
    • 并发:同一只连发两次,第二次 busy(409,「这只 profile 上一次补包还没结束」)。
    • 390:确认框 342 宽(x 24..366)、文档 scrollWidth 390 = clientWidth 390,不溢出。

    #缺包默认不报警(第四十轮追加)

    声明了但没装的包(多半来自别的机器或私有源)以前会把状态常亮成黄灯「有警告」—— 实例明明跑得好好的。这一轮把它降成默认不报警(design/design-fixes.md §40):

    • 新设置项 profile.ignoreMissingBundles(布尔,缺省 true),放在设置页「界面」组: 标题「忽略缺包警告」、说明「声明了但没装的包不影响运行时不再报警」、悬停 「缺的包仍然可以在抽屉里安装;关掉这一项,缺包会重新算作警告」。后端只负责存取与回显, 来源照旧在行上(默认 / 文件)。
    • 开着:缺包不参与状态灯判定(在跑就是绿的,不亮黄、不写「有警告」);关掉:回到旧行为。 判据只在一处:state.mbt 的 ignore_missing_bundles(model) → profile_light / instance_light / health_note_of。总览、抽屉、维护页与整体替换的配置卡都走这一份 (job_profile_pick 也带同一个参数)。
    • 缺包仍然可修:抽屉「配置」组里一行低调的小字「还缺 N 个包」+ 一颗「安装」(与 §39 同一个 repair-packages,确认框与回执都不变);「状态」组里不再出现「缺 N 个包」那行与那颗大按钮。
    • 状态组那句体检话在「忽略缺包」时只说另一半(cordis.patch.yml 也在):缺口还在, 不能写成「声明的包都已安装」。

    实测(隔离 5392;专门造了一只 warnme:声明的 bundles 里有 6 个装不上,实例照样起得来):

    • 默认(开,来源 默认):卡片灯 running(绿)、没有「有警告」;灯悬停里写着 「缺包这一项按设置不参与判灯」。抽屉状态组只有「运行中」+「cordis.patch.yml 也在」, 没有「缺 6 个包」;配置组里有「还缺 6 个包」+「安装」,悬停是 §39 那句(目录 / 命令 / 日志位置)。
    • 设置页里把这一项关掉:卡片立刻变 warn(黄)+「有警告」,灯悬停写「这只 profile 缺 6 个包」; 抽屉状态组那行「缺 6 个包」回来;配置组那行照旧在(照样能装)。
    • 刷新页面:仍是黄的;设置页显示 关、来源 文件(写进了 settings.json)。再打开 → 回绿。
    • 维护页侧栏的配置卡同样按这一份判据:warnme 那张 running(绿),悬停同一句 「缺包这一项按设置不参与判灯」。
    • 390×780:抽屉里「还缺 6 个包」17..71、「安装」81..144;设置行 29..361;两页都是 scrollWidth 390 = clientWidth 390,不溢出。

    #开关换成 MD2,并抽成组件(第五十一轮追加)

    设置页那颗开关原来是「药丸里塞开/关两个字」,像自造控件、也占宽度。这一轮换成 Material Design 2 的轨道 + 圆点,并抽成可复用组件(design/design-fixes.md §51):

    • view_parts.mbt 的 switch_control(on, disabled, label, title, on_click, small?, key?, elem_id?): 结构是 button.dsh-switch[role=switch][aria-checked] > .dsh-switch-track > .dsh-switch-thumb, 不再有开/关文字(状态由圆点位置 + 轨道颜色表达,读屏靠 aria-checked)。 key 是这一颗的语义路径尾巴(进 data-uid):同页多颗必须不同,否则 uid 撞车; elem_id 用来在保存之后把焦点送回来。
    • MD2 几何:轨道 36×14、圆角 7,圆点 20;圆点默认左移 3px,让圆心落在左端点(半径 7)上, 开时右移 22px 停到右端点,两端各外溢 3px((20−14)/2)。切换 120ms,prefers-reduced-motion 关掉动画;:focus-visible 给 2px 主色描边(离轨道 2px);禁用 0.45 不透明度; small 档轨道 32×12、圆点 18、位移 20。
    • 颜色:关 = 轨道 rgba(255,255,255,.24) + 圆点 $text-mid;开 = 轨道 rgba($accent-rgb,.5) + 圆点 $accent。悬停给圆点外一圈 8% 淡影。
    • 设置页三项(归档区默认展开 / 侧栏收起 / 忽略缺包警告)都改用它;旧的 .dsh-switch-text、 .dsh-switch-knob、.dsh-switch.small 那套规则删干净了(构建产物里 grep 0 命中)。
    • 一个顺带的修:保存成功之后把焦点送回那一颗开关(setting_switch_id(key) + focus_after_render)。 保存会让这一行冒出「恢复默认」按钮,节点结构一变焦点就掉到 body —— 不送回来的话, 键盘用户按一次空格就再也接不上第二下(§51.4 要空格与回车都能切)。

    实测(隔离 5392):三颗开关轨道都是 36×14(圆角 7px)、圆点 20×20;关的那两颗圆点在左端 (圆心距轨道左端 7px、距右端 29px,向左外溢 3px),开的那颗圆心距右端 7px、向右外溢 3px (thumb 右边缘 = 轨道右边缘 + 3)。计算颜色:关 track rgba(255,255,255,0.24) / thumb rgb(207,205,200); 开 track rgba(79,140,255,0.5) / thumb rgb(79,140,255)。role=switch、aria-checked 随状态变; 空格与回车都能切(切完焦点还在开关上,刷新后值保留)。.dsh-switch-text / .dsh-switch-knob 在 DOM 与 CSS 里都是 0 个。390 宽下文书不溢出(scrollWidth 390 = clientWidth 390), 开关在行右侧、与文字不重叠(390 下那一档是「文字在上、开关在下」的列布局)。

    #这一版没做的

    • 归档区没有读接口(见上),所以它不是「真实的归档清单」,是本次会话的记录。
    • 没有水合、没有 SSR。控件的交互都走 rabbita 的事件,不需要额外的 behaviors 脚本。
    • dist/assets/app.js 1.61 MB(未压缩,gzip 约 188 KB)。后端不发 Content-Encoding: gzip, 回环访问下可接受;构建链刻意不依赖 Node,所以没有加一道压缩步骤。
    • 两个空态图标(运行中空态、已停止空态)本机有实例时走不到,只在源码层面核对过路径常量。