From 7427e12051173e2f71e50b7d72259d6138f7f906 Mon Sep 17 00:00:00 2001 From: wushi2333 <837145630@qq.com> Date: Mon, 28 Sep 2026 19:15:27 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor(client):=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=8C=96=20+=20=E9=9B=B6=E4=BE=9D=E8=B5=96=E6=89=93=E5=8C=85?= =?UTF-8?q?=E5=99=A8=20+=20=E7=A8=B3=E5=81=A5=E4=BD=9C=E7=94=A8=E5=9F=9F?= =?UTF-8?q?=E5=8C=96=EF=BC=9B=E5=8E=BB=E6=AD=BB=E4=BB=A3=E7=A0=81=EF=BC=8C?= =?UTF-8?q?=E6=94=B6=E7=AA=84=E9=80=89=E6=8B=A9=E5=99=A8=E8=A7=82=E5=AF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 构建 - 构建移到 scripts/build.mjs(CLI + 库):src/client/ 下是真 ES 模块,零依赖打包器按依赖顺序 包成 IIFE,相对导入变解构、包导入变 require()(求值时机与原来相同),导出名自动收集 —— 去掉 4 个占位符、stripExports 与两份手工同步的导出名单 - 样式作用域化改为跳过注释与字符串的分词器,产物去注释(theme.css 92.8k → 52.4k 字符); 「注释里不许写花括号」的限制随之消失 客户端(src/client/) - index.js(inject + apply)/ stylesheet / settings(覆盖层 + 配置卡座位)/ theme-preview / settings-card / override / model-picker/{index, component, view} / constants / host - 删掉宿主保证过的防御分支:require 的 try/catch、typeof ctx.effect/inject/on、configForms 缺席、 getTheme 回落、卡片 view==='summary' 分支(宿主只以 view:'page' 渲染该座位)、 matchMedia / MutationObserver 探测、未用的 refresh/isActive 与 document/window 注入口 - 语言判断、「设置文档未到则保持现状」合并为 host.js 的 isEnglish / formValue - 性能:模型选择器的 MutationObserver 只在「新增/移除含席位的元素、会话标记变化、席位自身子节点变化」 时对账(流式输出不再每段全量扫描);未落定的席位仍在任何变动时重试;覆盖层 CSS 未变不重写 验收(行为不变) - 真实 dsh web 0.1.7-rc.2 上 14 个界面状态的逐元素计算样式快照(含伪元素与 html/body 全部令牌) 与重构前逐项比对:0 处差异 - check-repo 改为在 node:vm 里执行 client.js,直接断言 DSH 契约(id、inject、apply) - power-rail-verify 47/47 · live-gui-probe 14/14 · settings-page-verify 29/29 · elevation 19 · composer-shadow 23 · hero 25 · sidebar-align 6 · sidebar-surface 13 · audit 36/36 - model-picker-verify / rightbar-verify 靠 theme.css 里的注释分节定位,下一个提交随工具链一起改 --- client.js | 1899 ++++++++--------- scripts/build.mjs | 242 ++- scripts/check-repo.mjs | 79 +- scripts/install-plugin.mjs | 13 +- scripts/power-rail-verify.mjs | 10 +- src/build.mjs | 190 -- src/client.template.js | 278 --- src/client/constants.js | 17 + src/client/host.js | 26 + src/client/index.js | 31 + .../model-picker/component.js} | 376 +--- src/client/model-picker/index.js | 40 + src/client/model-picker/view.js | 155 ++ src/{ => client}/override.js | 44 +- src/client/settings-card.js | 309 +++ src/client/settings.js | 61 + src/client/stylesheet.js | 20 + src/client/theme-preview.js | 67 + src/settings-card.js | 442 ---- theme.css | 1425 +++++-------- 20 files changed, 2510 insertions(+), 3214 deletions(-) delete mode 100644 src/build.mjs delete mode 100644 src/client.template.js create mode 100644 src/client/constants.js create mode 100644 src/client/host.js create mode 100644 src/client/index.js rename src/{model-picker.js => client/model-picker/component.js} (61%) create mode 100644 src/client/model-picker/index.js create mode 100644 src/client/model-picker/view.js rename src/{ => client}/override.js (81%) create mode 100644 src/client/settings-card.js create mode 100644 src/client/settings.js create mode 100644 src/client/stylesheet.js create mode 100644 src/client/theme-preview.js delete mode 100644 src/settings-card.js diff --git a/client.js b/client.js index dd004e1..99f12d2 100644 --- a/client.js +++ b/client.js @@ -1,398 +1,53 @@ -/** - * codex-ui — Browser half(唯一样式源是 skins/codex-ink,经 src/build.mjs 生成;本文件是模板,勿手改)。 - * - * 职责四件: - * 1. 把 skins/codex-ink 的整套 Codex 化样式(L1/L2 令牌 + L3 组件层 + 设置页层) - * 以作用域 html[data-codex-ui] 注入到文档,并在卸载时完整收回; - * 2. 把设置页的取值渲染成一层覆盖(多一个属性 ⇒ 特异性压过皮肤,源样式一个字不动); - * 3. 在官方插件管理的组合包页(座位 plugins.bundle.config)注册那张配置卡; - * 4. 用自建组件顶替 composer 的模型位(Codex 模型列表 + 推理等级功率轨),设置里可关。 - * 不依赖 skin-center / dsh-web-all:样式文本随本文件一起下发,无外部请求。 - */ +/* codex-ui 0.6.0 —— 由 scripts/build.mjs 从 src/client/ 与 skins/codex-ink/ 生成,勿手改。 */ window.__ModuleLoader__.load({ - id: 'codex-ui', + id: "codex-ui", factory: (require) => { - /** 插件 id:style 标签归属标记,同时也是组合包座位的键。 */ - const PLUGIN_ID = 'codex-ui'; - /** 作用域根属性:所有规则都挂在它下面,卸载即整层失效。 */ - const ROOT_ATTR = 'data-codex-ui'; - /** - * 切主题时临时打的属性:皮肤里有一条 `html[data-codex-ui-switching] * { transition: none }`。 - * 主题切换会让整个页面的 token 同时换值,若各处还有 background/color 过渡,就是一次交叉淡出 —— - * 元素半旧半新的那几帧看起来就是「闪」。打上它、过两个 rAF 摘掉,切换变成一次到位。 - */ - const SWITCH_ATTR = 'data-codex-ui-switching'; - /** - * 本地预览标记:用户点了主题之后,宿主要把偏好写进设置文档、再回读,实测往返 ~0.8s。 - * 这一层让「点下去」与「变颜色」之间不等那个往返:立刻按目标主题应用, - * 等 `theme/change` 带着同一个结果回来就交还给宿主;超时没等到就按真值回滚。 - * 它只写宿主本来就会写的两处(body[data-ds-dark-theme] 与 html 的 color-scheme), - * 因此确认到达时是幂等的,不会出现第二次跳变。 - */ - const PREVIEW_ATTR = 'data-codex-ui-preview'; - /** 预览等待上限:够一次文档往返(实测 ~0.8s)再多给一倍余量。 */ - const PREVIEW_TIMEOUT_MS = 2500; - /** 生成期注入的样式表文本。 */ - const CSS = "/* ==== L1/L2 令牌与排版层(源:skins/codex-ink/skin.css)==== */\n/* ============================================================================\n codex-ink(墨白终端)· L1 令牌层 + L2 排版层\n ----------------------------------------------------------------------------\n 设计母题:OpenAI Codex / ChatGPT 系 —— 界面消失,只剩内容与光标。\n 黑白灰是 chrome,彩色只配给状态。\n\n 本文件只做两件事:\n 1) 把官方 --dsw-alias-* / --dsw-specific-* 语义令牌重映射到墨白灰阶;\n 2) 补齐官方缺失的 spacing / radius / typography-scale / motion / elevation 令牌层。\n\n 纪律:\n · 不出现任何 url()、@import、远程地址、内联 JS(skin-center 安全管线白名单);\n · 亮色令牌同时落在 :root 与 body —— 官方亮色令牌声明在 body 上,\n 只写 :root 会被 body 自身声明盖掉(加载器会把本文件强制限定在\n html[data-dsh-skin=\"codex-ink\"] 之下,从而取得高于官方的特异性);\n · 彩色白名单:state 三色 + diff 红绿 + 徽标底色 + 焦点环(Codex --color-border-focus 蓝 #339CFF),\n 其余一律墨/灰。\n ========================================================================== */\n\n/* ── 亮色主题 ────────────────────────────────────────────────────────────── */html[data-codex-ui],html[data-codex-ui] body{\n color-scheme: light;\n\n /* 画布自持:底色由本皮肤自己画在 html 与 body 上,不指望宿主那一帧是否已把 token 换好。\n 主题切换时的闪,十有八九是「某一帧没人画底」—— 那时露出的是 UA/宿主的默认画布色。 */\n background-color: #ffffff;\n\n /* 底色:白 → #F9F9F9 → #F1F1EF → #E5E5E5 四档灰阶即层级 */\n --dsw-alias-bg-base: #ffffff;\n --dsw-alias-bg-sidebar: #eef4f9; /* Codex 亮色侧栏实测取样(350×1377 截图点采样 = #EEF4F9) */\n --dsw-alias-bg-layer-1: #ffffff;\n --dsw-alias-bg-layer-2: #f1f1ef;\n --dsw-alias-bg-layer-3: #e5e5e5;\n --dsw-alias-bg-overlay: rgba(255, 255, 255, 0.88);\n --dsw-alias-bg-module-platform: #f9f9f9;\n --dsw-alias-bg-multi-select: #e5e5e5;\n --dsw-alias-bg-skeleton: rgba(13, 13, 13, 0.05);\n\n /* 描边:发丝即层级 */\n --dsw-alias-border-l1: rgba(13, 13, 13, 0.07);\n --dsw-alias-border-l2: rgba(13, 13, 13, 0.12);\n --dsw-alias-border-l3: rgba(13, 13, 13, 0.18);\n --dsw-alias-border-l4: rgba(13, 13, 13, 0.24);\n --dsw-alias-border-l2-darkmode-thin: rgba(13, 13, 13, 0.09);\n\n /* 文字:墨 / 次墨 / 辅墨。前景取 Codex 浅色主题的「前景」实测值 #1A1C1F\n (冷调,不是纯黑;取色面板截图 assets/reference/codex-theme-light.png)。 */\n --dsw-alias-label-primary: #1a1c1f;\n --dsw-alias-label-primary-dimmed: #5d5d5d;\n --dsw-alias-label-primary-inverted: #ffffff;\n --dsw-alias-label-primary-foreground: #ffffff;\n --dsw-alias-label-primary-bluish: #0d0d0d;\n --dsw-alias-label-secondary: #5d5d5d;\n --dsw-alias-label-tertiary: #767676;\n --dsw-alias-label-caption: #767676;\n --dsw-alias-label-dimmed: #c9c9c9;\n\n /* 主行动 = 墨块(全界面视觉重量最高的元素,无需任何品牌色) */\n --dsw-alias-brand-primary: #0d0d0d;\n --dsw-alias-brand-primary-invert: #ffffff;\n --dsw-alias-brand-text: #ffffff;\n --dsw-alias-brand-primary-new-colorprimary-new-color: #0d0d0d;\n /* 强调色 = Codex 浅色主题「强调色」实测值 #339CFF,只上链接;\n 主行动按钮在 Codex 里仍是墨色胶囊,故 brand-primary 不动。 */\n --dsw-alias-link: #339cff;\n\n --dsw-alias-button-primary-fill: #0d0d0d;\n --dsw-alias-button-primary-hover: #2a2a2a;\n --dsw-alias-button-primary-dimmed: #e5e5e5;\n --dsw-alias-button-contrast-fill: #0d0d0d;\n --dsw-alias-button-elevated-fill: #ffffff;\n --dsw-alias-button-floating-fill: #ffffff;\n --dsw-alias-button-floating-hover: #f1f1ef;\n --dsw-alias-button-ghost-active-fill: #e5e5e5;\n --dsw-alias-button-ghost-active-hover: #dcdcdc;\n --dsw-alias-button-ghost-active-border: #8f8f8f;\n --dsw-alias-button-info-fill: #0d0d0d;\n --dsw-alias-button-info-hover: #2a2a2a;\n --dsw-alias-button-tool-bar-fill: rgba(13, 13, 13, 0.28);\n --dsw-alias-button-tool-bar-fill-invisible: rgba(13, 13, 13, 0.18);\n --dsw-alias-button-tool-bar-hover: rgba(13, 13, 13, 0.36);\n\n /* 交互底色:背景升一档,无位移无阴影 */\n --dsw-alias-interactive-bg-hover: rgba(13, 13, 13, 0.04);\n /* composer 控件悬停底:Codex 实测 #F2F2F3(冷中性,不是本层灰阶的暖 #F1F1EF)。 */\n --dsw-codex-hover-fill: #f2f2f3;\n /* 焦点环:Codex --color-border-focus = --blue-300 = #339cff(亮色直接取色) */\n --dsw-codex-focus: #339cff;\n /* 宿主的焦点环令牌接到 Codex 焦点色上。宿主 ui-theme 的 :focus-visible 与 10 条用\n box-shadow 画环的组件规则都读 var(--dsw-focus-ring-color, 墨色回落),而宿主从不在根上\n 声明它 —— 不接的话那 10 处聚焦时是墨色内环。宿主在指针操作时会把它就地置成 transparent\n (html[data-input-modality=pointer] … :focus-visible),本皮肤的环也读这个令牌,\n 所以同一条「鼠标点击不出环」的宿主约定对皮肤一样生效。\n var() 在声明它的元素上解析:body 上读到的是 body 自己的 --dsw-codex-focus,\n 深色那一档(70%)因此不需要再写一遍。 */\n --dsw-focus-ring-color: var(--dsw-codex-focus);\n /* Codex 的两条中性描边(app.asar 令牌,CHANGELOG 0.5.9 / 0.5.10 取证):\n --color-border = --alpha-10(亮)/ --alpha-12(暗)\n --color-border-strong = 15%(亮)/ 20%(暗)\n 输入卡环与功率轨描边用前者,功率轨拇指用后者。 */\n --dsw-codex-border: rgba(13, 13, 13, 0.1);\n --dsw-codex-border-strong: rgba(13, 13, 13, 0.15);\n --dsw-alias-interactive-bg-active: rgba(13, 13, 13, 0.08);\n --dsw-alias-interactive-bg-hover-solid: #f1f1ef;\n --dsw-alias-interactive-bg-hover-accent: rgba(13, 13, 13, 0.06);\n --dsw-alias-interactive-bg-hover-danger: rgba(209, 36, 47, 0.08);\n\n /* Markdown / 代码 */\n --dsw-alias-markdown-code-block: #f1f1ef;\n --dsw-alias-markdown-code-block-banner: #e5e5e5;\n --dsw-alias-markdown-inline-code: #f1f1ef;\n --dsw-alias-markdown-code-segment-selected: #ffffff;\n --dsw-alias-markdown-code-segment-unselected: #f1f1ef;\n --dsw-alias-markdown-citation: #f1f1ef;\n --dsw-alias-markdown-placeholder: #e5e5e5;\n --dsw-alias-markdown-tag: #f1f1ef;\n\n /* 状态:彩色白名单的唯一合法来源 */\n --dsw-alias-state-success-primary: #1a7f37;\n --dsw-alias-state-success-secondary: #2e9e4c;\n --dsw-alias-state-success-tertiary: rgba(26, 127, 55, 0.08);\n --dsw-alias-state-error-primary: #d1242f;\n --dsw-alias-state-error-secondary: #e5484d;\n --dsw-alias-state-error-tertiary: rgba(209, 36, 47, 0.08);\n --dsw-alias-state-warn-primary: #8f5f00;\n --dsw-alias-state-warn-secondary: #bb8009;\n --dsw-alias-state-warn-tertiary: rgba(143, 95, 0, 0.08);\n --dsw-alias-state-warn-label: #8f5f00;\n --dsw-alias-state-business-primary: #0d0d0d;\n --dsw-alias-state-business-tertiary: rgba(13, 13, 13, 0.08);\n --dsw-alias-state-idle-primary: #767676;\n\n /* diff:白名单内的红绿 */\n --dsw-alias-code-diff-added: rgba(26, 127, 55, 0.1);\n --dsw-alias-code-diff-deleted: rgba(209, 36, 47, 0.1);\n --dsw-alias-file-diff-added-bg: #e6f4e7;\n --dsw-alias-file-diff-added-gutter: #edf7ed;\n --dsw-alias-file-diff-added-marker: #1a7f37;\n --dsw-alias-file-diff-deleted-bg: #fce6e2;\n --dsw-alias-file-diff-deleted-gutter: #fdece9;\n --dsw-alias-file-diff-deleted-marker: #d1242f;\n\n /* 浮层 / 提示 */\n --dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7);\n --dsw-alias-toast-bg: #1e1e1e;\n --dsw-alias-tooltip-bg: #1e1e1e;\n --dsw-alias-tooltip-fg: #ffffff;\n --dsw-alias-hovercard-bg: #ffffff;\n\n /* 滚动条:thumb = label-tertiary 30% / 50% */\n --dsw-alias-scrollbar-bg-l1: rgba(118, 118, 118, 0.3);\n --dsw-alias-scrollbar-bg-l2: rgba(118, 118, 118, 0.5);\n --dsw-alias-scrollbar-hover-l1: rgba(13, 13, 13, 0.35);\n --dsw-alias-scrollbar-hover-l2: rgba(13, 13, 13, 0.5);\n\n /* 具体面 */\n --dsw-specific-sidebar-fill: #eef4f9; /* 与 --dsw-alias-bg-sidebar 同源:Codex 亮色侧栏实测 */\n --dsw-specific-sidebar-nav-item-hover: #e8eef3; /* 侧栏底 ↔ 选中行的中点(推导值,非取样) */\n --dsw-specific-sidebar-nav-item-active: #e2e9ed; /* Codex 选中行(PROJIECT)实测取样 */\n --dsw-specific-sidebar-nav-item-active-accent: rgba(13, 13, 13, 0.1);\n --dsw-specific-input-major: #ffffff;\n /* 输入卡所坐的「表面色」。卡片底 = 5% 前景叠这个色(Codex 的 --card 公式)。\n 亮色 = 5% 墨叠白 = 243.55(参考图 codex-composer-reference.png 实测 240~246)。 */\n --dsw-composer-surface: #ffffff;\n --dsw-specific-login-input: #f9f9f9;\n --dsw-specific-menu: rgba(255, 255, 255, 0.88);\n --dsw-specific-selector: #f1f1ef;\n --dsw-specific-bubble: #f1f1ef;\n --dsw-specific-bubble-highlight: #e5e5e5;\n --dsw-specific-tip: #f1f1ef;\n\n --dsw-shadow-lv1: 0 1px 2px rgba(13, 13, 13, 0.06);\n --dsw-shadow-lv2: 0 2px 8px rgba(13, 13, 13, 0.08);\n --dsw-shadow-lv3: 0 8px 24px rgba(13, 13, 13, 0.1);\n --dsw-linear-gradient-think: linear-gradient(180deg, #ffffff 20.19%, rgba(255, 255, 255, 0) 100%);\n --dsw-linear-think-select: linear-gradient(180deg, #f1f1ef 20.19%, rgba(241, 241, 239, 0) 100%);\n}\n\n/* 切主题的那一两帧关掉全部过渡:交叉淡出(元素一半旧色一半新色)看起来就是「闪」。\n 属性由浏览器半在 ctx.on('theme/change') 时打上,两个 rAF 后摘掉。 */html[data-codex-ui-switching] *,html[data-codex-ui-switching] *::before,html[data-codex-ui-switching] *::after{\n transition: none !important;\n}\n\n/* html 在最外层,读不到 body 上那份深色令牌,只能用 :has() 自己取一次。 */html[data-codex-ui] :root:has(body[data-ds-dark-theme]){\n background-color: #111111;\n}\n\n/* ── 暗色主题 ─────────────────────────────────────────────────────────────\n 两个来源各管一半,别再把它们合成一个(0.1.2~0.5.5 就是合错了):\n · 窗口**背景** = 主题取色面板(assets/reference/codex-theme-dark.png)里写的\n 「背景 #111111 / 前景 #FCFCFC / 强调色 #0169CC」;\n · **表面** = app.asar 的 jdi.dark.surface #181818(卡片、侧栏这类抬起来的面)。\n 实测对账:用户给的 Codex 截图里,输入卡四周的背景恒为 17 = #111111 ✓,\n 卡片内部 35 = 5% 前景叠 #181818 ✓ —— 两个值同时成立,只有「背景/表面分层」能解释。\n 0.5.5 及以前把背景也写成 #181818,于是卡片与背景只差 12 级(Codex 差 18 级),\n 层次感就是这么丢的。\n 层级:背景 #111111 → 侧栏/表面 #181818 → 层1 #212121 → 层2 #282828 → 层3 #303030。\n alpha 家族由 252 抬到 255(Codex 深色 --alpha-base = #fff)。 */html[data-codex-ui] body[data-ds-dark-theme]{\n color-scheme: dark;\n\n /* 画布自持(深色):宿主只把 data-ds-dark-theme 打在 body 上,所以 html 那一份用 :has() 跟上。\n 这里重复一次字面值而不是引用令牌 —— 令牌就定义在 body 上,html 自己读不到它的深色值。 */\n background-color: #111111;\n\n /* 窗口背景取主题面板的 #111111;侧栏/表面取 jdi.dark.surface #181818。\n 输入卡坐在**表面**上(--dsw-composer-surface),不是坐在窗口背景上 —— 见下。 */\n --dsw-alias-bg-base: #111111;\n --dsw-alias-bg-sidebar: #181818;\n --dsw-alias-bg-layer-1: #212121;\n --dsw-alias-bg-layer-2: #282828;\n --dsw-alias-bg-layer-3: #303030;\n --dsw-alias-bg-overlay: rgba(24, 24, 24, 0.88);\n --dsw-alias-bg-module-platform: #1f1f1f;\n --dsw-alias-bg-multi-select: #303030;\n --dsw-alias-bg-skeleton: rgba(255, 255, 255, 0.07);\n\n --dsw-alias-border-l1: rgba(255, 255, 255, 0.08);\n --dsw-alias-border-l2: rgba(255, 255, 255, 0.14);\n --dsw-alias-border-l3: rgba(255, 255, 255, 0.2);\n --dsw-alias-border-l4: rgba(255, 255, 255, 0.26);\n --dsw-alias-border-l2-darkmode-thin: rgba(255, 255, 255, 0.1);\n\n --dsw-alias-label-primary: #ffffff;\n --dsw-alias-label-primary-dimmed: #b4b4b4;\n --dsw-alias-label-primary-inverted: #0d0d0d;\n --dsw-alias-label-primary-foreground: #0d0d0d;\n --dsw-alias-label-primary-bluish: #ececec;\n --dsw-alias-label-secondary: #b4b4b4;\n --dsw-alias-label-tertiary: #949494;\n --dsw-alias-label-caption: #949494;\n --dsw-alias-label-dimmed: #4a4a4a;\n\n --dsw-alias-brand-primary: #ffffff;\n --dsw-alias-brand-primary-invert: #0d0d0d;\n --dsw-alias-brand-text: #0d0d0d;\n --dsw-alias-brand-primary-new-colorprimary-new-color: #ffffff;\n --dsw-alias-link: #0169cc; /* Codex 深色主题「强调色」实测值 */\n\n --dsw-alias-button-primary-fill: #ffffff;\n --dsw-alias-button-primary-hover: #d9d9d9;\n --dsw-alias-button-primary-dimmed: #282828;\n --dsw-alias-button-contrast-fill: #ececec;\n --dsw-alias-button-elevated-fill: #212121;\n --dsw-alias-button-floating-fill: #1f1f1f;\n --dsw-alias-button-floating-hover: #282828;\n --dsw-alias-button-ghost-active-fill: #303030;\n --dsw-alias-button-ghost-active-hover: #383838;\n --dsw-alias-button-ghost-active-border: #7e7e7e;\n --dsw-alias-button-info-fill: #ffffff;\n --dsw-alias-button-info-hover: #d9d9d9;\n --dsw-alias-button-tool-bar-fill: rgba(255, 255, 255, 0.22);\n --dsw-alias-button-tool-bar-fill-invisible: rgba(255, 255, 255, 0.16);\n --dsw-alias-button-tool-bar-hover: rgba(255, 255, 255, 0.28);\n\n --dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.06);\n /* 深色悬停底:Codex --color-background-button-secondary-hover = --gray-0 8% = #ffffff14。 */\n --dsw-codex-hover-fill: rgba(255, 255, 255, 0.08);\n /* 焦点环:Codex 深色 --color-border-focus = --blue-300 70%,即 #339cffb3。 */\n --dsw-codex-focus: rgba(51, 156, 255, 0.7);\n --dsw-codex-border: rgba(255, 255, 255, 0.12);\n --dsw-codex-border-strong: rgba(255, 255, 255, 0.2);\n --dsw-alias-interactive-bg-active: rgba(255, 255, 255, 0.1);\n --dsw-alias-interactive-bg-hover-solid: #282828;\n --dsw-alias-interactive-bg-hover-accent: rgba(255, 255, 255, 0.08);\n --dsw-alias-interactive-bg-hover-danger: rgba(248, 81, 73, 0.12);\n\n --dsw-alias-markdown-code-block: #1f1f1f;\n --dsw-alias-markdown-code-block-banner: #141414;\n --dsw-alias-markdown-inline-code: #282828;\n --dsw-alias-markdown-code-segment-selected: #303030;\n --dsw-alias-markdown-code-segment-unselected: #1f1f1f;\n --dsw-alias-markdown-citation: #212121;\n --dsw-alias-markdown-placeholder: #282828;\n --dsw-alias-markdown-tag: #282828;\n\n --dsw-alias-state-success-primary: #3fb950;\n --dsw-alias-state-success-secondary: #56d364;\n --dsw-alias-state-success-tertiary: rgba(63, 185, 80, 0.16);\n --dsw-alias-state-error-primary: #ff7b72;\n --dsw-alias-state-error-secondary: #ff9492;\n --dsw-alias-state-error-tertiary: rgba(255, 123, 114, 0.16);\n --dsw-alias-state-warn-primary: #d29922;\n --dsw-alias-state-warn-secondary: #e3b341;\n --dsw-alias-state-warn-tertiary: rgba(210, 153, 34, 0.16);\n --dsw-alias-state-warn-label: #d29922;\n --dsw-alias-state-business-primary: #ececec;\n --dsw-alias-state-business-tertiary: rgba(255, 255, 255, 0.12);\n --dsw-alias-state-idle-primary: #7e7e7e;\n\n --dsw-alias-code-diff-added: rgba(63, 185, 80, 0.14);\n --dsw-alias-code-diff-deleted: rgba(248, 81, 73, 0.14);\n --dsw-alias-file-diff-added-bg: #1f3124;\n --dsw-alias-file-diff-added-gutter: #132016;\n --dsw-alias-file-diff-added-marker: #41c977;\n --dsw-alias-file-diff-deleted-bg: #3c1f1b;\n --dsw-alias-file-diff-deleted-gutter: #28130e;\n --dsw-alias-file-diff-deleted-marker: #fa423e;\n\n --dsw-alias-bg-mask-drop: rgba(39, 39, 48, 0.7);\n --dsw-alias-toast-bg: #282828;\n --dsw-alias-tooltip-bg: #212121;\n --dsw-alias-tooltip-fg: #ececec;\n --dsw-alias-hovercard-bg: #212121;\n\n --dsw-alias-scrollbar-bg-l1: rgba(160, 160, 160, 0.3);\n --dsw-alias-scrollbar-bg-l2: rgba(160, 160, 160, 0.5);\n --dsw-alias-scrollbar-hover-l1: rgba(255, 255, 255, 0.35);\n --dsw-alias-scrollbar-hover-l2: rgba(255, 255, 255, 0.5);\n\n --dsw-specific-sidebar-fill: #181818;\n --dsw-specific-sidebar-nav-item-hover: #212121;\n --dsw-specific-sidebar-nav-item-active: #282828;\n --dsw-specific-sidebar-nav-item-active-accent: rgba(255, 255, 255, 0.14);\n --dsw-specific-input-major: #212121;\n /* 深色表面 = jdi.dark.surface #181818(**不是**窗口背景 #111111):\n 5% 白叠 #181818 = 35.55 → 36,实测 Codex 卡片内部 35 ✓。 */\n --dsw-composer-surface: #181818;\n --dsw-specific-login-input: #1f1f1f;\n --dsw-specific-menu: rgba(24, 24, 24, 0.88);\n --dsw-specific-selector: #282828;\n --dsw-specific-bubble: #212121;\n --dsw-specific-bubble-highlight: #282828;\n --dsw-specific-tip: #1f1f1f;\n\n --dsw-shadow-lv1: 0 1px 2px rgba(0, 0, 0, 0.4);\n --dsw-shadow-lv2: 0 2px 8px rgba(0, 0, 0, 0.5);\n --dsw-shadow-lv3: 0 8px 24px rgba(0, 0, 0, 0.6);\n --dsw-linear-gradient-think: linear-gradient(180deg, #212121 20.19%, rgba(33, 33, 33, 0) 100%);\n --dsw-linear-think-select: linear-gradient(180deg, #1f1f1f 20.19%, rgba(40, 40, 40, 0) 100%);\n}\n\n/* ── 官方缺失的令牌层:空间 / 圆角 / 字阶 / 动效 ─────────────────────────── */html[data-codex-ui],html[data-codex-ui] body{\n /* 4px 网格(新增 12/20 密档,比 Claude 方向紧一档) */\n --dsw-space-1: 4px;\n --dsw-space-2: 8px;\n --dsw-space-3: 12px;\n --dsw-space-4: 16px;\n --dsw-space-5: 20px;\n --dsw-space-6: 24px;\n --dsw-space-8: 32px;\n\n /* 圆角刻度(唯一真源):所有可见边框的圆角都从这里取,各层只准引用、不准再写 px。\n 角色 → 刻度:\n xs 行内小片 / 菜单项\n s 控件(按钮 / 列表行 / 页签)\n m 卡片 / 浮层(菜单、下拉、tooltip)\n l 面板 / 模态(取 16px,与宿主窗口内容块同值 —— dsh-client-ui-layout 的\n --dsh-windows-content-radius: 16px)\n xl 输入卡(无 corner-shape 时的回退值)\n 2xl 输入卡(corner-shape: superellipse 版;超椭圆要更大数值才视觉等圆)\n menu 浮层菜单(模型菜单 / 触发菜单:参考仓库 composer-tool-menus 实测 18px,\n 比输入卡基准 20px 小 2px —— 与 Codex 自身 18/20 的关系一致)\n pill 徽标 / 圆形按钮\n\n 同心纪律(2026-09-27 定,改圆角前先读这一段):\n 嵌套圆角 = 外圆角 − 内衬。浮层菜单内衬 5px ⇒ 行圆角取\n calc(var(--dsw-radius-menu) - 5px) = 13px。宿主原生菜单本来就是同心的\n (MenuSurface 16px − 列表内衬 4px = 行 12px),本层不得再把它改散。\n 卡片内控件(模型 / 权限 chip)到卡片边的内衬是 8px,20/25 − 8 = 12~17,\n 大于控件高(28px)的一半 ⇒ 按同心规则控件必须取全圆角(pill),\n 与 assets/reference/codex-composer-chip-hover.png 实测 R = h/2 一致。 */\n --dsw-radius-xs: 4px;\n --dsw-radius-s: 8px;\n --dsw-radius-m: 12px;\n --dsw-radius-l: 16px;\n --dsw-radius-xl: 20px;\n --dsw-radius-2xl: 25px;\n --dsw-radius-menu: 18px;\n /* 输入卡圆角(单一真源):卡片本体与它的上栏条共用这一个令牌,谁都不许再自己写数。 */\n --dsw-radius-card: var(--dsw-radius-xl);\n /* 上栏条(⑬·2 filebar / ⑭ hero 工作区行)只有上面两角是圆角,它探出在卡片背后,\n 与卡片同心叠放 —— 所以它取的是卡片的圆角本身,不是「卡片 − 4」。\n\n 2026-09-28 复核(用户提供的 DSH / Codex 同屏对照图,两块同一缩放):\n 用合成真值标定过的角弧曲线法(±0.4px)+ 角亏损面积法逐像素量四条角弧,\n Codex 条/卡 = 1.03(条 29 / 卡 28),本皮肤旧值 条/卡 = 0.79(条 16 / 卡 20)。\n 旧的「卡片 − 4」来自另一张参考图上的目视弧长,量错了:条并不比卡片紧,\n 它和卡片一样圆。比值 0.79 就是用户看到的那两条对不上的角弧。 */\n /* 条不另立令牌:⑬·2 filebar 与 ⑭ hero 工作区行直接引用 --dsw-radius-card。\n 若写成 --dsw-radius-bar: var(--dsw-radius-card) 这种别名,var() 会在声明它的\n body 上就解析完,之后再有人在中层元素上改写 --dsw-radius-card,条不会跟着变\n —— 实测(scripts 之外的靶场)能复现「条 25 / 卡 20」的分家。 */\n --dsw-radius-pill: 999px;\n\n --dsw-content-max-width: 768px;\n\n /* 字阶(§3.2):11 / 12.5 / 14 / 16 / 20 / 28 */\n --dsw-text-2xs: 11px;\n --dsw-text-xs: 12.5px;\n --dsw-text-sm: 13px;\n --dsw-text-base: 14px;\n --dsw-text-lg: 16px;\n --dsw-text-xl: 20px;\n --dsw-text-2xl: 28px;\n --dsw-leading-2xs: 1.3;\n --dsw-leading-xs: 1.4;\n --dsw-leading-base: 1.65;\n --dsw-leading-lg: 1.4;\n --dsw-leading-xl: 1.35;\n --dsw-leading-2xl: 1.25;\n\n /* 字体栈(原样照抄宿主 dsh-client-ui-theme 的 base_css_default,0.1.7-rc.2 逐字比对一致)。\n 本皮肤自己声明一次,外观就不随宿主版本漂:UI 栈系统字体优先 + 中文回退链,\n 代码栈等宽优先 + 中文回退,两条都保留中文回退。设置页 fontUi / fontCode 覆盖的就是这两个令牌。 */\n --dsw-font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\",\n \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n --ds-font-family-code: \"SF Mono\", \"JetBrains Mono\", \"Fira Code\", Consolas,\n \"Liberation Mono\", Menlo, Courier, \"PingFang SC\", \"Microsoft YaHei\";\n\n /* 元信息 mono 规约:等宽 + 大写 + 加宽字距(Codex TUI 血统) */\n --dsw-font-meta: var(--ds-font-family-code);\n --dsw-meta-size: 11px;\n --dsw-meta-weight: 500;\n --dsw-meta-tracking: 0.08em;\n --dsw-meta-tracking-pill: 0.04em;\n\n /* 动效:取 Codex 应用自身的时长与曲线(26.727.4816.0 的 app-*.css)\n fast ← --transition-duration-basic: .15s\n base ← DSH 自带 --ds-transition-duration: .2s(Codex 用 duration-200 工具类)\n slow ← --transition-duration-relaxed: .3s\n ease ← --ease-in-out 与 --default-transition-timing-function,两者同值 */\n --dsw-motion-fast: 150ms;\n --dsw-motion-base: 200ms;\n --dsw-motion-slow: 300ms;\n --dsw-ease: cubic-bezier(0.4, 0, 0.2, 1);\n\n /* 输入区配件(skin-center 共享适配器消费的变量) */\n --dsh-composer-accessory-radius: var(--dsw-radius-l);\n --dsh-composer-accessory-bg: var(--dsw-alias-bg-layer-2);\n --dsh-composer-accessory-color: var(--dsw-alias-label-secondary);\n --dsh-composer-accessory-border: 0.5px solid var(--dsw-alias-border-l1);\n --dsh-composer-accessory-shadow: none;\n --dsh-composer-accessory-blur: 0px;\n}\n\n/* corner-shape 分支:卡片升到 25px 超椭圆,上栏条因为引用的是同一个令牌而自动跟随,\n 不需要第二处数字。卡片那一条 @supports 在 composer.css ⑭·1,两条必须同开同关。 */\n@supports (corner-shape: superellipse(1.5)) {html[data-codex-ui],html[data-codex-ui] body{\n --dsw-radius-card: var(--dsw-radius-2xl);\n }\n}\n\n/* ── elevation:描边即层级,阴影近零 ─────────────────────────────────────── */\n/* 官方把 --dsw-elevation-* 声明在 body, body * 上,因此必须同样覆盖到 body *,\n 否则每个后代元素都会用自身声明盖掉从 body 继承下来的值。 */html[data-codex-ui] body,html[data-codex-ui] body *{\n --dsw-elevation-stroke-color: rgba(13, 13, 13, 0.08);\n /* 描边几何引用颜色令牌,而不是把同一个 rgba 再抄一遍:颜色只有上一行这一个真源,\n 亮暗两套各改一处即可(Codex 原文 --elevation-stroke 同样是「几何 + 颜色令牌」)。\n 同一元素上声明、同一元素上解析,所以 body * 上逐元素都取到本元素的颜色。 */\n --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color);\n --dsw-elevation-panel: var(--dsw-elevation-stroke);\n /* Codex --elevation-prominent 逐字(app.asar @67930856):\n var(--elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d\n 旧值 0 4px 16px rgba(13,13,13,.08) 是自造单层,几何与层数都与 Codex 不同。\n 这个令牌是**宿主菜单与面板**的主阴影(.QsffPG_menu / ._7KE1Ra_menu / .JObwrW_panel\n 等 14 处消费),不是边角料。 */\n --dsw-elevation-prominent: var(--dsw-elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d;\n /* --dsw-elevation-soft **不覆盖**,交还宿主自己的值(stroke + 0 4px 16px #00000008 + 0 0 24px #00000008)。\n Codex 没有同名令牌(只有 stroke / stroke-subtle / prominent / sidebar / composer / composer-dark),\n 0.5.10 及以前这里写的 0 8px 28px 10% 墨(暗色 55% 黑)既无截图读数也无源码锚点,\n 却落在宿主 SegmentedControl 的选中片上 —— 设置页每个分段控件都背着一团自造的大影。\n 宿主值里的第一层仍是 var(--dsw-elevation-stroke),所以描边照样跟本皮肤走。 */\n}html[data-codex-ui] body[data-ds-dark-theme],html[data-codex-ui] body[data-ds-dark-theme] *{\n --dsw-elevation-stroke-color: rgba(255, 255, 255, 0.09);\n --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color);\n --dsw-elevation-panel: var(--dsw-elevation-stroke);\n /* 这里**不再覆盖** --dsw-elevation-prominent**:Codex 只在 :root 声明一次、没有暗色变体,\n 所以暗色沿用同一份字面值。后果是暗色菜单不再有 50% 黑的大外影,只剩发丝线 + 填充 ——\n 与 Codex 暗色的整体做法一致(0.5.7 已证输入卡暗色无外影,0.5.8 已证暗色靠填充分层)。\n 原先的 0 4px 16px rgba(0,0,0,.5) 无任何取证。 */\n}\n\n/* ── 排版层:标题字重 600 封顶 ───────────────────────────────────────────── */\n/* 官方 markdown 标题是 700;Codex 无衬线无重标题,层级靠灰阶 + 字号。\n 官方把字排令牌声明在 body 上,故同样写在 body 上(字号仍挂\n --dsh-content-font-delta,用户调字号不破比例)。 */html[data-codex-ui],html[data-codex-ui] body{\n --dsw-font-markdown-h1: 600 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family);\n --dsw-font-markdown-h1-font-weight: 600;\n --dsw-font-markdown-h2: 600 calc(19px + var(--dsh-content-font-delta)) / calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family);\n --dsw-font-markdown-h2-font-weight: 600;\n --dsw-font-markdown-h3: 600 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family);\n --dsw-font-markdown-h3-font-weight: 600;\n}html[data-codex-ui] body[data-ds-dark-theme]{\n --dsw-font-markdown-h1: 600 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family);\n --dsw-font-markdown-h1-font-weight: 600;\n --dsw-font-markdown-h2: 600 calc(19px + var(--dsh-content-font-delta)) / calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family);\n --dsw-font-markdown-h2-font-weight: 600;\n --dsw-font-markdown-h3: 600 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family);\n --dsw-font-markdown-h3-font-weight: 600;\n}\n\n\n/* ==== L3 组件层(源:skins/codex-ink/patches.css)==== */\n/* ============================================================================\n codex-ink(墨白终端)· L3 组件层契约\n ----------------------------------------------------------------------------\n 治理目标(对应方案 §7):\n ① 卡片统一契约:纯描边 + 12px 圆角(--dsw-radius-m)+ 近零阴影 + 16px padding;\n ② 徽标/chip 统一 mono pill;\n ③ 彩色白名单归一:插件自带调色板收敛到 state 三色 + 墨灰;\n ④ hover = 背景升一档,禁自创投影;\n ⑤ 焦点环、cursor、动效时长三条 a11y/交互底线。\n\n 锚点纪律:优先使用 skin-center 语义属性契约\n (data-dsh-surface / data-dsh-part / data-dsh-plugin / data-slot)。\n 宿主没有 data-* 的位置(⑰ 加号与两个触发器)才退到 CSS-Modules 类名的\n **后缀**写法(方括号 class 属性后缀匹配),只依赖语义后缀、不依赖随构建变化的哈希前缀;\n 哈希全名一律不用。计数由 src/build.mjs 每次构建自报。\n ========================================================================== */\n\n/* ── ① 焦点环:2px Codex 强调蓝 + 2px offset(--color-border-focus,亮 #339cff / 暗 70%)\n 颜色读宿主的 --dsw-focus-ring-color(skin.css 已把它接到 --dsw-codex-focus):宿主在指针\n 操作时就地把它置成 transparent,读令牌才能继承这条「鼠标点击不出环」的约定。 ── */html[data-codex-ui] :focus-visible{\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 2px;\n}\n\n/* ── ② 链接 = 强调色(--dsw-alias-link)+ hover 下划线 ──────────────────── */html[data-codex-ui] a{\n color: var(--dsw-alias-link);\n text-decoration: none;\n text-underline-offset: 2px;\n text-decoration-thickness: 1px;\n}html[data-codex-ui] a:hover,html[data-codex-ui] a:focus-visible{\n text-decoration: underline;\n}\n\n/* ── ③ 可点击元素:cursor + 150ms 干脆过渡(--dsw-motion-fast) ─────────── */html[data-codex-ui] button,html[data-codex-ui] a,html[data-codex-ui] summary,html[data-codex-ui] [role=\"button\"],html[data-codex-ui] [role=\"tab\"],html[data-codex-ui] [role=\"menuitem\"],html[data-codex-ui] [role=\"option\"]{\n cursor: pointer;\n}html[data-codex-ui] button,html[data-codex-ui] a,html[data-codex-ui] summary,html[data-codex-ui] [role=\"button\"],html[data-codex-ui] [role=\"tab\"],html[data-codex-ui] [role=\"menuitem\"]{\n transition:\n background-color var(--dsw-motion-fast) var(--dsw-ease),\n border-color var(--dsw-motion-fast) var(--dsw-ease),\n color var(--dsw-motion-fast) var(--dsw-ease),\n opacity var(--dsw-motion-fast) var(--dsw-ease),\n box-shadow var(--dsw-motion-fast) var(--dsw-ease);\n}html[data-codex-ui] button:disabled,html[data-codex-ui] button[aria-disabled=\"true\"],html[data-codex-ui] [aria-disabled=\"true\"]{\n cursor: not-allowed;\n opacity: 0.45;\n}\n\n/* ── ④ 卡片统一契约:描边即层级 ─────────────────────────────────────────── */html[data-codex-ui] [data-slot=\"web-ui.plugin.item\"] > *,html[data-codex-ui] [data-dsh-part=\"card\"],html[data-codex-ui] [data-dsh-part=\"column\"],html[data-codex-ui] [data-dsh-part=\"status\"],html[data-codex-ui] [data-dsh-part=\"today-card\"],html[data-codex-ui] [data-dsh-part=\"balance-card\"],html[data-codex-ui] [data-dsh-part=\"trend-card\"],html[data-codex-ui] [data-dsh-part=\"plan-card\"],html[data-codex-ui] [data-dsh-part=\"bank-card\"]{\n background: var(--dsw-alias-bg-layer-1);\n border: 0.5px solid var(--dsw-alias-border-l1);\n border-radius: var(--dsw-radius-m);\n box-shadow: var(--dsw-elevation-panel);\n padding: var(--dsw-space-4);\n}\n\n/* 弹层 / 模态:大圆角(阴影交给宿主的 elevation 令牌,本层不另画) */html[data-codex-ui] [role=\"dialog\"],html[data-codex-ui] [data-dsh-surface=\"overlay\"] [data-dsh-part=\"panel\"]{\n border-radius: var(--dsw-radius-l);\n}\n\n/* ── ⑤ 徽标 / chip:统一 mono pill ──────────────────────────────────────── */html[data-codex-ui] [data-dsh-part=\"composer-chip\"],html[data-codex-ui] [data-dsh-part=\"chip\"],html[data-codex-ui] [data-dsh-part=\"ref\"],html[data-codex-ui] [data-dsh-part=\"tag-chip\"],html[data-codex-ui] [data-dsh-part=\"tag-badge\"]{\n font-family: var(--dsw-font-meta);\n font-size: var(--dsw-meta-size);\n font-weight: var(--dsw-meta-weight);\n letter-spacing: var(--dsw-meta-tracking-pill);\n border-radius: var(--dsw-radius-pill);\n}\n\n/* ── ⑥ 彩色白名单归一:task-board 六档 tag tone 收敛到 state 三色 + 墨灰 ── */html[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"0\"],html[data-codex-ui] [data-dsh-part=\"tag-badge\"][data-tag-tone=\"0\"]{\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-secondary);\n}html[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"1\"],html[data-codex-ui] [data-dsh-part=\"tag-badge\"][data-tag-tone=\"1\"]{\n background: var(--dsw-alias-state-success-tertiary);\n color: var(--dsw-alias-state-success-primary);\n}html[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"2\"],html[data-codex-ui] [data-dsh-part=\"tag-badge\"][data-tag-tone=\"2\"]{\n background: var(--dsw-alias-state-warn-tertiary);\n color: var(--dsw-alias-state-warn-primary);\n}html[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"3\"],html[data-codex-ui] [data-dsh-part=\"tag-badge\"][data-tag-tone=\"3\"]{\n background: var(--dsw-alias-state-error-tertiary);\n color: var(--dsw-alias-state-error-primary);\n}html[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"4\"],html[data-codex-ui] [data-dsh-part=\"tag-badge\"][data-tag-tone=\"4\"]{\n background: var(--dsw-alias-state-business-tertiary);\n color: var(--dsw-alias-label-primary);\n}html[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"5\"],html[data-codex-ui] [data-dsh-part=\"tag-badge\"][data-tag-tone=\"5\"]{\n background: var(--dsw-alias-bg-layer-3);\n color: var(--dsw-alias-label-tertiary);\n}\n\n/* ── ⑦ 侧栏:比主区深/浅一档的整面 ─────────────────────────────────────── */html[data-codex-ui] [data-dsh-surface=\"sidebar\"],html[data-codex-ui] [data-slot=\"sidebar.workspaces\"]{\n background-color: var(--dsw-alias-bg-sidebar);\n}\n\n/* 侧栏行 hover = 背景升一档,无位移无阴影 */html[data-codex-ui] [data-dsh-part=\"sidebar-entry\"],html[data-codex-ui] [data-dsh-surface=\"sidebar\"] [role=\"button\"]{\n border-radius: var(--dsw-radius-s);\n}\n\n/* ── ⑧ 输入区:工具不是主角,16px 圆角(--dsw-radius-l)+ 纯描边 ─────────────\n 卡片([data-composer-card])的**圆角与阴影**只在 ⑭ 层(composer.css)声明一处:\n 圆角 20px(corner-shape 可用时 25px 超椭圆),阴影是 Codex --elevation-composer 三层。\n 旧版这里还各写过一次 —— 圆角 var(--dsw-radius-l)=16px、阴影 var(--dsw-elevation-panel) ——\n 与 ⑭ 层构成同一元素的第二真源,谁生效只看选择器权重。两条都已删:宿主只在会话滚动区里\n 渲染这张卡(真 GUI 实测 1 张、且在 [data-conversation-scroll] 内),⑭ 层全覆盖。 */html[data-codex-ui] [data-dsh-surface=\"composer\"] [data-dsh-part=\"composer-input\"]{\n border-radius: var(--dsw-radius-l);\n}html[data-codex-ui] [data-dsh-part=\"composer-input\"]{\n font-family: var(--dsw-font-family);\n line-height: var(--dsw-leading-base);\n}\n\n/* ── ⑨ 元信息 mono 化:token 数 / 模型名 / 时间戳 / 路径 / 快捷键 ───────── */html[data-codex-ui] [data-dsh-part=\"turn-tail\"],html[data-codex-ui] [data-dsh-part=\"queue-dock\"],html[data-codex-ui] [data-dsh-part=\"usage-chart\"],html[data-codex-ui] [data-dsh-part=\"voucher-preview\"]{\n font-family: var(--dsw-font-meta);\n}\n\n/* 微标签:11px / 大写 / .08em —— 仅作用于纯拉丁元信息锚点 */html[data-codex-ui] [data-dsh-part=\"composer-chip\"]{\n text-transform: uppercase;\n letter-spacing: var(--dsw-meta-tracking);\n}\n\n/* ── ⑩ CJK 豁免:中文不做字距加宽、不做大写(:lang(zh) 选择器) ────────── */html[data-codex-ui] :lang(zh) [data-dsh-part=\"composer-chip\"],html[data-codex-ui] :lang(zh) [data-dsh-part=\"chip\"],html[data-codex-ui] :lang(zh) [data-dsh-part=\"ref\"],html[data-codex-ui] :lang(zh) [data-dsh-part=\"tag-chip\"],html[data-codex-ui] :lang(zh) [data-dsh-part=\"tag-badge\"]{\n letter-spacing: 0;\n text-transform: none;\n}\n\n/* ── ⑪ reduced-motion:瞬时终态 ─────────────────────────────────────────── */\n@media (prefers-reduced-motion: reduce) {html[data-codex-ui] *,html[data-codex-ui] *::before,html[data-codex-ui] *::after{\n animation-duration: 0.01ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 0.01ms !important;\n scroll-behavior: auto !important;\n }\n}\n\n/* ═══════════════════════════════════════════════════════════════════════════\n ⑫ Codex 模型选择器(composer 模型位 conversation.input.model)\n ---------------------------------------------------------------------------\n 形态对齐 MichengAI/dsh-codex-ui:\n · 触发器 = 宿主原生形态(模型名 + 强度 + chevron),本层不重绘,\n 只把焦点环归一成一条细线。旧版藏 chevron、改 mono 字体、压 24px 高,\n 实测与仓库截图 conversation-light.png 的触发器不符,全部撤掉。\n · 菜单面板 = 仓库 composer-tool-menus 的皮肤契约:不透明白 + 18px 圆角 +\n 软影 0 8px 32px + 1px 描边环;行 28px、内衬 4px 9px、圆角 13px(同心:18 − 内衬 5)、\n hover 淡填充。旧版 TUI 编号行、页脚提示行、隐藏勾选列一并废弃。\n · 设置卡的「Codex 模型选择器」开着时,这一席位由 model-picker.css 的自建组件顶替,\n 本段只在开关关掉(或组件没挂上)时作用于宿主原生菜单 —— 即 A 面退化路径\n (B 面组件见 docs/plan-model-picker.zh-CN.md)。\n · 选中行 = 淡填充 + 勾选图标;pending 行 = 行尾 StateDot 转圈。\n 旧版把行尾整列 display:none,点强度后转圈被藏、全列表又被全局\n disabled 规则压到 45% 透明度 —— 点了像没反应,这就是「选择推理\n 强度时出现延迟」的全部成因。宿主 selectModel 的往返时长 CSS 改不了,\n 但反馈必须即时可见。\n 锚点纪律:触发器 data-slot;菜单 body > div[role=menu][aria-busy]\n (菜单经 createPortal 挂 document.body;aria-busy 恒在,用于排除\n primitives 的通用菜单)。不用 CSS-Modules 哈希类名。\n ═══════════════════════════════════════════════════════════════════════════ */\n\n/* ── ⑫·1 触发器:只归一焦点环(官方 box-shadow 环 + 全局 2px outline → 1px 细线) ── */html[data-codex-ui] [data-slot=\"conversation.input.model\"] button:focus-visible{\n outline: 1px solid var(--dsw-focus-ring-color);\n outline-offset: 1px;\n box-shadow: none;\n}\n\n/* ── ·2 菜单面板:不透明白 + 圆角 + 软影(参考仓库 composer-tool-menus 实测 18px\n = --dsw-radius-menu。旧版把它折到刻度 l=16,结果菜单 16 与输入卡 20/25 叠在\n 一起时两条角弧肉眼可辨;改回实测值后与卡片只差 2px,正是 Codex 自己\n 菜单 18 / 卡片 20 的关系。影 0 8px 32px #0002 + 环 1px #0000000a)────── */html[data-codex-ui] body > div[role=\"menu\"][aria-busy]{\n --dsw-menu-surface-fill: var(--dsw-alias-bg-layer-1);\n --dsw-menu-backdrop-filter: none;\n /* rc.1 的填充挂在菜单元素自身(specific-menu 半透明),rc.2 挂在\n MenuSurface 的 material 子层 —— 两边都压成不透明白。 */\n background: var(--dsw-alias-bg-layer-1);\n padding: 5px;\n border-radius: var(--dsw-radius-menu);\n box-shadow: 0 8px 32px rgba(13, 13, 13, 0.13), 0 0 0 1px rgba(13, 13, 13, 0.04);\n color: var(--dsw-alias-label-primary);\n}html[data-codex-ui] body[data-ds-dark-theme] > div[role=\"menu\"][aria-busy]{\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(236, 236, 236, 0.05);\n}\n\n/* ── ⑫·3 行契约:根面板两 cell(模型 / 推理强度)与选项行共用 ──────────────\n 圆角按同心纪律取「面板 18 − 内衬 5 = 13」。旧版写 8px(--dsw-radius-s):\n 面板 16、行 8 的两条角弧不同心,行看起来比面板\"方\"——宿主原生是同心的\n (MenuSurface 16 − 列表内衬 4 = 行 12),这条是皮肤自己改散的,已收回。 */html[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"],html[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"]{\n box-sizing: border-box;\n min-height: 28px;\n padding: 4px 9px;\n gap: 8px;\n border: 0;\n border-radius: calc(var(--dsw-radius-menu) - 5px);\n background: none;\n color: inherit;\n font-size: 13px;\n line-height: 20px;\n text-align: left;\n}html[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"]:hover,html[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"]:hover:not(:disabled),html[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"][aria-checked=\"true\"]{\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n/* 键盘焦点:全局 2px 环在菜单内不贴行盒,改 1px 内描线 */html[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"]:focus-visible,html[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"]:focus-visible{\n outline: 1px solid var(--dsw-focus-ring-color);\n outline-offset: -1px;\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n/* pending:全局 button:disabled 的 45% 透明度会把整列洗灰(看起来像卡住),\n 还原为 1,反馈交给行尾转圈 + 勾选;非当前行靠官方 dimmed 色区分 */html[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"]:disabled{\n opacity: 1;\n}\n\n/* pending 指示器:宿主在 aria-busy 窗口内把整列 disabled,但**不渲染任何转圈**。\n 2026-09-26 在真实 GUI(0.1.7-rc.x,headless Chromium 打 127.0.0.1:3099)实测:\n 点「推理等级」后第 60ms 菜单 aria-busy=true、6 行全 disabled、checked 已乐观\n 移到新值,而 document.getAnimations() 在菜单内为 0 —— 面板冻着不动约 1.1s 才关闭。\n 宿主 selectModel 的往返时长 CSS 改不了,能改的是这 1.1s 里必须看得见「在写」。\n 做法:pending 行行尾的勾选换成转圈(勾选已乐观前移,此处正好空出来),\n 并把 disabled 的 not-allowed 光标换成 progress。 */html[data-codex-ui] body > div[role=\"menu\"][aria-busy=\"true\"] button[role=\"menuitemradio\"][aria-checked=\"true\"] > span:last-child{\n position: relative;\n}html[data-codex-ui] body > div[role=\"menu\"][aria-busy=\"true\"] button[role=\"menuitemradio\"][aria-checked=\"true\"] > span:last-child > svg{\n visibility: hidden;\n}html[data-codex-ui] body > div[role=\"menu\"][aria-busy=\"true\"] button[role=\"menuitemradio\"][aria-checked=\"true\"] > span:last-child::after{\n content: \"\";\n position: absolute;\n inset: 0;\n box-sizing: border-box;\n width: 12px;\n height: 12px;\n margin: auto;\n border: 1.5px solid var(--dsw-alias-border-l3);\n border-top-color: var(--dsw-alias-label-primary);\n border-radius: 50%;\n /* Codex --animate-spin = spin 1s linear infinite(本插件保留自己的 keyframe 名)。 */\n animation: codex-ui-spin 1s linear infinite;\n}html[data-codex-ui] body > div[role=\"menu\"][aria-busy=\"true\"] button[role=\"menuitemradio\"]:disabled{\n cursor: progress;\n}\n\n@keyframes codex-ui-spin {\n to {\n transform: rotate(1turn);\n }\n}\n\n/* ═══════════════════════════════════════════════════════════════════════════\n ⑬ Codex 式输入区(圆角 / 阴影 / 顶栏消隐 / 面板按钮图标)\n ---------------------------------------------------------------------------\n 参考图 assets/reference/codex-composer-reference.png(1208×263)实测:\n · 页面底 #ffffff;卡片填充 #ffffff\n · 卡片下沿阴影 ≈ #fbfbfb(极淡、宽而散);卡片左描边 ≈ #e1e1e1 发丝线\n · 卡片上栏(PROJECT 条)填充 #f5f5f5,顶圆角、左右各内缩 ≈24px\n · 卡片圆角:白底白卡无法逐像素定值,按角弧轮廓落在 24–28px,取下沿 24px\n ═══════════════════════════════════════════════════════════════════════════ */\n\n/* ⑬·1 输入卡:几何与表面已移交 ⑭ 层(skins/codex-ink/composer.css)。\n 那一层从 MichengAI/dsh-codex-ui 原样移植:20px 圆角(corner-shape 可用时 25px 超椭圆)、\n 无边框、三层极淡投影;选择器优先级与层序都在本条之上,故此处不再重复声明,避免双真源。\n 旧值留档:24px 圆角 / 1px border-l1 / 0 1px 2px + 0 14px 36px。 */\n\n/* ⑬·2 输入卡上方的 Codex 条(文件/工作区选择栏)形态契约\n seat: conversation.input.dock(「Full-width entries above the composer card」)。\n 内容由客户端插件填入并打 [data-codex-filebar] 标记——只按标记上形,\n 避免把既有 QueueDock / TodoDock / GoalDock 也画成灰条。 */html[data-codex-ui] [data-codex-filebar]{\n box-sizing: border-box;\n width: calc(100% - 48px);\n margin: 0 auto -12px;\n padding: 8px 16px 22px;\n border-radius: var(--dsw-radius-card) var(--dsw-radius-card) 0 0;\n background: #f5f5f5;\n color: var(--dsw-alias-label-secondary);\n font-family: var(--dsw-font-meta);\n font-size: 12px;\n line-height: 20px;\n}html[data-codex-ui] body[data-ds-dark-theme] [data-codex-filebar]{\n background: #262626;\n}\n\n/* ⑬·3 顶栏瘦身:分割线上只留会话名 + 右上角按钮\n 官方 ConversationSessionHeader 的真实结构(rc1,读 dsh-client-ui-conversation 的 JSX):\n div.titleRow\n ├ div.titleCluster\n │ ├ nav.crumbs ← 会话名(crumbSeg / crumbCurrent),保留\n │ └ div.headerActions ← renderSlot(\"conversation.session.header.actions\"):预设徽标 / 任务列表,隐去\n ├ div.headerUtilities ← renderSlot(\"...utilities\"),隐去\n └ div.headerCorner[data-conversation-header-corner] ← 右栏展开按钮 / 会话日志,保留\n div.tabs[role=tablist][data-conversation-tabs] ← 视图页签,隐去\n titleRow 的官方布局是 titleCluster(flex:1)+ utilities + corner,\n 故去掉旧版的 justify-content:flex-end —— 标题要留在左边。\n\n ⚠ 本注释块里**不能出现花括号**(左右都不行):src/build.mjs 的\n scopeCss 是按「下一个左花括号」做括号配对的,注释里混进一个花括号就会把\n 后面的规则整段错位 —— 实测表现为紧随其后的规则被重复加 html[data-codex-ui]\n 前缀而失效、整份 theme.css 花括号数不平衡。所以注释里只写行式记法。 */\n/* ⑬·3c 顶栏两格**放开**(0.3.0):槽出口里的条目恢复渲染。\n 0.2.x 为了对齐参考图「只留会话名 + 右上角按钮」,这里把两格的槽出口内容整块 display:none。\n 代价是派出去的子代理在界面上完全看不见在跑:官方子代理包把「后代数量触发器」注册在\n conversation.session.header.actions(id subagent-catalog,order -30),同一格还有\n jobs 的 roster、预设徽标、在应用中打开 —— 一起被关掉了。\n 现在放开。这些条目**本身都是条件渲染**(没有子代理 / 没有 job / 没有预设 / 没有工作目录时\n 各自返回 null),所以常态顶栏仍旧是「会话名 + 右上角按钮」,只有真有东西时才多出条目。\n 配色不动:条目只用 --dsw-* 语义令牌(--dsw-alias-label-tertiary、--dsw-alias-interactive-bg-hover、\n --dsw-alias-border-l4 …),本皮肤已重锚这些令牌,因此跟着现在的配色走,不需要另配一套色。\n 出口恒为 div[data-slot=槽名][style=display:contents],不需要也不应该改出口本身。\n ⚠ 本部注释里不能出现花括号(src/build.mjs 的 scopeCss 按花括号配对)。 */html[data-codex-ui] [data-slot=\"conversation.session.header\"] > [data-conversation-tabs]{\n display: none;\n}\n\n/* ⑬·3b 顶栏**真正**瘦身:把被隐藏页签仍占着的那条 min-height 收掉。\n 官方规则 .wSkVaW_header:where(:not(:has(.wSkVaW_tabs))) 用 :has() 判断有没有页签,\n 但 :has() 只看元素树、**不看 display** —— 上面那条 display:none 之后 .tabs 还在树里,\n 于是 min-height:76px 与 padding-bottom:0 一直生效:页签没了,76px 空带还在,\n 分割线被顶到空带下方,看起来就是\"上方空了一大块 / 分割线太高\"。\n 这里按同一语义手动补上。header 元素本身没有 data-*,用它内部稳定的子锚点 :has 定位\n (data-conversation-header-leading,见 dsh-client-ui-conversation/lib/client.js:16223)。\n 必须再带 :has([data-conversation-tabs]):hero/空白态下 showTabs=false,页签根本不渲染,\n 官方 .wSkVaW_headerBlank 给的 min-height:0 与 padding-bottom:0 才是对的;\n 不带这个条件会反过来给空白顶栏塞 10px 下内边距(实测踩到过)。 */html[data-codex-ui] header:has([data-conversation-header-leading]):has([data-conversation-tabs]){\n min-height: 0;\n padding-bottom: 10px;\n}\n\n/* ⑬·6 「新会话」:参考图里 New chat 是**无边框行**(图标 + 文字,hover 浅填充),\n DSH 默认是带描边的整宽按钮 → 去掉描边与底色,改成行。\n 锚点 data-dsh-part=\"new-session\" 由 skin-center 兼容适配器补打(不依赖本地化文案)。 */html[data-codex-ui] [data-dsh-part=\"new-session\"]{\n border: 0;\n background: transparent;\n box-shadow: none;\n border-radius: var(--dsw-radius-s);\n justify-content: flex-start;\n color: var(--dsw-alias-label-primary);\n}html[data-codex-ui] [data-dsh-part=\"new-session\"]:hover{\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n/* ⑬·5 卡下方统计行(DSH 独有:轮数/步数 · tok/s · 缓存命中)——参考图没有这一行。\n 要恢复:把下面这条注释掉即可。 */html[data-codex-ui] [data-slot=\"conversation.composer.dock\"]{\n display: none;\n}\n\n/* ⑬·4 右上角面板按钮:**用官方图标**,不再手绘。\n 旧版把官方 svg display:none 掉、用纯 CSS 画了个「圆角方框 + 竖分割线」——\n 1.2px 描边配 16px 图标的视觉重量对不上,比官方图标糙。\n 查了 MichengAI/dsh-codex-ui:**它也没换这个图标**。它只做两件事\n (src/client/conversation-header.ts:26):\n ① 归零角落插槽的边距补偿 —— 官方 .headerCorner 是 margin-left:8px + margin-right:-16px,\n 配合 header 的 padding-right:28px 把按钮顶到距右 12px;归零后回到 28px 内缩。\n ② 用 order 重排,绝不物理搬移 React 节点(搬了会 NotFoundError 白屏)。\n 所以这里:撤掉手绘方框、恢复官方 svg,只补两点 —— 位置归零 + 展开态也镜像成 panel-right。\n\n 为什么镜像:官方展开按钮用**未镜像**的 IconPanelLeftOutlineRegular,收起按钮用\n collapseGlyph 的 scaleX(-1) 镜像版 —— 同一块面板的两个状态图标朝向不一致。\n 参考图 assets/reference/codex-app-reference.png 右上角是 panel-right,故把展开态也镜像过来。\n 锚点 data-sidebar-right-expand 见 dsh-client-ui-sidebar-right 的 ExpandButton。 */html[data-codex-ui] [data-conversation-header-corner]{\n margin-left: 0;\n margin-right: 0;\n}html[data-codex-ui] [data-sidebar-right-expand] svg{\n transform: scaleX(-1);\n}\n\n/* ═══════════════════════════════════════════════════════════════════════════\n ⑮ 侧栏开合动效参数(对齐 Codex 手感)\n ---------------------------------------------------------------------------\n 宿主本来就animate:AppFrame 是 grid-template-columns: 侧栏 / 1fr / 右栏,\n 开合时给 frame 打 [data-animating],grid-template-columns 的 transitionend 到了\n 才摘掉(dsh-client-ui-layout/lib/client.js:270-298)。\n 默认参数 --ds-transition-duration-slow = .3s、--ds-ease-in-out = cubic-bezier(.4,0,.2,1)。\n Codex 实测手感是 500ms + expo-out(MichengAI/dsh-codex-ui 用同值),这里换掉。\n 锚点全用稳定 data 属性:data-animating / data-dragging / data-side。\n ⚠ 不要用 [class*=…]:frame 的类名是 CSS-Modules 哈希,随构建变。\n ═══════════════════════════════════════════════════════════════════════════ */\n/* 只改**参数**,不改宿主\"关掉动画\"的开关。宿主有四个状态要求 transition:none:\n data-dragging(拖拽中,逐帧跟随指针)、data-rightbar-instant(右栏退出全屏,瞬时)、\n data-rightbar-fullscreen、以及 reduced-motion。\n 注意本层被作用域化成 html[data-codex-ui] [data-animating] = (0,2,1),\n 会**盖过**宿主的 .pI_x6G_frame[data-rightbar-instant] = (0,2,0) —— 所以必须显式还回去,\n 否则右栏退出全屏会被强行加上动画(实测踩到过)。 */html[data-codex-ui] [data-animating]{\n transition: grid-template-columns 500ms cubic-bezier(.16, 1, .3, 1);\n}\n\n/* 两侧拖拽柄都要覆盖:只写 [data-side=\"sidebar\"] 会让右栏柄停在宿主的 .3s,\n 与 frame 的 500ms 脱同步(柄和面板各走各的)。 */html[data-codex-ui] [data-animating] [data-side]{\n transition: left 500ms cubic-bezier(.16, 1, .3, 1);\n}html[data-codex-ui] [data-dragging],html[data-codex-ui] [data-rightbar-instant],html[data-codex-ui] [data-rightbar-fullscreen],html[data-codex-ui] [data-dragging] [data-side],html[data-codex-ui] [data-rightbar-instant] [data-side],html[data-codex-ui] [data-rightbar-fullscreen] [data-side]{\n transition: none;\n}\n\n@media (prefers-reduced-motion: reduce) {html[data-codex-ui] [data-animating],html[data-codex-ui] [data-animating] [data-side]{\n transition: none;\n }\n}\n\n/* ═══════════════════════════════════════════════════════════════════════════\n ⑯ 右栏「展开时选择组件」= 空栏 guide(对齐 Codex 右栏展开参考图实测)\n ---------------------------------------------------------------------------\n 参考图实测(488×1120,DPR=1,侧栏面板宽 474):\n · 条目行:宽 380 居中、行高 52、行距 14(pitch 66)、无描边无底色、圆角 12\n · 图标列 20×20(条目左沿内缩 20),图标 ↔ 文字 gap 14\n · 标题 14px 单行;参考图没有描述行\n · 快捷键:右侧灰底 pill,68×24、圆角 12、底 ≈ #f3f3f4、字 12px 辅墨\n · 无罗盘 hero;内容块真·垂直居中(宿主底部 10% 占位要去掉)\n 锚点:data-sidebar-right-guide / data-sidebar-right-guide-entry(官方稳定\n 属性),行内结构用位置伪类 + :has(kbd),不用哈希类名。\n ═══════════════════════════════════════════════════════════════════════════ */\n\n/* 罗盘 hero:参考图无 */html[data-codex-ui] [data-sidebar-right-guide] > span[aria-hidden=\"true\"]{\n display: none;\n}\n\n/* 底部 10% 占位 → 真居中 */html[data-codex-ui] [data-sidebar-right-guide]::after{\n display: none;\n}\n\n/* 条目:胶囊 → 平行 */html[data-codex-ui] [data-sidebar-right-guide-entry]{\n box-sizing: border-box;\n width: 380px;\n max-width: 100%;\n min-height: 52px;\n gap: 14px;\n padding: 0 20px;\n border: 0;\n border-radius: var(--dsw-radius-m);\n background: transparent;\n color: var(--dsw-alias-label-primary);\n font-size: 14px;\n}html[data-codex-ui] [data-sidebar-right-guide-entry]:hover{\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\n/* 图标列 20×20(宿主按有无描述画 22/26,拉回参考值) */html[data-codex-ui] [data-sidebar-right-guide-entry] > span:first-child{\n width: 20px;\n height: 20px;\n}html[data-codex-ui] [data-sidebar-right-guide-entry] > span:first-child svg{\n width: 20px;\n height: 20px;\n}html[data-codex-ui] [data-sidebar-right-guide-entry] > span:nth-child(2){\n gap: 0;\n}\n\n/* 描述行:仅当它存在时隐藏(nth-child(2) 不存在即标题独占) */html[data-codex-ui] [data-sidebar-right-guide-entry] > span:nth-child(2) > span:nth-child(2){\n display: none;\n}\n\n/* 终端行是 terminal 插件注册的自定义卡(TerminalGuide),不是标准卡:\n 子序为 button.main(absolute 点击层)→ span.icon → span.text → keys,\n 上面按标准卡子序写的图标/描述规则对它全部落空,按 kind 属性另点。\n 标题旁的 chevron 是选 Shell 菜单入口,功能件保留。 */html[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(2){\n width: 20px;\n height: 20px;\n}html[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(2) svg{\n width: 20px;\n height: 20px;\n}html[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(3) > span:nth-child(2){\n display: none;\n}\n\n/* 快捷键 → 灰底 pill(宿主 plain 变体是无底文字键) */html[data-codex-ui] [data-sidebar-right-guide-entry] > span:has(kbd){\n display: inline-flex;\n flex: none;\n align-items: center;\n gap: 3px;\n height: 24px;\n padding: 0 10px;\n border-radius: var(--dsw-radius-m);\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 16px;\n white-space: nowrap;\n}html[data-codex-ui] [data-sidebar-right-guide-entry] kbd{\n display: inline;\n min-width: 0;\n padding: 0;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: inherit;\n font: inherit;\n}\n\n/* 收起按钮(▢):参考图常驻灰底圆方(参考 42px,受页签条行高限制取 28,\n 形态对齐、尺寸如实记录) */html[data-codex-ui] [data-sidebar-right-toggle]{\n background: var(--dsw-alias-bg-layer-2);\n border-radius: var(--dsw-radius-m);\n}\n\n/* ═══════════════════════════════════════════════════════════════════════════\n ⑰ composer 底部控件:默认无框,悬停整块淡底\n ---------------------------------------------------------------------------\n 需求:Codex 的加号控件默认**没有**背景框,只有鼠标悬停才出现;\n 模型选择器与权限控件照它的悬停形态复刻。\n 参考图 assets/reference/codex-composer-plus.png(65×84)与 codex-composer-chip-hover.png(144×58):\n\n · 加号:40px 直径圆形,悬停时才填 --dsw-codex-hover-fill(实测 #F2F2F3)。\n · 悬停胶囊:整块填充 242,242,243,**没有描边**(逐像素量了外沿:白 255 → 243 → 242,\n 没有更深的环);左右弧顶到弧顶 41px,全圆角。\n · 圆角:胶囊**全圆角**。2026-09-27 逐像素复测 codex-composer-chip-hover.png:\n 胶囊高 42px、最左点 x=13,而最上一行的边界在 x=29 —— 离最左点 16px,正合\n R=h/2=21 的预测值 16.4;若真是 12px 圆角则应为 8.6。旧版只搬了填充色,\n 圆角留在宿主的 --dsw-radius-sm=8px,于是 28px 高的控件配 20/25px 的输入卡\n 不成同心(同心值 20−8=12、25−8=17,都 > 控件高的一半 14)—— 已补 pill。\n\n 锚点:加号按钮没有 data-* 只有哈希类名,按 composer.css 已备案的「后缀锚点」写法取\n button[class$=\"_add\"];两个触发器(模型 _7KE1Ra_trigger / 权限 iWlSmW_trigger)\n 同样按后缀取 button[class$=\"_trigger\"],整条规则限定在 [data-composer-card] 内。\n 实测(真 GUI,2026-09-26):加号 idle 与 hover 同为 rgb(241,241,239) —— 常驻灰底,\n 就是「多了个背景框」;两个触发器 idle 透明、hover rgba(13,13,13,.04),与参考图同形。\n 圆角不交给宿主:宿主 rc.2 的 .u91W7W_trigger / ._5Tq8wa_trigger 都是\n --dsw-radius-sm=8px(且历史版本换过值),皮肤显式写 pill 才与宿主版本无关。\n ═══════════════════════════════════════════════════════════════════════════ */html[data-codex-ui] [data-composer-card] button[class$=\"_add\"],html[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"]{\n transition: background-color var(--dsw-motion-fast) var(--dsw-ease);\n}\n\n/* 圆角:Codex 的 composer chip 是全圆角胶囊(参考图实测 R = h/2)。加号宿主自带\n 999px,一并显式声明,免得宿主改值后悄悄变方。 */html[data-codex-ui] [data-composer-card] button[class$=\"_add\"],html[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"]{\n border-radius: var(--dsw-radius-pill);\n}\n\n/* 加号:撤掉宿主常驻的 --dsw-alias-bg-layer-2 灰底 */html[data-codex-ui] [data-composer-card] button[class$=\"_add\"]{\n background: transparent;\n}html[data-codex-ui] [data-composer-card] button[class$=\"_add\"]:hover,html[data-codex-ui] [data-composer-card] button[class$=\"_add\"][aria-expanded=\"true\"],html[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"]:hover,html[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"][aria-expanded=\"true\"]{\n background: var(--dsw-codex-hover-fill);\n}\n\n\n\n/* ==== L3 模型选择器组件(源:skins/codex-ink/model-picker.css)==== */\n/* ============================================================================\n codex-ink(墨白终端)· L3 模型选择器组件(B 面:功率轨)\n ----------------------------------------------------------------------------\n 源文件:skins/codex-ink/model-picker.css\n 消费者:src/model-picker.js(自建 DOM,不是宿主的菜单)。\n 由 src/build.mjs 与其余各层一起拼接,并作用域化到 html[data-codex-ui];\n 本文件写裸选择器,注释里不写花括号(scopeCss 按花括号配对)。\n\n 纪律(0.5.0 卡顿的教训,check-repo 有一条断言守着):\n · 本层只画自建节点(.codex-mp-*),外加宿主席位的**一条**隐藏规则;\n · 一条选择器都不挂宿主的菜单 portal(body 直属的 role=menu),\n 也不改宿主菜单的 display / height / padding。\n\n 取值来源:\n Codex = app.asar 模型选择器实现块 _Root_xwb5v_19 / _Track_xwb5v_212 /\n _Tick_xwb5v_40 / _Thumb_xwb5v_12 的逐字值(复刻提示词第六节);\n 宿主 = dsh-client-ui-model-selection 0.1.7-rc.2 的 ModelSelect.module.css\n (触发器几何与 z-index 照抄,好让我们的触发器在工具条里与原生那一格同位同高);\n 本皮肤 = 已有令牌(⑫ 菜单面板契约、⑰ 胶囊、同心圆角)。\n ============================================================================ */\n\n\n/* ── ① 席位顶替 ─────────────────────────────────────────────────────────\n 我们的触发器在席时,宿主那一格隐藏(仍在 React 树里,生命周期不动)。\n 直接子代 :has() 的失效范围只有席位自己的几个子节点,不是 0.5.0 那种挂在\n portal 菜单上的长链。触发器一摘(设置里关掉、或组件卸载),宿主那一格立刻复原。 */html[data-codex-ui] [data-slot=\"conversation.input.model\"]:has(> .codex-mp-trigger) > :not(.codex-mp-trigger){\n display: none;\n}\n\n\n/* ── ② 触发器:模型名 + 档位名 + chevron ─────────────────────────────────\n 几何照宿主 _7KE1Ra_trigger(高 28、内衬 0 4 0 8、gap 4、13px/20px、\n max-width min(360px, 45cqw)),外观走 ⑰ 的 Codex 胶囊:全圆角、默认透明无描边、\n 悬停或展开才填 --dsw-codex-hover-fill。焦点环由 ⑫·1 的席位规则统一画(1px 细线)。 */html[data-codex-ui] .codex-mp-trigger{\n box-sizing: border-box;\n display: inline-flex;\n align-items: center;\n gap: 4px;\n height: 28px;\n min-width: 0;\n max-width: min(360px, 45cqw);\n padding: 0 4px 0 8px;\n border: 0;\n border-radius: var(--dsw-radius-pill);\n background: transparent;\n color: var(--dsw-alias-label-secondary);\n font-family: inherit;\n font-size: 13px;\n font-weight: 400;\n line-height: 20px;\n cursor: pointer;\n transition: background-color var(--dsw-motion-fast) var(--dsw-ease);\n}html[data-codex-ui] .codex-mp-trigger:hover,html[data-codex-ui] .codex-mp-trigger[aria-expanded=\"true\"]{\n background: var(--dsw-codex-hover-fill);\n}html[data-codex-ui] .codex-mp-trigger-model{\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n/* 档位文字叠层(Codex _ModelPickerTriggerEffortText 逐字):各档名叠在同一格,\n 格宽取最宽的名字;当前那层清晰,其余 opacity 0 + blur(--blur-xs = 4px)。\n 改档因此是模糊交叉淡入,不是换文本,触发器宽度也不跳。 */html[data-codex-ui] .codex-mp-effort-layers{\n display: grid;\n grid-template-columns: max-content;\n flex-shrink: 1000;\n width: max-content;\n min-width: 0;\n overflow: hidden;\n color: var(--dsw-alias-label-caption);\n}html[data-codex-ui] .codex-mp-effort-layers[hidden]{\n display: none;\n}html[data-codex-ui] .codex-mp-effort-text{\n grid-area: 1 / 1;\n justify-self: center;\n opacity: 0;\n filter: blur(4px);\n white-space: nowrap;\n will-change: opacity, filter;\n transition:\n opacity var(--dsw-motion-slow) var(--dsw-ease),\n filter var(--dsw-motion-slow) var(--dsw-ease);\n}html[data-codex-ui] .codex-mp-effort-text[data-active=\"true\"]{\n opacity: 1;\n filter: none;\n}\n\n/* chevron:宿主 _7KE1Ra_chevron 的 .12s 翻转 */html[data-codex-ui] .codex-mp-trigger-chevron{\n display: inline-flex;\n flex: none;\n color: var(--dsw-alias-label-caption);\n transition: transform 120ms var(--dsw-ease);\n}html[data-codex-ui] .codex-mp-trigger[aria-expanded=\"true\"] .codex-mp-trigger-chevron{\n transform: rotate(180deg);\n}\n\n\n/* ── ③ 弹层 ─────────────────────────────────────────────────────────────\n 宽:Codex calc(var(--spacing) * 63.5),--spacing = 4px ⇒ 254px。\n 入场:Codex .32s cubic-bezier(.23, 1, .32, 1) 30ms both,opacity 0 / scale(.98) → 1;\n 弹层在触发器上方、右沿对齐,所以从右下角长出来。\n 面板:⑫·2 的菜单契约 —— 不透明 layer-1 + 18px 圆角 + 内衬 5px + 0 8px 32px 软影 + 1px 环。\n z-index / max-height 照宿主 _7KE1Ra_menu。 */html[data-codex-ui] .codex-mp-popover{\n position: fixed;\n z-index: 1100;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n width: calc(4px * 63.5);\n max-width: calc(100vw - 24px);\n max-height: min(360px, 100vh - 96px);\n padding: 5px;\n border-radius: var(--dsw-radius-menu);\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n box-shadow: 0 8px 32px rgba(13, 13, 13, 0.13), 0 0 0 1px rgba(13, 13, 13, 0.04);\n font-size: 13px;\n line-height: 20px;\n transform-origin: bottom right;\n animation: codex-mp-enter 0.32s cubic-bezier(0.23, 1, 0.32, 1) 30ms both;\n}html[data-codex-ui] body[data-ds-dark-theme] .codex-mp-popover{\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(236, 236, 236, 0.05);\n}html[data-codex-ui] .codex-mp-popover[hidden]{\n display: none;\n}html[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"]{\n animation: none;\n}\n\n@keyframes codex-mp-enter {\n from {\n opacity: 0;\n transform: scale(0.98);\n }\n\n to {\n opacity: 1;\n transform: none;\n }\n}\n\n/* 提交失败那一条(宿主 _7KE1Ra_error 的底色、字色、11px/16px;圆角按同心取 18 − 5) */html[data-codex-ui] .codex-mp-error{\n flex: none;\n margin-bottom: 3px;\n padding: 6px 9px;\n border-radius: calc(var(--dsw-radius-menu) - 5px);\n background: var(--dsw-alias-interactive-bg-hover-danger);\n color: var(--dsw-alias-state-error-primary);\n font-size: 11px;\n line-height: 16px;\n}html[data-codex-ui] .codex-mp-error[hidden]{\n display: none;\n}\n\n\n/* ── ④ 模型列表:行几何同 ⑫·3(28px / 4px 9px / gap 8 / 圆角 18 − 5 = 13) ── */html[data-codex-ui] .codex-mp-list{\n min-height: 0;\n overflow-y: auto;\n scrollbar-width: thin;\n}html[data-codex-ui] .codex-mp-status{\n padding: 8px 9px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}html[data-codex-ui] .codex-mp-retry{\n padding: 0;\n border: 0;\n background: none;\n color: inherit;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n}\n\n/* 分组标题:宿主 _7KE1Ra_groupTitle 的 11px/16px/500 辅墨;横向内衬对齐行文字的 9px */html[data-codex-ui] .codex-mp-group{\n padding: 4px 9px 2px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n font-weight: 500;\n line-height: 16px;\n}html[data-codex-ui] .codex-mp-group:not(:first-child){\n margin-top: 3px;\n}html[data-codex-ui] .codex-mp-row{\n box-sizing: border-box;\n display: flex;\n align-items: center;\n gap: 8px;\n width: 100%;\n min-height: 28px;\n padding: 4px 9px;\n border: 0;\n border-radius: calc(var(--dsw-radius-menu) - 5px);\n background: none;\n color: inherit;\n font-family: inherit;\n font-size: 13px;\n line-height: 20px;\n text-align: left;\n cursor: pointer;\n}html[data-codex-ui] .codex-mp-row:hover,html[data-codex-ui] .codex-mp-row[aria-checked=\"true\"]{\n background: var(--dsw-alias-interactive-bg-active);\n}\n\n/* 键盘焦点:菜单内改 1px 内描线(全局 2px 环不贴行盒) */html[data-codex-ui] .codex-mp-row:focus-visible{\n outline: 1px solid var(--dsw-focus-ring-color);\n outline-offset: -1px;\n background: var(--dsw-alias-interactive-bg-active);\n}html[data-codex-ui] .codex-mp-row[aria-busy=\"true\"]{\n cursor: progress;\n}html[data-codex-ui] .codex-mp-row-copy{\n display: flex;\n flex: 1;\n flex-direction: column;\n min-width: 0;\n}html[data-codex-ui] .codex-mp-row-name{\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: 500;\n}\n\n/* 说明:宿主 _7KE1Ra_status 的 12px/18px 辅墨,单行截断(全文在 title 里) */html[data-codex-ui] .codex-mp-row-desc{\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n color: var(--dsw-alias-label-tertiary);\n font-size: 12px;\n line-height: 18px;\n}html[data-codex-ui] .codex-mp-row-check{\n display: grid;\n flex: 0 0 14px;\n place-items: center;\n color: var(--dsw-alias-label-primary);\n}\n\n/* pending 转圈:与 ⑫ 同一副(12px / 1.5px 环 / 顶边前景色 / Codex --animate-spin 1s linear) */html[data-codex-ui] .codex-mp-spinner{\n box-sizing: border-box;\n display: inline-block;\n flex: none;\n width: 12px;\n height: 12px;\n border: 1.5px solid var(--dsw-alias-border-l3);\n border-top-color: var(--dsw-alias-label-primary);\n border-radius: 50%;\n animation: codex-ui-spin 1s linear infinite;\n}\n\n\n/* ── ⑤ 推理等级:标题行 + 功率轨 ─────────────────────────────────────────\n 与列表之间一条 0.5px l1 发丝线(发丝即层级);标题行与菜单行同高同衬。 */html[data-codex-ui] .codex-mp-effort{\n flex: none;\n margin-top: 5px;\n padding-top: 5px;\n border-top: 0.5px solid var(--dsw-alias-border-l1);\n}html[data-codex-ui] .codex-mp-effort[hidden]{\n display: none;\n}html[data-codex-ui] .codex-mp-effort-head{\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n min-height: 28px;\n padding: 4px 9px;\n color: var(--dsw-alias-label-secondary);\n}html[data-codex-ui] .codex-mp-effort-value{\n display: inline-flex;\n align-items: center;\n gap: 6px;\n color: var(--dsw-alias-label-tertiary);\n}\n\n/* Codex _Container:高 32 / 上下 2 / 左右 6 / 外边 2,纵向居中 */html[data-codex-ui] .codex-mp-container{\n position: relative;\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n justify-content: center;\n height: 32px;\n margin-inline: 2px;\n padding-block: 2px;\n padding-inline: 6px;\n}\n\n/* Codex _Root:高 28、整宽、居中;touch-action: none 必须写,否则移动端手势被滚动抢走。\n 动效令牌逐字:主曲线 .3s cubic-bezier(.23, 1, .32, 1),档位变换 .12s,\n 拇指跟随时长首帧 0s(脚本 16ms 后打 data-armed 抬到 .3s,打开时不从 0 滑过来)。 */html[data-codex-ui] .codex-mp-root{\n --codex-mp-motion-duration: 0.3s;\n --codex-mp-motion-easing: cubic-bezier(0.23, 1, 0.32, 1);\n --codex-mp-tick-transform-duration: 0.12s;\n --codex-mp-thumb-motion-duration: 0s;\n\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n height: 28px;\n outline: none;\n touch-action: none;\n cursor: pointer;\n}html[data-codex-ui] .codex-mp-root[data-armed=\"true\"]{\n --codex-mp-thumb-motion-duration: var(--codex-mp-motion-duration);\n}html[data-codex-ui] .codex-mp-root[data-dragging=\"true\"],html[data-codex-ui] .codex-mp-root[data-dragging=\"true\"] .codex-mp-tick{\n cursor: grabbing;\n}html[data-codex-ui] .codex-mp-root[data-disabled=\"true\"]{\n opacity: 0.6;\n}html[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-root{\n --codex-mp-motion-duration: 0s;\n --codex-mp-tick-transform-duration: 0s;\n --codex-mp-thumb-motion-duration: 0s;\n}\n\n/* Codex _Track:24px 高、12px 圆角、前景 10% 底、0.5px 内描边(--color-border) */html[data-codex-ui] .codex-mp-track{\n position: relative;\n flex-grow: 1;\n height: 24px;\n overflow: hidden;\n border-radius: 12px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 10%, transparent);\n box-shadow: inset 0 0 0 0.5px var(--dsw-codex-border);\n}\n\n/* Codex _Range:强调色,只圆左侧两角,止于拇指中线。\n 位置公式与拇指、圆点同一条:calc(14px + (100% − 28px) × pos)。 */html[data-codex-ui] .codex-mp-range{\n position: absolute;\n top: 0;\n inset-inline-start: 0;\n width: calc(14px + (100% - 28px) * var(--codex-mp-pos, 0));\n height: 100%;\n border-radius: 12px 0 0 12px;\n background: var(--dsw-alias-link);\n transition: width var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing);\n}\n\n/* Codex _Tick:4px 圆点,热区 16px(::before 外扩 6px);走过的 30% 白,没走到的 description 50% */html[data-codex-ui] .codex-mp-tick{\n position: absolute;\n top: 50%;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: currentColor;\n color: color-mix(in srgb, var(--dsw-alias-label-tertiary) 50%, transparent);\n transform: translate(-50%, -50%);\n transition:\n transform var(--codex-mp-tick-transform-duration) var(--codex-mp-motion-easing),\n filter var(--codex-mp-tick-transform-duration) var(--codex-mp-motion-easing);\n}html[data-codex-ui] .codex-mp-tick::before{\n content: \"\";\n position: absolute;\n inset: -6px;\n}html[data-codex-ui] .codex-mp-tick:hover{\n filter: brightness(0.85);\n transform: translate(-50%, -50%) scale(2);\n}html[data-codex-ui] .codex-mp-tick[data-selected=\"true\"]{\n color: #ffffff4d;\n}\n\n/* Codex _ThumbScale + _Thumb:28px 定位盒 + 白色圆片(0.5px 强描边 + 0 0 2px 10% 微影)。\n 白片亮暗同色,Codex 原样如此。 */html[data-codex-ui] .codex-mp-thumb-scale{\n position: absolute;\n top: 50%;\n left: calc(14px + (100% - 28px) * var(--codex-mp-pos, 0));\n width: 28px;\n height: 28px;\n border-radius: 50%;\n transform: translate(-50%, -50%);\n pointer-events: none;\n will-change: left;\n transition: left var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing);\n}html[data-codex-ui] .codex-mp-thumb{\n position: absolute;\n inset: 0;\n box-sizing: border-box;\n display: block;\n border: 0.5px solid var(--dsw-codex-border-strong);\n border-radius: 50%;\n background: #fff;\n box-shadow: 0 0 2px #0000001a;\n}\n\n/* 键盘焦点:Codex outline 2px + offset 0,画在拇指上(不是全局那条 2px + 2px) */html[data-codex-ui] .codex-mp-root[data-keyboard-focused=\"true\"] .codex-mp-thumb-scale{\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 0;\n}\n\n/* 没有生效档(跟随提供方默认):拇指与色条不画,只剩轨与圆点 */html[data-codex-ui] .codex-mp-root[data-unset] .codex-mp-thumb-scale,html[data-codex-ui] .codex-mp-root[data-unset] .codex-mp-range{\n visibility: hidden;\n}\n\n\n/* ==== L3 侧栏对齐层(源:skins/codex-ink/sidebar-align.css)==== */\n/* ============================================================================\n codex-ink(墨白终端)· L3 侧栏对齐层\n ----------------------------------------------------------------------------\n 源文件:skins/codex-ink/sidebar-align.css\n 由 src/build.mjs 与 skin.css / patches.css 一起拼接,并作用域化到\n html[data-codex-ui]。因此本文件写「裸选择器」——不要自己再加 html[...] 前缀,\n 那会被 scopeCss 判为已作用域而直通,永不命中。\n\n 目标(三条,不多做):\n ① 品牌行 —— 完全不动。保留官方 fish 标与名称,不注册 sidebar.brand.* 洞。\n ② New Session 行 —— 压成列表行,图标/文字落到「工作区」下方列表行的两条竖线上。\n ③ 全局面板行(插件…)—— 同一组竖线。\n\n 目标列(相对侧栏根 div 左边缘,实测值见 scripts/sidebar-align-verify.mjs):\n 图标列 20px = 列表行左边缘 12px + padding-inline-start 8px\n 文字列 42px = 20px + 图标 16px + gap 6px\n 对照:工作区行(projectRow)实测 20 / 42,会话行(sessionRow)实测 20 / 40。\n\n 锚点纪律(遵守 patches.css 头部的约定):\n 本层只用 skin-center 语义契约 data-slot + 结构伪类,\n 不使用 CSS-Modules 哈希类名,不触发\n \"reliance on CSS-Modules hash class names\" 警告。\n\n 官方侧栏的 New Session 按钮不是 slot 内容,没有 data-slot 可挂;\n 但 Tooltip 只克隆锚点、不加包装元素,故它仍是侧栏根 div 唯一的直接\n button 子元素,用\n div:has([data-slot=\"sidebar.workspaces\"]) > button\n 定位。折叠态用 sidebar.toggle.badge 出口的存在与否排除 ——\n 官方只在 !wide(折叠)时才 renderSlot 该出口。\n\n 官方真实值(rc1 0.1.7-rc.1,取自 dsh-client-ui-sidebar 内联 css)。\n 为免触发 scopeCss 的括号解析,这里用行式记法,不写花括号:\n\n _newSession 高度 height:38px\n 边框 .5px solid var(--dsw-alias-border-l3)\n 底色 var(--dsw-alias-button-elevated-fill)\n 对齐 justify-content:center\n gap 6px / margin 0 2px 12px / padding 8px 16px\n 字体 14px / 500 / line-height 22px\n _newSession:hover 底色 var(--dsw-alias-button-floating-hover)\n _newSessionLabel white-space:nowrap / max-width:200px / overflow:hidden\n\n _panelRow box-sizing:border-box / min-height:36px\n 底色 0 0 / 边框 none / 圆角 12px\n 对齐 justify-content 未设(即 flex-start)\n gap 8px / margin 0 2px / padding 7px 8px\n 字体 font:inherit / line-height:22px / text-align:left\n _panelRow:hover 底色 var(--dsw-alias-interactive-bg-hover)\n _panelRow:focus-visible 2px solid var(--dsw-alias-label-primary),offset -2px\n _panelTitle text-overflow:ellipsis / white-space:nowrap\n min-width:0 / overflow:hidden\n _panelList flex-direction:column / gap:4px / margin-bottom:8px\n ============================================================================ */\n\n\n/* ── ① 品牌行:刻意留空 ──────────────────────────────────────────────────\n 官方 logoRow / brandIdentity / brandMark / brandName / fallbackBrandName\n 全部原样生效。本层不写任何相关规则。 */\n\n\n/* ── ② New Session → 压成列表行,落到「工作区」下方列表行的两条竖线 ────────\n 展开态判定:侧栏根 div 含 sidebar.workspaces 出口,\n 且不含 sidebar.toggle.badge 出口。\n\n 官方 New Session 是 38px 居中主按钮:margin 0 2px 12px / padding 8px 12px /\n justify-content:center,图标停在中线(实测 108px),与列表行差 88px。 */html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > button{\n box-sizing: border-box;\n\n /* 高度:38px → min-height:36px */\n height: auto;\n min-height: 36px;\n flex: none;\n\n /* 外观:主按钮 → 列表行 */\n background: 0 0;\n border: none;\n border-radius: var(--dsw-radius-s);\n\n /* 排版:居中主按钮 → 左对齐列表行 */\n justify-content: flex-start;\n align-items: center;\n gap: 6px;\n /* 左边距归零:行左边缘与列表行同为 12px,图标才落到 20px。\n 桌面壳(html[data-windows-titlebar])官方自己也写 margin-left:0,那条\n (0,4,0) 比本层 (0,3,3) 更高 —— 桌面端本来就吃到 0。这里显式写 0,\n 让 web profile(无 titlebar)与桌面端落到同一条竖线上。 */\n margin: 0 2px 4px 0;\n padding: 7px 8px;\n font: inherit;\n line-height: 22px;\n text-align: left;\n color: var(--dsw-alias-label-primary);\n cursor: pointer;\n overflow: hidden;\n}html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > button:hover{\n background: var(--dsw-alias-interactive-bg-hover);\n}html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > button:focus-visible{\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: -2px;\n}\n\n/* 图标:官方 newSession 用 14px,panelGlyph 用 16px → 统一 16px(rc.1 扁平结构) */html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > button > svg{\n width: 16px;\n height: 16px;\n flex: none;\n}\n\n/* 标签:官方只设了 max-width:200px → 补齐 panelTitle 的省略行为(rc.1 扁平结构) */html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > button > span{\n max-width: none;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n\n/* ── ②·b New Session · rc.2 嵌套结构 ─────────────────────────────────────\n 0.1.7-rc.2(桌面壳在跑的那一代)在按钮里又套了两层:\n button > span(newSessionLabelMask) > span(newSessionContent) > svg + span(newSessionLabel)\n 官方在 newSessionContent 上写死 width:100cqw + justify-content:center,\n 并把按钮设成 container-type:inline-size —— 于是只把 button 改成 flex-start\n 压不住它,图标仍旧停在按钮中线。这里把这一层拉回左对齐。 */html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > button > span > span:has(> svg){\n justify-content: flex-start;\n width: auto;\n}\n\n/* 图标(rc.2 嵌在内容容器里,与 rc.1 同一条 16px 契约) */html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > button > span > span > svg{\n width: 16px;\n height: 16px;\n flex: none;\n}\n\n/* 标签(rc.2 又深一层;span.keys 的键帽是 kbd,不会命中这条) */html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > button > span > span > span{\n max-width: none;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n\n/* ── ③ 全局面板行(插件…)→ 同两条竖线 ───────────────────────────────────\n 侧栏根 div 的直接 nav 子元素就是 panelList;它的每个直接 button 是一行面板。\n 官方 panelRow 是 margin:0 2px / padding:7px 8px / gap:8px\n → 图标 22px、文字 46px,比列表行右移 2px / 4px。 */html[data-codex-ui] div:has([data-slot=\"sidebar.workspaces\"]):not(:has([data-slot=\"sidebar.toggle.badge\"])) > nav > button{\n gap: 6px;\n margin-left: 0;\n}\n\n\n/* ==== L3 侧栏面层(源:skins/codex-ink/sidebar-surface.css)==== */\n/* ============================================================================\n codex-ink(墨白终端)· L3 侧栏面层\n ----------------------------------------------------------------------------\n 源文件:skins/codex-ink/sidebar-surface.css\n 由 scripts/build.mjs 与 skin.css / patches.css 一起拼接,并作用域化到\n html[data-codex-ui]。因此本文件写「裸选择器」——不要自己再加 html[...] 前缀。\n\n 组件:**侧栏滚动渐隐**(Codex 的 --sidebar-scroll-mask-image)。\n 验收:scripts/sidebar-surface-verify.mjs(夹具 A/B + 像素斜坡)。\n\n ── 为什么是「面」而不是「样式」 ──────────────────────────────────────────\n 宿主对同一件事有自己的实现:滚动容器旁边放一个 24px 的绝对定位覆盖层,\n 底色是 linear-gradient(transparent → var(--dsw-specific-sidebar-fill))。\n 覆盖层成立的前提是**底色不透明**;本皮肤支持侧栏半透明\n (src/override.js 的 SIDEBAR_ALPHA 会改写 --dsw-specific-sidebar-fill 的 alpha),\n 一旦半透明,覆盖层就只能糊上一层半透明的色,内容在它下面仍然看得见。\n Codex 用的是 mask:直接对内容做 alpha 遮罩,与底色是否透明无关。\n 所以本层把宿主那条覆盖层让位,换成 Codex 的 mask 形状。\n\n ── Codex 原文(codex-app-initial.css,_headerFadeMask_n9nga_1) ──────────\n 为免触发 scopeCss 的括号解析,这里用行式记法,不写花括号:\n\n --edge-fade-distance calc(var(--spacing) * 10)\n --sidebar-scroll-footer-edge calc(100% - var(--sidebar-footer-height))\n --sidebar-scroll-footer-fade-distance calc(var(--spacing) * 10) → 40px\n --sidebar-scroll-footer-fade-start calc(footer-edge - fade-distance)\n --sidebar-scroll-header-mask-distance var(--sidebar-scroll-header-fade-distance,\n var(--sidebar-scroll-header-spacing, spacing*2))\n --sidebar-scroll-mask-image linear-gradient(to bottom,\n transparent 0,\n transparent ,\n black ,\n black ,\n #000000e0 ,\n #00000085 ,\n #0000002e ,\n transparent ,\n transparent 100%)\n -webkit-mask-image / mask-image var(--sidebar-scroll-mask-image)\n mask-size 100% 100% / mask-repeat: no-repeat\n\n --spacing 是 Tailwind 的 4px 刻度(Codex 根块 --padding-panel-base: spacing*3 = 12px\n 可反推)。于是底部那段斜坡在 40px 内四段收敛:1 → 0.878 → 0.522 → 0.180 → 0。\n\n ── 两处按 DSH 现场的改写(逐条给依据) ──────────────────────────────────\n ① footer-edge 取 100%,不取 calc(100% - footer 高度)。\n Codex 的滚动视口与底部固定区**同层**(footer 压在滚动内容之上),\n 所以它的斜坡要在 footer 顶边就收敛完;DSH 的 regionArea 与 footArea\n 是 flex 列的**上下两块**,滚动容器底边本身就是 footArea 的顶边\n (真 GUI 实测:regionArea y=154 h=690 → 底边 844;footArea y=844)。\n 所以 100% 就是 Codex 的 footer-edge,不需要再减。\n ② 顶部不做渐入。\n Codex 的 header-mask-start 默认 0、距离 8px,是给「吸顶分组标题」用的:\n 内容从标题下面滚过去时在顶部渐隐。DSH 的分组标题(工作区表头)不在滚动\n 容器里,是常驻的一行,滚动视口顶上没有任何覆盖层——照抄这 8px 只会让\n 列表第一行永远发虚。Codex 自己在 _stickySectionHeaders 上也把这段距离\n 收到 spacing/2(2px)。\n\n ── 锚点纪律 ────────────────────────────────────────────────────────────\n 宿主没有给侧栏列表发任何语义锚点(真 GUI 实测:整个 [data-slot] 集合里\n 只有 sidebar.*、settings.*、conversation.* 这些席位出口,滚动容器与覆盖层\n 都没有 data-*)。本层因此分两类:\n · 作用域根:div:has(> [data-slot=\"sidebar.workspaces\"]) —— 语义锚点,\n 唯一命中 regionArea;沿用 sidebar-align.css 已经建立的用法。\n · 容器/覆盖层:副作用后缀锚点 class 后缀 _list / _fade(选择器写作方括号\n class 属性后缀匹配)。\n 后缀是 CSS-Modules 的**源类名**(哈希前缀每次构建会变:桌面壳 asar 是\n n_2Q3W_/MnE-JG_,npm 全局安装是 hHd-Xa_/bhn1Oq_),所以后缀形式\n 跨版本反而比哈希全名稳。代价是 build.mjs 的 hash 锚点计数 +2,\n 与 patches.css / composer.css 已有的 29 处同一类,写在这里备查。\n ============================================================================ */\n\n\n/* ── ① 宿主那条 24px 覆盖层让位 ───────────────────────────────────────────\n 它刷的是 --dsw-specific-sidebar-fill。半透明底时它挡不住内容,\n 且与下面的 mask 叠加会变成双渐隐(实测见验收脚本的 A/B 像素读数)。\n 只在本层作用域内摘除,区域外一律不动。 */html[data-codex-ui] div:has(> [data-slot=\"sidebar.workspaces\"]) [class$=\"_fade\"]{\n display: none;\n}\n\n\n/* ── ② 滚动容器:Codex 的 mask 斜坡 ──────────────────────────────────────\n 两层 mask,默认 mask-composite: add 叠加:\n 第一层 = 斜坡本体,横向只铺到「列表右内边距」为止;\n 第二层 = 右侧那一条不透明遮罩。\n 为什么分两层:mask-image 配 mask-repeat: no-repeat 时,图像没铺到的区域\n mask 值是 0(= 隐藏),所以只写第一层会把右侧 12px(宿主的滚动条槽,\n 由 --dsh-session-list-edge-inset / scrollbar-gutter: stable 预留)\n 整条抹掉。第二层把那一条补回不透明,宿主「渐隐不压滚动条」的既有取舍\n 就被保住了 —— 宿主的覆盖层也是 right: var(--dsh-session-list-edge-inset)。 */html[data-codex-ui] div:has(> [data-slot=\"sidebar.workspaces\"]) [class$=\"_list\"]{\n -webkit-mask-image:\n linear-gradient(\n to bottom,\n #000 0,\n #000 calc(100% - 40px),\n #000000e0 calc(100% - 24px),\n #00000085 calc(100% - 12px),\n #0000002e calc(100% - 4px),\n #00000000 100%\n ),\n linear-gradient(#000, #000);\n mask-image:\n linear-gradient(\n to bottom,\n #000 0,\n #000 calc(100% - 40px),\n #000000e0 calc(100% - 24px),\n #00000085 calc(100% - 12px),\n #0000002e calc(100% - 4px),\n #00000000 100%\n ),\n linear-gradient(#000, #000);\n -webkit-mask-size: calc(100% - var(--dsh-session-list-edge-inset, 12px)) 100%, var(--dsh-session-list-edge-inset, 12px) 100%;\n mask-size: calc(100% - var(--dsh-session-list-edge-inset, 12px)) 100%, var(--dsh-session-list-edge-inset, 12px) 100%;\n -webkit-mask-position: 0 0, 100% 0;\n mask-position: 0 0, 100% 0;\n -webkit-mask-repeat: no-repeat, no-repeat;\n mask-repeat: no-repeat, no-repeat;\n}\n\n\n/* ==== L3 窗口边缘阴影层(源:skins/codex-ink/window-shadow.css)==== */\n/* ============================================================================\n codex-ink · L3 窗口边缘阴影层\n ----------------------------------------------------------------------------\n 源文件:skins/codex-ink/window-shadow.css。src/build.mjs 把本文件的选择器\n 作用域化到 html[data-codex-ui] 后拼进 client.js 的内嵌样式表,所以这里写裸选择器:\n 自己再加 html[...] 前缀会被 scopeCss 判为已作用域而直通,永远不命中。\n 注释里禁止出现花括号 —— scopeCss 按「下一个左花括号」做括号配对。\n\n 目标(三条,不多做):\n 1. 会话窗口(AppFrame 中列):0.5px 发丝线 + 24px 全向环境影(左沿糊到侧栏、上沿糊进标题栏条);\n 2. 右栏面板:同样的顶沿,但**左边框只留发丝线、不带影**;\n 3. 右栏分界线拖拽柄悬停时的中段渐变聚焦。\n\n 取值依据(Codex 应用截图逐像素实测,非估计):\n assets/reference/codex-app-reference.png(1901x1107,DPR 1)\n · 会话窗口左沿 y=600:侧栏侧 x=340..355 由 238,241,247 渐变到 231,233,239(约 15px 渐变),\n x=356 单像素发丝线 212,215,221,x=357 起纯白。\n · 会话窗口上沿 x=800:y=30..45 由 237,242,247 渐变到 232,237,242,y=46 发丝线 214,218,224。\n · 右栏面板左沿 y=600:x=1437 单像素 237,237,237,两侧都是纯白 —— **没有影**。\n · 右栏面板上沿 x=1700:与中列同一套渐变 + 发丝线(y=46 处 213,218,224)。\n 结论:发丝线是 1 个设备像素,中列取 12% 墨(l2)、面板取 7% 墨(l1);\n 软影只出现在中列的左/上沿与面板的上沿,面板左沿不糊影。\n 本层按 DPR 折算成 0.5 CSS px 的发丝线:1.5 倍缩放下落到约 1 个设备像素,\n 与参考图同档;写成 1px 会在 1.5 倍缩放下被栅格化成 2 个像素(偏粗)。\n\n 锚点(只用宿主语义属性,不用 CSS-Modules 哈希类名):\n 中列 div:has(> [data-slot=\"main\"]);main 面板未注册时退回\n div:has(> [data-slot=\"sidebar\"]) + div(侧栏列的下一个兄弟)。\n 右栏面板 [data-sidebar-right-panel=\"push\"][data-sidebar-right-open]。\n 拖拽柄 [data-side=\"rightbar\"],宽 8px、骑跨分界线。\n\n 层序:标题栏拖拽条是 .frame 的绝对定位伪元素,静态定位的中列会被它盖住。\n 本层给中列补 position: relative 抬进定位层,靠 DOM 顺序压过它 —— 上沿的影因此\n 才画得到标题栏条上。右栏面板是宿主 absolute 拉伸的元素(top:0 / bottom:0),\n 切勿再叠 position: relative:会覆盖 absolute、面板塌成内容高度(已出过一次事故)。\n ============================================================================ */\n\n/* ── ① 会话窗口(中列):0.5px 发丝线 + 24px 全向环境影 ─────────────────────\n 范围:Windows 桌面壳(html[data-windows-titlebar])与 win32 的浏览器形态。\n macOS 是 vibrancy 透底,加外投影会脏,不生效。 */html[data-codex-ui][data-windows-titlebar] div:has(> [data-slot=\"main\"]),html[data-codex-ui][data-windows-titlebar] div:has(> [data-slot=\"sidebar\"]) + div,html[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) div:has(> [data-slot=\"main\"]),html[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) div:has(> [data-slot=\"sidebar\"]) + div{\n position: relative;\n box-shadow:\n 0 0 0 0.5px var(--dsw-alias-border-l2),\n 0 0 24px rgba(13, 13, 13, 0.05);\n}html[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] div:has(> [data-slot=\"main\"]),html[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] div:has(> [data-slot=\"sidebar\"]) + div,html[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) body[data-ds-dark-theme] div:has(> [data-slot=\"main\"]),html[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) body[data-ds-dark-theme] div:has(> [data-slot=\"sidebar\"]) + div{\n box-shadow:\n 0 0 0 0.5px var(--dsw-alias-border-l2),\n 0 0 24px rgba(0, 0, 0, 0.5);\n}\n\n/* ── ② 右栏(push 模式):左边框只留发丝线,影只往上泄 ─────────────────────\n 参考图实测:面板左沿 x=1437 是单像素 237,237,237、两侧纯白 —— 左沿不糊影,\n 所以这里不给全向影;顶沿仍与中列同套,用负 spread 把水平方向与下方收掉,\n 只在面板上沿留一段约 12px 的渐变(影盒左沿被收进面板 12px 内,左沿残余覆盖约\n 0.008,肉眼为零)。栏内 dockkit 叠的那条 .5px l4 深边框一并压成 0。 */html[data-codex-ui][data-windows-titlebar] [data-sidebar-right-panel=\"push\"][data-sidebar-right-open],html[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) [data-sidebar-right-panel=\"push\"][data-sidebar-right-open]{\n box-shadow:\n 0 0 0 0.5px var(--dsw-alias-border-l1),\n 0 -12px 24px -12px rgba(13, 13, 13, 0.05);\n}html[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] [data-sidebar-right-panel=\"push\"][data-sidebar-right-open],html[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) body[data-ds-dark-theme] [data-sidebar-right-panel=\"push\"][data-sidebar-right-open]{\n box-shadow:\n 0 0 0 0.5px var(--dsw-alias-border-l1),\n 0 -12px 24px -12px rgba(0, 0, 0, 0.5);\n}\n\n/* 宿主 dockkit 给右栏的 pane 画了一条 1px l4(24% 墨,实测合成值 197 —— 比参考图的\n 单像素 237 深两档),它落在面板左沿外侧,就是「边框又粗又重」的来源之一。\n 真实锚点是 pane 自身:SECTION[data-dockkit-pane][data-dockkit-column=\"0\"],\n 插在 [data-dockkit-host=\"dock\"] 的 tabCell 里 —— 旧版按 host 的 div 直选子元素,\n 选择器从来没命中过。压成 0,左沿只留面板自己的 0.5px l1 发丝线。 */html[data-codex-ui] [data-sidebar-right-panel=\"push\"][data-sidebar-right-open] [data-dockkit-pane][data-dockkit-column=\"0\"]{\n border-left: 0;\n}\n\n/* ── ③ 右栏分界线拖拽柄:悬停时的中段渐变聚焦 ─────────────────────────────\n 静态:边界只有那 0.5px 发丝线,淡且细。\n 悬停:柄内居中画 2px 竖向线,中段最深、两端淡出,入场自中间向两端展开。\n 实测 assets/reference/codex-rightbar-edge-hover.png(111x842,DPR 1):\n 柄心列 y=560 处 x=65/66 = 213,214,214 / 198,198,199(中段最深)\n 同一列 y=20 处 x=65/66 = 239,239,240 / 222,222,222(向两端淡出)\n 左分界线(侧栏 ↔ 中列)没有这条悬停线:参考图里它始终是一条静态发丝线。 */html[data-codex-ui] [data-side=\"rightbar\"]::before{\n content: \"\";\n position: absolute;\n top: 0;\n bottom: 0;\n left: 50%;\n width: 2px;\n translate: -50% 0;\n border-radius: 1px;\n background: linear-gradient(to bottom, rgba(13, 13, 13, 0.09), rgba(13, 13, 13, 0.27) 50%, rgba(13, 13, 13, 0.09));\n opacity: 0;\n scale: 1 0.35;\n transform-origin: center;\n transition: opacity var(--dsw-motion-base) var(--dsw-ease), scale var(--dsw-motion-slow) var(--dsw-ease);\n pointer-events: none;\n}html[data-codex-ui] [data-side=\"rightbar\"]:hover::before{\n opacity: 1;\n scale: 1 1;\n}html[data-codex-ui] body[data-ds-dark-theme] [data-side=\"rightbar\"]::before{\n background: linear-gradient(to bottom, rgba(236, 236, 236, 0.08), rgba(236, 236, 236, 0.24) 50%, rgba(236, 236, 236, 0.08));\n}\n\n/* ── ④ 浏览器形态(win32,无标题栏条)────────────────────────────────────\n 宿主给侧栏列画的是 .5px l3(18% 墨),比桌面壳(border-right: none)深一档,\n 与中列的 0.5px l2 发丝线叠在一起会变成双层深线,压到 l1。 */html[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) div:has(> [data-slot=\"sidebar\"]){\n border-right-color: var(--dsw-alias-border-l1);\n}\n\n\n/* ==== L3 输入区完整层(源:skins/codex-ink/composer.css)==== */\n/* ============================================================================\n codex-ink(墨白终端)· L3 输入区完整层\n ----------------------------------------------------------------------------\n 源文件:skins/codex-ink/composer.css\n 由 src/build.mjs 与其余各层一起拼接,并作用域化到 html[data-codex-ui]。\n 本文件写「裸选择器」——不要自己再加 html[...] 前缀,那会被 scopeCss 直通。\n\n 来源(上游仓库 MichengAI/dsh-codex-ui 的源码;行号是移植时那一版的):\n · src/client/CodexSidebar.tsx L119-L137 → ⑭·1 ⑭·2 ⑭·3\n · src/client/composer-tool-menus.ts L6-L30 → ⑭·4(仅官方锚点部分)\n · src/client/new-conversation-style.ts L3-L14 → ⑭·5\n\n 兼容性核对(对 rc1 0.1.7-rc.1 的 shipped 包逐条 grep,不是照抄):\n 上游 README 只声明兼容到 0.1.6-alpha.2。逐条核对后,以下锚点在本机 rc1 上\n 不存在,一律**不移植**,免得留死规则:\n ✗ [data-input-mirror] rc1 无此属性(conversation 包 0 命中)\n ✗ [class*=\"_rail\"](附件轨道补偿) rc1 无此类名段\n ✗ [class*=\"_selected\"] / [class*=\"_footer\"] rc1 无此类名段\n ✗ [data-dcu-tool-menu] / [data-gitgraph-popover] 由上游 JS 观察器打标,\n 本插件不含该半,规则永远不会命中 → 弹窗换肤留在「待移植 JS 半」清单\n ✗ .dcu-home-* 上游自有组件的类名,本皮肤没有这些节点\n 实测存在的锚点(本条全部基于它们):\n ✓ [data-composer-card] [data-input-scroll] [data-composer-placeholder]\n [data-lexical-editor=true] [data-conversation-scroll] [data-composer-seat]\n [data-trigger-menu] [data-phase](取值 settling / hero / active)\n\n 锚点纪律例外(必须说清):\n ⑭·4 ⑭·5 用到 [class*=\"_heroWorkspaceRow\"] / [class*=\"_composerHero\"] /\n [class$=\"_workspace\"] / [class$=\"_primary\"]。这类 CSS-Modules 哈希类名在\n patches.css 头部是被禁止的——那条禁令针对 **skin-center 加载器**的\n \"reliance on CSS-Modules hash class names\" 告警。本层走**插件路径**\n (client.js 直接注入 style 标签),不经加载器,故放行。\n 已尽量取「后缀锚点」写法([class$=_primary]),只依赖语义后缀、不依赖哈希前缀;\n 语义名若被上游改掉,由 scripts/hero-verify.mjs 的实测值暴露。\n hero 区没有 data-* 可用:jYtg_G_heroWorkspaceRow / jYtg_G_composerHero 只有类名。\n ============================================================================ */\n\n/* ── ⑭·1 卡片几何与表面 ───────────────────────────────────────────────────\n 上游把输入区放宽到 chat 内容宽 + 32px,左右留白 24px;卡片 20px 圆角(浏览器\n 支持 corner-shape 时升到 25px 超椭圆)。\n\n 表面:**亮暗两套机制相反** —— 这是本层最容易搞错的地方。\n\n 暗色 = 填充分层。卡片比页面亮一档:5% 白叠 surface #181818 = 35.6 → 36\n (Codex 暗色参考裁图 layer-codex.png / codex-card-ref.png 众数实测:卡内\n #232323 = 35、页面 #111111 = 17,落差 18)。暗色**没有投影**,见下方 inset 高光。\n\n 亮色 = 阴影分层,卡片**与页面同色**(都是 #ffffff)。三份独立实测同向:\n codex-composer-reference.png 卡内众数 #fffeff/#ffffff/#fefdfe 合计 62%;\n 用户 2026-09-28 同屏截图卡内众数 #fffeff 73.8% + #fefdfe 23.0% = 96.8%;\n codex-app-reference.png 整窗 #ffffff 76.5%。\n 0.5.6 及以前在亮色也叠了 5% 墨(= #f4f4f4,比实测暗 11 级),是把暗色模型错套到\n 亮色上。照抄的 --card: 5% foreground 属于 **widget 层**(codex.exe 的 artifact\n 契约),不是应用外壳的输入卡;外壳令牌里亮色 --gray-fixed-0 = #ffffff 与实测吻合\n (--color-surface-elevated-secondary = gray-50 = #f9f9f9 差 5 级,不采用)。\n 该令牌映射本身仍是推断,见 CHANGELOG 0.5.8 的边界说明。\n\n 阴影按源码逐字取 Codex 的 --elevation-composer(见下方声明)。0.5.4 那轮把\n 0 4px 80px 8px 判为「比实测宽三倍」并删掉,是误判 —— Codex 确有该层(第三层,\n 仅 2.4% 墨),缺的是它前面的 8px 近场。0.5.7 已恢复三层。\n 注:亮色参考图里卡片边缘实测约 12% 墨,而源码的环只有 #0000000a = 4%,两者不一致;\n 本皮肤按源码取 4%,该差异未解释,见 CHANGELOG 0.5.8 边界说明。 */html[data-codex-ui] [data-conversation-scroll]{\n --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px);\n --dsh-composer-side-clearance: 24px;\n /* 亮色**不叠罩**:直接取表面色(= #ffffff)—— Codex 亮色卡与页面同色,见上。\n 仍写成不透明实色、不用 transparent:DSH 的输入卡浮在会话滚动内容之上,\n 留 transparent 会让身后的文件列表透出来(实测已复现)。 */\n --dcu-composer-bg: var(--dsw-composer-surface);\n /* 阴影逐字取 Codex 的 --elevation-composer(app.asar @67930943):\n 0 0 0 1px ... 环:源码里输入卡有**两条互斥路径** ——\n 路径A(默认/large/single-line)box-shadow: var(--elevation-composer)\n 路径B([data-composer-radius-variant=compact])\n box-shadow: none; border: 1px solid var(--color-border) !important\n 实测走的是 B。而 --color-border = --alpha-10(亮)/ --alpha-12(暗)。\n 两种方法同时指向 10%:\n · 同图内比较(DPR 无关):Codex 环像素比页面低 26 级;\n 本皮肤 4% 环低 16 级 → 反推 α ≈ 10.4%;\n · 标定渲染(DPR 1.25):4% → Δ14、12% → Δ29 → 插值 α ≈ 10.4%。\n 0.5.8 取 12% 偏重(渲成 Δ29~35);0.5.10 按上两法收到 10%。\n 0.5.7 曾按路径A 取 4%,那是只看了三层里的第一层。\n 0 2px 8px 0 #0000000a 近场:8px 模糊 / 2px 位移,墨压在这一层\n 0 4px 80px 8px #00000006 远场:80px 宽晕、仅 2.4%,负责\"浮起来\"的空气感\n 三层缺一不可:只有远场会糊,只有环会像贴纸(本皮肤此前正是后者)。 */\n --dcu-composer-shadow: 0 0 0 1px rgba(13, 13, 13, 0.1), 0 2px 8px 0 #0000000a, 0 4px 80px 8px #00000006;\n}\n\n/* 暗色:Codex 的 --elevation-composer-dark 是 inset 0 0 1px 0 #fff3 —— **完全外无投影**,\n 改用 1px 内嵌高光把顶边点亮(实测 Codex 卡体 36、顶边 40,本皮肤此前内侧全平 36/36)。\n 机制差异:外描边读作\"画了一条线\",内嵌高光读作\"边被光打到\",后者才是 Codex 的层次来源。\n 外层 0.5px 发丝线一并撤掉:暗色下 Codex 卡外就是页面底色,无任何环。 */html[data-codex-ui] body[data-ds-dark-theme] [data-conversation-scroll]{\n /* 叠罩**只用于暗色**:5% 白叠 surface #181818 = 35.6 → 36(Codex 实测 35)。\n 用 srgb 插值不用 oklab —— 浏览器那边就是 sRGB alpha 合成,oklab 会压到 34。\n 基色取 --dsw-composer-surface(#181818 = Codex 的 surface),**不是**\n --dsw-alias-bg-base:后者是窗口背景 #111111,叠上去只有 29,卡片就不分层了。 */\n --dcu-composer-bg: color-mix(in srgb, var(--dsw-alias-label-primary) 5%, var(--dsw-composer-surface));\n --dcu-composer-shadow: inset 0 0 1px 0 #fff3;\n}html[data-codex-ui] [data-conversation-scroll] [data-composer-card]{\n padding-top: 12px;\n gap: 4px;\n border: 0;\n border-radius: var(--dsw-radius-card);\n background: var(--dcu-composer-bg);\n box-shadow: var(--dcu-composer-shadow);\n}\n\n@supports (corner-shape: superellipse(1.5)) {html[data-codex-ui] [data-conversation-scroll] [data-composer-card]{\n border-radius: var(--dsw-radius-card);\n corner-shape: superellipse(1.5);\n }\n}\n\n@media (max-width: 639px) {html[data-codex-ui] [data-conversation-scroll]{\n /* Codex 在 width<40rem 时把远场 80px 收到 40px(阈值同为 640,与本查询一致) */\n --dcu-composer-shadow: 0 0 0 1px rgba(13, 13, 13, 0.1), 0 2px 8px 0 #0000000a, 0 4px 40px 8px #00000006;\n }\n}\n\n@media (forced-colors: active) {html[data-codex-ui] [data-conversation-scroll] [data-composer-card]{\n outline: 1px solid CanvasText;\n }\n}\n\n/* ── ⑭·2 输入区几何:44px 编辑区 + 12px 内缩,占位元素与正文同轴 ────────── */html[data-codex-ui] [data-conversation-scroll] [data-composer-card] [data-input-scroll]{\n margin-right: 0;\n}html[data-codex-ui] [data-conversation-scroll] [data-input-scroll] [data-lexical-editor=true]{\n min-height: 44px;\n padding: 0 12px;\n}html[data-codex-ui] [data-conversation-scroll] [data-composer-placeholder]{\n inset: 0 12px auto;\n}\n\n/* ── ⑭·3 底栏行与发送键:28px 控件带,发送键改墨色圆点、无位移 ────────────\n 卡内结构是 card > [data-input-scroll] + row,故用相邻兄弟定位底栏。 */html[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div{\n padding: 0 8px 8px;\n gap: 5px;\n}html[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div > div{\n gap: 4px;\n}html[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div > div > div{\n gap: 4px;\n}html[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div :is(button, select):not([role]){\n min-height: 28px;\n height: 28px;\n}html[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div button[class$=_primary]{\n width: 28px;\n transform: none;\n background: var(--dsw-alias-label-primary);\n color: var(--dsw-alias-bg-base);\n}html[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div button[class$=_primary]:hover:not(:disabled){\n background: var(--dsw-alias-label-secondary);\n}html[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div :is(button, select):focus-visible{\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 2px;\n}\n\n/* ── ⑭·4 工具条与建议菜单(只保留官方锚点能命中的部分) ────────────────────\n hero 的工作区行按钮 → Codex 胶囊;@ / 指令建议菜单 → 与输入区同宽,\n 圆角走浮层刻度 --dsw-radius-menu(18px),与模型菜单同一族;\n 旧值 12px(--dsw-radius-m)与菜单族的 18 不同源,已收回。 */html[data-codex-ui] [class*=\"_heroWorkspaceRow\"] button{\n min-height: 28px;\n border-radius: var(--dsw-radius-pill);\n font-size: 13px;\n line-height: 20px;\n}html[data-codex-ui] [class*=\"_heroWorkspaceRow\"] button:hover,html[data-codex-ui] [class*=\"_heroWorkspaceRow\"] button[aria-expanded=true]{\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}html[data-codex-ui] [data-conversation-scroll] [data-trigger-menu]{\n box-sizing: border-box;\n left: 0;\n right: 0;\n width: auto;\n min-width: 0;\n max-width: none;\n padding: 5px;\n border-radius: var(--dsw-radius-menu);\n background: #fff;\n box-shadow: 0 8px 32px #0002, 0 0 0 1px #0000000a;\n}html[data-codex-ui] body[data-ds-dark-theme] [data-conversation-scroll] [data-trigger-menu]{\n background: #292929;\n box-shadow: 0 8px 32px #0003, 0 0 0 1px #ffffff08;\n}html[data-codex-ui] [data-conversation-scroll] [data-trigger-menu] > [role=listbox]{\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n align-self: stretch;\n scrollbar-width: thin;\n}\n\n/* 选项行圆角同「浮层 18 − 内衬 5 = 13」,与 ⑫·3 的菜单行同一公式 */html[data-codex-ui] [data-conversation-scroll] [data-trigger-menu] [role=option]{\n box-sizing: border-box;\n width: 100%;\n min-width: 0;\n min-height: 28px;\n padding: 4px 9px;\n gap: 8px;\n border-radius: calc(var(--dsw-radius-menu) - 5px);\n font-size: 13px;\n line-height: 20px;\n}html[data-codex-ui] [data-conversation-scroll] [data-trigger-menu] [role=option][aria-selected=true]{\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 10%, transparent);\n}\n\n/* ── ⑭·5 新建页(hero):输入区占满、工作区行贴在卡片上沿 ─────────────────\n 上游的 .dcu-home-* 建议卡属于它自己的组件,本皮肤没有这些节点,不移植;\n container 声明只为那些卡片服务,一并省掉。 */html[data-codex-ui] [data-phase=hero] [data-conversation-scroll][class]{\n --dsh-composer-side-clearance: 16px;\n justify-content: flex-start;\n scrollbar-gutter: stable both-edges;\n}html[data-codex-ui] [data-phase=hero] [data-composer-seat]{\n flex: 1 0 auto;\n min-height: 100%;\n}html[data-codex-ui] [data-phase=hero] [data-composer-seat] > :has([class*=\"_composerHero\"]){\n display: flex;\n flex: 1;\n flex-direction: column;\n}html[data-codex-ui] [data-phase=hero] [class*=\"_composerHero\"]{\n box-sizing: border-box;\n flex: 1;\n width: 100%;\n max-width: none;\n min-width: 0;\n margin-inline: auto;\n gap: 0;\n padding-bottom: 32px;\n}html[data-codex-ui] [data-phase=hero] [class*=\"_heroWorkspaceRow\"]{\n box-sizing: border-box;\n flex: none;\n width: min(calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance) - 56px), calc(100% - 56px));\n align-self: center;\n justify-content: flex-start;\n flex-wrap: wrap;\n gap: 8px;\n min-height: 48px;\n margin: 0 28px -10px;\n padding: 6px 12px 16px;\n /* 上栏条圆角 = 卡片圆角(同一个 --dsw-radius-card):它探在卡片背后,只有上面两角可见,\n 与卡片同心叠放。旧值 16px(卡片 20)时 条/卡 = 0.79,而 Codex 实测 1.03\n —— 条比卡片紧 5px 就是那两条对不上的角弧。 */\n border-radius: var(--dsw-radius-card) var(--dsw-radius-card) 0 0;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 4%, var(--dsw-alias-bg-base));\n}html[data-codex-ui] [data-phase=hero] [class*=\"_heroWorkspaceRow\"] > [class$=\"_workspace\"]{\n min-width: 0;\n max-width: 100%;\n font-weight: 400;\n}\n\n\n/* ==== L3 插件设置页层(源:skins/codex-ink/settings.css)==== */\n/* ============================================================================\n codex-ink · L3 插件设置页层\n ----------------------------------------------------------------------------\n 官方插件管理 → 组合包页里那块配置卡的版式(座位 plugins.bundle.config)。\n 取色只用 --dsw-alias-* / --dsw-specific-* 令牌,**不引入任何彩色** —— 与\n skin.css 的「无彩色 chrome」纪律一致;唯一的彩色是强调色本身,由设置值给。\n\n 为什么放在皮肤层而不是卡片内联:这一层同样受 html[data-codex-ui] 作用域约束,\n 进同一份 theme.css,可被体检脚本与审计脚本一起看见。\n ========================================================================== */html[data-codex-ui] .cx-form{\n display: flex;\n flex-direction: column;\n margin: 0;\n}html[data-codex-ui] .cx-row{\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: center;\n column-gap: 16px;\n padding: 10px 0;\n border-bottom: 0.5px solid var(--dsw-alias-border-l1);\n}html[data-codex-ui] .cx-row:last-child{\n border-bottom: none;\n}html[data-codex-ui] .cx-row__text{\n min-width: 0;\n}html[data-codex-ui] .cx-row__label{\n display: flex;\n align-items: center;\n gap: 8px;\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n line-height: 20px;\n}html[data-codex-ui] .cx-row__desc{\n margin-top: 2px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n line-height: 16px;\n}html[data-codex-ui] .cx-row__control{\n display: flex;\n align-items: center;\n gap: 8px;\n justify-self: end;\n}html[data-codex-ui] .cx-row--stack .cx-row__control{\n justify-self: stretch;\n}html[data-codex-ui] .cx-swatch{\n width: 24px;\n height: 24px;\n padding: 0;\n border: 0.5px solid var(--dsw-alias-border-l2);\n border-radius: var(--dsw-radius-s, 6px);\n background: none;\n cursor: pointer;\n}html[data-codex-ui] .cx-swatch::-webkit-color-swatch-wrapper{\n padding: 2px;\n}html[data-codex-ui] .cx-swatch::-webkit-color-swatch{\n border: none;\n border-radius: calc(var(--dsw-radius-s, 6px) - 2px);\n}html[data-codex-ui] .cx-hex{\n width: 96px;\n height: 24px;\n padding: 0 8px;\n border: 0.5px solid var(--dsw-alias-border-l2);\n border-radius: var(--dsw-radius-s, 6px);\n background: var(--dsw-alias-bg-base);\n color: var(--dsw-alias-label-primary);\n font-family: var(--ds-font-family-code);\n font-size: 11px;\n letter-spacing: 0.04em;\n}html[data-codex-ui] .cx-hex:focus-visible{\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 1px;\n}html[data-codex-ui] .cx-text{\n width: 220px;\n height: 24px;\n padding: 0 8px;\n border: 0.5px solid var(--dsw-alias-border-l2);\n border-radius: var(--dsw-radius-s, 6px);\n background: var(--dsw-alias-bg-base);\n color: var(--dsw-alias-label-primary);\n font-size: 12px;\n}html[data-codex-ui] .cx-text:focus-visible{\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 1px;\n}html[data-codex-ui] .cx-range{\n width: 160px;\n accent-color: var(--dsw-alias-link);\n}html[data-codex-ui] .cx-range__value{\n min-width: 24px;\n color: var(--dsw-alias-label-secondary);\n font-family: var(--ds-font-family-code);\n font-size: 11px;\n text-align: right;\n}html[data-codex-ui] .cx-note{\n margin: 0 0 8px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11px;\n line-height: 16px;\n}html[data-codex-ui] .cx-error{\n margin: 8px 0 0;\n /* 旧值读的是 --dsw-alias-state-error —— 这个令牌宿主与本皮肤都没有,于是永远落到回退的\n 主文本色,「保存没生效」的提示看起来跟普通说明一样。改读真实存在的 error-primary。 */\n color: var(--dsw-alias-state-error-primary);\n font-size: 11px;\n line-height: 16px;\n}\n"; - /** 生成期注入的覆盖层模块(源:src/override.js)。 */ - const __override = (() => { -/** - * override.js — 设置页写进页面的那一层覆盖(纯函数:无 DOM、无副作用、可单测)。 - * - * 为什么要单独一层:skins/*.css 是**默认值**,设置页改的是**用户覆盖**。 - * 把用户值写回源样式会让「默认长什么样」无法回答,也无法一键还原, - * 所以覆盖只在运行时以多一个属性的选择器(特异性 +1)压过皮肤,源文件一个字不动。 - * - * 取值纪律: - * · 空串 = 不覆盖(退默认层),默认值下本模块输出空串 —— 「装上不动一个字也不改外观」是夹具断言; - * · 颜色只认 6 位十六进制(与 Codex schema 的 /^#[0-9a-fA-F]{6}$/ 一致); - * · 本模块**不产生任何彩色**:除强调色(用户显式指定)外只有中性 rgba,与皮肤的「无彩色 chrome」纪律一致。 - * - * Codex 侧依据(app.asar 的 jdi / IOe): - * dark { accent:#339cff, contrast:60, ink:#ffffff, surface:#181818 } - * light { accent:#339cff, contrast:45, ink:#1a1c1f, surface:#ffffff } - * 对比度是**简化实现**:Codex 用线性 RGB 混合把文本往 ink 拉、并按同一常量抬升灰阶; - * 这里只做两件可解释的事 —— 文本档位按同一方向混合、发丝线与色调家族按比例缩放。 - * 差距写在 README 的「与 Codex 源码对账」一节,不含糊。 - */ - -/** 覆盖层生效时打在 上的第二个属性(比皮肤自身多一个属性 ⇒ 特异性稳赢)。 */ -const OVERRIDE_ATTR = 'data-codex-ui-theme'; -/** 皮肤自身的根属性,与 src/build.mjs 的 ATTR 同源。 */ -const SKIN_ATTR = 'data-codex-ui'; -/** 设置表单的命名空间 = profile 条目 id(= 包名)。 */ -const SETTINGS_ENTRY_ID = 'codex-ui'; - -/** Codex 默认对比度:亮 45 / 暗 60。 */ -const DEFAULT_CONTRAST = { light: 45, dark: 60 }; - -/** - * 皮肤默认值(空串即回落到这里)。surface = 设置页「背景」那一行 = --dsw-alias-bg-base。 - * 深色 surface 自 0.5.6 起是窗口背景 #111111(#181818 是表面/侧栏那一层);这里曾停在 #181818, - * 设置卡的背景色块因此显示错的默认值。check-repo 现在逐字段对账 skin.css,漂移即 FAIL。 - */ -const SKIN_DEFAULTS = { - light: { accent: '#339cff', focus: '#339cff', surface: '#ffffff', ink: '#1a1c1f', sidebar: '#eef4f9' }, - dark: { accent: '#0169cc', focus: '#339cff', surface: '#111111', ink: '#ffffff', sidebar: '#181818' }, -}; - -/** - * 文本档位(次要/辅助/说明/主文本弱化)—— 对比度把它们往 ink(或往底色)拉。 - * 值必须与 skins/codex-ink/skin.css 一致;夹具会核对,改一处忘另一处会 FAIL。 - */ -const LABEL_TIERS = { - light: { - '--dsw-alias-label-secondary': '#5d5d5d', - '--dsw-alias-label-primary-dimmed': '#5d5d5d', - '--dsw-alias-label-tertiary': '#767676', - '--dsw-alias-label-caption': '#767676', - }, - dark: { - '--dsw-alias-label-secondary': '#b4b4b4', - '--dsw-alias-label-primary-dimmed': '#b4b4b4', - '--dsw-alias-label-tertiary': '#949494', - '--dsw-alias-label-caption': '#949494', - }, -}; - -/** - * 中性 alpha 家族 —— 对比度按比例缩放它们。 - * 只收中性(亮 rgba(13,13,13,·) / 暗 rgba(255,255,255,·)):状态色与 diff 底色是**彩色**, - * 缩放它们等于把调色板搬进本模块,与「彩色只配给状态、且集中在 skin.css」的纪律冲突。 - * 浮层挡板(bg-overlay / bg-mask-drop / specific-menu)与滚动条也不在内:它们不是对比度语义。 - * 同样由夹具与 skin.css 对账。 - */ -const ALPHA_LADDER = { - light: { - '--dsw-alias-bg-skeleton': 0.05, - '--dsw-alias-border-l1': 0.07, - '--dsw-alias-border-l2': 0.12, - '--dsw-alias-border-l3': 0.18, - '--dsw-alias-border-l4': 0.24, - '--dsw-alias-border-l2-darkmode-thin': 0.09, - '--dsw-alias-button-tool-bar-fill': 0.28, - '--dsw-alias-button-tool-bar-fill-invisible': 0.18, - '--dsw-alias-button-tool-bar-hover': 0.36, - '--dsw-alias-interactive-bg-hover': 0.04, - '--dsw-alias-interactive-bg-active': 0.08, - '--dsw-alias-interactive-bg-hover-accent': 0.06, - '--dsw-alias-state-business-tertiary': 0.08, - '--dsw-specific-sidebar-nav-item-active-accent': 0.1, - }, - dark: { - '--dsw-alias-bg-skeleton': 0.07, - '--dsw-alias-border-l1': 0.08, - '--dsw-alias-border-l2': 0.14, - '--dsw-alias-border-l3': 0.2, - '--dsw-alias-border-l4': 0.26, - '--dsw-alias-border-l2-darkmode-thin': 0.1, - '--dsw-alias-button-tool-bar-fill': 0.22, - '--dsw-alias-button-tool-bar-fill-invisible': 0.16, - '--dsw-alias-button-tool-bar-hover': 0.28, - '--dsw-alias-interactive-bg-hover': 0.06, - '--dsw-alias-interactive-bg-active': 0.1, - '--dsw-alias-interactive-bg-hover-accent': 0.08, - '--dsw-alias-state-business-tertiary': 0.12, - '--dsw-specific-sidebar-nav-item-active-accent': 0.14, - /* 本插件自己的悬停填充(0.1.2 起用),深色专属。 */ - '--dsw-codex-hover-fill': 0.08, - }, -}; - -/** 半透明侧边栏的填充不透明度。 */ -const SIDEBAR_ALPHA = 0.72; -/** alpha 缩放的夹取区间:滑到底也不让发丝线彻底消失。 */ -const SCALE_RANGE = [0.5, 2]; -/** 文本档位混合的上限(按默认对比度归一后的偏移量)。 */ -const MIX_RANGE = [0, 0.5]; - -/** 6 位十六进制色。 */ -const HEX_RE = /^#[0-9a-fA-F]{6}$/; - +/* src/client/host.js */ +const __src_client_host = (() => { /** - * 是否是合法的覆盖色。 - * @param value - 待检查的值。 - * @returns 合法则 true;空串/未定义一律 false(= 不覆盖)。 + * 读宿主服务的两个小工具(设置覆盖层、设置卡、模型选择器共用)。 */ -function isHex(value) { - return typeof value === 'string' && HEX_RE.test(value.trim()); -} - -const toRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); - -/** 线性插值(逐通道四舍五入),t=0 返回 a、t=1 返回 b。 */ -function mixHex(a, b, t) { - const x = toRgb(a); - const y = toRgb(b); - return '#' + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0')).join(''); -} - -/** 由十六进制色加 alpha 得到 rgba() 文本。 */ -function withAlpha(hex, alpha) { - const [r, g, b] = toRgb(hex); - return 'rgba(' + r + ', ' + g + ', ' + b + ', ' + Number(alpha.toFixed(3)) + ')'; -} /** - * 对比度的影响:文本混合系数与 alpha 缩放系数。 - * @param contrast - 面板上的 0–100。 - * @param theme - 'light' | 'dark'。 - * @returns {{mix: number, scale: number}} 默认对比度下两者都是恒等值(0 / 1)。 + * 设置表单的当前值。设置文档还没送达(status=loading、连接刚重连)时 value 是 undefined, + * 这时返回 undefined,调用方应**保持现状** —— 撤掉已生效的覆盖会让用户的设置闪一下没了。 + * @param form - configForms.get(id) 的结果。 + * @returns 取值对象;未送达时 undefined。 */ -function contrastEffect(contrast, theme) { - const fallback = DEFAULT_CONTRAST[theme]; - const raw = Number(contrast); - const c = Number.isFinite(raw) ? Math.min(100, Math.max(0, raw)) : fallback; - const ratio = c / fallback; - const mix = Math.min(MIX_RANGE[1], Math.abs(ratio - 1) * 0.5) * (ratio >= 1 ? 1 : -1); - const scale = Math.min(SCALE_RANGE[1], Math.max(SCALE_RANGE[0], ratio)); - return { mix, scale }; +function formValue(form) { + const value = form.getSnapshot().value; + return value === undefined ? undefined : (value ?? {}); } /** - * 生成覆盖层 CSS。 - * @param values - configForms 的取值快照(空串表示不覆盖)。 - * @returns CSS 文本;默认值下为空串(不插任何规则)。 + * 界面语言是不是英文:先问宿主 locale 服务,再问浏览器;认不出就当中文。 + * @param locale - ctx.reflect.get('locale'),可能缺席。 + * @returns true 为英文。 */ -function themeOverrideCss(values = {}) { - const light = []; - const dark = []; - - /** 每主题一组声明。 */ - const emit = (theme, list) => { - const d = SKIN_DEFAULTS[theme]; - const accent = isHex(values[theme === 'light' ? 'accentLight' : 'accentDark']) - ? values[theme === 'light' ? 'accentLight' : 'accentDark'].trim() - : null; - const surfaceRaw = values[theme === 'light' ? 'surfaceLight' : 'surfaceDark']; - const surface = isHex(surfaceRaw) ? surfaceRaw.trim() : null; - const inkRaw = values[theme === 'light' ? 'inkLight' : 'inkDark']; - const ink = isHex(inkRaw) ? inkRaw.trim() : null; - const contrast = values[theme === 'light' ? 'contrastLight' : 'contrastDark']; - const { mix, scale } = contrastEffect(contrast, theme); - const effectiveSurface = surface ?? d.surface; - const effectiveInk = ink ?? d.ink; - - if (accent !== null) { - list.push(['--dsw-alias-link', accent]); - /* 焦点环跟着强调色走:亮色实色、暗色 70% —— 与皮肤里的两处默认值同构。 */ - list.push(['--dsw-codex-focus', theme === 'light' ? accent : withAlpha(accent, 0.7)]); - } - if (surface !== null) list.push(['--dsw-alias-bg-base', surface]); - if (ink !== null) list.push(['--dsw-alias-label-primary', ink]); - if (mix !== 0) { - const target = mix > 0 ? effectiveInk : effectiveSurface; - for (const [token, base] of Object.entries(LABEL_TIERS[theme])) { - list.push([token, mixHex(base, target, Math.abs(mix))]); - } - } - if (scale !== 1) { - const rgb = theme === 'light' ? '13, 13, 13' : '255, 255, 255'; - for (const [token, base] of Object.entries(ALPHA_LADDER[theme])) { - list.push([token, 'rgba(' + rgb + ', ' + Number(Math.min(1, base * scale).toFixed(3)) + ')']); - } - } - if (values.translucentSidebar === true) { - /* 侧栏填充转半透明:与 surface 同面时不改变观感,它只在侧栏压住别的内容时看得见 - —— Web 没有窗口层,这一点如实写进 README,不假装等价于 Codex 的窗口半透明。 */ - const fill = withAlpha(effectiveSurface, SIDEBAR_ALPHA); - list.push(['--dsw-alias-bg-sidebar', fill]); - list.push(['--dsw-specific-sidebar-fill', fill]); - /* 行填充跟着转半透明:面板半透明而行实色会露出「玻璃板上的不透明贴片」。 - 深色下侧栏与内容同面时,只有这两行还能把开关的效果显出来。 */ - const rgb = theme === 'light' ? '13, 13, 13' : '255, 255, 255'; - const rowAlpha = theme === 'light' ? [0.04, 0.08] : [0.06, 0.1]; - list.push(['--dsw-specific-sidebar-nav-item-hover', 'rgba(' + rgb + ', ' + Number(Math.min(1, rowAlpha[0] * scale).toFixed(3)) + ')']); - list.push(['--dsw-specific-sidebar-nav-item-active', 'rgba(' + rgb + ', ' + Number(Math.min(1, rowAlpha[1] * scale).toFixed(3)) + ')']); - } - }; - - emit('light', light); - emit('dark', dark); - - /* 字体与主题无关,单独两条。 */ - const fonts = []; - if (isFontStack(values.fontUi)) fonts.push(['--dsw-font-family', values.fontUi.trim()]); - if (isFontStack(values.fontCode)) fonts.push(['--ds-font-family-code', values.fontCode.trim()]); - - const blocks = []; - const fontDecls = fonts.map(([k, v]) => ' ' + k + ': ' + v + ';').join('\n'); - if (light.length > 0 || fontDecls !== '') { - const decls = [...light.map(([k, v]) => ' ' + k + ': ' + v + ';'), fontDecls].filter((s) => s !== '').join('\n'); - blocks.push(sel(SKIN_ATTR, 'light') + ',\n' + sel(SKIN_ATTR, 'light-body') + ' {\n' + decls + '\n}'); - } - if (dark.length > 0) { - const decls = dark.map(([k, v]) => ' ' + k + ': ' + v + ';').join('\n'); - blocks.push(sel(SKIN_ATTR, 'dark') + ' {\n' + decls + '\n}'); - } - return blocks.join('\n\n'); -} - -/** - * 一组合法的字体栈(不许出现会闭合声明、或能改变后文切分的字符)。 - * - * 值是原样拼进覆盖层 `' - + '' - + '
' - + '
' - + '
' - + '
' - + '
'; - -fs.mkdirSync(FIX, { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const htmlPath = join(FIX, 'composer-shadow-verify.html'); -fs.writeFileSync(htmlPath, page); - -const PORT = 9351; -const DSF = 2; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const PROFILE = tempDir('dsh-cdp-shadow-verify'); -fs.rmSync(PROFILE, { recursive: true, force: true }); -const child = spawn(chromePath(), ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + PROFILE, '--no-first-run', '--disable-gpu', '--hide-scrollbars', '--window-size=1100,700', 'about:blank'], { stdio: 'ignore' }); -let info; for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } -if (!info) { child.kill(); throw new Error('headless chrome did not start'); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const pending = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }; -const send = (method, params = {}, sid) => new Promise((res, rej) => { const id = ++seq; pending.set(id, (m) => m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sid ? { sessionId: sid } : {}) })); }); -const evalJs = async (expr) => { - const res = (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }, sessionId)).result; - if (res.value === undefined) throw new Error('eval returned undefined: ' + JSON.stringify(res)); - return res.value; -}; - -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: DSF, mobile: false }, sessionId); -await send('Page.navigate', { url: 'file:///' + htmlPath.replaceAll('\\', '/') + '?v=' + Date.now() }, sessionId); -await sleep(1500); - -const PROBE = fs.readFileSync(join(HERE, 'fixtures', 'composer-shadow.probe.js'), 'utf8'); -const probe = async () => JSON.parse(await evalJs(PROBE)); - -/** 把 rgba 计算值拆成层:inset 标记、四个长度、alpha。 */ -const parseLayers = (s) => s.split(/,(?![^(]*\))/).map((x) => x.trim()).filter(Boolean).map((x) => { - const inset = /(^|\s)inset(\s|$)/.test(x); - const nums = [...x.matchAll(/(-?\d+(?:\.\d+)?)px/g)].map((m) => parseFloat(m[1])); - const col = x.match(/rgba?\([^)]*\)/); - let alpha = null; - if (col) { - const parts = col[0].replace(/^rgba?\(|\)$/g, '').split(',').map((v) => v.trim()); - alpha = parts.length === 4 ? parseFloat(parts[3]) : 1; - } - return { inset, nums, alpha, raw: x }; -}); -const near = (a, b, tol = 0.006) => Math.abs(a - b) <= tol; -/** 把计算色换算成 0..255 通道值。color-mix 会算成 color(srgb r g b)(0..1 浮点), - 字面量则是 rgb(r, g, b) —— 两种记法都要认,否则断言会被记法差异误判。 */ -const chan = (c) => { - const m = String(c).match(/color\(srgb\s+([\d.]+)/); - if (m) return parseFloat(m[1]) * 255; - const r = String(c).match(/rgba?\(\s*([\d.]+)/); - return r ? parseFloat(r[1]) : NaN; -}; - -/* ── 像素测量:截图回灌进页面,用 canvas 取竖直扫描线 ─────────────────── */ -const scanAbove = async (rect, cssPx) => { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - return JSON.parse(await evalJs('(async () => {' - + 'const img = new Image(); img.src = "data:image/png;base64,' + shot.data + '";' - + 'await img.decode();' - + 'const c = document.createElement("canvas"); c.width = img.width; c.height = img.height;' - + 'const g = c.getContext("2d"); g.drawImage(img, 0, 0);' - + 'const D = ' + DSF + ';' - + 'const x = Math.round((' + rect.x + ' + ' + rect.w + ' / 2) * D);' - + 'const yTop = Math.round(' + rect.y + ' * D);' - + 'const out = [];' - + 'for (let d = 0; d < ' + cssPx + ' * D; d += 1) {' - + ' const y = yTop - d; if (y < 0) break;' - + ' const p = g.getImageData(x, y, 1, 1).data;' - + ' out.push([d / D, (p[0] + p[1] + p[2]) / 3]);' - + '}' - + 'const page = g.getImageData(x, 4, 1, 1).data;' - + 'const body = g.getImageData(x, yTop + Math.round(20 * D), 1, 1).data;' - + 'return JSON.stringify({ base: (page[0] + page[1] + page[2]) / 3, body: (body[0] + body[1] + body[2]) / 3, prof: out });' - + '})()')); -}; - -const results = []; -const check = (name, pass, detail) => { results.push([name, pass, detail]); }; - -/* ── ① 亮色 ─────────────────────────────────────────────────────────── */ -const light = await probe(); -const L = parseLayers(light.shadow); -console.log('LIGHT var ' + light.varShadow); -console.log('LIGHT calc ' + light.shadow); -console.log('LIGHT base=' + light.canvasBase + ' surface=' + light.surface); -check('亮色画布 = #ffffff', light.canvasBase.toLowerCase() === '#ffffff', light.canvasBase); -/* 亮色卡与页面**同色**:Codex 亮色靠阴影分层,不靠填充(三份实测同向,见 composer.css 头注)。 - 0.5.7 及以前亮色也叠 5% 墨 = #f4f4f4,比 Codex 暗 11 级。 */ -check('亮色输入卡 = #ffffff(与页面同色,不叠罩)', light.bg === 'rgb(255, 255, 255)', light.bg); -check('亮色输入卡 ≠ #f4f4f4(旧值,暗 11 级)', light.bg !== 'rgb(244, 244, 244)', light.bg); -check('亮色阴影为三层', L.length === 3, L.length + ' 层'); -check('亮色三层皆非 inset', L.every((l) => !l.inset), L.map((l) => l.inset).join(',')); -if (L.length === 3) { - /* 环取 12% 而非源码字面的 3.9%:实测边缘要 220~234,4% 只能渲到 241。见 composer.css 头注。 */ - /* 环取 10%(Codex --color-border = --alpha-10),不是源码路径A 的 3.9%、也不是 0.5.8 的 12%: - 同图内比较与标定渲染两法都指向 ~10.4%。见 composer.css 头注。 */ - check('层1 环 = 0 0 0 1px @10%(Codex --color-border,双法标定)', !L[0].inset && L[0].nums.join(' ') === '0 0 0 1' && near(L[0].alpha, 0.1, 0.015), L[0].raw); - check('层2 近场 = 0 2px 8px 0 @4%', L[1].nums.join(' ') === '0 2 8 0' && near(L[1].alpha, 0.039), L[1].raw); - check('层3 远场 = 0 4px 80px 8px @2.4%(旧皮肤缺这一层)', L[2].nums.join(' ') === '0 4 80 8' && near(L[2].alpha, 0.024, 0.004), L[2].raw); -} -/* 渲染边缘:Codex 参考图与用户截图都落在 220~234,本皮肤必须也落进去。 - 旧值 4% 渲出 241(偏亮),这正是 0.5.7 引入、0.5.8 修回的偏差。 */ -const lightEdge = await (async () => { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - return JSON.parse(await evalJs('(async () => {' - + 'const img = new Image(); img.src = "data:image/png;base64,' + shot.data + '"; await img.decode();' - + 'const c = document.createElement("canvas"); c.width = img.width; c.height = img.height;' - + 'const g = c.getContext("2d"); g.drawImage(img, 0, 0); const D = ' + DSF + ';' - + 'const y = Math.round((' + light.rect.y + ' + ' + light.rect.h + ' / 2) * D);' - + 'const xL = Math.round(' + light.rect.x + ' * D);' - + 'let best = 255; for (let d = 1; d <= 6; d += 1) {' - + ' const p = g.getImageData(xL - d, y, 1, 1).data; const v = (p[0] + p[1] + p[2]) / 3;' - + ' if (v < best) best = v; }' - + 'return JSON.stringify({ edge: Math.round(best) }); })()')); -})(); -console.log('LIGHT 渲染边缘(卡左 6px 内最暗)= ' + lightEdge.edge + ' Codex 实测 220~234'); -check('亮色卡边缘落在 Codex 实测带内(210~234)', lightEdge.edge >= 210 && lightEdge.edge <= 234, String(lightEdge.edge)); - -const lightScan = await scanAbove(light.rect, 60); -const lightDev = lightScan.prof.map(([d, v]) => [d, lightScan.base - v]); -const lightPeak1 = lightDev.find(([d]) => d === 1)?.[1] ?? 0; -const lightExtent = lightDev.filter(([, v]) => v >= 1).map(([d]) => d).pop() ?? 0; -console.log('LIGHT 1px处偏离 ' + lightPeak1.toFixed(1) + ' 衰减半径 ' + lightExtent + 'px'); -check('亮色衰减半径 ≥ 35px(远场真的画出来了;旧皮肤仅 9px)', lightExtent >= 35, lightExtent + 'px'); -/* 这条原先写「1px 处 ≤ 22」,是拿 DSF=1 + 4% 环标定的口径,量到的其实是**环本身**; - Codex 实测的环像素是 220(偏离 35),所以旧口径把「环」和「环外一格」比成了同一件事。 - 现在环的判据交给上面的显式边缘带断言(210~234),这里只留信息打印。 */ -console.log('LIGHT 1px处(即环像素)偏离 ' + lightPeak1.toFixed(1) + '(信息项,不作断言)'); - -/* ── ② 暗色 ─────────────────────────────────────────────────────────── */ -await evalJs('document.body.setAttribute("data-ds-dark-theme",""); "ok"'); -await sleep(400); -const dark = await probe(); -const D = parseLayers(dark.shadow); -console.log('DARK var ' + dark.varShadow); -console.log('DARK calc ' + dark.shadow); -console.log('DARK base=' + dark.canvasBase); -check('暗色画布 = #111111(Codex 主题面板的「背景」)', dark.canvasBase.toLowerCase() === '#111111', dark.canvasBase); -check('暗色表面 = #181818(卡片所坐的面,非窗口背景)', dark.surface.toLowerCase() === '#181818', dark.surface); -check('暗色属性确实生效(前提自检)', dark.dark === true, String(dark.dark)); -check('暗色阴影为单层', D.length === 1, D.length + ' 层'); -/* 暗色相反:靠**填充**分层 —— 5% 白叠 surface #181818 = 35.6 → 36(Codex 实测 35)。 */ -/* 5% 白叠 #181818 = 35.55(Codex 参考裁图实测 35)—— color-mix 会算成 color(srgb 0.1394…) */ -check('暗色输入卡 ≈ 35.55(5% 白叠 #181818;Codex 实测 35)', Math.abs(chan(dark.bg) - 35.55) < 1.0, dark.bg + ' → ' + chan(dark.bg).toFixed(2)); -check('暗色输入卡 ≠ 亮色值(两套机制相反)', dark.bg !== light.bg, dark.bg); -if (D.length === 1) { - check('暗色为 inset 内嵌高光(Codex --elevation-composer-dark)', D[0].inset === true, D[0].raw); - check('暗色几何 = 0 0 1px 0 @20%(#fff3)', D[0].nums.join(' ') === '0 0 1 0' && near(D[0].alpha, 0.2), D[0].raw); -} -const darkScan = await scanAbove(dark.rect, 60); -const darkDev = darkScan.prof.map(([d, v]) => [d, darkScan.base - v]); -const darkOuter = darkDev.filter(([d]) => d >= 2 && d <= 40).reduce((m, [, v]) => Math.max(m, Math.abs(v)), 0); -console.log('DARK 卡外 2..40px 最大偏离 ' + darkOuter.toFixed(1) + ' 卡内顶边 ' + darkScan.body.toFixed(0) + ' vs 卡体基线'); -check('暗色卡外零投影(2..40px 偏离 ≤ 1)', darkOuter <= 1, darkOuter.toFixed(1)); -/* 卡内顶边应比卡体更亮 —— inset 高光的签名。取 +0px 与 +6px 对比。 */ -const darkInside = await (async () => { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - return JSON.parse(await evalJs('(async () => {' - + 'const img = new Image(); img.src = "data:image/png;base64,' + shot.data + '"; await img.decode();' - + 'const c = document.createElement("canvas"); c.width = img.width; c.height = img.height;' - + 'const g = c.getContext("2d"); g.drawImage(img, 0, 0); const D = ' + DSF + ';' - + 'const x = Math.round((' + dark.rect.x + ' + ' + dark.rect.w + ' / 2) * D);' - + 'const yTop = Math.round(' + dark.rect.y + ' * D);' - + 'const px = (dy) => { const p = g.getImageData(x, yTop + dy, 1, 1).data; return (p[0] + p[1] + p[2]) / 3; };' - + 'return JSON.stringify({ edge: px(0), inner: px(Math.round(6 * D)) }); })()')); -})(); -console.log('DARK 卡内顶边 ' + darkInside.edge.toFixed(1) + ' 卡内 +6px ' + darkInside.inner.toFixed(1)); -check('暗色卡内顶边被点亮(比卡体亮 ≥ 2;旧皮肤 36/36 全平)', darkInside.edge - darkInside.inner >= 2, (darkInside.edge - darkInside.inner).toFixed(1)); - -/* ── ③ 窄屏:远场 80px → 40px(Codex 的 width<40rem 分支) ────────────── - 必须先摘掉深色属性:暗色分支的 inset 声明特异性更高,会盖掉窄屏覆盖值, - 不摘就会测到暗色阴影,让这条断言永远「通过」在错误的前提上。 */ -await evalJs('document.body.removeAttribute("data-ds-dark-theme"); "ok"'); -await send('Emulation.setDeviceMetricsOverride', { width: 600, height: 700, deviceScaleFactor: DSF, mobile: false }, sessionId); -await sleep(500); -check('窄屏段确实回到亮色(前提自检)', (await probe()).dark === false, 'body 无 data-ds-dark-theme'); -const narrow = await probe(); -const N = parseLayers(narrow.shadow); -const nFar = N.find((l) => l.nums.length === 4 && l.nums[2] === 40); -console.log('NARROW ' + narrow.shadow); -check('窄屏(600px)远场收到 40px', nFar !== undefined, narrow.shadow); -check('窄屏仍是三层', N.length === 3, N.length + ' 层'); - -/* 证据图:把亮/暗两态并排出图 */ -await send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: DSF, mobile: false }, sessionId); -await sleep(400); -const evidShot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); -fs.writeFileSync(join(EVID, 'composer-shadow-verify.png'), Buffer.from(evidShot.data, 'base64')); - -ws.close(); child.kill(); - -let pass = 0; -for (const [name, ok, detail] of results) { - console.log((ok ? 'PASS ' : 'FAIL ') + name + (detail !== undefined ? ' [' + detail + ']' : '')); - if (ok) pass += 1; -} -console.log('\n' + (pass === results.length ? 'ALL PASS' : 'FAILED') + ' (' + pass + '/' + results.length + ')'); -process.exit(pass === results.length ? 0 : 1); diff --git a/scripts/elevation-verify.mjs b/scripts/elevation-verify.mjs deleted file mode 100644 index 0c49e66..0000000 --- a/scripts/elevation-verify.mjs +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env node -/** - * elevation-verify.mjs — ⑲ --dsw-elevation-* 令牌与 Codex 源码对账。 - * - * 判据来源:Codex 桌面端 26.924.2738.0 的 resources/app.asar @67930525 起逐字: - * --elevation-stroke: 0 0 0 .5px var(--color-border-strong) - * --elevation-stroke-subtle: 0 0 0 .5px rgb(from var(--color-border-strong) r g b / .04) (暗色 .06) - * --elevation-prominent: var(--elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d - * --elevation-sidebar: var(--elevation-stroke), 0 3px 7.5px #00000008, 0 0 16px #00000005 - * - * 本夹具量四件事: - * ① 四个令牌在亮/暗两套下都解析得出来; - * ② --dsw-elevation-prominent 与 Codex 逐字一致(几何 + 两层 alpha); - * ③ 亮暗的 prominent **必须相同** —— Codex 只在 :root 声明一次、没有暗色变体; - * ④ --dsw-elevation-panel 仍等于 stroke(本皮肤的分层选择,防误改)。 - * 用法:node scripts/elevation-verify.mjs - */ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromePath, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const EVID = join(WB, 'assets', 'screenshots'); -const theme = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); - -/* 舞台上放一个"菜单面板",直接吃 --dsw-elevation-prominent;亮暗各一个。 */ -const page = '' - + '' - + '' - + '
'; - -fs.mkdirSync(join(tmpdir(), 'codex-ui-fixtures'), { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const htmlPath = join(tmpdir(), 'codex-ui-fixtures', 'elevation-verify.html'); -fs.writeFileSync(htmlPath, page); - -const PORT = 9355; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const PROFILE = tempDir('dsh-cdp-elev'); -fs.rmSync(PROFILE, { recursive: true, force: true }); -const child = spawn(chromePath(), ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + PROFILE, '--no-first-run', '--disable-gpu', '--hide-scrollbars', '--window-size=460,520', 'about:blank'], { stdio: 'ignore' }); -let info; for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } -if (!info) { child.kill(); throw new Error('headless chrome did not start'); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const pending = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }; -const send = (method, params = {}, sid) => new Promise((res, rej) => { const id = ++seq; pending.set(id, (m) => m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sid ? { sessionId: sid } : {}) })); }); -const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true }, sessionId)).result.value; - -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 460, height: 520, deviceScaleFactor: 2, mobile: false }, sessionId); -await send('Page.navigate', { url: 'file:///' + htmlPath.replaceAll('\\', '/') + '?v=' + Date.now() }, sessionId); -await sleep(1400); - -const TOKENS = '(() => { const cs = getComputedStyle(document.body);' - + 'const g = (n) => cs.getPropertyValue(n).trim();' - + 'const menu = document.getElementById("m");' - + 'return JSON.stringify({ stroke: g("--dsw-elevation-stroke"), panel: g("--dsw-elevation-panel"),' - + ' prominent: g("--dsw-elevation-prominent"), soft: g("--dsw-elevation-soft"),' - + ' menuShadow: getComputedStyle(menu).boxShadow,' - + ' menuFill: getComputedStyle(menu).backgroundColor }); })()'; - -const light = JSON.parse(await evalJs(TOKENS)); -await evalJs('document.body.setAttribute("data-ds-dark-theme",""); "ok"'); -await sleep(400); -const dark = JSON.parse(await evalJs(TOKENS)); - -console.log('LIGHT stroke ' + light.stroke); -console.log('LIGHT prominent ' + light.prominent); -console.log('LIGHT menu calc ' + light.menuShadow); -console.log('DARK stroke ' + dark.stroke); -console.log('DARK prominent ' + dark.prominent); -console.log('DARK menu calc ' + dark.menuShadow); - -/* Codex 逐字:var(--elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d - 计算值里 var 会被替换掉,两层 alpha 归一为 0.039 / 0.051(#0a=3.92%、#0d=5.10%)。 */ -const layers = (s) => s.split(/,(?![^(]*\))/).map((x) => x.trim()).filter(Boolean); -const hasGeom = (s, a, b, c, d) => { - const m = s.match(/(-?[\d.]+)px\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+(-?[\d.]+)px/); - return !!m && [+m[1], +m[2], +m[3], +m[4]].every((v, i) => Math.abs(v - [a, b, c, d][i]) < 0.01); -}; -const alphaNear = (s, want, tol = 0.006) => { - const m = s.match(/rgba?\([^)]*?,\s*([\d.]+)\s*\)/); - return !!m && Math.abs(parseFloat(m[1]) - want) <= tol; -}; - -const results = []; -const check = (n, ok, d) => results.push([n, ok, d]); - -/* 几何与 alpha 一律读**计算值**(menuShadow):自定义属性的字面值里 0 spread 会被省略、 - 颜色是 hex 不是 rgba,直接断言字面值会因记法差异误判。 */ -for (const [tag, t] of [['亮', light], ['暗', dark]]) { - const L = layers(t.prominent); - const C = layers(t.menuShadow); - check(tag + '色 --dsw-elevation-prominent 为三层', L.length === 3, L.length + ' 层'); - check(tag + '色 层2 几何 = 0 3px 7.5px 0(Codex 逐字)', hasGeom(C[1] ?? '', 0, 3, 7.5, 0), (C[1] ?? '').slice(0, 60)); - check(tag + '色 层2 alpha = 3.92%(#0000000a)', alphaNear(C[1] ?? '', 0.039), (C[1] ?? '').slice(0, 60)); - check(tag + '色 层3 几何 = 0 0 20px 0(Codex 逐字)', hasGeom(C[2] ?? '', 0, 0, 20, 0), (C[2] ?? '').slice(0, 60)); - check(tag + '色 层3 alpha = 5.10%(#0000000d)', alphaNear(C[2] ?? '', 0.051), (C[2] ?? '').slice(0, 60)); - check(tag + '色 层1 = --dsw-elevation-stroke', L[0] === t.stroke, (L[0] ?? '').slice(0, 60)); - check(tag + '色 --dsw-elevation-panel 仍等于 stroke', t.panel === t.stroke, t.panel); - check(tag + '色 菜单面板确实吃到 prominent', t.menuShadow !== 'none' && t.menuShadow.length > 20, t.menuShadow.slice(0, 70)); -} -/* Codex 只在 :root 声明 --elevation-prominent 一次、没有暗色覆盖 —— 所以**第 2、3 层**必须 - 亮暗逐字相同。(第 1 层是 var(--elevation-stroke),它本来就随主题反相,不参与这条比较。) */ -const lit = layers(light.prominent), dk = layers(dark.prominent); -check('亮暗 层2 同值(Codex 无暗色变体)', lit[1] === dk[1], lit[1] + ' | ' + dk[1]); -check('亮暗 层3 同值(Codex 无暗色变体)', lit[2] === dk[2], lit[2] + ' | ' + dk[2]); -check('亮暗 stroke 不同值(描边随主题反相)', light.stroke !== dark.stroke, light.stroke + ' | ' + dark.stroke); - -/* 证据图:暗色态 */ -const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); -fs.writeFileSync(join(EVID, 'elevation-verify.png'), Buffer.from(shot.data, 'base64')); -ws.close(); child.kill(); - -let pass = 0; -for (const [n, ok, d] of results) { console.log((ok ? 'PASS ' : 'FAIL ') + n + (d !== undefined ? ' [' + d + ']' : '')); if (ok) pass += 1; } -console.log('\n' + (pass === results.length ? 'ALL PASS' : 'FAILED') + ' (' + pass + '/' + results.length + ')'); -process.exit(pass === results.length ? 0 : 1); diff --git a/scripts/fixtures/composer-shadow.probe.js b/scripts/fixtures/composer-shadow.probe.js deleted file mode 100644 index dfb5511..0000000 --- a/scripts/fixtures/composer-shadow.probe.js +++ /dev/null @@ -1,21 +0,0 @@ -(() => { - /* 深色令牌声明在 body[data-ds-dark-theme] 上(不是 :root), - 所以取令牌一律以 body 为先、html 兜底 —— 只读 documentElement 会在深色下读到亮色值。 */ - const b = getComputedStyle(document.body); - const h = getComputedStyle(document.documentElement); - const tok = (n) => b.getPropertyValue(n).trim() || h.getPropertyValue(n).trim(); - const card = document.querySelector('[data-composer-card]'); - const scroll = document.querySelector('[data-conversation-scroll]'); - const cs = getComputedStyle(card); - const r = card.getBoundingClientRect(); - return JSON.stringify({ - shadow: cs.boxShadow, - bg: cs.backgroundColor, - radius: cs.borderTopLeftRadius, - varShadow: getComputedStyle(scroll).getPropertyValue('--dcu-composer-shadow').trim(), - canvasBase: tok('--dsw-alias-bg-base'), - surface: tok('--dsw-composer-surface'), - dark: document.body.hasAttribute('data-ds-dark-theme'), - rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }, - }); -})() \ No newline at end of file diff --git a/scripts/fixtures/hero-verify.probe.js b/scripts/fixtures/hero-verify.probe.js deleted file mode 100644 index 873c495..0000000 --- a/scripts/fixtures/hero-verify.probe.js +++ /dev/null @@ -1,32 +0,0 @@ -(() => { - const H = '[data-slot="conversation.session.header"]'; - const q = (s) => document.querySelector(s); - const vis = (el) => el ? el.getClientRects().length > 0 : null; - const cs = (el, p) => el ? getComputedStyle(el).getPropertyValue(p) : null; - const row = q('[data-phase=hero] [class$=_heroWorkspaceRow]'); - const card = q('[data-composer-card]'); - /* ⑬·3c 放开后,槽里的条目按**官方真实 class** 建模:量它的取色是否跟着本皮肤令牌走。 */ - const label = q('[data-slot="conversation.session.header.actions"] span'); - return JSON.stringify({ - header: { - title: vis(q(H + ' [class$=_crumbs]')), - presetBadge: vis(q('[data-slot="conversation.session.header.actions"] span')), - utilityItem: vis(q('[data-slot="conversation.session.header.utilities"] span')), - tabs: vis(q(H + ' [data-conversation-tabs]')), - corner: vis(q('[data-conversation-header-corner]')), - }, - presetLabel: { - vis: vis(label), - color: cs(label, 'color'), - bg: cs(label, 'background-color'), - radius: cs(label, 'border-top-left-radius'), - h: label ? Math.round(label.getBoundingClientRect().height) : null, - font: cs(label, 'font-size'), - }, - card: { radius: cs(card, 'border-top-left-radius'), shadow: cs(card, 'box-shadow'), border: cs(card, 'border-top-width'), token: cs(document.documentElement, '--dsw-radius-card').trim(), h: card ? Math.round(card.getBoundingClientRect().height) : null, w: card ? Math.round(card.getBoundingClientRect().width) : null, bg: cs(card, 'background-color'), padTop: cs(card, 'padding-top') }, - heroRow: { present: row !== null, bg: cs(row, 'background-color'), radius: cs(row, 'border-top-left-radius'), height: row ? Math.round(row.getBoundingClientRect().height) : null }, - /* ⑭·2 输入区纵向留白:Codex 实测(同一字号标定)编辑区 ≈79、底衬 ≈12,本层按此对齐。 */ - editor: (() => { const e = q('[data-lexical-editor=true]'); return e ? { minH: cs(e, 'min-height'), h: Math.round(e.getBoundingClientRect().height) } : null; })(), - footPad: (() => { const s = q('[data-input-scroll]'); const r = s && s.nextElementSibling; return r ? cs(r, 'padding-bottom') : null; })(), - }); -})() \ No newline at end of file diff --git a/scripts/fixtures/preview.html b/scripts/fixtures/preview.html deleted file mode 100644 index 526c9f7..0000000 --- a/scripts/fixtures/preview.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - -codex-ink preview - - - - - -
-
-

墨白终端 · 令牌重映射

- CODEX-INK - V1.0.0 - 2026-09-24 08:52 · 768px 列宽 -
-
-
-
-
You
-
把 Codex 方向的墨白终端皮肤落到 skin 包:零彩色 chrome,主按钮即墨块。
-
-
-
Dsh
-
-
-

墨白终端 · 令牌重映射完成

-

亮色底 #FFFFFF,暗色底 #212121;灰阶承担层级,彩色只留给状态。

-
-
skin.css+12 −3
-
+  --dsw-alias-brand-primary: var(--dsw-alias-label-primary);-  --dsw-alias-brand-primary: var(--dsw-static-deepseek-500);   --dsw-alias-bg-layer-2: var(--dsw-alias-bg-layer-2);
-
-
-
-
-
-
Dsh
-
-
-

36 组对比度全部本机实测通过,15 组 AAA;全界面彩色审计无白名单外色值。

-
-
-
codex-ink
-
墨白终端 · 36 组实测 · 无背景画
-
-
- PASS 36/36 - AAA 15 - 0 违规 -
-
-
-
-
-
-
-
-
-
给 DSH 发消息…
-
- SKIN v2 - #0D0D0D - - ⌘↵ - - -
-
-
-
-
- - diff --git a/scripts/fixtures/sidebar-align.probe.js b/scripts/fixtures/sidebar-align.probe.js deleted file mode 100644 index 9cda203..0000000 --- a/scripts/fixtures/sidebar-align.probe.js +++ /dev/null @@ -1,34 +0,0 @@ -(async () => { - const plugin = document.getElementById('codex-ui-theme'); - if (plugin === null) throw new Error('codex-ui theme style tag missing'); - const cls = JSON.parse(document.body.dataset.cls); - const q = (c) => '.' + CSS.escape(c); - const rect = (root, sel) => { - const el = root.querySelector(sel); - if (el === null) return null; - const r = el.getBoundingClientRect(); - const base = root.getBoundingClientRect(); - return { l: +(r.left - base.left).toFixed(2), r: +(r.right - base.left).toFixed(2), w: +r.width.toFixed(2) }; - }; - const read = (root) => ({ - newSessionIcon: rect(root, q(cls.S.newSession) + ' svg'), - newSessionText: rect(root, q(cls.S.newSessionLabel)), - panelIcon: rect(root, q(cls.S.panelGlyph)), - panelText: rect(root, q(cls.S.panelTitle)), - headerLabel: rect(root, q(cls.W.sectionLabel)), - projectIcon: rect(root, q(cls.R.projectRow) + ' ' + q(cls.R.folder)), - projectText: rect(root, q(cls.R.projectRow) + ' ' + q(cls.R.title)), - sessionText: rect(root, q(cls.R.sessionRow) + ' ' + q(cls.R.title)), - newSessionBox: rect(root, q(cls.S.newSession)), - panelRowBox: rect(root, q(cls.S.panelRow)), - projectRowBox: rect(root, q(cls.R.projectRow)) - }); - const left = document.querySelector('.plain'); - plugin.disabled = true; - await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); - const before = read(left); - plugin.disabled = false; - await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); - const after = read(document.querySelectorAll('.col')[1]); - return JSON.stringify({ before, after }); -})() diff --git a/scripts/fixtures/sidebar-surface.probe.js b/scripts/fixtures/sidebar-surface.probe.js deleted file mode 100644 index c552b60..0000000 --- a/scripts/fixtures/sidebar-surface.probe.js +++ /dev/null @@ -1,28 +0,0 @@ -(() => { - const cols = ['A', 'At']; - const out = { cols: {} }; - for (const id of cols) { - const root = document.getElementById('col-' + id); - if (root === null) { out.cols[id] = null; continue; } - const list = root.querySelector('[class$="_list"]'); - const fade = root.querySelector('[class$="_fade"]'); - const region = root.querySelector('[data-slot="sidebar.workspaces"]').parentElement; - const cs = getComputedStyle(list); - const fr = fade === null ? null : fade.getBoundingClientRect(); - out.cols[id] = { - maskImage: cs.maskImage, - maskSize: cs.maskSize, - maskPosition: cs.maskPosition, - maskRepeat: cs.maskRepeat, - webkitMaskImage: cs.webkitMaskImage, - fadeDisplay: fade === null ? 'missing' : getComputedStyle(fade).display, - fadeRect: fr === null ? null : { x: fr.x, y: fr.y, w: fr.width, h: fr.height }, - list: box(list), - region: box(region), - fill: getComputedStyle(root).getPropertyValue('--dsw-specific-sidebar-fill').trim(), - rootBg: getComputedStyle(root).backgroundColor, - }; - } - function box(el) { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height, bottom: b.bottom, right: b.right, left: b.left }; } - return JSON.stringify(out); -})() diff --git a/scripts/hero-verify.mjs b/scripts/hero-verify.mjs deleted file mode 100644 index bf4891c..0000000 --- a/scripts/hero-verify.mjs +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env node -/** - * hero-verify.mjs — ⑬·3 顶栏瘦身 + ⑭ 输入区(hero)验收。 - * 用真实 shipped 组件 CSS(ConversationRoot + InputBar)+ 本插件真实 theme.css - * 渲染两个夹具,断言计算样式并出证据图。 - * 用法:node scripts/hero-verify.mjs - */ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromePath, globalModules, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const EVID = join(WB, 'assets', 'screenshots'); -/** 夹具 HTML 是中间产物,落系统临时目录,不堆进 assets/screenshots/。 */ -const FIX = join(tmpdir(), 'codex-ui-fixtures'); -const ROOT = join(globalModules(), '@deepseek-ai'); -const CONV = join(ROOT, 'dsh-client-ui-conversation', 'lib', 'client.js'); -const SRC = fs.readFileSync(CONV, 'utf8'); - -/** 取某个 .module.css 的内联样式串:按 tagId 回退到最近的一条 `const cssN = "…";`。 */ -const cssFor = (moduleFile, src = SRC) => { - const i = src.indexOf(moduleFile); - if (i < 0) throw new Error('tagId not found: ' + moduleFile); - const decl = src.lastIndexOf('const css', i); - if (decl < 0) throw new Error('css declaration not found for ' + moduleFile); - const start = src.indexOf('"', decl) + 1; - const end = src.indexOf('";', start); - const text = src.slice(start, end).replaceAll('\\"', '"'); - if (!text.includes('{')) throw new Error('css extraction looks wrong for ' + moduleFile); - return text; -}; -/** 取某个 Module_css_default 类映射表。 */ -const mapFor = (varName, src = SRC) => { - const i = src.indexOf('var ' + varName + ' = {'); - if (i < 0) throw new Error('class map not found: ' + varName); - const start = src.indexOf('{', i); - const end = src.indexOf('};', start); - const body = src.slice(start, end + 1); - const out = {}; - for (const m of body.matchAll(/"([^"]+)":\s*"([^"]+)"/g)) out[m[1]] = m[2]; - return out; -}; - -const rootCss = cssFor('@deepseek-ai/dsh-client-ui-conversation/ConversationRoot.module.css'); -const barCss = cssFor('@deepseek-ai/dsh-client-ui-conversation/InputBar.module.css'); -const R = mapFor('ConversationRoot_module_css_default'); -const B = mapFor('InputBar_module_css_default'); -const theme = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); -/* ⑬·3c 放开顶栏两格后,槽里的条目改用**官方真实样式**建模:预设徽标来自 agent-preset 的 - AgentPresetLabel.module.css(class 前缀 PfFEtG_)。它只用 --dsw-* 语义令牌取色, - 所以“配色跟着现在的皮肤走”这条能被夹具直接量到。 */ -const AP = fs.readFileSync(join(ROOT, 'dsh-client-ui-agent-preset', 'lib', 'client.js'), 'utf8'); -const labelCss = cssFor('@deepseek-ai/dsh-client-ui-agent-preset/AgentPresetLabel.module.css', AP); -const L = mapFor('AgentPresetLabel_module_css_default', AP); -/** - * 徽标圆角的**宿主原值**:rc.1 在 .label 上写字面量 6px,rc.2 改成 var(--dsw-radius-xs)(宿主 ui-theme - * 给 4px)。断言防的是「本皮肤改写了它」,所以期望值从宿主源码里现取,不写死某一版的字面量。 - */ -const labelRadius = (() => { - const rule = new RegExp('\\.' + L.label + '\\{([^}]*)\\}').exec(labelCss); - const decl = rule === null ? null : /border-radius:([^;]+)/.exec(rule[1]); - if (decl === null) throw new Error('宿主徽标规则里找不到 border-radius:' + L.label); - const value = decl[1].trim(); - const token = /^var\((--[\w-]+)\)$/.exec(value); - if (token === null) return value; - const themeSrc = fs.readFileSync(join(ROOT, 'dsh-client-ui-theme', 'lib', 'client.js'), 'utf8'); - const declared = new RegExp(token[1] + ':\\s*([^;}]+)').exec(themeSrc); - if (declared === null) throw new Error('宿主 ui-theme 没有声明 ' + token[1]); - return declared[1].trim(); -})(); - -const chip = (label, glyph) => ''; - -const page = '' + - '' + - /* 宿主底样式复刻:加号常驻 --dsw-alias-bg-layer-2 圆底并全圆角;两个触发器默认透明。 - 触发器圆角旧夹具写 24px(2026-09-26 记录);2026-09-27 复核宿主 rc.2 源码, - dsh-client-ui-model-selection 的 .u91W7W_trigger 与 dsh-client-ui-permission-presets - 的 ._5Tq8wa_trigger 都是 border-radius: var(--dsw-radius-sm),而该令牌在 - dsh-client-ui-theme 里只有一处定义 = 8px。夹具按源码取 8px(本页不引宿主令牌层, - 所以写死字面量);这条基线的意义是「皮肤必须显式压过它」,不是复刻某个历史值。 */ - '' + - '' + - '
' + - '

⑬·3 会话分割线(会话名 + 槽位条目 + 右上角按钮;页签仍隐去)

' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '
' + - '✳ Agent-Evo RSI' + - '
' + - '
' + - '
工具
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '
' + - '

⑭ 输入框上方的卡片(hero:工作区 / 模式)

' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - chip('dsh-agency-agents', '📁') + chip('Standard mode', '⚙️') + chip('main', '⑂') + - '
' + - '
' + - '
' + - '
' + - '
' + - '
Describe what you want to build... / commands, @ files or sessions
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '

⑰ 宿主基线:同款哈希类名、但在 [data-composer-card] 之外(皮肤锚点不命中)

' + - '
' + - '
'; - -fs.mkdirSync(FIX, { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const htmlPath = join(FIX, 'hero-verify.html'); -fs.writeFileSync(htmlPath, page); - -const CHROME = chromePath(); -const PORT = 9347; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -// file:// 也吃磁盘缓存:夹具同路径反复改写时会读到旧内容,每次跑前清干净 profile。 -const PROFILE = tempDir('dsh-cdp-hero'); -fs.rmSync(PROFILE, { recursive: true, force: true }); -const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + PROFILE, '--no-first-run', '--disable-gpu', '--hide-scrollbars', '--window-size=1100,760', 'about:blank'], { stdio: 'ignore' }); -let info; for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } -if (!info) { child.kill(); throw new Error('headless chrome did not start'); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const pending = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }; -const send = (method, params = {}, sid) => new Promise((res, rej) => { const id = ++seq; pending.set(id, (m) => m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sid ? { sessionId: sid } : {}) })); }); -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 760, deviceScaleFactor: 2, mobile: false }, sessionId); -// file:// 也会走 Chrome 的磁盘缓存:同一个路径反复改写夹具时会读到旧版本,加查询串破缓存。 -await send('Page.navigate', { url: 'file:///' + htmlPath.replaceAll('\\', '/') + '?v=' + Date.now() }, sessionId); -await sleep(1600); -const probe = fs.readFileSync(join(HERE, 'fixtures', 'hero-verify.probe.js'), 'utf8'); -const r = await send('Runtime.evaluate', { expression: probe, returnByValue: true }, sessionId); -const headerRaw = r.result.value; -console.log(headerRaw); - -/* ── ⑰ composer 底部控件:默认无框、悬停才出现淡底 ──────────────────────── - 模型/权限触发器在真实 DOM 里没有 data-*,锚点是哈希类名的后缀 - (_add / _trigger,见 composer.css 头部备案),夹具按同一个后缀建模。 */ -const controls = '(() => {' - + 'const cs = (el) => el ? getComputedStyle(el) : null;' - + 'const read = (el) => el ? { w: Math.round(el.getBoundingClientRect().width), bg: cs(el).backgroundColor, radius: cs(el).borderTopLeftRadius } : null;' - + 'const pick = (suffix) => [...document.querySelectorAll("[data-composer-card] button")].find((b) => String(b.className).endsWith(suffix));' - + 'return JSON.stringify({' - + ' add: read(pick("_add")),' - + ' trig: read(pick("_trigger")),' - + ' trigRaw: read(document.querySelector("[data-host-baseline=trigger]")),' - + ' hoverFill: cs(document.querySelector("[data-composer-card]")).getPropertyValue("--dsw-codex-hover-fill").trim(),' - + '}); })()'; -const readControls = async () => { - const res = (await send('Runtime.evaluate', { expression: controls, returnByValue: true }, sessionId)).result; - if (res.value === undefined) throw new Error('controls probe failed: ' + JSON.stringify(res)); - return JSON.parse(res.value); -}; -const boxOf = async (sel) => (await send('Runtime.evaluate', { expression: '(() => { const b = document.querySelector(' + JSON.stringify(sel) + '); const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', returnByValue: true }, sessionId)).result.value; -/* 悬停要等两件事:浏览器把 :hover 算完,以及过渡跑完。 - 过渡期间 backgroundColor 的 alpha 是小数(实测 0.92 / 0.992), - 只判断「非透明」会读到中途值,所以改成「非透明且连续两次取样相同」才算落定。 */ -const hoverUntil = async (sel, pick) => { - const box = await boxOf(sel); - await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: box.x, y: box.y }, sessionId); - let prev = null; - for (let i = 0; i < 24; i += 1) { - await sleep(100); - const snap = await readControls(); - const now = pick(snap); - if (now !== 'rgba(0, 0, 0, 0)' && now === prev) return snap; - prev = now; - } - return readControls(); -}; -/* 焦点环 = Codex --color-border-focus(亮色 #339cff)。 - 先发一次 Tab 让浏览器进入键盘模态,再 focus(),按钮才会命中 :focus-visible。 */ -await send('Input.dispatchKeyEvent', { type: 'rawKeyDown', windowsVirtualKeyCode: 9, key: 'Tab' }, sessionId); -await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 9, key: 'Tab' }, sessionId); -await sleep(120); -const focusRing = (await send('Runtime.evaluate', { - expression: '(() => { const el = [...document.querySelectorAll("[data-composer-card] button")].find((b) => String(b.className).endsWith("_add"));' - + 'el.focus(); const cs = getComputedStyle(el); return cs.outlineColor + " " + cs.outlineWidth + " " + cs.outlineStyle; })()', - returnByValue: true, -}, sessionId)).result.value; -console.log('FOCUS outline ' + focusRing); - -const idle = await readControls(); -const addHover = await hoverUntil('[data-composer-card] button[class$="_add"]', (s) => s.add.bg); -const trigHover = await hoverUntil('[data-composer-card] button[class$="_trigger"]', (s) => s.trig.bg); -console.log('IDLE ' + JSON.stringify(idle)); -console.log('ADD-H ' + JSON.stringify(addHover.add)); -console.log('TRIG-H ' + JSON.stringify(trigHover.trig)); -const hdr = JSON.parse(headerRaw); -const checks = [ - /* ⑬·3c:两格放开后,槽里的条目必须真的渲染出来;页签仍旧隐去(参考图没有页签)。 */ - ['会话名仍在', hdr.header.title === true], - ['预设徽标可见(顶栏两格已放开)', hdr.header.presetBadge === true], - ['右侧工具条目可见(顶栏两格已放开)', hdr.header.utilityItem === true], - ['页签仍隐去', hdr.header.tabs === false], - ['右上角按钮仍在', hdr.header.corner === true], - ['徽标配色 = 本皮肤 --dsw-alias-label-tertiary', hdr.presetLabel.color === 'rgb(118, 118, 118)'], - /* 徽标圆角是**宿主自己的值**(rc.1 字面量 6px / rc.2 的 --dsw-radius-xs),不是本皮肤的刻度;这条断言防的是我们误改它。 */ - ['徽标圆角保持宿主原值 ' + labelRadius + '(未被皮肤改写)', hdr.presetLabel.radius === labelRadius], - ['徽标高 22px(官方控件高度,未改写)', hdr.presetLabel.h === 22], - ['徽标不填底色(令牌未定义即透明,不另配色)', hdr.presetLabel.bg === 'rgba(0, 0, 0, 0)'], - /* ⑭ 上栏条(hero 工作区行)只有上面两角是圆角,它探在卡片背后,与卡片同心叠放。 - 2026-09-28 同屏对照实测(DSH / Codex 两块同一缩放):Codex 条/卡 = 1.03, - 本皮肤旧值 条/卡 = 0.79(条 16 / 卡 20)—— 条比卡片紧 5px,就是那两条对不上的角弧。 - 现在条与卡片共用 --dsw-radius-card,比值恒为 1;这两条断言防的就是它们再次分家。 */ - ['上栏条与卡片同值(字面量)', hdr.heroRow.radius === hdr.card.radius], - ['上栏条与卡片同值(比值 = 1)', parseFloat(hdr.heroRow.radius) === parseFloat(hdr.card.radius)], - ['上栏条取 --dsw-radius-card(条 = 卡 = 令牌)', hdr.heroRow.radius === hdr.card.token && hdr.heroRow.radius === hdr.card.radius], - /* ⑭·2 输入区纵向留白:2026-09-28 用户同屏对照实测(DPR 1.25,两侧卡片角弧 29.6 / 29.5 完全同形 - ⇒ 同缩放)——Codex 卡 149 设备像素、本皮肤 147,总高本来就对齐;差的是**卡片上内衬** - (DSH 22.4 vs Codex 29.25)。故只把上内衬 8 → 12,编辑区与底衬回到宿主原值。 - 上一版把编辑区抬到 64 是拿「声明栈」推的,实测超了 35 设备像素(≈28 CSS px),已回退。 */ - ['输入区编辑区 min-height 44(宿主原值)', hdr.editor.minH === '44px'], - ['卡片上内衬 12(Codex 实测)', hdr.card.padTop === '12px'], - /* 输入卡必须**不透明**:Codex 的 --card 是叠在不透明画布上的,DSH 的卡浮在会话滚动内容之上, - 留 transparent 会让身后的文件列表透出来(2026-09-28 实测复现)。计算值里出现 "/" 即带 alpha。 */ - ['输入卡底不透明(不透视身后会话内容)', !hdr.card.bg.includes('/')], - ['底栏下内衬 8(宿主原值)', hdr.footPad === '8px'], - ['加号默认无底色框', idle.add.bg === 'rgba(0, 0, 0, 0)'], - ['加号悬停才出现淡底', addHover.add.bg === 'rgb(242, 242, 243)'], - ['加号圆形(999px)', idle.add.radius === '999px'], - ['触发器默认无底色框', idle.trig.bg === 'rgba(0, 0, 0, 0)'], - ['触发器悬停才出现淡底', trigHover.trig.bg === 'rgb(242, 242, 243)'], - /* 圆角不再交给宿主:Codex 的 composer chip 是全圆角胶囊(参考图实测 R = h/2), - 皮肤显式声明 999px。卡外同款类名的对照证明这是皮肤写的、不是宿主给的。 */ - ['触发器全圆角胶囊(999px)', idle.trig.radius === '999px'], - ['原生对照:卡外同款触发器仍是宿主 8px', idle.trigRaw.radius === '8px'], - ['悬停色标 = Codex 实测 #F2F2F3', idle.hoverFill === '#f2f2f3'], - ['焦点环 = Codex --color-border-focus', focusRing === 'rgb(51, 156, 255) 2px solid'], -]; -let fail = 0; -for (const [name, ok] of checks) { if (!ok) fail += 1; console.log((ok ? 'PASS ' : 'FAIL ') + name); } -await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 10, y: 10 }, sessionId); -await sleep(300); -const shot = await send('Page.captureScreenshot', { format: 'png', clip: { x: 0, y: 0, width: 1100, height: 760, scale: 2 } }, sessionId); -fs.writeFileSync(join(EVID, 'hero-verify.png'), Buffer.from(shot.data, 'base64')); -console.log('WROTE', join(EVID, 'hero-verify.png')); -ws.close(); child.kill(); -console.log(fail === 0 ? 'ALL PASS (' + checks.length + ')' : fail + ' FAIL'); -process.exit(fail === 0 ? 0 : 1); \ No newline at end of file diff --git a/scripts/host-paths.mjs b/scripts/host-paths.mjs deleted file mode 100644 index c351825..0000000 --- a/scripts/host-paths.mjs +++ /dev/null @@ -1,112 +0,0 @@ -/** - * host-paths.mjs — 解析验收脚本要读取的宿主路径。 - * - * 三个路径都跟机器有关,因此按同一优先级解析,仓库里不写死任何一台机器的绝对路径: - * 1. 环境变量 DSH_ASAR / DSH_GLOBAL_MODULES / DSH_CHROME - * 2. scripts/host.local.json(本机配置文件,已 gitignore,字段 asar / globalModules / chrome) - * 3. 常见安装位置扫描(%LOCALAPPDATA%/Programs、/Applications、Playwright 缓存、npm root -g) - * - * 找不到时抛出,并打印已尝试的候选与设置方法。 - * - * 本机配置示例(scripts/host.local.json): - * { "asar": "D:/path/to/DeepSeek Harness/resources/app.asar" } - */ -import fs from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import { homedir, tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = dirname(fileURLToPath(import.meta.url)); - -/** 夹具与 CDP 用户目录统一落系统临时目录。 */ -export const tempDir = (name) => join(tmpdir(), name); - -/** 本机配置:缺失或损坏都当空对象。 */ -const localConfig = (() => { - try { - return JSON.parse(fs.readFileSync(join(HERE, 'host.local.json'), 'utf8')); - } catch { - return {}; - } -})(); - -const env = (name) => { - const v = process.env[name]; - return v && v.trim() !== '' ? v.trim() : null; -}; -const firstExisting = (candidates) => candidates.find((p) => p && fs.existsSync(p)) ?? null; - -const localAppData = env('LOCALAPPDATA'); -const programFiles = env('ProgramFiles') ?? env('PROGRAMFILES'); - -/** 桌面壳的 app.asar。 */ -export function asarPath() { - const candidates = [ - env('DSH_ASAR'), - localConfig.asar, - localAppData && join(localAppData, 'Programs', 'DeepSeek Harness', 'resources', 'app.asar'), - localAppData && join(localAppData, 'Programs', 'deepseek-harness', 'resources', 'app.asar'), - programFiles && join(programFiles, 'DeepSeek Harness', 'resources', 'app.asar'), - '/Applications/DeepSeek Harness.app/Contents/Resources/app.asar', - ].filter(Boolean); - const hit = firstExisting(candidates); - if (!hit) { - throw new Error('找不到 app.asar。设 DSH_ASAR=,或写 scripts/host.local.json 的 asar 字段。已尝试:\n ' + candidates.join('\n ')); - } - return hit; -} - -/** 全局安装里 @deepseek-ai/dsh 的 node_modules(内含 @deepseek-ai/dsh-client-ui-*)。 */ -export function globalModules() { - const fromNpm = (() => { - try { - const root = execFileSync('npm root -g', { shell: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); - return root ? join(root, '@deepseek-ai', 'dsh', 'node_modules') : null; - } catch { - return null; - } - })(); - const candidates = [ - env('DSH_GLOBAL_MODULES'), - localConfig.globalModules, - fromNpm, - localAppData && join(localAppData, 'npm', 'node_modules', '@deepseek-ai', 'dsh', 'node_modules'), - ].filter(Boolean); - const hit = firstExisting(candidates); - if (!hit) { - throw new Error('找不到 @deepseek-ai/dsh 的 node_modules。设 DSH_GLOBAL_MODULES=,或写 scripts/host.local.json 的 globalModules 字段。已尝试:\n ' + candidates.join('\n ')); - } - return hit; -} - -/** Chromium 可执行文件:扫 Playwright 的浏览器缓存,取版本号最大的一个。 */ -export function chromePath() { - const explicit = env('DSH_CHROME') ?? localConfig.chrome; - if (explicit) { - if (!fs.existsSync(explicit)) throw new Error('DSH_CHROME / host.local.json 指向的文件不存在:' + explicit); - return explicit; - } - const roots = [ - env('PLAYWRIGHT_BROWSERS_PATH'), - localAppData && join(localAppData, 'ms-playwright'), - join(homedir(), '.cache', 'ms-playwright'), - join(homedir(), 'Library', 'Caches', 'ms-playwright'), - ].filter(Boolean); - const relative = [ - join('chrome-win64', 'chrome.exe'), - join('chrome-linux', 'chrome'), - join('chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'), - ]; - for (const root of roots) { - if (!fs.existsSync(root)) continue; - const dirs = fs.readdirSync(root) - .filter((d) => /^chromium-\d+$/.test(d)) - .sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1])); - for (const dir of dirs) { - const hit = firstExisting(relative.map((r) => join(root, dir, r))); - if (hit) return hit; - } - } - throw new Error('找不到 Playwright 的 Chromium。设 DSH_CHROME=,或先跑 npx playwright install chromium。已扫:\n ' + roots.join('\n ')); -} diff --git a/scripts/install-plugin.mjs b/scripts/install-plugin.mjs deleted file mode 100644 index 7e14101..0000000 --- a/scripts/install-plugin.mjs +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env node -/** - * install-plugin.mjs — 把 /plugin/codex-ui 安装进某个 profile(默认 web)。 - * - * 做四件事(默认只读体检,--write 才落盘): - * 1. 调 scripts/build.mjs,把 skins/codex-ink 的样式作用域化到 html[data-codex-ui], - * 写成 plugin/codex-ui/theme.css; - * 2. 把同一份 CSS 内嵌进 client.template.js,写成 plugin/codex-ui/client.js; - * 3. 在 profiles//node_modules 建立指向插件目录的 junction,使其可按包名解析; - * 4. 在 profiles//cordis.patch.yml 里确保存在 insert 条目(按 id 幂等)。 - * - * 用法: - * node scripts/install-plugin.mjs # 只读体检(默认 web profile) - * node scripts/install-plugin.mjs --write # 落盘 - * node scripts/install-plugin.mjs --profile desktop --write - * node scripts/install-plugin.mjs --bundle --write # 把包名写进 dsh.profile.bundles(并清掉冗余 insert) - * - * --profile 选目标 profile(默认 web)。桌面壳(Electron)的 profile 名是 - * desktop;它由 Electron 独占,CLI 的 `dsh plugin --profile desktop` 会被硬拒绝, - * 所以桌面端只能走本脚本直接落文件,改完需重启应用才生效。 - */ -import fs from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { homedir } from 'node:os'; -import { build } from './build.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const PLUGIN_DIR = WB; -const HOME = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh'); -/** 目标 profile 名:--profile ,缺省 web。 */ -const PROFILE_NAME = (() => { - const i = process.argv.indexOf('--profile'); - const v = i >= 0 ? process.argv[i + 1] : undefined; - return v !== undefined && v.trim() !== '' ? v.trim() : 'web'; -})(); -const PROFILE = join(HOME, 'profiles', PROFILE_NAME); -const INSTALL_DIR = join(PROFILE, 'vendor', 'codex-ui'); -const LINK = join(PROFILE, 'node_modules', 'codex-ui'); -const PROFILE_PKG = join(PROFILE, 'package.json'); -/** 装到 profile 的文件(client.template.js 是源码,不装)。 */ -const SHIPPED = ['package.json', 'index.js', 'cordis.patch.yml', 'client.js', 'theme.css']; -const PATCH = join(PROFILE, 'cordis.patch.yml'); -const write = process.argv.includes('--write'); -/** --bundle:把包名写进 profile 的 dsh.profile.bundles,而不是往 patch 里手写 insert。 */ -const wantBundle = process.argv.includes('--bundle'); -/* 去 BOM:PowerShell 的 Set-Content -Encoding UTF8 会写 BOM,JSON.parse 见到它会直接抛。 */ -let pkgText = fs.readFileSync(PROFILE_PKG, 'utf8').replace(/^\uFEFF/, ''); - -/** - * 把 codex-ui 追加进 dsh.profile.bundles,保留原文件的缩进与顺序。 - * @param text - profile package.json 原文。 - * @returns 新文本;已经在数组里则返回 null。 - */ -function addToBundles(text) { - const bundles = JSON.parse(text)?.dsh?.profile?.bundles; - if (!Array.isArray(bundles)) throw new Error('profile 没有 dsh.profile.bundles 数组:' + PROFILE_PKG); - if (bundles.includes('codex-ui')) return null; - const open = text.indexOf('[', text.indexOf('"bundles"')); - let depth = 0; - let i = open; - for (; i < text.length; i += 1) { - if (text[i] === '[') depth += 1; - else if (text[i] === ']') { depth -= 1; if (depth === 0) break; } - } - /* 闭合方括号前的空白先摘掉:数组末尾本来就没有逗号,靠它判断要不要补。 */ - const head = text.slice(0, i).trimEnd(); - const tail = text.slice(i); - const next = head + (head.endsWith('[') ? '' : ',') + '\n "codex-ui"\n ' + tail; - JSON.parse(next); /* 兜底:写坏 JSON 就当场抛,别落盘 */ - return next; -} -/** - * 往 profile 的 cordis.patch.yml 追加一个块式(`- insert:`)列表项。 - * - * dsh 0.1.7-rc.2 新建 profile 时写进去的是**流式空列表** `[]`(前面三行注释)。 - * 直接在它后面接 `- insert:` 会得到「流式节点 + 块式序列」两个顶层节点,YAML 解析失败, - * `dsh web` 在 boot 阶段就抛 `failed to parse overlay ... cordis.patch.yml`,整个实例起不来。 - * 所以:空的 `[]` 先摘掉再追加;非空的流式列表无法安全续写,直接拒绝,让人手动改。 - * @param text - 原文。 - * @param block - 以换行开头的块式列表项文本。 - * @returns 新文本。 - */ -function appendBlockList(text, block) { - const lines = text.split(/\r?\n/); - const content = lines.filter((line) => line.trim() !== '' && !line.trim().startsWith('#')); - if (content.some((line) => line.trim().startsWith('[') && !/^\[\s*\]$/.test(line.trim()))) { - throw new Error(PATCH + ' 是非空的流式列表([...]),无法安全追加块式条目;请改成块式 `- ` 列表后重跑。'); - } - const kept = lines.filter((line) => !/^\s*\[\s*\]\s*$/.test(line)); - return kept.join(text.includes('\r\n') ? '\r\n' : '\n').replace(/\s*$/, '') + block; -} - -if (wantBundle) { - if (!write) { - console.log('BUNDLE : 计划把 codex-ui 写进 dsh.profile.bundles(--write 才落盘)'); - } else { - const next = addToBundles(pkgText); - if (next === null) console.log('BUNDLE : 已在 dsh.profile.bundles 中,跳过'); - else { fs.writeFileSync(PROFILE_PKG, next); pkgText = next; console.log('BUNDLE : 已写入 dsh.profile.bundles'); } - } -} - -/* ── 生成 ──────────────────────────────────────────────────────────────── */ -/* 作用域化与打包只有一份实现,在 scripts/build.mjs;安装产物与 CI 校验同源。 */ -const { themeCss, clientJs } = build(); - -/* ── 报告 ──────────────────────────────────────────────────────────────── */ -console.log('profile : ' + PROFILE); -console.log('plugin : ' + PLUGIN_DIR); -console.log('theme.css: ' + themeCss.length + ' B'); -console.log('client.js: ' + clientJs.length + ' B'); -console.log('link : ' + LINK); -console.log('patch : ' + PATCH); -const patchText = fs.readFileSync(PATCH, 'utf8'); -/** - * 注册方式二选一,绝不能同时用 —— 这是本插件最容易踩的坑: - * bundle 路径:包名写进 profile package.json 的 dsh.profile.bundles, - * 由包自带 cordis.patch.yml 完成 insert(dshmarket 市场走这条); - * insert 路径:在 profile 的 cordis.patch.yml 里手写 - insert: - * (web profile 走这条:免 pnpm install、改完即热加载)。 - * 两条同时存在 → loader 出现两个同名条目,市场校验判 fail 并直接把插件停用, - * 日志原话:duplicate loader entry id "codex-ui" (2 rows) / 重复的 loader 条目 id "codex-ui"。 - */ -const inBundles = (() => { - try { - return (JSON.parse(pkgText)?.dsh?.profile?.bundles ?? []).includes('codex-ui'); - } catch { - return false; - } -})(); -/** profile patch 里 codex-ui 的冗余 - insert: 块数量(inBundles 时全部冗余)。 */ -const dupInserts = (() => { - const lines = patchText.split(/\r?\n/); - let n = 0; - for (let i = 0; i < lines.length - 2; i += 1) { - if (lines[i].trim() === '- insert:' && (lines[i + 1] ?? '').trim() === '- id: codex-ui') n += 1; - } - return n; -})(); -/* 允许缩进:insert 块里的 "- id: codex-ui" 带 4 空格缩进。 */ -const registered = inBundles || /^\s*-\s*id:\s*['"]?codex-ui['"]?\s*$/m.test(patchText); -console.log('注册方式: ' + (inBundles ? 'bundle(package.json 的 dsh.profile.bundles)' : 'insert(profile cordis.patch.yml)')); -console.log('patch 条目: ' + (registered ? '已注册' : '缺失(--write 时补)') + - (dupInserts > 0 ? ';- insert: 块 ' + dupInserts + ' 处' + (inBundles ? '(与 bundle 冲突,冗余,--write 时清除)' : '') : '')); - -if (!write) { - console.log('\nDRY RUN:加 --write 落盘(生成 client.js / theme.css / junction / patch 条目)'); - process.exit(registered ? 0 : 1); -} - -fs.writeFileSync(join(PLUGIN_DIR, 'theme.css'), themeCss); -fs.writeFileSync(join(PLUGIN_DIR, 'client.js'), clientJs); -console.log('WROTE client.js + theme.css'); - -// 同步到 profile 的 vendor/(正本仍在 workbench;vendor 是安装副本) -fs.mkdirSync(INSTALL_DIR, { recursive: true }); -for (const name of SHIPPED) { - fs.copyFileSync(join(PLUGIN_DIR, name), join(INSTALL_DIR, name)); -} -console.log('SYNCED ' + SHIPPED.length + ' 个文件 → ' + INSTALL_DIR); - -// node_modules/codex-ui → vendor/codex-ui(按包名可解析;不跑 pnpm) -const linkTarget = fs.existsSync(LINK) ? fs.realpathSync.native(LINK) : null; -if (linkTarget !== INSTALL_DIR) { - if (linkTarget !== null) { - execFileSync('powershell.exe', ['-NoProfile', '-Command', `Remove-Item -LiteralPath '${LINK}' -Force -Recurse`], { stdio: 'inherit' }); - } - fs.mkdirSync(dirname(LINK), { recursive: true }); - execFileSync('powershell.exe', ['-NoProfile', '-Command', - `New-Item -ItemType Junction -Path '${LINK}' -Target '${INSTALL_DIR}' | Out-Null`], { stdio: 'inherit' }); - console.log('LINKED ' + LINK + ' → ' + INSTALL_DIR); -} else { - console.log('junction 已指向 vendor,跳过'); -} - -// profile package.json 声明 link 依赖(让将来的 pnpm install 能重建这个链接) -if (!pkgText.includes('"dependencies"') || !/"codex-ui"\s*:/.test(pkgText)) { - /* 空 dependencies 时不能补尾逗号,否则写出非法 JSON —— 分两支插。 */ - const EMPTY_DEPS = /("dependencies"\s*:\s*\{)\s*\}/; - const patched = EMPTY_DEPS.test(pkgText) - ? pkgText.replace(EMPTY_DEPS, '$1\n "codex-ui": "link:./vendor/codex-ui"\n }') - : pkgText.replace(/("dependencies"\s*:\s*\{)/, '$1\n "codex-ui": "link:./vendor/codex-ui",'); - JSON.parse(patched); - fs.writeFileSync(PROFILE_PKG, patched); - console.log('DECLARED "codex-ui": "link:./vendor/codex-ui" 到 profile package.json'); -} else { - console.log('profile package.json 已声明,跳过'); -} - -/** - * 先清除冗余 - insert: 块 —— 但仅在 bundle 路径已覆盖时。 - * inBundles 为假时这段 insert 是唯一的注册来源,删掉等于把插件摘掉(web profile 就是这种形状)。 - */ -if (inBundles) { - const lines = patchText.split(/\r?\n/); - let removed = 0; - for (let i = 0; i < lines.length - 2; i += 1) { - if (lines[i].trim() === '- insert:' && (lines[i + 1] ?? '').trim() === '- id: codex-ui') { - lines.splice(i, 3); - removed += 1; - i -= 1; - } - } - if (removed > 0) { - fs.writeFileSync(PATCH, lines.join(patchText.includes('\r\n') ? '\r\n' : '\n')); - console.log('REMOVED ' + removed + ' 处冗余 - insert: 块(bundle 路径已覆盖)'); - } -} - -if (!registered) { - if (inBundles) { - console.log('已在 dsh.profile.bundles 中,无需 patch 条目'); - } else { - const block = [ - '', - '# codex-ui:独立的 Codex 化客户端插件(自带样式表,不依赖 dsh-web-all 皮肤系统)', - '# 注意:本 profile 未把 codex-ui 列进 dsh.profile.bundles,故此处手写 insert。', - '# 若将来它被列入 bundles,必须删掉本块 —— 两条同时存在会造成重复 loader 条目 id。', - '- insert:', - " - id: codex-ui", - " name: 'codex-ui'", - '', - ].join('\n'); - fs.writeFileSync(PATCH, appendBlockList(fs.readFileSync(PATCH, 'utf8'), block)); - console.log('APPENDED patch entry 到 cordis.patch.yml'); - } -} else if (inBundles) { - console.log('patch 条目已由 bundle 提供,跳过'); -} else { - console.log('patch 条目已存在,跳过'); -} -console.log('\nOK:插件已安装(若未自动热加载,重启 dsh web 生效)'); diff --git a/scripts/lib/cdp.mjs b/scripts/lib/cdp.mjs new file mode 100644 index 0000000..70475c8 --- /dev/null +++ b/scripts/lib/cdp.mjs @@ -0,0 +1,294 @@ +/** + * cdp.mjs — 无头 Chromium + Chrome DevTools Protocol,只用 Node 自带的 WebSocket / zlib。 + * + * const browser = await launch(); + * const page = await browser.newPage({ width: 1100, height: 700, dpr: 2 }); + * await page.setContent(html); + * const value = await page.evaluate((sel) => getComputedStyle(document.querySelector(sel)).color, '.x'); + * await browser.close(); + * + * 端口:--remote-debugging-port=0 交给系统挑空闲端口,从 profile 里的 DevToolsActivePort 读回来 —— + * 不占任何固定端口,多个进程可以同时跑。profile 是一次性的临时目录,关浏览器时删掉。 + * evaluate 收真函数:在页面里执行的是 `(${fn})(...args)`,所以函数体不能引用外层变量,要用的值走参数。 + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import zlib from 'node:zlib'; +import { chromePath } from './host.mjs'; + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** 起浏览器并接上 CDP。 */ +export async function launch({ chrome = chromePath() } = {}) { + const profile = fs.mkdtempSync(join(tmpdir(), 'codex-ui-cdp-')); + const child = spawn(chrome, ['--headless=new', '--remote-debugging-port=0', '--user-data-dir=' + profile, + '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--hide-scrollbars', 'about:blank'], { stdio: 'ignore' }); + let endpoint = null; + for (let i = 0; i < 100 && endpoint === null && child.exitCode === null; i += 1) { + await sleep(100); + try { + const [port, path] = fs.readFileSync(join(profile, 'DevToolsActivePort'), 'utf8').split('\n').map((s) => s.trim()); + if (port && path) endpoint = 'ws://127.0.0.1:' + port + path; + } catch { /* 还没写出来 */ } + } + if (endpoint === null) { + child.kill(); + throw new Error('浏览器没起来:' + chrome); + } + const ws = new WebSocket(endpoint); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = () => rej(new Error('连不上 ' + endpoint)); }); + + let seq = 0; + const waiting = new Map(); + const listeners = new Set(); + ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + if (msg.id !== undefined) { + const done = waiting.get(msg.id); + waiting.delete(msg.id); + done?.(msg); + } else { + for (const fn of listeners) fn(msg); + } + }; + /** 每条命令 60s 没回音就报错,卡死的页面不会把整个进程挂住。 */ + const send = (method, params = {}, sessionId) => new Promise((res, rej) => { + const id = ++seq; + const timer = setTimeout(() => { waiting.delete(id); rej(new Error(method + ': 60s 没有回应')); }, 60000).unref(); + waiting.set(id, (m) => { clearTimeout(timer); if (m.error) rej(new Error(method + ': ' + JSON.stringify(m.error))); else res(m.result); }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + }); + /* 进程被中断时别留下孤儿浏览器。 */ + const orphan = () => child.kill(); + process.once('exit', orphan); + + let pages = 0; + return { + /** 开一个新页签,视口与设备像素比由 Emulation 固定(与窗口大小无关)。 */ + async newPage({ width = 1280, height = 800, dpr = 2 } = {}) { + const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); + const page = createPage(send, sessionId, listeners, join(profile, 'page-' + (pages += 1) + '.html')); + await page.send('Page.enable'); + await page.send('Runtime.enable'); + await page.viewport(width, height, dpr); + return page; + }, + async close() { + process.removeListener('exit', orphan); + await Promise.race([send('Browser.close').catch(() => {}), sleep(2000)]); + ws.close(); + if (child.exitCode === null) await Promise.race([new Promise((r) => child.once('exit', r)), sleep(3000)]); + if (child.exitCode === null) child.kill(); + for (let i = 0; i < 10; i += 1) { + try { fs.rmSync(profile, { recursive: true, force: true }); break; } catch { await sleep(200); } + } + }, + }; +} + +function createPage(browserSend, sessionId, listeners, htmlFile) { + const send = (method, params) => browserSend(method, params, sessionId); + /** 页面里的未捕获异常与 console.error / warn,调用方自己决定要不要断言。 */ + const errors = []; + const logs = []; + listeners.add((msg) => { + if (msg.sessionId !== sessionId) return; + if (msg.method === 'Runtime.exceptionThrown') { + errors.push(String(msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text).slice(0, 300)); + } else if (msg.method === 'Runtime.consoleAPICalled') { + logs.push({ type: msg.params.type, text: msg.params.args.map((a) => String(a.value ?? a.description ?? '')).join(' ') }); + } + }); + const once = (method) => new Promise((resolve) => { + const fn = (msg) => { if (msg.sessionId === sessionId && msg.method === method) { listeners.delete(fn); resolve(msg.params); } }; + listeners.add(fn); + }); + const mouse = (type, x, y, buttons = 0) => send('Input.dispatchMouseEvent', { + type, x, y, button: type === 'mouseMoved' ? (buttons ? 'left' : 'none') : 'left', buttons, clickCount: 1, + }); + + const page = { + send, + errors, + logs, + viewport: (width, height, dpr) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: dpr, mobile: false }), + /** 在页面里执行一个函数(或表达式字符串),返回可序列化的结果;页面里抛错就在这里抛。 */ + async evaluate(fn, ...args) { + const expression = typeof fn === 'function' ? '(' + fn + ')(...' + JSON.stringify(args) + ')' : fn; + const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error('页面里抛错:' + (r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); + return r.result.value; + }, + /** 导航并等 load 事件。 */ + async goto(url) { + const loaded = once('Page.loadEventFired'); + await send('Page.navigate', { url }); + await Promise.race([loaded, sleep(30000)]); + }, + /** 把一段 HTML 写进临时文件再打开(file:// 页面,样式与脚本都内联)。 */ + async setContent(html, settle = 300) { + fs.writeFileSync(htmlFile, html); + await page.goto(pathToFileURL(htmlFile).href); + await page.evaluate(() => document.fonts.ready.then(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))))); + await sleep(settle); + }, + /** 等页面里某个条件成立(函数返回真值),超时返回 null。 */ + async waitFor(fn, { timeout = 20000, interval = 250 } = {}, ...args) { + for (const end = Date.now() + timeout; Date.now() < end; await sleep(interval)) { + const value = await page.evaluate(fn, ...args).catch(() => null); + if (value) return value; + } + return null; + }, + /** 下一帧(两次 rAF)之后。 */ + frame: () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))), + mouse, + move: (x, y, buttons = 0) => mouse('mouseMoved', x, y, buttons), + async click(x, y) { + await mouse('mouseMoved', x, y); + await mouse('mousePressed', x, y, 1); + await mouse('mouseReleased', x, y, 0); + }, + /** 按一次键:key 取 KeyboardEvent.key,keyCode 为 Windows 虚拟键码。 */ + async key(key, keyCode) { + await send('Input.dispatchKeyEvent', { type: 'keyDown', key, code: key, windowsVirtualKeyCode: keyCode }); + await send('Input.dispatchKeyEvent', { type: 'keyUp', key, code: key, windowsVirtualKeyCode: keyCode }); + }, + /** 模拟媒体特性,例 { 'prefers-reduced-motion': 'reduce' }。 */ + media: (features) => send('Emulation.setEmulatedMedia', { features: Object.entries(features).map(([name, value]) => ({ name, value })) }), + /** 元素中心点(视口坐标,取整);不存在或没有尺寸返回 null。 */ + center: (selector) => page.evaluate((sel) => { + const el = document.querySelector(sel); + const r = el === null ? null : el.getBoundingClientRect(); + return r === null || r.width === 0 ? null : [Math.round(r.left + r.width / 2), Math.round(r.top + r.height / 2)]; + }, selector), + /** 截图(PNG Buffer);给了 path 就同时写盘。clip 为 CSS 像素 { x, y, width, height, scale }。 + 后台页签不出帧,截图会一直等 —— 先提到前台。 */ + async screenshot({ clip, path } = {}) { + await send('Page.bringToFront'); + const { data } = await send('Page.captureScreenshot', { format: 'png', ...(clip ? { clip } : {}) }); + const png = Buffer.from(data, 'base64'); + if (path) fs.writeFileSync(path, png); + return png; + }, + /** 截图并解码成像素(设备像素坐标)。 */ + pixels: async (clip) => decodePng(await page.screenshot({ clip })), + }; + return page; +} + +/** + * 解 Chromium 截出来的 PNG(8 位、非隔行、RGB / RGBA),只用 zlib + 标准反滤波。 + * @returns {{ w: number, h: number, ch: number, data: Buffer, rgb: (x: number, y: number) => number[] }} + */ +export function decodePng(buf) { + if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('不是 PNG'); + let off = 8; + let w = 0, h = 0, depth = 0, type = 0; + const idat = []; + while (off + 8 <= buf.length) { + const len = buf.readUInt32BE(off); + const kind = buf.toString('ascii', off + 4, off + 8); + const data = buf.subarray(off + 8, off + 8 + len); + if (kind === 'IHDR') { + w = data.readUInt32BE(0); h = data.readUInt32BE(4); depth = data[8]; type = data[9]; + if (data[12] !== 0) throw new Error('不支持隔行 PNG'); + } else if (kind === 'IDAT') idat.push(data); + else if (kind === 'IEND') break; + off += 12 + len; + } + const ch = type === 6 ? 4 : type === 2 ? 3 : 0; + if (depth !== 8 || ch === 0) throw new Error('不支持的 PNG 格式:位深 ' + depth + ' 色彩类型 ' + type); + const raw = zlib.inflateSync(Buffer.concat(idat)); + const stride = w * ch; + const out = Buffer.alloc(h * stride); + for (let y = 0, p = 0; y < h; y += 1) { + const filter = raw[p]; + const line = raw.subarray(p + 1, p + 1 + stride); + p += 1 + stride; + const cur = out.subarray(y * stride, (y + 1) * stride); + const prev = y === 0 ? null : out.subarray((y - 1) * stride, y * stride); + for (let x = 0; x < stride; x += 1) { + const a = x >= ch ? cur[x - ch] : 0; + const b = prev === null ? 0 : prev[x]; + const c = prev === null || x < ch ? 0 : prev[x - ch]; + let pred = 0; + if (filter === 1) pred = a; + else if (filter === 2) pred = b; + else if (filter === 3) pred = (a + b) >> 1; + else if (filter === 4) { + const pp = a + b - c; + const pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); + pred = pa <= pb && pa <= pc ? a : pb <= pc ? b : c; + } else if (filter !== 0) throw new Error('未知的 PNG 滤波 ' + filter); + cur[x] = (line[x] + pred) & 0xff; + } + } + return { w, h, ch, data: out, rgb: (x, y) => { const o = (y * w + x) * ch; return [out[o], out[o + 1], out[o + 2]]; } }; +} + +/* ── 真 GUI:token 登录、首启弹层、按文字点 ─────────────────────────── */ + +/** + * 用 dsh web 打印的 token URL 换 dsh-auth cookie(303 + Set-Cookie),注入后打开首页。 + * Node 的 fetch 在 redirect:'manual' 下读不到 Set-Cookie,所以直接走 http。token 有存活期,401 就重启一次 dsh 换新的。 + */ +export async function login(page, tokenUrl) { + const url = new URL(tokenUrl); + const setCookie = await new Promise((resolve, reject) => { + http.get({ hostname: url.hostname, port: url.port, path: url.pathname + url.search }, (res) => { + resolve((res.headers['set-cookie'] ?? [''])[0]); + res.resume(); + }).on('error', reject); + }); + const pair = setCookie.split(';')[0]; + const eq = pair.indexOf('='); + if (eq <= 0) throw new Error('拿不到 dsh-auth cookie,token 可能已过期:' + tokenUrl); + await page.send('Network.enable'); + await page.send('Network.setCookie', { name: pair.slice(0, eq), value: pair.slice(eq + 1), domain: url.hostname, path: '/', httpOnly: true }); + await openHome(page, url.origin); + return url.origin; +} + +/** 打开首页并等应用骨架出来(侧栏槽位出现),再给插件与首启弹层一点时间。 */ +export async function openHome(page, origin) { + await page.goto(origin + '/'); + await page.waitFor(() => document.querySelector('[data-slot="sidebar"]') !== null, { timeout: 30000 }); + await sleep(2000); +} + +/** 按可见文字(或 aria-label)找可点元素的中心点;pattern 为正则源码。 */ +export function findByText(page, pattern, selector = 'button, a, [role=tab], [role=menuitem], [role=button], [role=switch]') { + return page.evaluate((source, sel) => { + const re = new RegExp(source); + const el = [...document.querySelectorAll(sel)] + .find((e) => (re.test(e.getAttribute('aria-label') ?? '') || re.test(e.textContent.trim())) && e.offsetParent !== null); + if (el === undefined) return null; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), text: (el.getAttribute('aria-label') || el.textContent.trim()).slice(0, 40) }; + }, pattern, selector); +} + +/** 按文字点一下(真鼠标事件);找不到返回 null。 */ +export async function clickByText(page, pattern, selector) { + const box = await findByText(page, pattern, selector); + if (box !== null) await page.click(box.x, box.y); + return box; +} + +/** 关掉首启引导弹层(内测声明 → 配置 API Key);每次加载都会再出现。返回关掉的按钮文字。 */ +export async function dismissOnboarding(page) { + const closed = []; + for (let round = 0; round < 5; round += 1) { + const box = await clickByText(page, '^(继续|稍后配置|跳过|知道了|Continue|Later|Skip)$', 'button'); + if (box === null) break; + closed.push(box.text); + await sleep(1200); + } + return closed; +} diff --git a/scripts/lib/checks.mjs b/scripts/lib/checks.mjs new file mode 100644 index 0000000..416280b --- /dev/null +++ b/scripts/lib/checks.mjs @@ -0,0 +1,34 @@ +/** + * checks.mjs — 所有验收脚本共用的断言记录与输出格式: + * + * PASS <断言名> [实测] + * FAIL <断言名> [实测] + * … + * ALL PASS (n/n) | FAILED (p/n) 退出码 0 / 1 + */ +export function checklist() { + const results = []; + const record = (name, ok, detail) => { + results.push({ name, ok: Boolean(ok) }); + const extra = detail === undefined || detail === null || detail === '' ? '' : ' [' + String(detail).replace(/\s+/g, ' ').slice(0, 200) + ']'; + console.log((ok ? 'PASS ' : 'FAIL ') + name + extra); + return Boolean(ok); + }; + return { + results, + /** 布尔断言。 */ + check: record, + /** 函数式断言:fn 抛错即失败(错误信息作为实测),返回值作为实测。 */ + attempt(name, fn) { + try { return record(name, true, fn()); } catch (e) { return record(name, false, e.message); } + }, + }; +} + +/** 打印总计并设退出码。 */ +export function summarize(results) { + const pass = results.filter((r) => r.ok).length; + console.log('\n' + (pass === results.length ? 'ALL PASS' : 'FAILED') + ' (' + pass + '/' + results.length + ')'); + process.exitCode = pass === results.length ? 0 : 1; + return pass === results.length; +} diff --git a/scripts/lib/host.mjs b/scripts/lib/host.mjs new file mode 100644 index 0000000..9709c43 --- /dev/null +++ b/scripts/lib/host.mjs @@ -0,0 +1,238 @@ +/** + * host.mjs — 找宿主、读宿主。夹具与探针只从这里拿宿主的东西。 + * + * 路径都跟机器有关,按同一优先级解析,仓库里不写死任何一台机器的绝对路径: + * 1. 环境变量 DSH_ASAR / DSH_GLOBAL_MODULES / DSH_CHROME + * 2. scripts/host.local.json(本机配置,已 gitignore;字段 asar / globalModules / chrome) + * 3. 常见安装位置扫描 + * 显式给出(1、2)却不存在的路径直接报错,不静默落到下一项。 + * + * 宿主 shipped 文件只有一个读取入口 openHost():桌面壳的 app.asar 与 npm 装出来的 node_modules + * 里是同一批 @deepseek-ai/* 包,夹具只写包内相对路径,不关心来源。 + */ +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const LOCAL_FILE = join(dirname(dirname(fileURLToPath(import.meta.url))), 'host.local.json'); +const local = (() => { + try { return JSON.parse(fs.readFileSync(LOCAL_FILE, 'utf8')); } catch { return {}; } +})(); +const env = (name) => process.env[name]?.trim() || null; + +/** 按顺序取第一个存在的候选;显式项(explicit)不存在就报错。 */ +function resolve(what, explicit, scans, hint) { + for (const [source, path] of explicit) { + if (path === null || path === undefined || path === '') continue; + if (!fs.existsSync(path)) throw new Error(source + ' 指向的' + what + '不存在:' + path); + return path; + } + const tried = []; + for (const scan of scans) { + for (const path of [scan()].flat()) { + if (!path) continue; + tried.push(path); + if (fs.existsSync(path)) return path; + } + } + throw new Error('找不到' + what + '。' + hint + ' 已尝试:\n ' + tried.join('\n ')); +} + +const localAppData = env('LOCALAPPDATA'); +const programFiles = [env('ProgramFiles'), env('ProgramFiles(x86)')].filter(Boolean); + +/** 宿主来源:app.asar(文件)或含 @deepseek-ai/* 的 node_modules(目录)。 */ +export function hostPath() { + return resolve('宿主', [ + ['DSH_ASAR', env('DSH_ASAR')], + ['DSH_GLOBAL_MODULES', env('DSH_GLOBAL_MODULES')], + ['host.local.json asar', local.asar], + ['host.local.json globalModules', local.globalModules], + ], [ + () => [ + localAppData && join(localAppData, 'Programs', 'DeepSeek Harness', 'resources', 'app.asar'), + localAppData && join(localAppData, 'Programs', 'deepseek-harness', 'resources', 'app.asar'), + programFiles[0] && join(programFiles[0], 'DeepSeek Harness', 'resources', 'app.asar'), + '/Applications/DeepSeek Harness.app/Contents/Resources/app.asar', + ], + /* npm -g 装的 dsh:只有真装了才算(全局 node_modules 本身总是存在的)。 */ + () => { + try { + const root = execFileSync('npm root -g', { shell: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + return fs.existsSync(join(root, '@deepseek-ai', 'dsh')) ? root : null; + } catch { return null; } + }, + ], '设 DSH_ASAR= 或 DSH_GLOBAL_MODULES=<含 @deepseek-ai 的 node_modules>,或写 scripts/host.local.json。'); +} + +/** Chromium 系浏览器:Playwright 缓存里版本号最大的一个,其次是常见的 Chrome / Edge 安装。 */ +export function chromePath() { + const playwright = () => [env('PLAYWRIGHT_BROWSERS_PATH'), localAppData && join(localAppData, 'ms-playwright'), + join(homedir(), '.cache', 'ms-playwright'), join(homedir(), 'Library', 'Caches', 'ms-playwright')] + .filter((root) => root && fs.existsSync(root)) + .flatMap((root) => fs.readdirSync(root).filter((d) => /^chromium-\d+$/.test(d)) + .sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1])) + .flatMap((d) => [join(root, d, 'chrome-win64', 'chrome.exe'), join(root, d, 'chrome-linux', 'chrome'), + join(root, d, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')])); + const installed = () => [ + ...programFiles.flatMap((root) => [join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'), join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe')]), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/google-chrome', + ]; + return resolve('浏览器', [['DSH_CHROME', env('DSH_CHROME')], ['host.local.json chrome', local.chrome]], + [playwright, installed], '设 DSH_CHROME=,或 npx playwright install chromium。'); +} + +/* ── 读 shipped 文件 ──────────────────────────────────────────────────── */ + +/** asar:头部是两层 Pickle([4][头 Pickle 长度] + [4][JSON 长度][JSON]),文件体从 8 + 头 Pickle 长度起。 */ +function asarFs(archive) { + const fd = fs.openSync(archive, 'r'); + const head = Buffer.alloc(16); + fs.readSync(fd, head, 0, 16, 0); + const json = Buffer.alloc(head.readUInt32LE(12)); + fs.readSync(fd, json, 0, json.length, 16); + const tree = JSON.parse(json.toString('utf8')); + const base = 8 + head.readUInt32LE(4); + const node = (path) => path.split('/').filter(Boolean).reduce((n, seg) => n?.files?.[seg], tree) ?? null; + return { + read(path) { + const n = node(path); + if (n === null || n.files !== undefined) return null; + if (n.unpacked) return fs.readFileSync(join(archive + '.unpacked', path), 'utf8'); + const buf = Buffer.alloc(n.size); + fs.readSync(fd, buf, 0, n.size, base + Number(n.offset)); + return buf.toString('utf8'); + }, + list: (path) => Object.keys(node(path)?.files ?? {}), + }; +} + +function dirFs(root) { + return { + read: (path) => { + try { return fs.readFileSync(join(root, path), 'utf8'); } catch { return null; } + }, + list: (path) => { + try { return fs.readdirSync(join(root, path)); } catch { return []; } + }, + }; +} + +/** + * 打开宿主。返回的 read / list 收包内路径('@deepseek-ai/dsh-client-ui-theme/lib/client.js'), + * 依次在这些根下找:asar 里的 dsh/node_modules;目录形态下 @deepseek-ai/dsh 自带的 node_modules(npm -g 的嵌套布局) + * 与目录本身(npm --prefix 的扁平布局)。 + */ +export function openHost(path = hostPath()) { + const isAsar = fs.statSync(path).isFile(); + const base = isAsar ? asarFs(path) : dirFs(path); + const roots = isAsar ? ['dsh/node_modules/', 'node_modules/'] : ['@deepseek-ai/dsh/node_modules/', '']; + const read = (rel) => { + for (const root of roots) { + const text = base.read(root + rel); + if (text !== null) return text; + } + return null; + }; + const list = (rel) => [...new Set(roots.flatMap((root) => base.list(root + rel)))]; + const version = (() => { + try { return JSON.parse(read('@deepseek-ai/dsh-client-ui-theme/package.json')).version; } catch { return '?'; } + })(); + return { + path, + label: (isAsar ? 'app.asar ' : 'node_modules ') + path + '(ui-theme ' + version + ')', + read, + list, + /** 必须存在的文件。 */ + file(rel) { + const text = read(rel); + if (text === null) throw new Error('宿主里没有 ' + rel + '(' + path + ')'); + return text; + }, + }; +} + +/* ── 从打包后的客户端 bundle 里取样式与类名映射 ──────────────────────── */ + +const ESCAPES = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', v: '\v', 0: '\0' }; + +/** 从引号处读一个 JS 字符串字面量并反转义(样式里有转义引号,不能用 indexOf 找结尾)。 */ +export function readStringLiteral(src, quoteIndex) { + const quote = src[quoteIndex]; + let out = ''; + for (let i = quoteIndex + 1; i < src.length; i += 1) { + const ch = src[i]; + if (ch === quote) return out; + if (ch !== '\\') { out += ch; continue; } + const next = src[++i]; + if (next === 'x') { out += String.fromCharCode(parseInt(src.slice(i + 1, i + 3), 16)); i += 2; } + else if (next === 'u' && src[i + 1] === '{') { const end = src.indexOf('}', i); out += String.fromCodePoint(parseInt(src.slice(i + 2, end), 16)); i = end; } + else if (next === 'u') { out += String.fromCharCode(parseInt(src.slice(i + 1, i + 5), 16)); i += 4; } + else if (next !== '\n') out += ESCAPES[next] ?? next; + } + throw new Error('字符串字面量没有闭合:@' + quoteIndex); +} + +const DECL = /(?:const|var|let)\s+([\w$]+)\s*=\s*"/g; + +/** 某个 .module.css 的内联样式:tagId 字符串之前最近的一条含花括号的字符串声明(tagId 自己也是一条,不含花括号)。 */ +export function cssFor(src, moduleFile) { + const at = src.indexOf(moduleFile); + if (at < 0) throw new Error('bundle 里找不到 ' + moduleFile); + let last = null; + for (const m of src.matchAll(DECL)) { + if (m.index > at) break; + const text = readStringLiteral(src, m.index + m[0].length - 1); + if (text.includes('{')) last = text; + } + if (last === null) throw new Error('找不到 ' + moduleFile + ' 的样式串'); + return last; +} + +/** 与 cssFor 相反:标记之后的第一条样式串(sidebar-terminal 把样式串放在模块标记后面)。 */ +export function cssAfter(src, marker) { + const at = src.indexOf(marker); + if (at < 0) throw new Error('bundle 里找不到 ' + marker); + for (const m of src.slice(at).matchAll(DECL)) { + const text = readStringLiteral(src, at + m.index + m[0].length - 1); + if (text.includes('{')) return text; + } + throw new Error(marker + ' 之后没有样式串'); +} + +/** CSS-Modules 类名映射 `var X_module_css_default = { key: "hash_key", … }`。 */ +export function mapFor(src, varName) { + const at = src.indexOf('var ' + varName + ' = {'); + if (at < 0) throw new Error('bundle 里找不到类名映射 ' + varName); + const start = src.indexOf('{', at); + let depth = 0; + let end = start; + for (; end < src.length; end += 1) { + if (src[end] === '{') depth += 1; + else if (src[end] === '}' && (depth -= 1) === 0) break; + } + return Object.fromEntries([...src.slice(start, end + 1).matchAll(/"([^"]+)":\s*"([^"]+)"/g)].map((m) => [m[1], m[2]])); +} + +/** + * ui-theme 的令牌层:shipped 组件样式消费 --dsw-*,皮肤只覆盖其中一部分,夹具必须先垫上宿主这层。 + * 变量名跨版本会变,所以按「`*_css_default` 且含 --dsw-」全量收集,顺序即层叠顺序。 + */ +export function themeLayers(host) { + const src = host.file('@deepseek-ai/dsh-client-ui-theme/lib/client.js'); + const parts = [...src.matchAll(DECL)].filter((m) => m[1].endsWith('_css_default')) + .map((m) => readStringLiteral(src, m.index + m[0].length - 1)).filter((text) => text.includes('--dsw-')); + if (parts.length === 0) throw new Error('ui-theme 里没有令牌层'); + return parts.join('\n'); +} + +/** dsh-web-frontend 的主样式表(文件名带构建哈希,按模式找)。 */ +export function frontendCss(host) { + const dir = '@deepseek-ai/dsh-web-frontend/dist/assets/'; + const names = host.list(dir).filter((n) => /^index-[\w-]+\.css$/.test(n)); + if (names.length === 0) throw new Error('宿主里没有 ' + dir + 'index-*.css'); + return names.map((n) => host.file(dir + n)).join('\n'); +} diff --git a/scripts/live-gui-probe.mjs b/scripts/live-gui-probe.mjs deleted file mode 100644 index 258e153..0000000 --- a/scripts/live-gui-probe.mjs +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env node -/** - * live-gui-probe.mjs — 对真实 GUI(dsh web 实例)取证:DOM 结构、计算样式、悬停态、 - * 模型菜单 pending 时序,并出图。 - * - * 为什么要有这一支:其它 *-verify.mjs 都是「从 app.asar 取真实 CSS + 按渲染代码复刻 DOM」 - * 的夹具,夹具里没有标题栏条、没有真的 AppFrame 网格、也没有真的 RPC。阴影层、分界线悬停、 - * pending 反馈这三类改动只能在真 GUI 上验收。 - * - * 用法: - * 1) dsh --profile web --port 3099 --no-open (终端会打印带 token 的 URL) - * 2) node scripts/live-gui-probe.mjs --url "http://127.0.0.1:3099/?token=..." [--out assets/screenshots/live-gui.png] [--dpr 1.5] - * - * token 可反复使用但有存活期(实测约半小时后 401);Node 的 fetch 不自动带 cookie, - * 本探针显式取 Set-Cookie 再经 CDP 注入。401 时重起一次 dsh web 换新 token 即可。 - * - * 期望值(2026-09-26 实测基线,DPR 2 / 1280x800 / 侧栏 280px / 标题栏 40px): - * centerShadow : rgba(13,13,13,.12) 0 0 0 1px, rgba(13,13,13,.05) 0 0 24px - * 上沿纵切 : y=40..77 由 238,244,249 渐变到 233,239,244;y=78 发丝线 205,211,215;y=80 起纯白 - * 左分界线柄 : 静默 opacity 0 / scale 1 0.35;悬停 opacity 1 / scale 1 / 宽 2px - * 模型菜单 : 点档位后 ~60ms 内 aria-busy=true、整列 disabled、勾选乐观前移、 - * 转圈 codex-ui-spin 在跑;~1.1s 后菜单关闭 - */ -import { spawn } from 'node:child_process'; -import http from 'node:http'; -import fs from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromePath, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const arg = (name, fallback) => { - const i = process.argv.indexOf('--' + name); - return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; -}; -const TOKEN_URL = arg('url', ''); -const OUT = arg('out', join(WB, 'assets', 'screenshots', 'live-gui.png')); -const CHROME = arg('chrome', '') || chromePath(); -const PORT = Number(arg('cdp-port', '9340')); -/* 设备像素比:默认 2;对 Windows 桌面壳做发丝线粗细对账时用 1.5(与实机 device-scale-factor 一致)。 */ -const DPR = Number(arg('dpr', '2')); -/* A 面 pending 阶段给模型往返加的时延(ms):本机往返 <60ms,采不到窗口;0 = 不加。 */ -const LATENCY = Number(arg('latency', '800')); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -if (!TOKEN_URL) { console.error('缺少 --url "http://127.0.0.1:/?token=..."'); process.exit(2); } - -/* ── 用 token 换 cookie(303 + Set-Cookie)────────────────────────────── - fetch 的 redirect:'manual' 在 Node 里返回 opaque 响应、读不到 Set-Cookie,故直接走 http。 */ -const origin = new URL(TOKEN_URL).origin; -const grab = (url) => new Promise((resolve, reject) => { - const u = new URL(url); - http.get({ hostname: u.hostname, port: u.port, path: u.pathname + u.search }, (res) => { - resolve((res.headers['set-cookie'] || [''])[0]); - res.resume(); - }).on('error', reject); -}); -const cookie = (await grab(TOKEN_URL)).split(';')[0]; -if (!cookie.includes('=')) { console.error('拿不到 dsh-auth cookie,token 可能已过期:' + TOKEN_URL); process.exit(2); } - -/* ── 起 headless Chromium 并接 CDP ────────────────────────────────────── */ -const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + PORT, - '--user-data-dir=' + tempDir('dsh-cdp-live-gui'), '--no-first-run', - '--no-default-browser-check', '--disable-gpu', '--window-size=1280,800', '--hide-scrollbars', 'about:blank'], { stdio: 'ignore' }); -let info; -for (let i = 0; i < 60 && !info; i += 1) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch { /* 未就绪 */ } if (!info) await sleep(250); } -if (!info) { console.error('Chromium 没起来:' + CHROME); process.exit(2); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const waiting = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); const fn = waiting.get(m.id); if (fn) { waiting.delete(m.id); fn(m); } }; -const send = (method, params = {}, sessionId) => new Promise((res, rej) => { - const id = ++seq; - waiting.set(id, (m) => (m.error ? rej(new Error(method + ': ' + JSON.stringify(m.error))) : res(m.result))); - ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); -}); -const evaluate = async (expression, sessionId) => (await send('Runtime.evaluate', { expression, returnByValue: true }, sessionId)).result.value; -const click = async (x, y, sessionId) => { - await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, sessionId); - await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, sessionId); - await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, sessionId); -}; - -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Runtime.enable', {}, sessionId); -await send('Network.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: DPR, mobile: false }, sessionId); -const eq = cookie.indexOf('='); -await send('Network.setCookie', { name: cookie.slice(0, eq), value: cookie.slice(eq + 1), domain: new URL(origin).hostname, path: '/', httpOnly: true }, sessionId); -await send('Page.navigate', { url: origin + '/' }, sessionId); -await sleep(12000); -/* 桌面壳标记由 preload 在 document-start 打;浏览器形态没有 preload,这里补打一次, - CSS 层等价 —— 这是本探针能在浏览器里验 Windows 桌面壳阴影层的前提。 */ -await evaluate('document.documentElement.setAttribute("data-windows-titlebar","");document.documentElement.setAttribute("data-platform","win32");' - + 'document.documentElement.style.setProperty("--dsh-windows-titlebar-height","40px");', sessionId); -await sleep(1000); -/* 新 profile(没配 API Key、没点过内测声明)一进来就是两层引导弹层,后面的点击全落在遮罩上 —— - 右栏打不开、模型菜单点不到,结果是三条右栏断言「实测 none」。与 settings-page-verify 同一套关法。 */ -for (let round = 0; round < 5; round += 1) { - const dismiss = await evaluate('(() => { const re = /^(继续|稍后配置|跳过|知道了|Continue|Later|Skip)$/;' - + 'const b = [...document.querySelectorAll("button")].find((e) => re.test(e.textContent.trim()) && e.offsetParent !== null);' - + 'if (!b) return null; const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), text: b.textContent.trim() }; })()', sessionId); - if (dismiss === null) break; - await click(dismiss.x, dismiss.y, sessionId); - await sleep(1200); - console.log('DISMISS ' + dismiss.text); -} - -const FRAME = '(() => {' - + 'const cs = (el, p) => (el ? getComputedStyle(el, p) : null);' - + 'const center = document.querySelector("div:has(> [data-slot=main])");' - + 'const side = document.querySelector("div:has(> [data-slot=sidebar])");' - + 'const read = (el) => el ? { opacity: cs(el, "::before").opacity, width: cs(el, "::before").width, scale: cs(el, "::before").scale, grad: /linear-gradient/.test(cs(el, "::before").backgroundImage) } : null;' - + 'const panel = document.querySelector("[data-sidebar-right-panel]");' - + 'return JSON.stringify({' - + ' centerShadow: cs(center).boxShadow,' - + ' centerRadius: cs(center).borderTopLeftRadius,' - + ' sidebarBorder: cs(side).borderRightWidth + " " + cs(side).borderRightColor,' - + ' panelShadow: panel ? cs(panel).boxShadow : null,' - + ' handleLeft: read(document.querySelector("[data-side=sidebar]")),' - + ' handleRight: read(document.querySelector("[data-side=rightbar]")),' - + '}); })()'; -const idle = JSON.parse(await evaluate(FRAME, sessionId)); -console.log('IDLE ' + JSON.stringify(idle)); -if (await evaluate('!!document.querySelector("[data-side=sidebar]")', sessionId)) { - await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 280, y: 400 }, sessionId); - await sleep(600); - console.log('HOVER ' + (await evaluate(FRAME, sessionId))); -} - -/* ── 打开右侧边栏,量面板两条边 ───────────────────────────────────────── */ -const openRight = await evaluate('(() => { const b = [...document.querySelectorAll("button")].find(x => x.getAttribute("aria-label") === "打开右侧边栏"); if (!b) return null; const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); -let panel = null; -if (openRight) { - await click(openRight.x, openRight.y, sessionId); - await sleep(1200); - panel = JSON.parse(await evaluate(FRAME, sessionId)); - console.log('PANEL ' + JSON.stringify(panel)); -} else { - console.log('SKIP 右侧边栏:找不到「打开右侧边栏」按钮'); -} - -/* ── 模型菜单 pending 时序 ────────────────────────────────────────────── */ -/* 没有打开的会话时,composer 的模型槽指向工作区选择器,菜单里没有推理档位。 - 先点一次新会话(幂等:已有会话时只是又开一个空会话),等 composer 就绪再量。 */ -const startedNew = await evaluate('(() => {' - + 'const b = [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === "新会话");' - + 'if (!b) return false; b.click(); return true; })()', sessionId); -if (startedNew) { console.log('NEW 点了新会话,等 composer 就绪'); await sleep(2500); } -const SLOT_BTN = '([...document.querySelectorAll("[data-slot]")]' - + '.find(e => e.getAttribute("data-slot") === "conversation.input.model")' - + ' || { querySelector: () => null }).querySelector("button")'; -let trigger = null; -/** pending 窗口内逐帧快照,供末尾断言。 */ -let pendingSnaps = []; -/** B 面(自建选择器)量到的结果;A 面(宿主菜单)时为 null。 */ -let bFace = null; -/* 「Codex 模型选择器」开着(默认)时席位由自建组件顶替,宿主菜单根本不会出现 —— - 这时量 B 面:顶替、弹层几何、拇指落点,再用键盘真改一档、看宿主自己的 store 是否跟着变,最后改回去。 - pending 转圈的时序在本机往返(<60ms)里采不到,那一段由 power-rail-verify.mjs 的慢往返夹具覆盖。 */ -for (let i = 0; i < 10 && bFace === null; i += 1) { - const has = await evaluate('!!document.querySelector("[data-slot=\\"conversation.input.model\\"] > .codex-mp-trigger")', sessionId); - if (has) bFace = {}; - else if (await evaluate('!!document.querySelector("[data-slot=\\"conversation.input.model\\"]")', sessionId)) break; - else await sleep(1000); -} -if (bFace !== null) { - const HOST_TITLE = '(() => { const b = document.querySelector("[data-slot=\\"conversation.input.model\\"] ._7KE1Ra_trigger, [data-slot=\\"conversation.input.model\\"] > :not(.codex-mp-trigger) button"); return b ? b.title : null; })()'; - const MINE = '(() => { const slot = document.querySelector("[data-slot=\\"conversation.input.model\\"]"); const mine = slot.querySelector(":scope > .codex-mp-trigger");' - + 'const r = mine.getBoundingClientRect(); const others = [...slot.children].filter((c) => c !== mine);' - + 'return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), h: r.height, title: mine.title,' - + ' hostHidden: others.length > 0 && others.every((c) => getComputedStyle(c).display === "none") }; })()'; - const mine = await evaluate(MINE, sessionId); - bFace.seat = mine; - await click(mine.x, mine.y, sessionId); - await sleep(900); - bFace.open = await evaluate('(() => { const p = document.querySelector(".codex-mp-popover"); const root = p && p.querySelector(".codex-mp-root");' - + 'if (!p || p.hidden) return null; const pr = p.getBoundingClientRect(); const rr = root.getBoundingClientRect();' - + 'const ticks = [...root.querySelectorAll(".codex-mp-tick")].map((t) => { const b = t.getBoundingClientRect(); return b.left + b.width / 2 - rr.left; });' - + 'const th = root.querySelector(".codex-mp-thumb-scale").getBoundingClientRect();' - + 'return { w: pr.width, rows: p.querySelectorAll(".codex-mp-row").length, ticks, now: Number(root.getAttribute("aria-valuenow")), thumb: th.left + th.width / 2 - rr.left, railH: rr.height }; })()', sessionId); - console.log('B-FACE ' + JSON.stringify({ seat: bFace.seat, open: bFace.open })); - if (bFace.open !== null && bFace.open.ticks.length >= 2) { - const before = await evaluate(HOST_TITLE, sessionId); - const dir = bFace.open.now > 0 ? ['ArrowLeft', 37, 'ArrowRight', 39] : ['ArrowRight', 39, 'ArrowLeft', 37]; - await evaluate('document.querySelector(".codex-mp-root").focus()', sessionId); - const press = async (k, code) => { - await send('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k, windowsVirtualKeyCode: code }, sessionId); - await send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code: k, windowsVirtualKeyCode: code }, sessionId); - }; - await press(dir[0], dir[1]); - await sleep(1500); - bFace.changed = { before, after: await evaluate(HOST_TITLE, sessionId), mine: await evaluate('document.querySelector(".codex-mp-trigger").title', sessionId), - rows: await evaluate('document.querySelectorAll(".codex-mp-row").length', sessionId) }; - await press(dir[2], dir[3]); - await sleep(1500); - bFace.restored = await evaluate(HOST_TITLE, sessionId); - console.log('B-FACE 改档 ' + JSON.stringify(bFace.changed) + ' 复原 ' + bFace.restored); - } - await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }, sessionId); - await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 }, sessionId); - await sleep(300); -} -for (let i = 0; i < 20 && !trigger && bFace === null; i += 1) { - trigger = await evaluate('(() => { const b = ' + SLOT_BTN + '; if (!b) return null;' - + 'const r = b.getBoundingClientRect(); if (!r.width) return null;' - + 'return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), text: b.textContent }; })()', sessionId); - if (!trigger) await sleep(1000); -} -if (!trigger && bFace === null) console.log('SKIP 模型菜单:composer 的 conversation.input.model 槽没出现(会话未就绪)'); -if (trigger) { - await click(trigger.x, trigger.y, sessionId); - await sleep(900); - const cell = await evaluate('(() => { const m = document.querySelector("body > div[role=menu]"); if (!m) return null;' - + 'const b = [...m.querySelectorAll("button")].find(x => /推理|Reasoning/.test(x.textContent)); if (!b) return null;' - + 'const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); - if (!cell) { - const dump = await evaluate('(() => { const m = document.querySelector("body > div[role=menu]"); if (!m) return "(没有菜单)"; const rows = [...m.querySelectorAll("button")].map((b) => b.textContent.trim()); return JSON.stringify({ busy: m.getAttribute("aria-busy"), rows }); })()', sessionId); - console.log('SKIP 模型菜单:菜单里找不到推理档位按钮;菜单内容 ' + dump); - } - if (cell) { - await click(cell.x, cell.y, sessionId); - await sleep(700); - const options = await evaluate('(() => [...document.querySelectorAll("body > div[role=menu] button[role=menuitemradio]")]' - + '.map(b => ({ text: b.textContent.trim(), checked: b.getAttribute("aria-checked") === "true" })))()', sessionId); - const pick = options.find((o) => !o.checked); - if (!pick) console.log('SKIP 模型菜单:所有档位都处于选中态,无待切换项'); - if (pick) { - const box = await evaluate('(() => { const b = [...document.querySelectorAll("body > div[role=menu] button[role=menuitemradio]")]' - + '.find(x => x.textContent.trim() === ' + JSON.stringify(pick.text) + '); const r = b.getBoundingClientRect();' - + 'return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); - /* 本机往返不到 60ms,pending 窗口在第一次采样前就结束了 —— 用 CDP 给这一次往返加时延, - 把它拉回真实机器上的量级(实测 ~1.1s),不然三条 pending 断言量的是空气。 */ - if (LATENCY > 0) await send('Network.emulateNetworkConditions', { offline: false, latency: LATENCY, downloadThroughput: -1, uploadThroughput: -1 }, sessionId); - await evaluate('window.__t0 = performance.now()', sessionId); - await click(box.x, box.y, sessionId); - pendingSnaps = []; - for (let i = 0; i < 6; i += 1) { - await sleep(60); - const snap = await evaluate('(() => {' - + 'const m = document.querySelector("body > div[role=menu]");' - + 'const row = m ? m.querySelector("button[role=menuitemradio][aria-checked=true]") : null;' - + 'const cellEl = row ? row.lastElementChild : null;' - + 'const spin = cellEl ? getComputedStyle(cellEl, "::after") : null;' - + 'const host = cellEl ? cellEl.querySelector("svg") : null;' - + 'const b = ' + SLOT_BTN + ';' - + 'return {' - + ' t: Math.round(performance.now() - window.__t0),' - + ' menu: !!m, busy: m ? m.getAttribute("aria-busy") : "-",' - + ' disabled: m ? m.querySelectorAll("button[role=menuitemradio]:disabled").length : 0,' - + ' spinner: spin && spin.content !== "none" ? spin.animationName + " " + spin.width : "none",' - + ' hostCheck: host ? getComputedStyle(host).visibility : "-",' - + ' cursor: row ? getComputedStyle(row).cursor : "-",' - + ' trigger: b ? b.textContent : "-",' - + '}; })()', sessionId); - pendingSnaps.push(snap); - console.log('PENDING ' + JSON.stringify(snap)); - if (!snap.menu) break; - } - if (LATENCY > 0) await send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }, sessionId); - /* 改回原档:探针不留现场。 */ - const original = options.find((o) => o.checked); - await sleep(Math.max(1500, LATENCY * 2)); - if (original) { - const reopen = await evaluate('(() => { const b = ' + SLOT_BTN + '; if (!b) return null; const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); - if (reopen !== null) { - await click(reopen.x, reopen.y, sessionId); - await sleep(700); - const cell2 = await evaluate('(() => { const m = document.querySelector("body > div[role=menu]"); const b = m && [...m.querySelectorAll("button")].find(x => /推理|Reasoning/.test(x.textContent)); if (!b) return null; const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); - if (cell2 !== null) { - await click(cell2.x, cell2.y, sessionId); - await sleep(600); - const back = await evaluate('(() => { const b = [...document.querySelectorAll("body > div[role=menu] button[role=menuitemradio]")].find(x => x.textContent.trim() === ' + JSON.stringify(original.text) + '); if (!b) return null; const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); - if (back !== null) { await click(back.x, back.y, sessionId); await sleep(1200); console.log('RESTORE 推理档位改回 ' + original.text); } - } - } - } - } - } -} - -/* ── 断言:只对这次真的量到的状态判定 ──────────────────────────────────── */ -let failed = 0; -const expect = (name, cond, actual) => { - if (cond) console.log('PASS ' + name); - else { failed += 1; console.error('FAIL ' + name + ' 实测 ' + actual); } -}; -expect('中列 0.5px 发丝线 + 24px 环境影', /\.5px/.test(idle.centerShadow) && /24px/.test(idle.centerShadow), idle.centerShadow); -expect('中列圆角 16px', idle.centerRadius === '16px', idle.centerRadius); -expect('侧栏无右边框', String(idle.sidebarBorder).startsWith('0px'), idle.sidebarBorder); -expect('左分界线无悬停渐变', idle.handleLeft !== null && idle.handleLeft.grad === false, JSON.stringify(idle.handleLeft)); -/* 右栏已开着时(宿主记得上一次状态),IDLE 那一帧就能量到面板,不必等 PANEL 阶段。 */ -const panelState = panel ?? (idle.panelShadow && idle.panelShadow !== 'none' ? idle : null); -if (panelState) { - expect('右栏面板左沿发丝线', /\.5px/.test(String(panelState.panelShadow)), panelState.panelShadow); - expect('右栏面板影只往上泄(负 spread)', /-12px 24px -12px/.test(String(panelState.panelShadow)), panelState.panelShadow); - expect('右分界线带渐变', panelState.handleRight !== null && panelState.handleRight.grad === true, JSON.stringify(panelState.handleRight)); -} -if (pendingSnaps.length > 0) { - expect('pending 窗口内 aria-busy', pendingSnaps.some((x) => x.busy === 'true'), JSON.stringify(pendingSnaps.map((x) => x.busy))); - expect('pending 窗口内整列 disabled', pendingSnaps.some((x) => x.disabled >= 2), JSON.stringify(pendingSnaps.map((x) => x.disabled))); - expect('pending 转圈在跑', pendingSnaps.some((x) => /codex-ui-spin/.test(x.spinner)), JSON.stringify(pendingSnaps.map((x) => x.spinner))); -} -if (bFace !== null) { - expect('B 面:自建触发器在席、宿主那一格隐藏', bFace.seat.hostHidden === true && Math.round(bFace.seat.h) === 28, JSON.stringify(bFace.seat)); - expect('B 面:弹层 254px、有模型行', bFace.open !== null && Math.round(bFace.open.w) === 254 && bFace.open.rows >= 1, JSON.stringify(bFace.open)); - if (bFace.open !== null && bFace.open.ticks.length >= 2) { - const t = bFace.open.ticks; - const step = (t.at(-1) - t[0]) / (t.length - 1); - expect('B 面:档位等距、首档在 14px', Math.abs(t[0] - 14) <= 1 && t.every((x, i) => Math.abs(x - (t[0] + step * i)) <= 1), JSON.stringify(t)); - expect('B 面:拇指落在生效档上', Math.abs(bFace.open.thumb - t[bFace.open.now]) <= 1, bFace.open.thumb + ' vs ' + t[bFace.open.now]); - expect('B 面:键盘改档写进宿主的 store(宿主自己的触发器标题跟着变)', bFace.changed.after !== null && bFace.changed.after !== bFace.changed.before && bFace.changed.mine === bFace.changed.after, JSON.stringify(bFace.changed)); - expect('B 面:改档往返中列表不清空', bFace.changed.rows === bFace.open.rows, bFace.changed.rows + ' / ' + bFace.open.rows); - expect('B 面:改回原档(探针不留现场)', bFace.restored === bFace.changed.before, bFace.restored + ' / ' + bFace.changed.before); - } -} -fs.mkdirSync(dirname(OUT), { recursive: true }); -const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); -fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64')); -console.log('SHOT ' + OUT); -ws.close(); child.kill(); -console.log('\n' + (failed === 0 ? 'RESULT PASS' : 'RESULT FAIL ' + failed) - + (bFace !== null ? '(模型位:B 面自建选择器)' : pendingSnaps.length === 0 ? '(模型菜单阶段未量到,见上面的 SKIP)' : '(模型位:A 面宿主菜单)')); -process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/live/gui.mjs b/scripts/live/gui.mjs new file mode 100644 index 0000000..7d27027 --- /dev/null +++ b/scripts/live/gui.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node +/** + * live/gui.mjs — 对真实 dsh web 实例取证:窗口阴影层、两条分界线、右栏面板,以及模型位。 + * + * node scripts/live/gui.mjs --url "http://127.0.0.1:3099/?token=…" [--shot ] [--dpr 2] [--latency 800] + * + * 为什么要有这一支:verify.mjs 的夹具里没有标题栏条、没有真的 AppFrame 网格、也没有真的 RPC; + * 阴影层、分界线悬停、模型位的往返只能在真 GUI 上验收。 + * 模型位两种形态:设置卡的「Codex 模型选择器」开着(默认)时量 B 面(自建组件顶替席位、几何、 + * 键盘改档写进宿主 store 再改回);关着时量 A 面宿主菜单的 pending 窗口 —— 本机往返不到 60ms, + * --latency 给这一次往返加时延(默认 800ms,0 = 不加),不然采不到窗口。 + * 浏览器形态没有桌面壳的 preload,这里补打 data-windows-titlebar / data-platform,CSS 层等价。 + */ +import fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { clickByText, dismissOnboarding, launch, login, sleep } from '../lib/cdp.mjs'; +import { checklist, summarize } from '../lib/checks.mjs'; + +const arg = (name, fallback) => { + const i = process.argv.indexOf('--' + name); + return i >= 0 && process.argv[i + 1] !== undefined ? process.argv[i + 1] : fallback; +}; +const TOKEN_URL = arg('url', ''); +const SHOT = arg('shot', join(tmpdir(), 'codex-ui-shots', 'live-gui.png')); +const DPR = Number(arg('dpr', '2')); +const LATENCY = Number(arg('latency', '800')); +if (TOKEN_URL === '') { + console.error('缺少 --url "http://127.0.0.1:/?token=…"(dsh web 启动时打印)'); + process.exit(2); +} + +/** 中列 / 侧栏 / 右栏面板与两条分界线柄的计算样式。 */ +function probeFrame() { + const cs = (el, p) => (el ? getComputedStyle(el, p) : null); + const line = (el) => (el ? { opacity: cs(el, '::before').opacity, width: cs(el, '::before').width, grad: /linear-gradient/.test(cs(el, '::before').backgroundImage) } : null); + const center = document.querySelector('div:has(> [data-slot=main])'); + const side = document.querySelector('div:has(> [data-slot=sidebar])'); + const panel = document.querySelector('[data-sidebar-right-panel]'); + return { + centerShadow: cs(center).boxShadow, + centerRadius: cs(center).borderTopLeftRadius, + sidebarBorder: cs(side).borderRightWidth + ' ' + cs(side).borderRightColor, + panelShadow: panel ? cs(panel).boxShadow : null, + handleLeft: line(document.querySelector('[data-side=sidebar]')), + handleRight: line(document.querySelector('[data-side=rightbar]')), + }; +} + +/** B 面:席位里我们的触发器、宿主那一格是否隐藏。 */ +function probeSeat() { + const slot = document.querySelector('[data-slot="conversation.input.model"]'); + const mine = slot.querySelector(':scope > .codex-mp-trigger'); + const r = mine.getBoundingClientRect(); + const others = [...slot.children].filter((c) => c !== mine); + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), h: r.height, hostHidden: others.length > 0 && others.every((c) => getComputedStyle(c).display === 'none') }; +} + +/** B 面弹层:宽、行数、档位圆点与拇指相对轨左沿的位置。 */ +function probePopover() { + const p = document.querySelector('.codex-mp-popover'); + if (!p || p.hidden) return null; + const root = p.querySelector('.codex-mp-root'); + const rr = root.getBoundingClientRect(); + const th = root.querySelector('.codex-mp-thumb-scale').getBoundingClientRect(); + return { + w: p.getBoundingClientRect().width, + rows: p.querySelectorAll('.codex-mp-row').length, + ticks: [...root.querySelectorAll('.codex-mp-tick')].map((t) => { const b = t.getBoundingClientRect(); return b.left + b.width / 2 - rr.left; }), + now: Number(root.getAttribute('aria-valuenow')), + thumb: th.left + th.width / 2 - rr.left, + }; +} + +/** 宿主自己的触发器标题(被隐藏的那一格):B 面改档后看宿主的 store 有没有跟着变。 */ +const hostTitle = () => document.querySelector('[data-slot="conversation.input.model"] > :not(.codex-mp-trigger) button')?.title ?? null; + +/** A 面:宿主模型触发器与菜单。 */ +const hostTrigger = () => { + const b = document.querySelector('[data-slot="conversation.input.model"] button'); + const r = b?.getBoundingClientRect(); + return r && r.width > 0 ? { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), text: b.textContent } : null; +}; +/** A 面 pending 窗口内的一帧快照。 */ +function pendingSnapshot(t0) { + const m = document.querySelector('body > div[role=menu]'); + const row = m?.querySelector('button[role=menuitemradio][aria-checked=true]') ?? null; + const spin = row ? getComputedStyle(row.lastElementChild, '::after') : null; + return { + t: Math.round(performance.now() - t0), + menu: m !== null, busy: m ? m.getAttribute('aria-busy') : '-', + disabled: m ? m.querySelectorAll('button[role=menuitemradio]:disabled').length : 0, + spinner: spin && spin.content !== 'none' ? spin.animationName + ' ' + spin.width : 'none', + }; +} +const MENU = 'body > div[role=menu] button'; +/** 菜单项按全文精确匹配(文字里可能有正则元字符)。 */ +const exact = (text) => '^' + text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$'; + +const browser = await launch(); +const { results, check } = checklist(); +try { + const page = await browser.newPage({ width: 1280, height: 800, dpr: DPR }); + await login(page, TOKEN_URL); + await page.evaluate(() => { + const html = document.documentElement; + html.setAttribute('data-windows-titlebar', ''); + html.setAttribute('data-platform', 'win32'); + html.style.setProperty('--dsh-windows-titlebar-height', '40px'); + }); + await sleep(1000); + /* 新 profile(没配 API Key、没点过内测声明)一进来就是两层引导弹层,后面的点击全落在遮罩上。 */ + for (const text of await dismissOnboarding(page)) console.log('DISMISS ' + text); + + const idle = await page.evaluate(probeFrame); + console.log('IDLE ' + JSON.stringify(idle)); + let panel = null; + if (await clickByText(page, '^打开右侧边栏$') !== null) { + await sleep(1200); + panel = await page.evaluate(probeFrame); + console.log('PANEL ' + JSON.stringify(panel)); + } else { + console.log('SKIP 右侧边栏:找不到「打开右侧边栏」按钮(可能已开着)'); + } + + /* 没有打开的会话时,composer 的模型槽指向工作区选择器,没有推理档位 —— 先开一个空会话。 */ + if (await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((x) => x.textContent.trim() === '新会话'); b?.click(); return b !== undefined; })) { + console.log('NEW 点了新会话,等 composer 就绪'); + await sleep(2500); + } + const face = await page.waitFor(() => { + const slot = document.querySelector('[data-slot="conversation.input.model"]'); + return slot === null ? null : slot.querySelector(':scope > .codex-mp-trigger') ? 'B' : 'A'; + }, { timeout: 10000, interval: 1000 }); + + let bFace = null; + let pendingSnaps = []; + if (face === 'B') { + bFace = { seat: await page.evaluate(probeSeat) }; + await page.click(bFace.seat.x, bFace.seat.y); + await sleep(900); + bFace.open = await page.evaluate(probePopover); + console.log('B-FACE ' + JSON.stringify(bFace)); + if (bFace.open !== null && bFace.open.ticks.length >= 2) { + const before = await page.evaluate(hostTitle); + const [there, back] = bFace.open.now > 0 ? [['ArrowLeft', 37], ['ArrowRight', 39]] : [['ArrowRight', 39], ['ArrowLeft', 37]]; + await page.evaluate(() => document.querySelector('.codex-mp-root').focus()); + await page.key(...there); + await sleep(1500); + bFace.changed = { before, after: await page.evaluate(hostTitle), mine: await page.evaluate(() => document.querySelector('.codex-mp-trigger').title), rows: await page.evaluate(() => document.querySelectorAll('.codex-mp-row').length) }; + await page.key(...back); + await sleep(1500); + bFace.restored = await page.evaluate(hostTitle); + console.log('B-FACE 改档 ' + JSON.stringify(bFace.changed) + ' 复原 ' + bFace.restored); + } + await page.key('Escape', 27); + await sleep(300); + } else if (face === 'A') { + const trigger = await page.waitFor(hostTrigger, { timeout: 20000, interval: 1000 }); + if (trigger !== null) { await page.click(trigger.x, trigger.y); await sleep(900); } + if (trigger === null || await clickByText(page, '推理|Reasoning', MENU) === null) { + console.log('SKIP 模型菜单:菜单里找不到推理档位按钮'); + } else { + await sleep(700); + const options = await page.evaluate(() => [...document.querySelectorAll('body > div[role=menu] button[role=menuitemradio]')].map((b) => ({ text: b.textContent.trim(), checked: b.getAttribute('aria-checked') === 'true' }))); + const pick = options.find((o) => !o.checked); + const original = options.find((o) => o.checked); + if (pick === undefined) console.log('SKIP 模型菜单:所有档位都处于选中态,无待切换项'); + else { + /* 用 CDP 给这一次往返加时延,把 pending 窗口拉回真实机器上的量级(实测 ~1.1s)。 */ + if (LATENCY > 0) await page.send('Network.emulateNetworkConditions', { offline: false, latency: LATENCY, downloadThroughput: -1, uploadThroughput: -1 }); + const t0 = await page.evaluate(() => performance.now()); + await clickByText(page, exact(pick.text), MENU); + for (let i = 0; i < 6; i += 1) { + await sleep(60); + const snap = await page.evaluate(pendingSnapshot, t0); + pendingSnaps.push(snap); + console.log('PENDING ' + JSON.stringify(snap)); + if (!snap.menu) break; + } + if (LATENCY > 0) await page.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); + /* 改回原档:探针不留现场。 */ + await sleep(Math.max(1500, LATENCY * 2)); + const again = await page.evaluate(hostTrigger); + if (original !== undefined && again !== null) { + await page.click(again.x, again.y); + await sleep(700); + if (await clickByText(page, '推理|Reasoning', MENU) !== null) { + await sleep(600); + if (await clickByText(page, exact(original.text), MENU) !== null) { await sleep(1200); console.log('RESTORE 推理档位改回 ' + original.text); } + } + } + } + } + } else { + console.log('SKIP 模型位:composer 的 conversation.input.model 槽没出现(会话未就绪)'); + } + + /* 只对这次真的量到的状态判定。 */ + check('中列 0.5px 发丝线 + 24px 环境影', /\.5px/.test(idle.centerShadow) && /24px/.test(idle.centerShadow), idle.centerShadow); + check('中列圆角 16px', idle.centerRadius === '16px', idle.centerRadius); + check('侧栏无右边框', String(idle.sidebarBorder).startsWith('0px'), idle.sidebarBorder); + check('左分界线无悬停渐变', idle.handleLeft !== null && idle.handleLeft.grad === false, JSON.stringify(idle.handleLeft)); + /* 右栏已开着时(宿主记得上一次状态),IDLE 那一帧就能量到面板。 */ + const panelState = panel ?? (idle.panelShadow && idle.panelShadow !== 'none' ? idle : null); + if (panelState !== null) { + check('右栏面板左沿发丝线', /\.5px/.test(String(panelState.panelShadow)), panelState.panelShadow); + check('右栏面板影只往上泄(负 spread)', /-12px 24px -12px/.test(String(panelState.panelShadow)), panelState.panelShadow); + check('右分界线带渐变', panelState.handleRight !== null && panelState.handleRight.grad === true, JSON.stringify(panelState.handleRight)); + } + if (pendingSnaps.length > 0) { + check('pending 窗口内 aria-busy', pendingSnaps.some((x) => x.busy === 'true'), JSON.stringify(pendingSnaps.map((x) => x.busy))); + check('pending 窗口内整列 disabled', pendingSnaps.some((x) => x.disabled >= 2), JSON.stringify(pendingSnaps.map((x) => x.disabled))); + check('pending 转圈在跑', pendingSnaps.some((x) => /codex-ui-spin/.test(x.spinner)), JSON.stringify(pendingSnaps.map((x) => x.spinner))); + } + if (bFace !== null) { + check('B 面:自建触发器在席、宿主那一格隐藏', bFace.seat.hostHidden === true && Math.round(bFace.seat.h) === 28, JSON.stringify(bFace.seat)); + check('B 面:弹层 254px、有模型行', bFace.open !== null && Math.round(bFace.open.w) === 254 && bFace.open.rows >= 1, JSON.stringify(bFace.open)); + if (bFace.open !== null && bFace.open.ticks.length >= 2) { + const ticks = bFace.open.ticks; + const step = (ticks.at(-1) - ticks[0]) / (ticks.length - 1); + check('B 面:档位等距、首档在 14px', Math.abs(ticks[0] - 14) <= 1 && ticks.every((x, i) => Math.abs(x - (ticks[0] + step * i)) <= 1), JSON.stringify(ticks)); + check('B 面:拇指落在生效档上', Math.abs(bFace.open.thumb - ticks[bFace.open.now]) <= 1, bFace.open.thumb + ' vs ' + ticks[bFace.open.now]); + check('B 面:键盘改档写进宿主的 store(宿主自己的触发器标题跟着变)', bFace.changed.after !== null && bFace.changed.after !== bFace.changed.before && bFace.changed.mine === bFace.changed.after, JSON.stringify(bFace.changed)); + check('B 面:改档往返中列表不清空', bFace.changed.rows === bFace.open.rows, bFace.changed.rows + ' / ' + bFace.open.rows); + check('B 面:改回原档(探针不留现场)', bFace.restored === bFace.changed.before, bFace.restored + ' / ' + bFace.changed.before); + } + } + console.log('模型位:' + (bFace !== null ? 'B 面自建选择器' : pendingSnaps.length > 0 ? 'A 面宿主菜单' : '未量到(见上面的 SKIP)')); + fs.mkdirSync(dirname(SHOT), { recursive: true }); + await page.screenshot({ path: SHOT }); + console.log('截图 ' + SHOT); +} catch (e) { + check('探针运行出错', false, e.stack ?? e.message); +} finally { + await browser.close(); +} +summarize(results); diff --git a/scripts/live/parity.mjs b/scripts/live/parity.mjs new file mode 100644 index 0000000..bd82fa2 --- /dev/null +++ b/scripts/live/parity.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +/** + * live/parity.mjs — 重构前后的「零视觉差」对账:把真 GUI 走过一串固定状态,逐元素存下全部计算样式,再逐项比。 + * + * node scripts/live/parity.mjs snap --url "http://127.0.0.1:3099/?token=…" --out <目录> [--only 状态,…] + * node scripts/live/parity.mjs diff <基线目录> <新目录> [--ignore 属性,…] [--detail 12] + * + * 用法:旧版本 snap 一份、改完再 snap 一份(同一个 profile、同一个窗口尺寸),diff 为 0 即外观没变,退出码 0。 + * 状态:首页(右栏开着)亮 / 悬停触发器 / 弹层开 / 深色 / 深色弹层、右栏收起亮暗、reduced-motion、宿主设置页亮暗、 + * 组合包卡亮暗、关掉 Codex 模型选择器后的宿主菜单与推理档位子菜单;最后把设置卡全部「重置」(开关回到默认开)。 + * 深色用直接打 body[data-ds-dark-theme] 的方式切(不写宿主偏好),标题栏属性补打成 Windows 桌面壳形态。 + */ +import fs from 'node:fs'; +import { join } from 'node:path'; +import { clickByText, dismissOnboarding, findByText, launch, login, openHome, sleep } from '../lib/cdp.mjs'; + +const [command, ...rest] = process.argv.slice(2); +const arg = (name, fallback) => { + const i = rest.indexOf('--' + name); + return i >= 0 && rest[i + 1] !== undefined ? rest[i + 1] : fallback; +}; + +/** 整棵 DOM 的计算样式:属性名列表存一次,每个元素只存值;外加 html / body 上所有自定义属性与 html 的属性。 */ +function dumpStyles() { + const names = new Set(); + for (const sheet of document.styleSheets) { + let text = ''; + try { for (const r of sheet.cssRules) text += r.cssText; } catch { /* 跨域样式表读不到 */ } + for (const m of text.matchAll(/--[\w-]+/g)) names.add(m[0]); + } + const custom = [...names].sort(); + const vars = (el) => { + const cs = getComputedStyle(el); + return Object.fromEntries(custom.map((n) => [n, cs.getPropertyValue(n).trim()]).filter(([, v]) => v !== '')); + }; + let props = null; + const row = (cs) => { + props ??= Array.from({ length: cs.length }, (_, i) => cs[i]); + return props.map((p) => cs.getPropertyValue(p)); + }; + const rows = []; + const walk = (el, path) => { + const rec = { k: path, c: typeof el.className === 'string' ? el.className.slice(0, 80) : '', v: row(getComputedStyle(el)) }; + for (const pseudo of ['::before', '::after']) { + const ps = getComputedStyle(el, pseudo); + if (ps.content !== 'none' && ps.content !== 'normal') rec[pseudo] = row(ps); + } + rows.push(rec); + const counts = {}; + for (const child of el.children) { + if (['SCRIPT', 'STYLE', 'LINK'].includes(child.tagName)) continue; + const tag = child.tagName.toLowerCase(); + counts[tag] = (counts[tag] ?? 0) + 1; + const slot = child.getAttribute('data-slot'); + walk(child, path + '>' + tag + (slot ? '[' + slot + ']' : '') + ':' + counts[tag]); + } + }; + walk(document.documentElement, 'html'); + return JSON.stringify({ props, rows, htmlVars: vars(document.documentElement), bodyVars: vars(document.body), htmlAttrs: [...document.documentElement.attributes].map((a) => a.name + '=' + a.value).sort() }); +} + +async function snap() { + const url = arg('url', ''); + const out = arg('out', ''); + const only = arg('only', '').split(',').filter(Boolean); + if (url === '' || out === '') { + console.error('用法:parity.mjs snap --url --out <目录> [--only 状态,…]'); + process.exit(2); + } + fs.mkdirSync(out, { recursive: true }); + const browser = await launch(); + try { + const page = await browser.newPage({ width: 1280, height: 800, dpr: 1 }); + const motion = (value) => page.media({ 'prefers-color-scheme': 'light', 'prefers-reduced-motion': value }); + await motion('no-preference'); + const origin = await login(page, url); + const corner = () => page.move(5, 795); + const save = async (name) => { + if (only.length > 0 && !only.includes(name)) return; + await sleep(900); + const text = await page.evaluate(dumpStyles); + fs.writeFileSync(join(out, name + '.json'), text); + console.log('SNAP ' + name.padEnd(28) + JSON.parse(text).rows.length + ' 个元素'); + }; + const dark = (on) => page.evaluate((flag) => { + document.body.toggleAttribute('data-ds-dark-theme', flag); + document.documentElement.style.colorScheme = flag ? 'dark' : 'light'; + }, on); + const home = async () => { + await openHome(page, origin); + await dismissOnboarding(page); + await page.evaluate(() => { + document.documentElement.setAttribute('data-windows-titlebar', ''); + document.documentElement.setAttribute('data-platform', 'win32'); + document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px'); + }); + await corner(); + await sleep(500); + }; + const tap = async (pattern, selector) => (await clickByText(page, pattern, selector)) !== null; + const openCard = async () => { + await tap('^插件$'); + await sleep(2000); + await tap('^查看 codex-ui$'); + await sleep(3000); + }; + + await home(); + if (await tap('^打开右侧边栏$')) await sleep(1200); + await save('home-light'); + const trig = await page.center('[data-slot="conversation.input.model"] > .codex-mp-trigger') ?? await page.center('[data-slot="conversation.input.model"] button'); + if (trig === null) throw new Error('输入区的模型位没出现'); + await page.move(...trig); + await sleep(400); + await save('home-light-hover-trigger'); + await page.click(...trig); + await sleep(700); + await corner(); + await save('picker-open-light'); + await page.key('Escape', 27); + await sleep(300); + await corner(); + await dark(true); + await sleep(500); + await save('home-dark'); + await page.click(...trig); + await sleep(700); + await corner(); + await save('picker-open-dark'); + await page.key('Escape', 27); + await sleep(300); + await dark(false); + await sleep(400); + if (await tap('^收起右侧边栏$')) { + await sleep(1200); + await corner(); + await save('rightbar-collapsed-light'); + await dark(true); + await save('rightbar-collapsed-dark'); + await dark(false); + await tap('^打开右侧边栏$'); + await sleep(1200); + } + await motion('reduce'); + await save('home-light-reduced-motion'); + await motion('no-preference'); + if (await tap('^设置$')) { + await sleep(2000); + await corner(); + await save('settings-light'); + await dark(true); + await save('settings-dark'); + await dark(false); + } + /* 组合包卡;然后关掉 Codex 模型选择器,量宿主原生菜单(A 面)。 */ + await home(); + await openCard(); + await corner(); + await save('bundle-card-light'); + await dark(true); + await save('bundle-card-dark'); + await dark(false); + await tap('^Codex 模型选择器$'); + await sleep(1500); + await home(); + const hostTrig = await page.center('[data-slot="conversation.input.model"] button'); + if (hostTrig !== null) { + await page.click(...hostTrig); + await sleep(900); + await corner(); + await save('host-menu-light'); + if (await tap('推理等级', 'body > div[role=menu] button')) { + await sleep(700); + await corner(); + await save('host-menu-effort-light'); + } + await page.key('Escape', 27); + await sleep(300); + await page.key('Escape', 27); + } + /* 复位:设置卡上的「重置」全点一遍,开关回到默认开。 */ + await home(); + await openCard(); + for (let i = 0; i < 12; i += 1) { + const box = await findByText(page, '^重置$', '.cx-row button'); + if (box === null) break; + await page.click(box.x, box.y); + await sleep(900); + } + console.log('复位后 Codex 模型选择器:' + await page.evaluate(() => document.querySelector('[aria-label="Codex 模型选择器"]')?.getAttribute('aria-checked'))); + } finally { + await browser.close(); + } +} + +function diff() { + const [a, b] = rest.filter((x, i) => !x.startsWith('--') && !(i > 0 && rest[i - 1].startsWith('--'))); + if (a === undefined || b === undefined) { + console.error('用法:parity.mjs diff <基线目录> <新目录> [--ignore 属性,…] [--detail 12]'); + process.exit(2); + } + const ignore = new Set(arg('ignore', '').split(',').filter(Boolean)); + const detail = Number(arg('detail', '12')); + let total = 0; + for (const file of fs.readdirSync(a).filter((f) => f.endsWith('.json')).sort()) { + if (!fs.existsSync(join(b, file))) { + console.log('MISSING ' + file + '(新目录里没有这个状态)'); + total += 1; + continue; + } + const A = JSON.parse(fs.readFileSync(join(a, file), 'utf8')); + const B = JSON.parse(fs.readFileSync(join(b, file), 'utf8')); + const diffs = []; + const rowsA = new Map(A.rows.map((r) => [r.k, r])); + const rowsB = new Map(B.rows.map((r) => [r.k, r])); + const missing = [...rowsA.keys()].filter((k) => !rowsB.has(k)); + const extra = [...rowsB.keys()].filter((k) => !rowsA.has(k)); + const compare = (va, vb, key) => { + if (!va && !vb) return; + if (!va || !vb) { diffs.push({ key, prop: '(伪元素有无)', a: !!va, b: !!vb }); return; } + const byName = new Map(B.props.map((p, i) => [p, vb[i]])); + A.props.forEach((p, i) => { if (!ignore.has(p) && va[i] !== byName.get(p)) diffs.push({ key, prop: p, a: va[i], b: byName.get(p) }); }); + }; + for (const [k, ra] of rowsA) { + const rb = rowsB.get(k); + if (!rb) continue; + if (ra.c !== rb.c) diffs.push({ key: k, prop: '(class)', a: ra.c, b: rb.c }); + compare(ra.v, rb.v, k); + compare(ra['::before'], rb['::before'], k + '::before'); + compare(ra['::after'], rb['::after'], k + '::after'); + } + for (const [tag, x, y] of [['html(vars)', A.htmlVars, B.htmlVars], ['body(vars)', A.bodyVars, B.bodyVars]]) { + for (const n of new Set([...Object.keys(x), ...Object.keys(y)])) if (x[n] !== y[n]) diffs.push({ key: tag, prop: n, a: x[n], b: y[n] }); + } + if (A.htmlAttrs.join() !== B.htmlAttrs.join()) diffs.push({ key: 'html', prop: '(attrs)', a: A.htmlAttrs.join(' '), b: B.htmlAttrs.join(' ') }); + const n = diffs.length + missing.length + extra.length; + total += n; + console.log((n === 0 ? 'SAME ' : 'DIFF ') + file.padEnd(34) + ' 元素 ' + A.rows.length + '/' + B.rows.length + (n ? ' 差异 ' + diffs.length + ' 缺 ' + missing.length + ' 多 ' + extra.length : '')); + if (n === 0) continue; + const byProp = new Map(); + for (const d of diffs) byProp.set(d.prop, (byProp.get(d.prop) ?? 0) + 1); + console.log(' 按属性:' + [...byProp].sort((x, y) => y[1] - x[1]).slice(0, 12).map(([p, c]) => p + '×' + c).join(', ')); + for (const d of diffs.slice(0, detail)) console.log(' ' + d.key.slice(-90) + ' | ' + d.prop + ': ' + String(d.a).slice(0, 70) + ' → ' + String(d.b).slice(0, 70)); + for (const k of missing.slice(0, 5)) console.log(' 缺 ' + k.slice(-100)); + for (const k of extra.slice(0, 5)) console.log(' 多 ' + k.slice(-100)); + } + console.log('\n差异合计 ' + total); + process.exitCode = total === 0 ? 0 : 1; +} + +if (command === 'snap') await snap(); +else if (command === 'diff') diff(); +else { + console.error('用法:parity.mjs snap --url --out <目录> | parity.mjs diff <基线目录> <新目录>'); + process.exit(2); +} diff --git a/scripts/live/settings.mjs b/scripts/live/settings.mjs new file mode 100644 index 0000000..86a92f7 --- /dev/null +++ b/scripts/live/settings.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +/** + * live/settings.mjs — 对真 GUI 取证:官方插件管理 → codex-ui 组合包页上那张设置卡。 + * + * node scripts/live/settings.mjs --url "http://127.0.0.1:3099/?token=…" [--shots <目录>] + * + * 为什么必须真 GUI:座位 plugins.bundle.config 只在插件管理页存在时才出现,卡片的注入面(scope / locale) + * 由那一页的 owner 提供,夹具复刻不出来。profile 要启用插件管理页(web 模板默认启用)。 + * 覆盖:9 行结构、默认不覆盖、半透明开关与强调色写入、模型选择器开关、主题分段(点下去立刻变色、 + * 逐帧无中间态)、刷新后仍在;收工把主题、覆盖与开关都复位。 + */ +import fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { clickByText, dismissOnboarding, findByText, launch, login, openHome, sleep } from '../lib/cdp.mjs'; +import { checklist, summarize } from '../lib/checks.mjs'; + +const arg = (name, fallback) => { + const i = process.argv.indexOf('--' + name); + return i >= 0 && process.argv[i + 1] !== undefined ? process.argv[i + 1] : fallback; +}; +const TOKEN_URL = arg('url', ''); +const SHOTS = arg('shots', join(tmpdir(), 'codex-ui-shots')); +if (TOKEN_URL === '') { + console.error('缺少 --url "http://127.0.0.1:/?token=…"(dsh web 启动时打印)'); + process.exit(2); +} +fs.mkdirSync(SHOTS, { recursive: true }); + +/* ── 页面里跑的函数 ───────────────────────────────────────────────────── */ + +const cardShape = () => { + const rows = [...document.querySelectorAll('.cx-row')]; + return { + form: document.querySelectorAll('.cx-form').length, + rows: rows.length, + labels: rows.map((r) => r.querySelector('.cx-row__label')?.textContent ?? ''), + overrideAttr: document.documentElement.hasAttribute('data-codex-ui-theme'), + }; +}; +const rootState = () => { + const root = document.documentElement; + const cs = getComputedStyle(root); + return { + overrideAttr: root.hasAttribute('data-codex-ui-theme'), + link: cs.getPropertyValue('--dsw-alias-link').trim(), + sidebar: cs.getPropertyValue('--dsw-alias-bg-sidebar').trim(), + skin: root.hasAttribute('data-codex-ui'), + tag: [...document.querySelectorAll('style')].find((s) => s.dataset.pluginCss === 'codex-ui/settings-override.css')?.textContent ?? '', + }; +}; +/** 深色令牌落在 body 上(不在 html),生效值一律读 body。 */ +const bodyVar = (name) => getComputedStyle(document.body).getPropertyValue(name).trim(); +const isDark = () => document.body.hasAttribute('data-ds-dark-theme'); +const selectedSegment = () => document.querySelector('[role=tab][aria-selected=true]')?.textContent.trim() ?? null; +const pickerSwitch = () => { + const el = document.querySelector('[aria-label="Codex 模型选择器"]'); + return el === null ? null : el.getAttribute('aria-checked') ?? String(el.checked); +}; +/** 按 aria-label 找元素中心。 */ +const boxByLabel = (label) => { + const r = document.querySelector('[aria-label="' + label + '"]')?.getBoundingClientRect(); + return r ? { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } : null; +}; +/** 设置卡里第一个可见的「重置」按钮。 */ +const resetButton = () => { + const b = [...document.querySelectorAll('.cx-row button')].find((x) => x.textContent.trim() === '重置' && x.offsetParent !== null); + const r = b?.getBoundingClientRect(); + return r ? { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) } : null; +}; +/** + * 逐帧采样「屏幕上真画出来的颜色」:沿祖先找第一个不透明底色(多数元素是透明的),记在三处取样点上。 + * 主题切换的闪是 1–3 帧的事,截图拍不到,这里按 rAF 记下来,回到 Node 侧找既不是起点也不是终点的帧。 + */ +const startSampler = () => { + const backdrop = (x, y) => { + for (let el = document.elementFromPoint(x, y); el !== null; el = el.parentElement) { + const m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(getComputedStyle(el).backgroundColor); + if (m !== null && (m[4] === undefined || Number(m[4]) > 0.99)) return m[1] + ',' + m[2] + ',' + m[3] + '@' + (el.getAttribute('data-slot') || String(el.className).slice(0, 20) || el.tagName); + } + return 'none'; + }; + window.__frames = []; + const loop = () => { + window.__frames.push([[640, 450], [1100, 450], [140, 700]].map(([x, y]) => backdrop(x, y)).join(' | ')); + if (window.__frames.length < 2000) window.__sampler = requestAnimationFrame(loop); + }; + loop(); +}; +const stopSampler = () => { cancelAnimationFrame(window.__sampler); return window.__frames; }; +/** 中间帧 = 既不是第一帧、也不是最后一帧的画面。 */ +const intermediate = (frames) => frames.filter((f, i) => i > 0 && i < frames.length - 1 && f !== frames[0] && f !== frames.at(-1)); + +/* ── 流程 ─────────────────────────────────────────────────────────────── */ + +const browser = await launch(); +const { results, check } = checklist(); +try { + const page = await browser.newPage({ width: 1280, height: 900, dpr: 1.5 }); + const origin = await login(page, TOKEN_URL); + const shot = (name) => page.screenshot({ path: join(SHOTS, 'settings-page-' + name + '.png') }); + const clickLabel = async (label) => { + const box = await page.evaluate(boxByLabel, label); + if (box !== null) await page.click(box.x, box.y); + return box; + }; + const visibleButtons = () => page.evaluate(() => [...document.querySelectorAll('button, a, [role=tab]')].filter((e) => e.offsetParent !== null) + .map((e) => e.getAttribute('aria-label') || e.textContent.trim().slice(0, 30)).filter(Boolean).slice(0, 60)); + /** 侧栏「插件」→ 组合包页「查看 codex-ui」。 */ + const openCard = async () => { + if (await clickByText(page, '^插件$') === null) throw new Error('侧栏里没有「插件」入口(插件管理页被停用了?)可见按钮:' + JSON.stringify(await visibleButtons())); + await sleep(2000); + if (await clickByText(page, '^查看 codex-ui$') === null) throw new Error('已安装分组里没有 codex-ui 的「查看」入口。可见按钮:' + JSON.stringify(await visibleButtons())); + await sleep(2500); + }; + /** 卡上的「重置」一个个点掉,直到一个不剩(每行一个,只在该行有覆盖时出现)。 */ + const resetAll = async () => { + for (let round = 0; round < 20; round += 1) { + const box = await page.evaluate(resetButton); + if (box === null) return; + await page.click(box.x, box.y); + await sleep(800); + } + }; + for (const text of await dismissOnboarding(page)) console.log('DISMISS ' + text); + await openCard(); + + /* 等宿主把设置文档送达:覆盖层要等 scope 有值才打属性,早读会把「还没到」当成「没有覆盖」。 + 然后清掉上一次留下的覆盖(反复点「重置」直到属性消失),起始态固定为亮色。 */ + await sleep(4000); + await resetAll(); + const segmentNow = await page.evaluate(selectedSegment); + if (segmentNow !== null && segmentNow !== '亮色' && await clickByText(page, '^亮色$') !== null) { + await sleep(1200); + console.log('RESET 主题回亮色(原 ' + segmentNow + ')'); + } + + const shape = await page.evaluate(cardShape); + check('组合包页上有配置卡', shape.form === 1, JSON.stringify(shape)); + check('九行:主题/强调色/背景/前景/UI 字体/代码字体/半透明侧边栏/Codex 模型选择器/对比度', shape.rows === 9 && shape.labels.length === 9, JSON.stringify(shape.labels)); + check('默认值下不产生覆盖(无 data-codex-ui-theme)', shape.overrideAttr === false, 'attr=' + shape.overrideAttr); + const bodyLink = await page.evaluate(bodyVar, '--dsw-alias-link'); + check('默认强调色仍是皮肤给的那一支(亮 #339cff / 暗 #0169cc)', ['#339cff', '#0169cc'].includes(bodyLink.toLowerCase()), 'link=' + bodyLink); + await shot('default'); + + /* 半透明侧边栏 */ + const toggled = await clickLabel('半透明侧边栏'); + check('找到半透明侧边栏开关', toggled !== null, JSON.stringify(toggled)); + await sleep(1500); + const afterToggle = await page.evaluate(rootState); + check('开覆盖后打上 data-codex-ui-theme', afterToggle.overrideAttr === true, JSON.stringify(afterToggle).slice(0, 300)); + check('侧栏填充转为半透明 rgba(255,255,255,0.72)', afterToggle.sidebar.replace(/\s/g, '') === 'rgba(255,255,255,0.72)', 'sidebar=' + afterToggle.sidebar); + check('覆盖样式表只写覆盖项', afterToggle.tag.includes('--dsw-alias-bg-sidebar') && afterToggle.tag.includes('data-codex-ui-theme'), afterToggle.tag.slice(0, 200)); + await shot('translucent'); + + /* 强调色:焦点 + 输入 + 回车 */ + const hexBox = await clickLabel('强调色 hex'); + check('找到强调色的十六进制输入框', hexBox !== null, JSON.stringify(hexBox)); + if (hexBox !== null) { + await page.send('Input.insertText', { text: '#ff0000' }); + await page.key('Enter', 13); + await sleep(1500); + const { link } = await page.evaluate(rootState); + check('回车提交后强调色生效', link.toLowerCase() === '#ff0000', 'link=' + link); + const marked = await page.evaluate(() => document.querySelector('.cx-row .cx-row__label span:nth-child(2)') !== null); + check('该行显示「已覆盖」徽标', marked === true, 'marked=' + marked); + await shot('accent'); + } + + /* 关掉「Codex 模型选择器」:刷新后在首页量宿主那一格是否复原(组合包页上没有输入区可量)。 */ + check('模型选择器开关默认开着', (await page.evaluate(pickerSwitch)) === 'true', 'checked=' + (await page.evaluate(pickerSwitch))); + const pickerOff = await clickLabel('Codex 模型选择器'); + check('找到模型选择器开关', pickerOff !== null, JSON.stringify(pickerOff)); + await sleep(1500); + check('开关写入后显示为关', (await page.evaluate(pickerSwitch)) === 'false', 'checked=' + (await page.evaluate(pickerSwitch))); + + /* 主题那一行写的是宿主主题偏好,整个应用一起变。 */ + const switches = []; + const segment = await page.evaluate(selectedSegment); + console.log('THEME 切换前 ' + JSON.stringify({ segment, base: await page.evaluate(bodyVar, '--dsw-alias-bg-base'), dark: await page.evaluate(isDark) })); + check('主题行存在且显示了当前偏好', segment !== null, 'segment=' + segment); + const toDark = await findByText(page, '^深色$'); + check('找到「深色」分段', toDark !== null, JSON.stringify(toDark)); + if (toDark !== null) { + await page.evaluate(startSampler); + /* 预览层:点下去要立刻变色,而不是等设置文档往返(那一次实测 780–824ms)。 */ + await page.click(toDark.x, toDark.y); + const t0 = Date.now(); + let flipMs = null; + for (let i = 0; i < 50 && flipMs === null; i += 1) { + if (await page.evaluate(isDark)) flipMs = Date.now() - t0; + else await sleep(40); + } + check('点下去立刻变色(≤300ms,预览层生效)', flipMs !== null && flipMs <= 300, 'flip=' + flipMs + 'ms'); + await sleep(1500); + switches.push(await page.evaluate(stopSampler)); + const settled = await page.evaluate(() => ({ preview: document.documentElement.getAttribute('data-codex-ui-preview'), scheme: document.documentElement.style.colorScheme })); + check('文档落地后预览标记被摘掉(没有卡在预览态)', settled.preview === null, JSON.stringify(settled)); + const dark = { + dark: await page.evaluate(isDark), + base: await page.evaluate(bodyVar, '--dsw-alias-bg-base'), + segment: await page.evaluate(selectedSegment), + swatch: await page.evaluate(() => [...document.querySelectorAll('.cx-swatch')].find((x) => x.getAttribute('aria-label') === '背景')?.value ?? null), + }; + check('切深色后整个应用进了深色(body[data-ds-dark-theme])', dark.dark === true, JSON.stringify(dark)); + /* 深色窗口背景自 0.5.6 起是 #111111(#181818 是表面 / 侧栏那一层)。 */ + check('深色底色生效 #111111', String(dark.base).toLowerCase() === '#111111', 'base=' + dark.base); + check('分段显示深色', dark.segment === '深色', 'segment=' + dark.segment); + check('下面三行改为编辑深色那一套(背景色块 = #111111)', String(dark.swatch).toLowerCase() === '#111111', 'swatch=' + dark.swatch); + await shot('dark'); + } + + /* 刷新后覆盖仍在(设置是持久化的,不是页面内存) */ + await openHome(page, origin); + await page.waitFor(() => document.documentElement.hasAttribute('data-codex-ui-theme'), { timeout: 10000 }); + const afterReload = await page.evaluate(rootState); + check('刷新后皮肤仍生效', afterReload.skin === true, JSON.stringify({ ...afterReload, tag: undefined })); + check('刷新后覆盖仍在(强调色 #ff0000)', afterReload.link.toLowerCase() === '#ff0000', 'link=' + afterReload.link); + check('刷新后侧栏半透明仍在', afterReload.sidebar.replace(/\s/g, '') === 'rgba(255,255,255,0.72)', 'sidebar=' + afterReload.sidebar); + check('刷新后主题仍是深色(写的是宿主偏好,不是页面状态)', (await page.evaluate(isDark)) === true); + const seatOff = await page.evaluate(() => { + const slot = document.querySelector('[data-slot="conversation.input.model"]'); + return { slot: slot !== null, ours: document.querySelectorAll('.codex-mp-trigger').length, host: slot === null ? null : [...slot.children].filter((c) => getComputedStyle(c).display !== 'none').length }; + }); + check('选择器关着:席位里没有自建触发器、宿主那一格可见', seatOff.slot === true && seatOff.ours === 0 && seatOff.host >= 1, JSON.stringify(seatOff)); + + /* 收工:走回组合包页,主题还给亮色(同样逐帧采样),覆盖与开关全部「重置」(开关回到默认开),探针不留现场。 */ + await dismissOnboarding(page); + if ((await page.evaluate(cardShape)).form === 0) await openCard(); + const toLight = await findByText(page, '^亮色$'); + if (toLight !== null) { + await page.evaluate(startSampler); + await page.click(toLight.x, toLight.y); + await sleep(1500); + switches.push(await page.evaluate(stopSampler)); + } + await resetAll(); + await sleep(400); + check('收工复位:模型选择器回到默认开', (await page.evaluate(pickerSwitch)) === 'true', 'checked=' + (await page.evaluate(pickerSwitch))); + const restored = { segment: await page.evaluate(selectedSegment), base: await page.evaluate(bodyVar, '--dsw-alias-bg-base') }; + check('收工复位回亮色', String(restored.base).toLowerCase() === '#ffffff' && restored.segment === '亮色', JSON.stringify(restored)); + /* 两趟都要真的采到了翻转(首帧 ≠ 末帧),中间帧才有意义。 */ + const flashes = switches.flatMap(intermediate); + const frames = switches.reduce((n, f) => n + f.length, 0); + check('主题切换(亮 → 暗 → 亮)逐帧没有中间态(有效底色只取起点或终点)', + switches.length === 2 && switches.every((f) => f[0] !== f.at(-1)) && flashes.length === 0, + '中间帧 ' + flashes.length + ' / 共 ' + frames + ' 帧' + (flashes.length ? ':' + flashes.slice(0, 3).join(' / ') : '')); + + const noisy = page.logs.filter((l) => l.type === 'error' || l.type === 'warning').map((l) => l.type + ': ' + l.text.slice(0, 300)).concat(page.errors); + if (noisy.length > 0) console.log('\n页面日志:\n ' + noisy.slice(0, 12).join('\n ')); +} catch (e) { + check('探针运行出错', false, e.stack ?? e.message); +} finally { + await browser.close(); +} +summarize(results); diff --git a/scripts/make-preview.mjs b/scripts/make-preview.mjs deleted file mode 100644 index f6b31fe..0000000 --- a/scripts/make-preview.mjs +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env node -/** - * codex-ink 预览图生成器。 - * - * node scripts/make-preview.mjs - * - * 读取 skins/codex-ink/skin.css 的真实令牌,注入 scripts/fixtures/preview.html, - * 产出亮/暗两份自包含 HTML 到 scripts/.preview-build/,再由 Edge headless - * 截图成 preview/light.png 与 preview/dark.png。 - * - * 预览是皮肤自身令牌的真实渲染,不是 live shell 截图。 - */ -import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { resolve, join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const here = dirname(fileURLToPath(import.meta.url)); -const root = resolve(here, '..'); -const skinDir = join(root, 'skins', 'codex-ink'); -const skinCss = readFileSync(join(skinDir, 'skin.css'), 'utf8'); -const page = readFileSync(join(here, 'preview', 'page.html'), 'utf8'); -const buildDir = join(here, '.preview-build'); -mkdirSync(buildDir, { recursive: true }); - -for (const theme of ['light', 'dark']) { - const html = page - .replace('/*{{SKIN_CSS}}*/', () => skinCss) - .replace('{{DARK_ATTR}}', theme === 'dark' ? ' data-ds-dark-theme' : ''); - const file = join(buildDir, theme + '.html'); - writeFileSync(file, html, 'utf8'); - console.log('wrote ' + file); -} diff --git a/scripts/make-verify-profile.mjs b/scripts/make-verify-profile.mjs deleted file mode 100644 index 00da091..0000000 --- a/scripts/make-verify-profile.mjs +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env node -/** - * make-verify-profile.mjs — 造一个一次性的 DSH profile,用来在真 GUI 里验收本插件。 - * - * 为什么需要它:组合包页的设置卡只在**插件管理页**存在时才出现,而插件管理页在常规 web - * profile 里被禁用(`- id: web-ui-plugin-manager / disabled: true`)。桌面壳里它开着,但桌面 - * profile 由 Electron 独占、CLI 进不去。所以验收用的 profile 要自己造一个:插件管理页开着、 - * 只挂本插件,且**不碰任何现有 profile**(node_modules 里的包用 junction 指向源 profile)。 - * - * node scripts/make-verify-profile.mjs [--home ] [--source ] [--name ] - * - * 之后: - * DSH_HOME= node scripts/install-plugin.mjs --profile --write - * DSH_HOME= dsh --profile --port 3098 --no-open - * node scripts/settings-page-verify.mjs --url "http://127.0.0.1:3098/?token=..." - */ -import fs from 'node:fs'; -import { homedir, tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const PLUGIN_DIR = dirname(HERE); -const arg = (name, fallback) => { - const i = process.argv.indexOf('--' + name); - return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : fallback; -}; -const HOME = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh'); -const SOURCE = arg('source', 'web'); -const NAME = arg('name', 'verify'); -const VERIFY_HOME = arg('home', join(tmpdir(), 'codex-ui-verify-home')); -const PROFILE = join(VERIFY_HOME, 'profiles', NAME); -const SOURCE_MODULES = join(HOME, 'profiles', SOURCE, 'node_modules'); - -if (!fs.existsSync(SOURCE_MODULES)) { - console.error('源 profile 的 node_modules 不存在:' + SOURCE_MODULES + '(用 --source 指定别的 profile)'); - process.exit(1); -} -fs.mkdirSync(join(PROFILE, 'node_modules'), { recursive: true }); - -/** 用 junction 把源 profile 的包目录借过来,避免复制几百 MB。 */ -const linkScope = (scope) => { - const from = join(SOURCE_MODULES, scope); - const to = join(PROFILE, 'node_modules', scope); - if (!fs.existsSync(from)) return scope + ': 源里没有,跳过'; - if (fs.existsSync(to)) return scope + ': 已存在'; - fs.symlinkSync(from, to, 'junction'); - return scope + ': junction'; -}; -const notes = ['@deepseek-ai', '@linxin666'].map(linkScope); - -fs.writeFileSync(join(PROFILE, 'package.json'), JSON.stringify({ - name: 'dsh-profile-' + NAME, - private: true, - dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@linxin666/dsh-web-all', 'codex-ui'] } }, - dependencies: { 'codex-ui': 'link:./vendor/codex-ui' }, -}, null, 2) + '\n'); -/* 插件管理页在 web profile 里是关的,验收 profile 要开着才有点得进去的组合包页。 */ -fs.writeFileSync(join(PROFILE, 'cordis.patch.yml'), '- id: web-ui-plugin-manager\n disabled: false\n'); - -console.log('DSH_HOME = ' + VERIFY_HOME); -console.log('profile = ' + PROFILE); -console.log('借用 : ' + notes.join(' | ')); -console.log('下一步 : DSH_HOME=' + VERIFY_HOME + ' node scripts/install-plugin.mjs --profile ' + NAME + ' --write'); -console.log('本插件目录: ' + PLUGIN_DIR); diff --git a/scripts/model-picker-verify.mjs b/scripts/model-picker-verify.mjs deleted file mode 100644 index 8f5cf03..0000000 --- a/scripts/model-picker-verify.mjs +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env node -/** - * model-picker-verify.mjs — ⑫ Codex 模型选择器的计算样式断言 + 出图。 - * - * 为什么用夹具而不是截屏:launch token 只在宿主进程内存里,headless 打不开真实 GUI。 - * 这里用 app.asar 的真实 shipped CSS(ui-theme 令牌 + model-selection 组件 + - * primitives MenuSurface/ShortcutKeys)+ 按渲染代码复刻的 DOM + 本插件 theme.css, - * 用 getComputedStyle 读值 —— 不接受「看起来像」。 - * - * 左列 = 原生 shipped(⑫ 段从皮肤里剥掉),右列 = 完整 codex-ui。 - * 断言只跑右列;左列供肉眼对照。 - * - * 用法:node scripts/model-picker-verify.mjs [--no-shot] - */ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { asarPath, chromePath, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const EVID = join(WB, 'assets', 'screenshots'); -/** 夹具 HTML 是中间产物,落系统临时目录,不堆进 assets/screenshots/。 */ -const FIX = join(tmpdir(), 'codex-ui-fixtures'); -const THEME = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); -const ASAR = asarPath(); - -/* ── asar 读取 ─────────────────────────────────────────────────────────── */ -const fd = fs.openSync(ASAR, 'r'); -const head = Buffer.alloc(16); fs.readSync(fd, head, 0, 16, 0); -const headerSize = head.readUInt32LE(12); -const hb = Buffer.alloc(headerSize); fs.readSync(fd, hb, 0, headerSize, 16); -const root = JSON.parse(hb.toString('utf8')); -const dataBase = 16 + headerSize; -function asarNode(p) { - let n = root; - for (const seg of p.split('/').filter(Boolean)) { n = n.files?.[seg]; if (!n) return null; } - return n; -} -function read(p) { - const n = asarNode(p); - if (!n || n.type === 'directory' || !Number.isFinite(Number(n.offset))) return null; - const buf = Buffer.alloc(n.size); - fs.readSync(fd, buf, 0, n.size, dataBase + Number(n.offset)); - return buf.toString('utf8'); -} -function readStringLiteral(src, quoteIndex) { - let out = ''; - for (let i = quoteIndex + 1; i < src.length; i += 1) { - const ch = src[i]; - if (ch === '\\') { out += src[i + 1]; i += 1; continue; } - if (ch === '"') return out; - out += ch; - } - throw new Error('unterminated string literal at ' + quoteIndex); -} -function cssFor(src, moduleFile) { - const i = src.indexOf(moduleFile); - if (i < 0) throw new Error('tagId not found: ' + moduleFile); - const re = /(?:const|var)\s+[\w$]+\s*=\s*"/g; - let last = null, hit; - while ((hit = re.exec(src)) !== null && hit.index < i) { - const text = readStringLiteral(src, hit.index + hit[0].length - 1); - if (text.includes('{')) last = text; - } - if (last === null) throw new Error('css declaration not found for ' + moduleFile); - return last; -} -function mapFor(src, varName) { - const i = src.indexOf('var ' + varName + ' = {'); - if (i < 0) throw new Error('class map not found: ' + varName); - const start = src.indexOf('{', i); - let depth = 0, end = -1; - for (let j = start; j < src.length; j += 1) { - if (src[j] === '{') depth += 1; - else if (src[j] === '}') { depth -= 1; if (depth === 0) { end = j; break; } } - } - const out = {}; - for (const m of src.slice(start, end + 1).matchAll(/"([^"]+)":\s*"([^"]+)"/g)) out[m[1]] = m[2]; - return out; -} - -const themeSrc = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-theme/lib/client.js'); -const modelSrc = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-model-selection/lib/client.js'); -const primSrc = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js'); -if (!themeSrc || !modelSrc || !primSrc) throw new Error('shipped bundles not found'); - -const themeLayers = (() => { - const re = /(?:const|var|let)\s+([\w$]+_css_default)\s*=\s*"/g; - const parts = []; - let m; - while ((m = re.exec(themeSrc)) !== null) { - const text = readStringLiteral(themeSrc, m.index + m[0].length - 1); - if (text.includes('--dsw-')) parts.push(text); - } - if (parts.length === 0) throw new Error('no ui-theme token layers found'); - return parts.join('\n'); -})(); - -const modelCss = cssFor(modelSrc, '@deepseek-ai/dsh-client-ui-model-selection/ModelSelect.module.css'); -const M = mapFor(modelSrc, 'ModelSelect_module_css_default'); -/* primitives 的 CSS 是独立 .module.css 文件、类名不哈希:直接取原文。 */ -const PRIM_DIR = 'dsh/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/'; -const rawCss = (f) => read(PRIM_DIR + f).replace(/^\s*}\s*/, ''); -const msCss = rawCss('MenuSurface.module.css'); -const MS = { surface: 'surface', material: 'material' }; -const skCss = rawCss('ShortcutKeys.module.css'); - -/* ── 复刻 DOM(与 model-selection client.js 渲染代码一致)────────────────── */ -const svg = (d, size = 16) => ''; -const CHEVRON_DOWN = 'M4 6l4 4 4-4'; -const CHEVRON_RIGHT = 'M6 4l4 4-4 4'; -const CHECK = 'M3 8l3 3 7-7'; -const SPINNER = ''; - -function surface(children, pos, busy) { - return ''; -} -const cell = (label, value) => - ''; -/* 行结构照真实 DOM:勾选列是空的 span,选中行里放宿主的勾选 svg。 - 宿主没有 pending 行,也没有 StateDot —— busy 窗口内是「整列 disabled + - 勾选乐观前移到新值」,转圈由 ⑫ 段自己补。 */ -const option = (name, selected, disabled) => - ''; - -const menuRoot = surface(cell('模型', 'DeepSeek V4.1 Flash') + cell('推理强度', 'High'), 'position:fixed;left:40px;top:120px'); -const menuModel = surface('
' - + '
DeepSeek
' - + option('DeepSeek V4.1 Flash', true, false) - + option('DeepSeek V4.1 Pro', false, false) - + '
', 'position:fixed;left:340px;top:120px'); -/* 真实 busy 快照(2026-09-26 headless 实测):点「Medium」后第 60ms 起, - aria-busy=true、四行全 disabled、aria-checked 已挪到 Medium,约 1.1s 后菜单关闭。 */ -const menuEffort = surface( - option('Provider default', false, true) - + option('High', false, true) - + option('Medium', true, true) - + option('Low', false, true), 'position:fixed;left:640px;top:120px', true); - -const trigger = '
' - + '
'; - -/* 菜单必须直挂 body(⑫ 段作用域选择器是 body > div[role=menu])。 - 所以出两页:before = theme 去掉 ⑫ 段(原生对照),after = 完整 theme。 */ -const i12 = THEME.indexOf('⑫ Codex 模型选择器'); -const i13 = THEME.indexOf('⑬ Codex 式输入区'); -if (i12 < 0 || i13 < 0) throw new Error('section markers missing in theme.css'); -const cut12 = THEME.lastIndexOf('/*', i12); -const cut13 = THEME.lastIndexOf('/*', i13); -const themeNo12 = THEME.slice(0, cut12) + THEME.slice(cut13); - -const page = (theme, label) => '' - + '' + label + '' - + '' - + '' - + '' - + '

' + label + '

' - + '
' + trigger + '
' - + menuRoot + menuModel + menuEffort - + ''; - -fs.mkdirSync(FIX, { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const beforePath = join(FIX, 'model-picker-before.html'); -const afterPath = join(FIX, 'model-picker-after.html'); -fs.writeFileSync(beforePath, page(themeNo12, '原生 shipped(无 ⑫ 段)')); -fs.writeFileSync(afterPath, page(THEME, '+ codex-ui ⑫ 段')); - -const PROBE = '(() => { try {' - + 'const cs = (el, p) => el ? getComputedStyle(el, p) : null;' - + 'const trig = document.querySelector(\'[data-slot="conversation.input.model"] button\');' - + 'const chev = trig.querySelector("svg:last-child");' - + 'const menu = document.querySelector("body > [role=menu]");' - + 'const material = menu.querySelector("[aria-hidden]");' - + 'const radio = document.querySelector("[role=menuitemradio]") || document.querySelector("[role=menuitemradio]");' - + 'const selected = document.querySelector("[role=menuitemradio][aria-checked=true]");' - + 'const checkCol = selected.lastElementChild;' - + 'const busyMenu = document.querySelector("body > [role=menu][aria-busy=true]");' - + 'const busyRow = busyMenu.querySelector("[role=menuitemradio][aria-checked=true]");' - + 'const busyCheck = busyRow.lastElementChild;' - + 'const busySpin = cs(busyCheck, "::after");' - + 'const hostCheck = busyCheck.querySelector("svg");' - + 'const cellEl = document.querySelector("[role=menuitem]");' - + 'return JSON.stringify({' - + 'trigger: { height: cs(trig).height, size: cs(trig).fontSize, chevron: cs(chev).display },' - + 'menu: { radius: cs(menu).borderTopLeftRadius, pad: cs(menu).paddingTop, footer: cs(menu, "::after").content, fill: cs(material).backgroundColor },' - + 'row: { minH: cs(radio).minHeight, radius: cs(radio).borderTopLeftRadius, prefix: cs(radio, "::before").content },' - + 'selected: { bg: cs(selected).backgroundColor, checkShown: cs(checkCol).display },' - + 'pending: { after: busySpin.content, w: busySpin.width, borderTop: busySpin.borderTopColor, anim: busySpin.animationName + " " + busySpin.animationDuration, hostCheck: hostCheck ? cs(hostCheck).visibility : "none", cursor: cs(busyRow).cursor },' - + 'pendingBusyRows: busyMenu.querySelectorAll("[role=menuitemradio]:disabled").length,' - + 'disabledOpacity: cs(document.querySelector("[role=menuitemradio][disabled]")).opacity,' - + 'cell: { minH: cs(cellEl).minHeight }' - + '}); } catch (e) { return JSON.stringify({ error: String(e) }); } })()'; - -const probePage = async (path, shotName) => { - const { targetId } = await send('Target.createTarget', { url: 'file:///' + path.replaceAll('\\', '/') }); - const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); - await send('Page.enable', {}, sessionId); - await send('Emulation.setDeviceMetricsOverride', { width: 1000, height: 760, deviceScaleFactor: 2, mobile: false }, sessionId); - await sleep(1000); - const out = { sessionId }; - const probeRaw = (await send('Runtime.evaluate', { expression: PROBE, returnByValue: true }, sessionId)).result; - out.value = JSON.parse(probeRaw.value); - if (shotName) { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - fs.writeFileSync(join(EVID, shotName), Buffer.from(shot.data, 'base64')); - console.log('SHOT assets/screenshots/' + shotName); - } - return out; -}; - -const args = process.argv.slice(2); -const noShot = args.includes('--no-shot'); -const CHROME = chromePath(); -const port = 9337; -const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + port, - '--user-data-dir=' + tempDir('dsh-cdp-profile5'), '--no-first-run', - '--no-default-browser-check', '--disable-gpu', '--window-size=1000,760', '--hide-scrollbars', 'about:blank'], { stdio: 'ignore' }); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -let info; -for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + port + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const pendingQ = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pendingQ.has(m.id)) { pendingQ.get(m.id)(m); pendingQ.delete(m.id); } }; -const send = (method, params = {}, sessionId) => new Promise((res, rej) => { const id = ++seq; pendingQ.set(id, (m) => m.error ? rej(new Error(method + ': ' + JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); - -const before = await probePage(beforePath, noShot ? null : 'model-picker-before.png'); -const after = await probePage(afterPath, noShot ? null : 'model-picker-after.png'); -const got = after.value; -console.log('BEFORE', JSON.stringify(before.value)); -console.log('AFTER ', JSON.stringify(got)); -const checks = [ - ['触发器高度 28(原生,不再压 24)', got.trigger.height === '28px'], - ['触发器字号 13', got.trigger.size === '13px'], - ['chevron 可见(不再隐藏)', got.trigger.chevron !== 'none'], - ['菜单圆角 18(浮层刻度 --dsw-radius-menu)', got.menu.radius === '18px'], - ['菜单内衬 5', got.menu.pad === '5px'], - ['无 TUI 页脚', got.menu.footer === 'none'], - ['菜单不透明白', got.menu.fill === 'rgb(255, 255, 255)'], - ['行高 28', got.row.minH === '28px'], - ['行圆角 13(同心:菜单 18 − 内衬 5)', got.row.radius === '13px'], - /* 同心闭环:改任何一边都要两边一起动。宿主原生就是同心的(16 − 4 = 12), - 这条防的是皮肤又把行圆角写死成某个跟面板不同源的刻度。 */ - ['原生对照:宿主菜单 16 / 行 12', before.value.menu.radius === '16px' && before.value.row.radius === '12px'], - ['同心闭环:行圆角 = 菜单圆角 − 内衬', parseFloat(got.row.radius) === parseFloat(got.menu.radius) - parseFloat(got.menu.pad)], - ['无编号前缀', got.row.prefix === 'none'], - ['选中行淡填充', got.selected.bg.includes('13, 13, 13')], - ['勾选列可见', got.selected.checkShown !== 'none'], - ['busy 窗口四行全 disabled', got.pendingBusyRows === 4], - ['pending 转圈已补位', got.pending.w === '12px' && got.pending.after === '""'], - ['pending 宿主导航勾选让位', got.pending.hostCheck === 'hidden'], - ['pending 动效在跑', got.pending.anim.startsWith('codex-ui-spin')], - ['pending 光标 progress', got.pending.cursor === 'progress'], - ['disabled 行不洗灰', got.disabledOpacity === '1'], -]; -let fail = 0; -for (const [name, ok] of checks) { if (!ok) fail += 1; console.log((ok ? 'PASS ' : 'FAIL ') + name); } -ws.close(); child.kill(); -console.log(fail === 0 ? 'ALL PASS (' + checks.length + ')' : fail + ' FAIL'); -process.exit(fail === 0 ? 0 : 1); diff --git a/scripts/pack-host-asar.mjs b/scripts/pack-host-asar.mjs deleted file mode 100644 index fd4da02..0000000 --- a/scripts/pack-host-asar.mjs +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env node -/** - * pack-host-asar.mjs — 没有桌面壳时,用 npm 装的宿主包拼一份夹具能读的 app.asar。 - * - * 夹具(*-verify.mjs)读的是 app.asar 里 dsh/node_modules/@deepseek-ai/<包>/… 的 shipped 源码。 - * 桌面壳与 npm 发布的是同一批 dsh-client-ui-* 包(0.1.7-rc.2 两边 dsh-web-frontend 的 - * index-DUvMhLle.css 哈希一致),所以把 npm 装下来的那一批按同样的目录打成 asar,夹具就能照跑。 - * - * npm install @deepseek-ai/dsh@0.1.7-rc.2 --prefix <临时目录> - * node scripts/pack-host-asar.mjs --from <临时目录>/node_modules [--out <路径>/app.asar] - * DSH_ASAR= DSH_GLOBAL_MODULES=<临时目录>/node_modules node scripts/hero-verify.mjs - * - * 只收 dsh-client-ui-* 与 dsh-web-frontend(夹具只读这些),跳过字体、图片与 source map。 - * - * 格式:标准 asar(Pickle 头 + JSON 目录 + 文件体)。JSON 用空格补到 4 字节整数倍 —— - * 夹具按 16 + JSON 长度 求数据起点,而规范是 8 + 头 Pickle 长度,两者只在 JSON 长度是 4 的倍数时相等。 - */ -import fs from 'node:fs'; -import { join } from 'node:path'; -import { tempDir } from './host-paths.mjs'; - -const arg = (name, fallback) => { - const i = process.argv.indexOf('--' + name); - return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : fallback; -}; -const FROM = arg('from', process.env.DSH_GLOBAL_MODULES ?? ''); -const OUT = arg('out', join(tempDir('codex-ui-host'), 'app.asar')); -const SCOPE = join(FROM, '@deepseek-ai'); -if (FROM === '' || !fs.existsSync(SCOPE)) { - console.error('找不到 ' + SCOPE + '。先 npm install @deepseek-ai/dsh --prefix <目录>,再用 --from <目录>/node_modules 指过来。'); - process.exit(2); -} - -const packages = fs.readdirSync(SCOPE).filter((name) => name.startsWith('dsh-client-ui-') || name === 'dsh-web-frontend').sort(); -const tree = { files: {} }; -const bodies = []; -let offset = 0; -const SKIP_DIR = new Set(['node_modules', 'fonts', 'langs']); -const SKIP_FILE = /\.(woff2?|ttf|otf|png|jpe?g|gif|webp|map|wasm)$/i; - -function add(abs, parts) { - let node = tree; - for (const seg of parts.slice(0, -1)) { - node.files[seg] ??= { files: {} }; - node = node.files[seg]; - } - const size = fs.statSync(abs).size; - node.files[parts.at(-1)] = { size, offset: String(offset) }; - bodies.push(abs); - offset += size; -} -function walk(abs, parts) { - for (const entry of fs.readdirSync(abs, { withFileTypes: true })) { - if (entry.isDirectory()) { - if (!SKIP_DIR.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]); - } else if (entry.isFile() && !SKIP_FILE.test(entry.name)) { - add(join(abs, entry.name), [...parts, entry.name]); - } - } -} -for (const name of packages) walk(join(SCOPE, name), ['dsh', 'node_modules', '@deepseek-ai', name]); - -let json = JSON.stringify(tree); -while (Buffer.byteLength(json) % 4 !== 0) json += ' '; -const header = Buffer.from(json); -const head = Buffer.alloc(16); -head.writeUInt32LE(4, 0); -head.writeUInt32LE(8 + header.length, 4); -head.writeUInt32LE(4 + header.length, 8); -head.writeUInt32LE(header.length, 12); -fs.mkdirSync(join(OUT, '..'), { recursive: true }); -const fd = fs.openSync(OUT, 'w'); -fs.writeSync(fd, head); -fs.writeSync(fd, header); -for (const file of bodies) fs.writeSync(fd, fs.readFileSync(file)); -fs.closeSync(fd); - -const version = (() => { - try { return JSON.parse(fs.readFileSync(join(SCOPE, 'dsh', 'package.json'), 'utf8')).version; } catch { return '?'; } -})(); -console.log('宿主版本 : @deepseek-ai/dsh ' + version); -console.log('收录 : ' + packages.length + ' 个包 · ' + bodies.length + ' 个文件 · ' + offset + ' B'); -console.log('写出 : ' + OUT); -console.log('接着 : DSH_ASAR="' + OUT + '" DSH_GLOBAL_MODULES="' + FROM + '" node scripts/<夹具>.mjs'); diff --git a/scripts/power-rail-verify.mjs b/scripts/power-rail-verify.mjs deleted file mode 100644 index e205383..0000000 --- a/scripts/power-rail-verify.mjs +++ /dev/null @@ -1,345 +0,0 @@ -#!/usr/bin/env node -/** - * power-rail-verify.mjs — 模型选择器 B 面(src/model-picker.js + model-picker.css)的夹具验收。 - * - * 不需要宿主:真实的 theme.css + 真实的组件模块(与 client.js 同一个打包器包成 IIFE), - * 配一个按宿主契约写的假 modelDirectories —— select() 的往返**故意放慢到 600ms**, - * 本机真宿主往返不到 60ms,pending 期间的行为在真 GUI 上根本来不及量。 - * 指针与键盘都走 CDP 的真输入事件(setPointerCapture 只认真指针)。 - * - * 覆盖:席位顶替与复原、触发器几何、弹层几何与入场动画、功率轨 Codex 逐字几何、 - * 拖动中不提交 / 松手对齐提交一次、pending 不回弹不清空且有转圈、键盘四键、 - * 焦点环、Escape、换模型带默认档、失败提示、reduced-motion、深色、开关。 - * - * 用法:node scripts/power-rail-verify.mjs [--no-shot] - */ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromePath, tempDir } from './host-paths.mjs'; -import { bundle } from './build.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const EVID = join(WB, 'assets', 'screenshots'); -const SHOT = !process.argv.includes('--no-shot'); -const theme = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); -const picker = bundle(join(WB, 'src', 'client', 'model-picker', 'component.js')); -const pickerIife = '(() => {\n' + picker.code + '\nreturn ' + picker.entryVar + ';\n})()'; - -/* 假目录:字段、状态机与返回值照 dsh-client-ui-model-selection 0.1.7-rc.2 的 ModelDirectory.select()。 */ -const fake = ` -const EFFORTS = [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }]; -const GROUPS = [{ id: 'deepseek-official', name: 'DeepSeek', models: [ - { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', reasoning: { defaultEffort: 'high', efforts: EFFORTS } }, - { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', reasoning: { defaultEffort: 'max', efforts: EFFORTS } }, - { id: 'plain', name: 'Plain' }, -] }]; -const makeStore = (initial) => { - let state = initial; const subs = new Set(); - return { getSnapshot: () => state, subscribe: (fn) => { subs.add(fn); return () => subs.delete(fn); }, - update: (patch) => { state = { ...state, ...patch }; subs.forEach((fn) => fn()); } }; -}; -const dir = { - store: makeStore({ groups: GROUPS, current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' }, status: 'ready', pending: null, error: null }), - calls: [], delay: 600, fail: null, - load() { return Promise.resolve(this.store.getSnapshot()); }, - select(selection) { - this.calls.push(selection); - this.store.update({ status: 'selecting', pending: selection, error: null }); - return new Promise((resolve) => setTimeout(() => { - if (this.fail !== null) { - const error = this.fail; this.fail = null; - this.store.update({ status: 'error', pending: null, error: error.code + ': ' + error.message }); - resolve({ ok: false, error }); return; - } - this.store.update({ status: 'ready', pending: null, current: { ...selection } }); - resolve({ ok: true }); - }, this.delay)); - }, -}; -window.__dir = dir; -window.__picker = __mp.mountModelPicker({ - models: { directoryFor: (id) => { if (id !== 'session-a') throw new Error('ui-model-selection: session "' + id + '" resolved no scope'); return dir; } }, - sessionFallback: () => null, - locale: null, -}); -`; - -const page = '' - + '' - + '' - + '' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - /* 子代理会话:宿主在那里返回 null(席位是空的),组件不许出头。 */ - + '
' - + '' - + ''; - -fs.mkdirSync(join(tmpdir(), 'codex-ui-fixtures'), { recursive: true }); -const htmlPath = join(tmpdir(), 'codex-ui-fixtures', 'power-rail-verify.html'); -fs.writeFileSync(htmlPath, page); - -const PORT = 9357; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const PROFILE = tempDir('dsh-cdp-power-rail'); -fs.rmSync(PROFILE, { recursive: true, force: true }); -const child = spawn(chromePath(), ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + PROFILE, '--no-first-run', '--disable-gpu', '--hide-scrollbars', '--window-size=900,640', 'about:blank'], { stdio: 'ignore' }); -let info; for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } -if (!info) { child.kill(); throw new Error('headless chrome did not start'); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const waiting = new Map(); -const pageErrors = []; -ws.onmessage = (ev) => { - const m = JSON.parse(ev.data); - if (m.method === 'Runtime.exceptionThrown') pageErrors.push(String(m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text).slice(0, 300)); - if (m.id && waiting.has(m.id)) { waiting.get(m.id)(m); waiting.delete(m.id); } -}; -const send = (method, params = {}, sid) => new Promise((res, rej) => { const id = ++seq; waiting.set(id, (m) => m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sid ? { sessionId: sid } : {}) })); }); -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -const S = (method, params) => send(method, params, sessionId); -const evalJs = async (expr) => { - const r = await S('Runtime.evaluate', { expression: expr, returnByValue: true }); - if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails).slice(0, 400)); - return r.result.value; -}; -const J = async (expr) => JSON.parse(await evalJs('JSON.stringify(' + expr + ')')); -const mouse = async (type, x, y, buttons = 0) => S('Input.dispatchMouseEvent', { type, x, y, button: type === 'mouseMoved' ? (buttons ? 'left' : 'none') : 'left', buttons, clickCount: 1 }); -const click = async (x, y) => { await mouse('mouseMoved', x, y); await mouse('mousePressed', x, y, 1); await mouse('mouseReleased', x, y, 0); }; -const key = async (k, code) => { - await S('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k, windowsVirtualKeyCode: code }); - await S('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code: k, windowsVirtualKeyCode: code }); -}; -const center = async (sel) => J('(() => { const r = document.querySelector(' + JSON.stringify(sel) + ').getBoundingClientRect(); return [Math.round(r.left + r.width / 2), Math.round(r.top + r.height / 2)]; })()'); -const media = async (reduced) => S('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: reduced ? 'reduce' : 'no-preference' }] }); - -await S('Page.enable'); -await S('Runtime.enable'); -/* DPR 2:0.5px 的 border 在 DPR 1 下会被 Chromium 抬成 1 个设备像素,量不出发丝线(其余夹具同为 2)。 */ -await S('Emulation.setDeviceMetricsOverride', { width: 900, height: 640, deviceScaleFactor: 2, mobile: false }); -await media(false); -await S('Page.navigate', { url: 'file:///' + htmlPath.replaceAll('\\', '/') + '?v=' + Date.now() }); -await sleep(1200); - -const results = []; -const check = (name, ok, detail) => results.push([name, !!ok, detail]); -const near = (a, b, tol = 0.6) => Math.abs(a - b) <= tol; - -/** 一次取全弹层 / 轨的几何与状态。 */ -const STATE = `(() => { - const q = (s) => document.querySelector(s); - const box = (e) => { if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height, r: b.right, b: b.bottom }; }; - const cs = (e, p) => e ? getComputedStyle(e).getPropertyValue(p) : null; - const pop = q('.codex-mp-popover'); const root = q('.codex-mp-root'); const trig = q('.codex-mp-trigger'); - return { - open: pop !== null && !pop.hidden, - pop: box(pop), trig: box(trig), root: box(root), track: box(q('.codex-mp-track')), range: box(q('.codex-mp-range')), - thumb: box(q('.codex-mp-thumb-scale')), - ticks: [...document.querySelectorAll('.codex-mp-tick')].map((t) => ({ ...box(t), sel: t.dataset.selected, color: cs(t, 'background-color') })), - rows: document.querySelectorAll('.codex-mp-row').length, - checked: [...document.querySelectorAll('.codex-mp-row')].map((r) => r.getAttribute('aria-checked')), - now: root ? root.getAttribute('aria-valuenow') : null, - pos: root ? root.style.getPropertyValue('--codex-mp-pos') : null, - value: q('.codex-mp-effort-value') ? q('.codex-mp-effort-value').textContent : null, - spinner: !!q('.codex-mp-effort-value .codex-mp-spinner'), - trigEffort: trig ? [...trig.querySelectorAll('.codex-mp-effort-text')].filter((e) => e.dataset.active === 'true').map((e) => e.textContent).join() : null, - trigModel: trig ? trig.querySelector('.codex-mp-trigger-model').textContent : null, - expanded: trig ? trig.getAttribute('aria-expanded') : null, - error: q('.codex-mp-error') && !q('.codex-mp-error').hidden ? q('.codex-mp-error').textContent : null, - calls: window.__dir.calls.length, lastCall: window.__dir.calls.at(-1) ?? null, status: window.__dir.store.getSnapshot().status, - focus: document.activeElement ? document.activeElement.className : null, - kbd: root ? root.dataset.keyboardFocused ?? null : null, - thumbOutline: cs(q('.codex-mp-thumb-scale'), 'outline'), - }; -})()`; -const state = () => J(STATE); - -/* ── 1. 席位顶替 ───────────────────────────────────────────────────────── */ -const seat = await J(`(() => { - const slot = document.querySelector('[data-conversation-session="session-a"] [data-slot="conversation.input.model"]'); - const host = slot.querySelector('._7KE1Ra_root'); const ours = slot.querySelector('.codex-mp-trigger'); - const cs = ours ? getComputedStyle(ours) : null; - return { host: getComputedStyle(host).display, ours: ours !== null, h: ours ? ours.getBoundingClientRect().height : 0, - radius: cs ? cs.borderTopLeftRadius : null, bg: cs ? cs.backgroundColor : null, - sub: document.getElementById('sub-seat').children.length, text: ours ? ours.textContent : '' }; -})()`); -check('席位里有自建触发器', seat.ours, seat.text); -check('宿主那一格 display:none(仍在 DOM 里)', seat.host === 'none', seat.host); -check('宿主没渲染的席位(子代理会话)不接管', seat.sub === 0, seat.sub + ' 个子节点'); -check('触发器 28px 高、全圆角胶囊、默认透明', near(seat.h, 28) && seat.radius === '999px' && seat.bg === 'rgba(0, 0, 0, 0)', JSON.stringify(seat)); - -/* ── 2. 弹层与轨几何 ───────────────────────────────────────────────────── */ -const [tx, ty] = await center('.codex-mp-trigger'); -await click(tx, ty); -await sleep(80); -const anim = await J(`(() => { const cs = getComputedStyle(document.querySelector('.codex-mp-popover')); return { name: cs.animationName, dur: cs.animationDuration, delay: cs.animationDelay, ease: cs.animationTimingFunction, origin: cs.transformOrigin }; })()`); -await sleep(500); -let st = await state(); -const popStyle = await J(`(() => { const cs = getComputedStyle(document.querySelector('.codex-mp-popover')); return { radius: cs.borderTopLeftRadius, pad: cs.paddingTop, shadow: cs.boxShadow, bg: cs.backgroundColor }; })()`); -check('点触发器打开弹层(aria-expanded=true)', st.open && st.expanded === 'true', JSON.stringify({ open: st.open, expanded: st.expanded })); -check('弹层宽 254px(Codex spacing × 63.5)', near(st.pop.w, 254), st.pop.w); -check('弹层在触发器上方 8px、右沿对齐(宿主 place())', near(st.trig.y - st.pop.b, 8, 1) && near(st.trig.r, st.pop.r, 1), 'gap=' + (st.trig.y - st.pop.b) + ' right=' + (st.trig.r - st.pop.r)); -check('弹层 18px 圆角 + 内衬 5px + 菜单软影', popStyle.radius === '18px' && popStyle.pad === '5px' && /0px 8px 32px/.test(popStyle.shadow), JSON.stringify(popStyle)); -check('入场 .32s cubic-bezier(.23,1,.32,1) 30ms,从右下角长出', anim.name === 'codex-mp-enter' && anim.dur === '0.32s' && anim.delay === '0.03s' && /0\.23, 1, 0\.32, 1/.test(anim.ease), JSON.stringify(anim)); -check('列表三行、当前模型打勾', st.rows === 3 && st.checked[0] === 'true', JSON.stringify(st.checked)); -/* 宿主字典缺席(本夹具 locale 为 null)时,内置模型的说明要落回目录原文,不能把字典键名画出来。 */ -const desc = await evalJs('(document.querySelector(".codex-mp-row-desc") || {}).textContent || null'); -check('说明文字是目录原文、不是字典键名', typeof desc === 'string' && desc.startsWith('Fast, efficient') && !desc.startsWith('option.'), desc); -const railStyle = await J(`(() => { const t = getComputedStyle(document.querySelector('.codex-mp-track')); const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); - const c = getComputedStyle(document.querySelector('.codex-mp-container')); const rg = getComputedStyle(document.querySelector('.codex-mp-range')); - return { tRadius: t.borderTopLeftRadius, tBg: t.backgroundColor, tRing: t.boxShadow, thBg: th.backgroundColor, thBorder: th.borderTopWidth + ' ' + th.borderTopStyle, thShadow: th.boxShadow, - cH: c.height, cPad: c.paddingTop + ' ' + c.paddingLeft, rRadius: rg.borderTopLeftRadius + ' ' + rg.borderTopRightRadius, touch: getComputedStyle(document.querySelector('.codex-mp-root')).touchAction }; })()`); -check('轨 24px 高、12px 圆角', near(st.track.h, 24) && railStyle.tRadius === '12px', st.track.h + ' / ' + railStyle.tRadius); -check('轨底 = 前景 10%、0.5px 内描边', /0\.1\)$/.test(railStyle.tBg) && /inset/.test(railStyle.tRing) && /0\.5px/.test(railStyle.tRing), railStyle.tBg + ' | ' + railStyle.tRing); -check('容器 32px、上下 2 / 左右 6;根 28px、touch-action none', railStyle.cH === '32px' && railStyle.cPad === '2px 6px' && near(st.root.h, 28) && railStyle.touch === 'none', JSON.stringify(railStyle)); -/* 描边断言**声明值**:Chromium 153 把 0.5px 的 border 在计算值里取整成 1px(DPR 2 也一样), - Codex 源码同样写的是 border .5px、跑在同一个引擎上,渲染结果一致;计算值量不出「写的是不是 0.5」。 */ -const thumbDecl = await evalJs(`(() => { for (const sheet of document.styleSheets) for (const rule of sheet.cssRules) { - if (rule.selectorText && / \\.codex-mp-thumb$/.test(rule.selectorText)) return rule.style.getPropertyValue('border'); } - return null; })()`); -check('拇指 28px 白圆片、声明 0.5px 描边、0 0 2px 微影', near(st.thumb.w, 28) && railStyle.thBg === 'rgb(255, 255, 255)' && /^0\.5px solid/.test(thumbDecl ?? '') && /0px 0px 2px/.test(railStyle.thShadow), JSON.stringify({ w: st.thumb.w, bg: railStyle.thBg, decl: thumbDecl, computed: railStyle.thBorder })); -check('档位圆点 4px × 4 个', st.ticks.length === 4 && st.ticks.every((t) => near(t.w, 4) && near(t.h, 4)), st.ticks.length); -const W = st.root.w; -const expectX = (i) => st.root.x + 14 + (W - 28) * i / 3; -check('圆点等距落在拇指行程 [14, 宽 − 14] 上', st.ticks.every((t, i) => near(t.x + t.w / 2, expectX(i))), st.ticks.map((t) => Math.round((t.x + t.w / 2 - st.root.x) * 10) / 10).join(' / ')); -check('拇指中心 = 生效档(High = 第 3 档)', near(st.thumb.x + 14, expectX(2)) && st.now === '2', 'thumb=' + (st.thumb.x + 14 - st.root.x) + ' now=' + st.now); -check('强调色条止于拇指中线、只圆左侧两角', near(st.range.r, st.thumb.x + 14) && railStyle.rRadius === '12px 0px', 'range.r=' + st.range.r + ' thumb=' + (st.thumb.x + 14)); -check('走过的圆点 30% 白、没走到的另一色', st.ticks[0].sel === 'true' && st.ticks[3].sel === 'false' && st.ticks[0].color === 'rgba(255, 255, 255, 0.3)' && st.ticks[3].color !== st.ticks[0].color, st.ticks.map((t) => t.sel + ':' + t.color).join(' | ')); -if (SHOT) { - const s = await S('Page.captureScreenshot', { format: 'png', clip: { x: st.pop.x - 20, y: st.pop.y - 20, width: st.pop.w + 40, height: st.trig.b - st.pop.y + 40, scale: 1 } }); - fs.writeFileSync(join(EVID, 'power-rail-verify.png'), Buffer.from(s.data, 'base64')); -} - -/* ── 3. 拖动:中途不提交、松手对齐提交一次;pending 不回弹、不清空、有转圈 ───── */ -const thumbX = Math.round(st.thumb.x + 14), railY = Math.round(st.root.y + st.root.h / 2); -await mouse('mouseMoved', thumbX, railY); -await mouse('mousePressed', thumbX, railY, 1); -for (const x of [thumbX - 30, thumbX - 80, Math.round(st.root.x - 20)]) { await mouse('mouseMoved', x, railY, 1); await sleep(30); } -st = await state(); -check('拖动中不提交(select 调用 0 次)', st.calls === 0, 'calls=' + st.calls); -check('拖动中拇指跟随指针(越界夹到 0)', st.pos === '0', 'pos=' + st.pos); -await mouse('mouseReleased', Math.round(st.root.x - 20), railY, 0); -await sleep(120); -st = await state(); -check('松手提交一次、对齐到最左档 off', st.calls === 1 && st.lastCall && st.lastCall.reasoningEffort === 'off', JSON.stringify(st.lastCall)); -check('往返中(selecting)轨停在新档、不回弹', st.status === 'selecting' && st.now === '0' && st.pos === '0', 'status=' + st.status + ' now=' + st.now); -check('往返中列表不清空', st.rows === 3, 'rows=' + st.rows); -check('往返中档位名旁转圈、触发器已按新档显示', st.spinner && st.trigEffort === 'Off', 'spinner=' + st.spinner + ' trig=' + st.trigEffort); -await sleep(800); -st = await state(); -check('往返结束:转圈撤掉、弹层仍开着(改档不关)', !st.spinner && st.open && st.now === '0' && st.value === 'Off', JSON.stringify({ spinner: st.spinner, open: st.open, value: st.value })); - -/* ── 4. 键盘 ───────────────────────────────────────────────────────────── */ -await evalJs('document.querySelector(".codex-mp-root").focus()'); -await key('ArrowRight', 39); -await sleep(700); -st = await state(); -check('→ 进一档(low)', st.lastCall.reasoningEffort === 'low' && st.now === '1', JSON.stringify(st.lastCall)); -check('键盘焦点:拇指上 2px 焦点环(offset 0)', st.kbd === 'true' && /2px/.test(st.thumbOutline) && /solid/.test(st.thumbOutline), st.thumbOutline); -await key('End', 35); -await sleep(700); -st = await state(); -check('End 到最后一档(max)', st.lastCall.reasoningEffort === 'max' && st.now === '3', JSON.stringify(st.lastCall)); -const before = st.calls; -await key('End', 35); -await sleep(200); -st = await state(); -check('同一档不重复提交', st.calls === before, 'calls ' + before + ' → ' + st.calls); -await key('Home', 36); -await sleep(700); -st = await state(); -check('Home 回第一档(off)', st.lastCall.reasoningEffort === 'off' && st.now === '0', JSON.stringify(st.lastCall)); - -/* ── 5. 失败:宿主把会话占用报回来 ───────────────────────────────────────── */ -await evalJs('window.__dir.fail = { code: "session/writer-held", message: "held" }; "ok"'); -await key('ArrowRight', 39); -await sleep(800); -st = await state(); -check('提交失败:弹层顶上出现占用提示', st.error !== null && /(已被占用|already in use)/.test(st.error), st.error); -check('提交失败:轨退回生效档', st.now === '0', 'now=' + st.now); - -/* ── 6. Escape / 换模型 ───────────────────────────────────────────────── */ -await key('Escape', 27); -await sleep(150); -st = await state(); -check('Escape 关闭并把焦点还给触发器', !st.open && st.expanded === 'false' && st.focus === 'codex-mp-trigger', JSON.stringify({ open: st.open, focus: st.focus })); -await click(tx, ty); -await sleep(500); -const [rx, ry] = await J(`(() => { const r = document.querySelectorAll('.codex-mp-row')[1].getBoundingClientRect(); return [Math.round(r.left + r.width / 2), Math.round(r.top + r.height / 2)]; })()`); -await click(rx, ry); -await sleep(120); -const rowBusy = await J(`(() => { const r = document.querySelectorAll('.codex-mp-row')[1]; return { busy: r.getAttribute('aria-busy'), spin: !!r.querySelector('.codex-mp-spinner') }; })()`); -check('换模型往返中:那一行行尾转圈', rowBusy.busy === 'true' && rowBusy.spin, JSON.stringify(rowBusy)); -await sleep(800); -st = await state(); -check('换模型连带该模型的默认档提交(pro → max)', st.lastCall.model === 'deepseek-v4-pro' && st.lastCall.reasoningEffort === 'max', JSON.stringify(st.lastCall)); -check('换模型成功后关弹层、触发器换名', !st.open && st.trigModel === 'DeepSeek-V4-Pro' && st.trigEffort === 'Max', st.trigModel + ' · ' + st.trigEffort); - -/* ── 7. reduced-motion ─────────────────────────────────────────────────── */ -await media(true); -await click(tx, ty); -await sleep(300); -const rm = await J(`(() => { const p = document.querySelector('.codex-mp-popover'); return { attr: p.dataset.reducedMotion, anim: getComputedStyle(p).animationName, thumb: getComputedStyle(document.querySelector('.codex-mp-thumb-scale')).transitionDuration }; })()`); -check('reduced-motion:入场动画取消、拇指不过渡', rm.attr === 'true' && rm.anim === 'none' && /^0s|^1e-05s|0\.00001s/.test(rm.thumb), JSON.stringify(rm)); -/* 鼠标打开后焦点在触发器上:Escape 也得关(宿主的键盘处理挂在整格根上)。 */ -const focusBefore = await evalJs('document.activeElement ? document.activeElement.className : null'); -await key('Escape', 27); -await sleep(150); -st = await state(); -check('鼠标打开后(焦点在触发器)Escape 也能关', focusBefore === 'codex-mp-trigger' && !st.open, 'focus=' + focusBefore + ' open=' + st.open); -await media(false); - -/* ── 8. 深色 ───────────────────────────────────────────────────────────── */ -await evalJs('document.body.setAttribute("data-ds-dark-theme", ""); "ok"'); -await click(tx, ty); -await sleep(500); -const dark = await J(`(() => { const p = getComputedStyle(document.querySelector('.codex-mp-popover')); const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); - return { shadow: p.boxShadow, bg: p.backgroundColor, thumb: th.backgroundColor, border: th.borderTopColor, track: getComputedStyle(document.querySelector('.codex-mp-track')).boxShadow }; })()`); -st = await state(); -check('深色下弹层打开', st.open && near(st.pop.w, 254), 'open=' + st.open); -check('深色:菜单影 50% 黑 + 5% 亮环(暗色靠填充分层)', /rgba\(0, 0, 0, 0\.5\) 0px 8px 32px/.test(dark.shadow) && dark.bg === 'rgb(33, 33, 33)', JSON.stringify(dark)); -check('深色:拇指仍是白片、描边 20% 白、轨环 12% 白', dark.thumb === 'rgb(255, 255, 255)' && dark.border === 'rgba(255, 255, 255, 0.2)' && /rgba\(255, 255, 255, 0\.12\)/.test(dark.track), JSON.stringify(dark)); -if (SHOT) { - const p = await state(); - const s = await S('Page.captureScreenshot', { format: 'png', clip: { x: p.pop.x - 20, y: p.pop.y - 20, width: p.pop.w + 40, height: p.trig.b - p.pop.y + 40, scale: 1 } }); - fs.writeFileSync(join(EVID, 'power-rail-verify-dark.png'), Buffer.from(s.data, 'base64')); -} -await key('Escape', 27); -await evalJs('document.body.removeAttribute("data-ds-dark-theme"); "ok"'); - -/* ── 9. 开关 ───────────────────────────────────────────────────────────── */ -await evalJs('window.__picker.setEnabled(false); "ok"'); -await sleep(100); -const off = await J(`(() => ({ ours: document.querySelectorAll('.codex-mp-trigger').length, host: getComputedStyle(document.querySelector('._7KE1Ra_root')).display }))()`); -check('关掉:自建触发器撤走、宿主那一格复原', off.ours === 0 && off.host !== 'none', JSON.stringify(off)); -await evalJs('window.__picker.setEnabled(true); "ok"'); -await sleep(100); -const on = await J(`(() => ({ ours: document.querySelectorAll('.codex-mp-trigger').length, host: getComputedStyle(document.querySelector('._7KE1Ra_root')).display }))()`); -check('再打开:重新接管', on.ours === 1 && on.host === 'none', JSON.stringify(on)); -/* React 换掉宿主子节点:标记不会丢,因为根本没有标记 —— 只看我们在不在席。 */ -await evalJs(`(() => { const slot = document.querySelector('[data-conversation-session="session-a"] [data-slot="conversation.input.model"]'); - const fresh = document.createElement('div'); fresh.className = '_7KE1Ra_root'; fresh.innerHTML = ''; - slot.querySelector('._7KE1Ra_root').replaceWith(fresh); return 'ok'; })()`); -await sleep(50); -const swapped = await J(`(() => { const h = document.querySelector('._7KE1Ra_root'); return { host: getComputedStyle(h).display, ours: document.querySelectorAll('.codex-mp-trigger').length }; })()`); -check('宿主换掉自己的子节点后仍隐藏、不闪回', swapped.host === 'none' && swapped.ours === 1, JSON.stringify(swapped)); -check('页面没有未捕获异常', pageErrors.length === 0, pageErrors.join(' | ') || '0'); - -ws.close(); child.kill(); -let pass = 0; -for (const [n, ok, d] of results) { console.log((ok ? 'PASS ' : 'FAIL ') + n + (d !== undefined ? ' [' + String(d).slice(0, 160) + ']' : '')); if (ok) pass += 1; } -console.log('\n' + (pass === results.length ? 'ALL PASS' : 'FAILED') + ' (' + pass + '/' + results.length + ')'); -process.exit(pass === results.length ? 0 : 1); diff --git a/scripts/rightbar-verify.mjs b/scripts/rightbar-verify.mjs deleted file mode 100644 index 5176859..0000000 --- a/scripts/rightbar-verify.mjs +++ /dev/null @@ -1,383 +0,0 @@ -#!/usr/bin/env node -/** - * rightbar-verify.mjs — 右栏(文件/浏览器/终端)三件套的计算样式断言 + 出图: - * ① 面板顶沿边框阴影(与中列同源 elevation-soft) - * ② 左边框淡细(压掉宿主 dockkit 的 .5px l4,边界只剩中列环) - * + 拖拽柄悬停聚焦:2px 纵向渐变,中段最深、向两端淡出、从中间展开 - * ③ 空栏 guide「展开选择组件」:平行(无胶囊)、图标 20、无描述、快捷键灰底 pill - * - * 夹具理由同 model-picker-verify:launch token 只在宿主内存,headless 打不开真实 GUI。 - * 样式取 app.asar 真实 shipped(ui-theme / layout / sidebar-right / dockkit / primitives), - * DOM 按渲染代码复刻。before 列剥掉 ⑯ 段与右栏阴影段,after 列完整。 - * - * 用法:node scripts/rightbar-verify.mjs [--no-shot] - */ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { asarPath, chromePath, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const EVID = join(WB, 'assets', 'screenshots'); -/** 夹具 HTML 是中间产物,落系统临时目录,不堆进 assets/screenshots/。 */ -const FIX = join(tmpdir(), 'codex-ui-fixtures'); -const THEME = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); -const ASAR = asarPath(); - -const fd = fs.openSync(ASAR, 'r'); -const head = Buffer.alloc(16); fs.readSync(fd, head, 0, 16, 0); -const headerSize = head.readUInt32LE(12); -const hb = Buffer.alloc(headerSize); fs.readSync(fd, hb, 0, headerSize, 16); -const root = JSON.parse(hb.toString('utf8')); -const dataBase = 16 + headerSize; -function asarNode(p) { - let n = root; - for (const seg of p.split('/').filter(Boolean)) { n = n.files?.[seg]; if (!n) return null; } - return n; -} -function read(p) { - const n = asarNode(p); - if (!n || n.type === 'directory' || !Number.isFinite(Number(n.offset))) return null; - const buf = Buffer.alloc(n.size); - fs.readSync(fd, buf, 0, n.size, dataBase + Number(n.offset)); - return buf.toString('utf8'); -} -function readStringLiteral(src, quoteIndex) { - let out = ''; - for (let i = quoteIndex + 1; i < src.length; i += 1) { - const ch = src[i]; - if (ch === '\\') { out += src[i + 1]; i += 1; continue; } - if (ch === '"') return out; - out += ch; - } - throw new Error('unterminated string literal at ' + quoteIndex); -} -function cssFor(src, moduleFile) { - const i = src.indexOf(moduleFile); - if (i < 0) throw new Error('tagId not found: ' + moduleFile); - const re = /(?:const|var)\s+[\w$]+\s*=\s*"/g; - let last = null, hit; - while ((hit = re.exec(src)) !== null && hit.index < i) { - const text = readStringLiteral(src, hit.index + hit[0].length - 1); - if (text.includes('{')) last = text; - } - if (last === null) throw new Error('css declaration not found for ' + moduleFile); - return last; -} -function cssAfter(src, marker) { - const i = src.indexOf(marker); - if (i < 0) throw new Error('marker not found: ' + marker); - const re = /(?:const|var)\s+[\w$]+\s*=\s*"/g; - re.lastIndex = i; - const hit = re.exec(src); - if (!hit) throw new Error('css literal not found after ' + marker); - const text = readStringLiteral(src, hit.index + hit[0].length - 1); - if (!text.includes('{')) throw new Error('not a css literal after ' + marker); - return text; -} -function mapFor(src, varName) { - const i = src.indexOf('var ' + varName + ' = {'); - if (i < 0) throw new Error('class map not found: ' + varName); - const start = src.indexOf('{', i); - let depth = 0, end = -1; - for (let j = start; j < src.length; j += 1) { - if (src[j] === '{') depth += 1; - else if (src[j] === '}') { depth -= 1; if (depth === 0) { end = j; break; } } - } - const out = {}; - for (const m of src.slice(start, end + 1).matchAll(/"([^"]+)":\s*"([^"]+)"/g)) out[m[1]] = m[2]; - return out; -} - -const themeSrc = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-theme/lib/client.js'); -const layoutSrc = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-layout/lib/client.js'); -const srSrc = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-sidebar-right/lib/client.js'); -const primSrc = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/index.js'); -const termSrc = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-sidebar-terminal/lib/client.js'); -const frontCss = read('dsh/node_modules/@deepseek-ai/dsh-web-frontend/dist/assets/index-DUvMhLle.css'); -if (!themeSrc || !layoutSrc || !srSrc || !primSrc || !termSrc || !frontCss) throw new Error('shipped bundles not found'); - -const themeLayers = (() => { - const re = /(?:const|var|let)\s+([\w$]+_css_default)\s*=\s*"/g; - const parts = []; - let m; - while ((m = re.exec(themeSrc)) !== null) { - const text = readStringLiteral(themeSrc, m.index + m[0].length - 1); - if (text.includes('--dsw-')) parts.push(text); - } - return parts.join('\n'); -})(); - -const frameCss = cssFor(layoutSrc, '@deepseek-ai/dsh-client-ui-layout/AppFrame.module.css'); -const F = mapFor(layoutSrc, 'AppFrame_module_css_default'); -const panelCss = cssFor(srSrc, '@deepseek-ai/dsh-client-ui-sidebar-right/SidebarRight.module.css'); -const P = mapFor(srSrc, 'SidebarRight_module_css_default'); -const guideCss = cssFor(srSrc, '@deepseek-ai/dsh-client-ui-sidebar-right/GuideBody.module.css'); -const G = mapFor(srSrc, 'GuideBody_module_css_default'); -const skCss = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/ShortcutKeys.module.css').replace(/^\s*}\s*/, ''); -const btnCss = read('dsh/node_modules/@deepseek-ai/dsh-client-ui-primitives/lib/Button.module.css').replace(/^[\s\S]*?(?=\/\*|\.)/, ''); -const tgCss = cssAfter(termSrc, 'TerminalGuide.module.css.mjs'); -const T = mapFor(termSrc, 'TerminalGuide_module_css_default'); - -/* dockkit 模块(前端 bundle 内,哈希随构建变):抽规则原文 + 按前缀取类名 */ -const dockRules = []; -for (const m of frontCss.matchAll(/([^{}]+)\{([^{}]*)\}/g)) if (m[1].includes('_1s7ij_')) dockRules.push(m[0]); -if (dockRules.length === 0) throw new Error('dockkit rules not found in frontend css (hash changed?)'); -const dockCss = dockRules.join('\n'); -const dockName = (key) => { - const m = new RegExp('\\._' + key + '_([\\w-]+)').exec(dockCss); - if (!m) throw new Error('dockkit class not found: ' + key); - return '_' + key + '_' + m[1]; -}; -const D = { tabCell: dockName('tabCell'), tabHost: dockName('tabHost'), tabHostHeader: dockName('tabHostHeader'), tabHostBody: dockName('tabHostBody'), stripTabs: dockName('stripTabs'), stripFill: dockName('stripFill'), stripChrome: dockName('stripChrome'), pane: dockName('pane'), paneBody: dockName('paneBody') }; - -/* ── DOM 复刻 ─────────────────────────────────────────────────────────────── */ -const svg = (d, size = 16) => ''; -const FOLDER = 'M1.5 4.5h4l1.2 1.5h7.8v7.5h-13z'; -const GLOBE = 'M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13M1.5 8h13M8 1.5c2 1.8 3 4 3 6.5s-1 4.7-3 6.5c-2-1.8-3-4-3-6.5s1-4.7 3-6.5'; -const TERM = 'M2 3l4 4-4 4M8 11h6'; -const COMPASS = 'M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13M10.5 5.5l-1.6 3.4-3.4 1.6 1.6-3.4z'; -const entry = (icon, title, desc, combo, kind) => - '
'; -/* TerminalGuide(terminal 插件注册的自定义卡):button.main 绝对定位点击层在前, - 子序 button→icon→text(titleRow+description)→keys,与标准卡不同 */ -const termEntry = () => - '
' - + '' - + '' - + '' - + '' - + '' - + 'Ctrl+`' - + '
'; - -const guide = '
' - + '' - + entry(FOLDER, '工作区文件', '浏览会话工作区的文件', 'Ctrl+P', 'workspace') - + entry(GLOBE, '浏览器', '浏览网页', 'Ctrl+T', 'browser') - + termEntry() - + '
'; - -const strip = '
' - + '
开始
' - + '
' - + '
' - + '' - + '' - + '
'; - -const stage = '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + strip - + '
' + guide + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
'; - -/* before = theme 剥掉 ⑯ 段与 window-shadow 右栏段(两段各自切除,中间层保留) */ -const cut16 = THEME.lastIndexOf('/*', THEME.indexOf('⑯ 右栏「展开时选择组件」')); -const end16 = THEME.indexOf('/* ==== L3 侧栏对齐层', cut16); -const cutRB = THEME.lastIndexOf('/*', THEME.indexOf('右栏(push 模式)')); -const endRB = THEME.indexOf('/* ==== L3 输入区完整层', cutRB); -if (cut16 < 0 || end16 < 0 || cutRB < 0 || endRB < 0) throw new Error('rightbar section markers missing in theme.css'); -let themeNoRB = THEME.slice(0, cut16) + THEME.slice(end16, cutRB) + THEME.slice(endRB); -/* before 页剥掉阴影层本体(中列的发丝线 + 环境影、面板的上沿影), - 本轮修复项在这一项上必须有真实差异 */ -themeNoRB = themeNoRB.replace(/box-shadow:\s*0 0 0 0\.5px var\(--dsw-alias-border-l2\),\s*0 0 24px rgba\(13, 13, 13, 0\.05\);/g, 'box-shadow: none;'); -themeNoRB = themeNoRB.replace(/box-shadow:\s*0 0 0 0\.5px var\(--dsw-alias-border-l2\),\s*0 0 24px rgba\(0, 0, 0, 0\.5\);/g, 'box-shadow: none;'); -const page = (theme, label, titlebar = true) => '' - + '' + label + '' - + '' - + '' - + '' - + '' - + stage + ''; - -fs.mkdirSync(FIX, { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const beforePath = join(FIX, 'rightbar-before.html'); -const afterPath = join(FIX, 'rightbar-after.html'); -const webPath = join(FIX, 'rightbar-web.html'); -fs.writeFileSync(beforePath, page(themeNoRB, 'before')); -fs.writeFileSync(afterPath, page(THEME, 'after')); -fs.writeFileSync(webPath, page(THEME, 'web', false)); - -const PROBE = '(() => { try {' - + 'const cs = (el, p) => el ? getComputedStyle(el, p) : null;' - + 'const panel = document.querySelector("[data-sidebar-right-panel]");' - + 'const pane = document.querySelector("[data-dockkit-host=\\"dock\\"] [data-dockkit-pane]");' - + 'const handle = document.querySelector("[data-side=\\"rightbar\\"]");' - + 'const handleLeft = document.querySelector("[data-side=\\"sidebar\\"]");' - + 'const hero = document.querySelector("[data-sidebar-right-guide] > span[aria-hidden]");' - + 'const guideEl = document.querySelector("[data-sidebar-right-guide]");' - + 'const entry = document.querySelector("[data-sidebar-right-guide-entry]");' - + 'const icon = entry.querySelector("span:first-child svg");' - + 'const desc = entry.querySelectorAll("span:nth-child(2) > span");' - + 'const keysEl = entry.lastElementChild;' - + 'const toggle = document.querySelector("[data-sidebar-right-toggle]");' - + 'return JSON.stringify({' - + 'panelShadow: cs(panel).boxShadow !== "none",' - + 'panelBox: cs(panel).boxShadow,' - + 'panelTop: Math.round(panel.getBoundingClientRect().top),' - + 'panelH: Math.round(panel.getBoundingClientRect().height),' - + 'centerBox: cs(document.querySelector("[data-slot=main]").parentElement).boxShadow,' - + 'paneBorder: cs(pane).borderLeftWidth + " " + cs(pane).borderLeftColor,' - + 'handleBefore: { opacity: cs(handle, "::before").opacity, width: cs(handle, "::before").width, grad: /linear-gradient/.test(cs(handle, "::before").backgroundImage) },' - + 'handleLeftBefore: { opacity: cs(handleLeft, "::before").opacity, width: cs(handleLeft, "::before").width, grad: /linear-gradient/.test(cs(handleLeft, "::before").backgroundImage), scale: cs(handleLeft, "::before").scale, bg: cs(handleLeft, "::before").backgroundImage, content: cs(handleLeft, "::before").content },' - + 'hero: cs(hero).display,' - + 'after: cs(guideEl, "::after").display,' - + 'entry: { border: cs(entry).borderTopWidth, bg: cs(entry).backgroundColor, minH: cs(entry).minHeight, radius: cs(entry).borderTopLeftRadius, width: cs(entry).width },' - + 'icon: icon.getAttribute("width") + "x" + icon.getAttribute("height"),' - + 'iconBox: cs(entry.querySelector("span:first-child")).width,' - + 'desc: desc.length > 1 ? cs(desc[1]).display : "absent",' - + 'keys: { h: cs(keysEl).height, radius: cs(keysEl).borderTopLeftRadius, bg: cs(keysEl).backgroundColor },' - + 'toggle: { bg: cs(toggle).backgroundColor, radius: cs(toggle).borderTopLeftRadius },' - + 'sidebarBorder: (function () { const s = cs(document.querySelector("[data-slot=sidebar]").parentElement); return s.borderRightColor + "|" + s.borderRightWidth + "|" + s.borderRightStyle; })(),' - + 'term: (function () {' - + 'const t = document.querySelector("[data-sidebar-right-guide-entry=\\"terminal\\"]");' - + 'if (!t) return { missing: true };' - + 'const icon = t.querySelector(":scope > span:nth-child(2)");' - + 'const text = t.querySelector(":scope > span:nth-child(3)");' - + 'const desc = text ? text.querySelector(":scope > span:nth-child(2)") : null;' - + 'const title = text ? text.querySelector(":scope > span:nth-child(1) > span:first-child") : null;' - + 'const chev = t.querySelector("button[aria-haspopup=\\"menu\\"]");' - + 'const keys = t.querySelector(":scope > span:has(kbd)");' - + 'const tr = t.getBoundingClientRect(); const rr = { top: tr.top + tr.height / 2 };' - + 'const pr = title ? title.getBoundingClientRect() : null;' - + 'const filesRow = document.querySelector("[data-sidebar-right-guide-entry=\\"workspace\\"]");' - + 'return {' - + ' border: cs(t).borderTopWidth, bg: cs(t).backgroundColor, minH: cs(t).minHeight,' - + ' iconBox: cs(icon).width, iconSvg: icon.querySelector("svg") ? cs(icon.querySelector("svg")).width : "none",' - + ' desc: desc ? cs(desc).display : "absent",' - + ' chev: chev ? cs(chev).display + "/" + Math.round(chev.getBoundingClientRect().width) : "missing",' - + ' titleCenterDelta: pr ? Math.round(((pr.top + pr.height / 2) - rr.top) * 10) / 10 : "n/a",' - + ' rowH: Math.round(tr.height * 10) / 10, filesRowH: filesRow ? Math.round(filesRow.getBoundingClientRect().height * 10) / 10 : -1,' - + ' keysH: keys ? cs(keys).height : "missing"' - + '};' - + '})()' - + '}); } catch (e) { return JSON.stringify({ error: String(e) }); } })()'; - -const args = process.argv.slice(2); -const noShot = args.includes('--no-shot'); -const CHROME = chromePath(); -const port = 9338; -const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + port, - '--user-data-dir=' + tempDir('dsh-cdp-profile6'), '--no-first-run', - '--no-default-browser-check', '--disable-gpu', '--window-size=1000,780', '--hide-scrollbars', 'about:blank'], { stdio: 'ignore' }); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -let info; -for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + port + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const pendingQ = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pendingQ.has(m.id)) { pendingQ.get(m.id)(m); pendingQ.delete(m.id); } }; -const send = (method, params = {}, sessionId) => new Promise((res, rej) => { const id = ++seq; pendingQ.set(id, (m) => m.error ? rej(new Error(method + ': ' + JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); }); - -async function openPage(path) { - const { targetId } = await send('Target.createTarget', { url: 'file:///' + path.replaceAll('\\', '/') }); - const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); - await send('Page.enable', {}, sessionId); - await send('Emulation.setDeviceMetricsOverride', { width: 1000, height: 780, deviceScaleFactor: 2, mobile: false }, sessionId); - await sleep(900); - return sessionId; -} -const probe = async (sessionId) => JSON.parse((await send('Runtime.evaluate', { expression: PROBE, returnByValue: true }, sessionId)).result.value); - -const sb = await openPage(beforePath); -const sa = await openPage(afterPath); -const before = await probe(sb); -const idle = await probe(sa); -/* 悬停左分界线拖拽柄(柄 8px 骑跨 300 分界)→ 读 ::before */ -await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 300, y: 390 }, sa); -await sleep(500); -const hoverLeft = await probe(sa); -/* 悬停右分界线拖拽柄(柄 8px 骑跨 526 分界,取柄内偏右点避开中列右沿)→ 读 ::before */ -await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 528, y: 390 }, sa); -await sleep(500); -const hovered = await probe(sa); -/* web 页最后开:headless 单窗口只有前台页签处理 :hover,先开会抢走 sa 的前台态 */ -const sw = await openPage(webPath); -const after = idle; -console.log('BEFORE', JSON.stringify(before)); -console.log('IDLE ', JSON.stringify(idle)); -console.log('HOVER-L', JSON.stringify(hoverLeft.handleLeftBefore)); -console.log('HOVER-R', JSON.stringify(hovered.handleBefore)); -const checks = [ - ['面板有边框阴影', after.panelShadow === true], - ['面板左沿 0.5px l1 发丝线', /rgba\(13, 13, 13, 0\.07\) 0px 0px 0px 0\.5px/.test(after.panelBox)], - ['面板左沿不糊影(影只往上泄)', /rgba\(13, 13, 13, 0\.05\) 0px -12px 24px -12px/.test(after.panelBox)], - ['面板顶沿贴标题栏条下沿', after.panelTop === 36], - ['面板撑满列高(塌高即回归)', after.panelH === 720], - ['中列 0.5px l2 发丝线', /rgba\(13, 13, 13, 0\.12\) 0px 0px 0px 0\.5px/.test(after.centerBox)], - ['中列全向环境影 24px', after.centerBox.includes('rgba(13, 13, 13, 0.05) 0px 0px 24px')], - ['栏内 pane 的 1px l4 深边框被压掉', after.paneBorder === '0px rgb(0, 0, 0)' && before.paneBorder === '1px rgba(13, 13, 13, 0.24)'], - ['右栏悬停线默认隐藏', idle.handleBefore.opacity === '0'], - ['右栏悬停线出现', hovered.handleBefore.opacity === '1'], - ['右栏悬停线宽 2px', hovered.handleBefore.width === '2px'], - ['右栏悬停线纵向渐变', hovered.handleBefore.grad === true], - ['左分界线无悬停线(静默)', idle.handleLeftBefore.content === 'none' && idle.handleLeftBefore.bg === 'none'], - ['左分界线无悬停线(悬停仍是静默)', hoverLeft.handleLeftBefore.content === 'none' && hoverLeft.handleLeftBefore.bg === 'none'], - ['罗盘 hero 隐藏', after.hero === 'none'], - ['底部占位移除', after.after === 'none'], - ['条目无描边', after.entry.border === '0px'], - ['条目无底色', /rgba\(0, 0, 0, 0\)|transparent/.test(after.entry.bg)], - ['条目行高 52', after.entry.minH === '52px'], - ['条目圆角 12', after.entry.radius === '12px'], - ['条目宽 380', after.entry.width === '380px'], - ['图标 20', after.iconBox === '20px'], - ['描述行隐藏', after.desc === 'none'], - ['快捷键 pill 高 24', after.keys.h === '24px'], - ['快捷键 pill 圆角 12', after.keys.radius === '12px'], - ['快捷键 pill 灰底', after.keys.bg === 'rgb(241, 241, 239)'], - ['收起按钮灰底', after.toggle.bg === 'rgb(241, 241, 239)'], - ['收起按钮圆角 12', after.toggle.radius === '12px'], - ['终端行无描边', after.term.border === '0px'], - ['终端行无底色', /rgba\(0, 0, 0, 0\)|transparent/.test(after.term.bg)], - ['终端行小字隐藏', after.term.desc === 'none'], - ['终端图标 20', after.term.iconBox === '20px'], - ['终端图标 svg 20', after.term.iconSvg === '20px'], - ['终端标题垂直居中', Math.abs(after.term.titleCenterDelta) <= 1], - ['终端行高与标准行一致', after.term.rowH === after.term.filesRowH], - ['选 Shell chevron 保留', /^flex\/20|^block\/20|inline-flex\/20/.test(after.term.chev)], - ['终端快捷键 pill 高 24', after.term.keysH === '24px'], -]; -const web = await probe(sw); -checks.push( - ['web 中列全向环境影', web.centerBox.includes('rgba(13, 13, 13, 0.05) 0px 0px 24px')], - ['web 中列 0.5px l2 发丝线', /rgba\(13, 13, 13, 0\.12\) 0px 0px 0px 0\.5px/.test(web.centerBox)], - ['web 侧栏边界 l1 细线', /rgba\(13, 13, 13, 0\.07\)\|(0\.5|1)px\|solid/.test(web.sidebarBorder)], - ['web 面板上沿影(负 spread)', /rgba\(13, 13, 13, 0\.05\) 0px -12px 24px -12px/.test(web.panelBox)], - ['web 面板撑满列高', web.panelH === 720], -); -let fail = 0; -for (const [name, ok] of checks) { if (!ok) fail += 1; console.log((ok ? 'PASS ' : 'FAIL ') + name); } -if (!noShot) { - for (const [sessionId, name] of [[sb, 'rightbar-before.png'], [sa, 'rightbar-after.png'], [sw, 'rightbar-web.png']]) { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - fs.writeFileSync(join(EVID, name), Buffer.from(shot.data, 'base64')); - console.log('SHOT assets/screenshots/' + name); - } -} -ws.close(); child.kill(); -console.log(fail === 0 ? 'ALL PASS (' + checks.length + ')' : fail + ' FAIL'); -process.exit(fail === 0 ? 0 : 1); diff --git a/scripts/settings-page-verify.mjs b/scripts/settings-page-verify.mjs deleted file mode 100644 index caeb4c6..0000000 --- a/scripts/settings-page-verify.mjs +++ /dev/null @@ -1,349 +0,0 @@ -#!/usr/bin/env node -/** - * settings-page-verify.mjs — 对真 GUI 取证:官方插件管理 → codex-ui 组合包页上那张设置卡。 - * - * 为什么必须真 GUI:座位 plugins.bundle.config 只在插件管理页存在时才出现,卡片的注入面 - * (scope / locale)由那一页的 owner 提供,夹具复刻不出来。 - * - * 用法: - * 1) DSH_HOME=<临时家> dsh --profile

--port 3098 --no-open (终端打印带 token 的 URL) - * 2) node scripts/settings-page-verify.mjs --url "http://127.0.0.1:3098/?token=..." [--out x.png] - * - * --explore 只把候选可点元素与页面文本打出来,用于定位入口,不做断言。 - */ -import { spawn } from 'node:child_process'; -import http from 'node:http'; -import fs from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromePath, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const arg = (name, fallback) => { - const i = process.argv.indexOf('--' + name); - return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : fallback; -}; -const flag = (name) => process.argv.includes('--' + name); -const TOKEN_URL = arg('url', ''); -const OUT = arg('out', join(WB, 'assets', 'screenshots', 'settings-page.png')); -const CHROME = arg('chrome', '') || chromePath(); -const PORT = Number(arg('cdp-port', '9341')); -const DPR = Number(arg('dpr', '1.5')); -const EXPLORE = flag('explore'); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -if (!TOKEN_URL) { console.error('缺少 --url "http://127.0.0.1:/?token=..."'); process.exit(2); } - -const origin = new URL(TOKEN_URL).origin; -const grab = (url) => new Promise((resolve, reject) => { - const u = new URL(url); - http.get({ hostname: u.hostname, port: u.port, path: u.pathname + u.search }, (res) => { - resolve((res.headers['set-cookie'] || [''])[0]); - res.resume(); - }).on('error', reject); -}); -const cookie = (await grab(TOKEN_URL)).split(';')[0]; -if (!cookie.includes('=')) { console.error('拿不到 dsh-auth cookie,token 可能已过期:' + TOKEN_URL); process.exit(2); } - -const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + PORT, - '--user-data-dir=' + tempDir('dsh-cdp-settings'), '--no-first-run', - '--no-default-browser-check', '--disable-gpu', '--window-size=1280,900', '--hide-scrollbars', 'about:blank'], { stdio: 'ignore' }); -let info; -for (let i = 0; i < 60 && !info; i += 1) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch { /* 未就绪 */ } if (!info) await sleep(250); } -if (!info) { console.error('Chromium 没起来:' + CHROME); process.exit(2); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const waiting = new Map(); -/** 页面控制台与未捕获异常:座位注册失败只留一条 warn,不抓就什么都看不见。 */ -const pageLogs = []; -ws.onmessage = (ev) => { - const m = JSON.parse(ev.data); - if (m.method === 'Runtime.consoleAPICalled' && (m.params.type === 'error' || m.params.type === 'warning')) { - pageLogs.push('console.' + m.params.type + ': ' + m.params.args.map((a) => String(a.value ?? a.description ?? '')).join(' ').slice(0, 300)); - } - if (m.method === 'Runtime.exceptionThrown') { - pageLogs.push('exception: ' + String(m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text).slice(0, 300)); - } - const fn = waiting.get(m.id); - if (fn) { waiting.delete(m.id); fn(m); } -}; -const send = (method, params = {}, sessionId) => new Promise((res, rej) => { - const id = ++seq; - waiting.set(id, (m) => (m.error ? rej(new Error(method + ': ' + JSON.stringify(m.error))) : res(m.result))); - ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); -}); -const evaluate = async (expression, sessionId) => (await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId)).result.value; -const click = async (x, y, sessionId) => { - await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, sessionId); - await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, sessionId); - await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, sessionId); -}; - -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Runtime.enable', {}, sessionId); -await send('Network.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: DPR, mobile: false }, sessionId); -const eq = cookie.indexOf('='); -await send('Network.setCookie', { name: cookie.slice(0, eq), value: cookie.slice(eq + 1), domain: new URL(origin).hostname, path: '/', httpOnly: true }, sessionId); -await send('Page.navigate', { url: origin + '/' }, sessionId); -await sleep(11000); - -/** 按可见文本点一个元素。 */ -const clickByText = async (pattern, sessionId) => evaluate('(() => {' - + 'const re = new RegExp(' + JSON.stringify(pattern) + ');' - + 'const els = [...document.querySelectorAll("button, a, [role=tab], [role=menuitem]")];' - + 'const el = els.find((e) => (re.test(e.getAttribute("aria-label") || "") || re.test(e.textContent.trim())) && e.offsetParent !== null);' - + 'if (!el) return null;' - + 'const r = el.getBoundingClientRect();' - + 'return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), text: (el.getAttribute("aria-label") || el.textContent.trim()).slice(0, 40) };' - + '})()', sessionId); - -const candidates = await evaluate('(() => [...document.querySelectorAll("button, a, [role=tab], [role=menuitem]")]' - + '.filter((e) => e.offsetParent !== null)' - + '.map((e) => (e.getAttribute("aria-label") || "") + " | " + e.textContent.trim().slice(0, 30))' - + '.filter((t) => t.trim() !== "|")' - + '.slice(0, 80))()', sessionId); -console.log('BOOT candidates: ' + JSON.stringify(candidates)); - -/** 关掉启动时的引导弹层:内测声明 → 配置 API Key。刷新后它们会再出现一次,所以抽成函数。 */ -const dismissOnboarding = async () => { - for (let round = 0; round < 5; round += 1) { - const dismiss = await clickByText('^(继续|稍后配置|跳过|知道了)$', sessionId); - if (dismiss === null) return; - await click(dismiss.x, dismiss.y, sessionId); - await sleep(1200); - console.log('DISMISS ' + dismiss.text); - } -}; -await dismissOnboarding(); - -/* ── 进插件管理页 ─────────────────────────────────────────────────────── */ -const entry = await clickByText('^插件$', sessionId); -if (entry === null) { console.error('FAIL 侧栏里没有「插件」入口:插件管理页在本 profile 里被停用了'); process.exit(1); } -await click(entry.x, entry.y, sessionId); -await sleep(2000); -console.log('PLUGINS page candidates: ' + JSON.stringify(await evaluate('(() => [...document.querySelectorAll("button, a, [role=tab], [role=menuitem], [role=button]")]' - + '.filter((e) => e.offsetParent !== null)' - + '.map((e) => (e.getAttribute("aria-label") || "") + " | " + e.textContent.trim().slice(0, 40))' - + '.slice(0, 60))()', sessionId))); -console.log('PLUGINS text: ' + JSON.stringify((await evaluate('document.body.innerText.slice(0, 3000)', sessionId)))); - -if (EXPLORE) { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64')); - console.log('SHOT ' + OUT); - ws.close(); child.kill(); - process.exit(0); -} - -/* ── 断言 ─────────────────────────────────────────────────────────────── */ -let failed = 0; -const ok = (name, extra = '') => console.log('ok ' + name + (extra ? ' ' + extra : '')); -const bad = (name, detail) => { failed += 1; console.error('FAIL ' + name + '\n ' + detail); }; -const check = (name, cond, detail) => (cond ? ok(name, typeof detail === 'string' ? detail : '') : bad(name, String(detail))); -const shot = async (file) => { - const s = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - fs.writeFileSync(file, Buffer.from(s.data, 'base64')); -}; -/** 读一个自定义属性的计算值。 */ -const cssVar = (name) => evaluate('getComputedStyle(document.documentElement).getPropertyValue(' + JSON.stringify(name) + ').trim()', sessionId); -/** 按 aria-label 点一个元素。 */ -const clickByLabel = async (label) => { - const box = await evaluate('(() => { const el = document.querySelector("[aria-label=" + JSON.stringify(' + JSON.stringify(label) + ') + "]");' - + 'if (!el) return null; const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); - if (box === null) return null; - await click(box.x, box.y, sessionId); - return box; -}; - -/* 打开 codex-ui 的组合包页 */ -const open = await clickByText('^查看 codex-ui$', sessionId); -if (open === null) { console.error('FAIL 已安装分组里没有 codex-ui 的「查看」入口'); process.exit(1); } -await click(open.x, open.y, sessionId); -await sleep(2500); -console.log('BUNDLE text: ' + JSON.stringify((await evaluate('document.body.innerText.slice(0, 1200)', sessionId)))); - -if (flag('dump')) { - console.log('FORM HTML: ' + JSON.stringify((await evaluate('(document.querySelector(".cx-form") || {outerHTML:null}).outerHTML.slice(0, 2500)', sessionId)))); - await shot(OUT); - ws.close(); child.kill(); - process.exit(0); -} -/** 有没有残留覆盖:覆盖层打了属性就说明用户层还有东西。 */ -const overridden = () => evaluate('document.documentElement.hasAttribute("data-codex-ui-theme")', sessionId); -/** 清掉所有已有覆盖,让每次运行都从默认态开始(幂等:反复点「重置」直到属性消失)。 */ -const resetAll = async () => { - for (let round = 0; round < 20; round += 1) { - if ((await overridden()) === false) return round; - const box = await evaluate('(() => {' - + 'const b = [...document.querySelectorAll("button")].find((x) => x.textContent.trim() === "重置" && x.offsetParent !== null);' - + 'if (!b) return null; const r = b.getBoundingClientRect();' - + 'return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); - if (box === null) return round; - await click(box.x, box.y, sessionId); - await sleep(800); - } - return 20; -}; -/* 等宿主把设置文档送达:覆盖层要等 scope 有值才打属性,早读会把「还没到」当成「没有覆盖」。 */ -await sleep(4000); -if (await overridden()) { - const rounds = await resetAll(); - await sleep(600); - console.log('RESET 清了 ' + rounds + ' 轮,残留覆盖=' + (await overridden())); -} -/* 起始态固定为亮色:主题控件就在这一页上,先摆平它再断言默认值。 */ -const segmentNow = await evaluate('(() => { const t = document.querySelector("[role=tab][aria-selected=true]"); return t === null ? null : t.textContent.trim(); })()', sessionId); -if (segmentNow !== null && segmentNow !== '亮色') { - const box = await clickByText('^亮色$', sessionId); - if (box !== null) { await click(box.x, box.y, sessionId); await sleep(1200); console.log('RESET 主题回亮色(原 ' + segmentNow + ')'); } -} - -const shape = JSON.parse(await evaluate('(() => {' - + 'const rows = [...document.querySelectorAll(".cx-row")];' - + 'return JSON.stringify({' - + ' form: document.querySelectorAll(".cx-form").length,' - + ' rows: rows.length,' - + ' labels: rows.map((r) => (r.querySelector(".cx-row__label") || {}).textContent || ""),' - + ' overrideAttr: document.documentElement.hasAttribute("data-codex-ui-theme"),' - + ' sidebar: getComputedStyle(document.documentElement).getPropertyValue("--dsw-alias-bg-sidebar").trim(),' - + ' link: getComputedStyle(document.documentElement).getPropertyValue("--dsw-alias-link").trim(),' - + '}); })()', sessionId)); -check('组合包页上有配置卡', shape.form === 1, JSON.stringify(shape)); -check('九行:主题/强调色/背景/前景/UI 字体/代码字体/半透明侧边栏/Codex 模型选择器/对比度', shape.rows === 9 && shape.labels.length === 9, JSON.stringify(shape.labels)); -check('默认值下不产生覆盖(无 data-codex-ui-theme)', shape.overrideAttr === false, 'attr=' + shape.overrideAttr); -/* 读 body 上的生效值:深色的链接默认是 #0169cc,与亮色不同,读 html 会永远看到亮色那一份。 */ -const bodyLink = await evaluate('getComputedStyle(document.body).getPropertyValue("--dsw-alias-link").trim()', sessionId); -check('默认强调色仍是皮肤给的那一支(亮 #339cff / 暗 #0169cc)', ['#339cff', '#0169cc'].includes(bodyLink.toLowerCase()), 'link=' + bodyLink); -await shot(OUT.replace(/\.png$/, '-default.png')); - -/* 拨「半透明侧边栏」 */ -const toggled = await clickByLabel('半透明侧边栏'); -check('找到半透明侧边栏开关', toggled !== null, JSON.stringify(toggled)); -await sleep(1500); -const afterToggle = JSON.parse(await evaluate('(() => {' - + 'const root = document.documentElement;' - + 'return JSON.stringify({' - + ' overrideAttr: root.hasAttribute("data-codex-ui-theme"),' - + ' sidebar: getComputedStyle(root).getPropertyValue("--dsw-alias-bg-sidebar").trim(),' - + ' tag: ([...document.querySelectorAll("style")].find((s) => s.dataset.pluginCss === "codex-ui/settings-override.css") || { textContent: "" }).textContent' - + '}); })()', sessionId)); -check('开覆盖后打上 data-codex-ui-theme', afterToggle.overrideAttr === true, JSON.stringify(afterToggle).slice(0, 300)); -check('侧栏填充转为半透明 rgba(255,255,255,0.72)', afterToggle.sidebar.replace(/\s/g, '') === 'rgba(255,255,255,0.72)', 'sidebar=' + afterToggle.sidebar); -check('覆盖样式表只写覆盖项', afterToggle.tag.includes('--dsw-alias-bg-sidebar') && afterToggle.tag.includes('data-codex-ui-theme'), afterToggle.tag.slice(0, 200)); -await shot(OUT.replace(/\.png$/, '-translucent.png')); - -/* 写一个强调色:焦点 + 输入 + 回车 */ -const hexBox = await clickByLabel('强调色 hex'); -check('找到强调色的十六进制输入框', hexBox !== null, JSON.stringify(hexBox)); -if (hexBox !== null) { - await send('Input.insertText', { text: '#ff0000' }, sessionId); - await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, sessionId); - await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, sessionId); - await sleep(1500); - const link = await cssVar('--dsw-alias-link'); - check('回车提交后强调色生效', link.toLowerCase() === '#ff0000', 'link=' + link); - const marked = await evaluate('!!document.querySelector(".cx-row .cx-row__label span:nth-child(2)")', sessionId); - check('该行显示「已覆盖」徽标', marked === true, 'marked=' + marked); - await shot(OUT.replace(/\.png$/, '-accent.png')); -} - -/* 关掉「Codex 模型选择器」:刷新后在首页量宿主那一格是否复原(组合包页上没有输入区可量)。 */ -const pickerSwitch = () => evaluate('(() => { const el = document.querySelector("[aria-label=\\"Codex 模型选择器\\"]"); return el === null ? null : (el.getAttribute("aria-checked") ?? String(el.checked)); })()', sessionId); -check('模型选择器开关默认开着', (await pickerSwitch()) === 'true', 'checked=' + (await pickerSwitch())); -const pickerOff = await clickByLabel('Codex 模型选择器'); -check('找到模型选择器开关', pickerOff !== null, JSON.stringify(pickerOff)); -await sleep(1500); -check('开关写入后显示为关', (await pickerSwitch()) === 'false', 'checked=' + (await pickerSwitch())); - -/* ── 主题那一行:写的是宿主主题偏好,整个应用一起变 ───────────────────── */ -/** 读分段控件当前选中的那一段文本。 */ -const selectedSegment = () => evaluate('(() => {' - + 'const tab = document.querySelector("[role=tab][aria-selected=true]");' - + 'return tab === null ? null : tab.textContent.trim(); })()', sessionId); -/** 读 body 上的令牌:深色令牌落在 body 上,不在 html。 */ -const bodyVar = (name) => evaluate('getComputedStyle(document.body).getPropertyValue(' + JSON.stringify(name) + ').trim()', sessionId); - -const beforeTheme = { segment: await selectedSegment(), base: await bodyVar('--dsw-alias-bg-base'), dark: await evaluate('document.body.hasAttribute("data-ds-dark-theme")', sessionId) }; -console.log('THEME 切换前 ' + JSON.stringify(beforeTheme)); -check('主题行存在且显示了当前偏好', beforeTheme.segment !== null, 'segment=' + beforeTheme.segment); - -const toDark = await clickByText('^深色$', sessionId); -check('找到「深色」分段', toDark !== null, JSON.stringify(toDark)); -if (toDark !== null) { - /* 预览层:点下去要**立刻**变色,而不是等设置文档往返(那一次实测 780–824ms)。 */ - await click(toDark.x, toDark.y, sessionId); - const t0 = Date.now(); - let flipMs = null; - for (let i = 0; i < 50; i += 1) { - if (await evaluate('document.body.hasAttribute("data-ds-dark-theme")', sessionId)) { flipMs = Date.now() - t0; break; } - await sleep(40); - } - console.log('THEME 点击 → 变色 ' + flipMs + 'ms'); - check('点下去立刻变色(≤300ms,预览层生效)', flipMs !== null && flipMs <= 300, 'flip=' + flipMs + 'ms'); - await sleep(1500); - const settled = await evaluate('({ preview: document.documentElement.getAttribute("data-codex-ui-preview"), scheme: document.documentElement.style.colorScheme })', sessionId); - check('文档落地后预览标记被摘掉(没有卡在预览态)', settled.preview === null, JSON.stringify(settled)); - const swatchExpr = '(() => { const el = [...document.querySelectorAll(".cx-swatch")].find((x) => x.getAttribute("aria-label") === "背景"); return el === undefined ? null : el.value; })()'; - const dark = { - dark: await evaluate('document.body.hasAttribute("data-ds-dark-theme")', sessionId), - base: await bodyVar('--dsw-alias-bg-base'), - segment: await selectedSegment(), - swatch: await evaluate(swatchExpr, sessionId), - }; - console.log('THEME 切到深色 ' + JSON.stringify(dark)); - check('切深色后整个应用进了深色(body[data-ds-dark-theme])', dark.dark === true, JSON.stringify(dark)); - /* 深色窗口背景自 0.5.6 起是 #111111(#181818 是表面/侧栏那一层)。 */ - check('深色底色生效 #111111', String(dark.base).toLowerCase() === '#111111', 'base=' + dark.base); - check('分段显示深色', dark.segment === '深色', 'segment=' + dark.segment); - check('下面三行改为编辑深色那一套(背景色块 = #111111)', String(dark.swatch).toLowerCase() === '#111111', 'swatch=' + dark.swatch); - await shot(OUT.replace(/\.png$/, '-dark.png')); -} - -/* 刷新后覆盖仍在(设置是持久化的,不是页面内存) */ -await send('Page.navigate', { url: origin + '/' }, sessionId); -await sleep(11000); -const afterReload = JSON.parse(await evaluate('(() => {' - + 'const root = document.documentElement;' - + 'return JSON.stringify({' - + ' overrideAttr: root.hasAttribute("data-codex-ui-theme"),' - + ' link: getComputedStyle(root).getPropertyValue("--dsw-alias-link").trim(),' - + ' sidebar: getComputedStyle(root).getPropertyValue("--dsw-alias-bg-sidebar").trim(),' - + ' skin: root.hasAttribute("data-codex-ui")' - + '}); })()', sessionId)); -check('刷新后皮肤仍生效', afterReload.skin === true, JSON.stringify(afterReload)); -check('刷新后覆盖仍在(强调色 #ff0000)', afterReload.link.toLowerCase() === '#ff0000', 'link=' + afterReload.link); -check('刷新后侧栏半透明仍在', afterReload.sidebar.replace(/\s/g, '') === 'rgba(255,255,255,0.72)', 'sidebar=' + afterReload.sidebar); -check('刷新后主题仍是深色(写的是宿主偏好,不是页面状态)', await evaluate('document.body.hasAttribute("data-ds-dark-theme")', sessionId) === true, JSON.stringify(afterReload)); -const seatOff = JSON.parse(await evaluate('(() => { const slot = document.querySelector("[data-slot=\\"conversation.input.model\\"]");' - + 'return JSON.stringify({ slot: slot !== null, ours: document.querySelectorAll(".codex-mp-trigger").length,' - + ' host: slot === null ? null : [...slot.children].filter((c) => getComputedStyle(c).display !== "none").length }); })()', sessionId)); -check('选择器关着:席位里没有自建触发器、宿主那一格可见', seatOff.slot === true && seatOff.ours === 0 && seatOff.host >= 1, JSON.stringify(seatOff)); - -/* 收工把主题还给亮色:刷新后已经在首页,先走回组合包页,别留下深色现场。 */ -const onCard = async () => (await evaluate('document.querySelectorAll(".cx-form").length', sessionId)) > 0; -await dismissOnboarding(); -if (!(await onCard())) { - const back = await clickByText('^插件$', sessionId); - if (back !== null) { await click(back.x, back.y, sessionId); await sleep(2200); } - const reopen = await clickByText('^查看 codex-ui$', sessionId); - if (reopen !== null) { await click(reopen.x, reopen.y, sessionId); await sleep(2500); } -} -const toLight = await clickByText('^亮色$', sessionId); -if (toLight !== null) { await click(toLight.x, toLight.y, sessionId); await sleep(1500); } -/* 模型选择器开关还原:清掉用户层那一格(点这一行的「重置」),回到默认开。 */ -const pickerReset = await evaluate('(() => { const row = [...document.querySelectorAll(".cx-row")].find((r) => (r.querySelector(".cx-row__label") || {}).textContent?.includes("Codex 模型选择器"));' - + 'const b = row === undefined ? null : [...row.querySelectorAll("button")].find((x) => x.textContent.trim() === "重置");' - + 'if (!b) return null; const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); -if (pickerReset !== null) { await click(pickerReset.x, pickerReset.y, sessionId); await sleep(1200); } -check('收工复位:模型选择器回到默认开', (await pickerSwitch()) === 'true', 'checked=' + (await pickerSwitch())); -const restored = { segment: await selectedSegment(), base: await bodyVar('--dsw-alias-bg-base') }; -console.log('THEME 复位 ' + JSON.stringify(restored)); -check('收工复位回亮色', String(restored.base).toLowerCase() === '#ffffff' && restored.segment === '亮色', JSON.stringify(restored)); - -if (pageLogs.length > 0) console.log('\nPAGE LOGS:\n ' + pageLogs.slice(0, 12).join('\n ')); -console.log('\n' + (failed === 0 ? 'PASS:全部通过' : 'FAIL:' + failed + ' 项未通过')); -ws.close(); child.kill(); -process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/sidebar-align-verify.mjs b/scripts/sidebar-align-verify.mjs deleted file mode 100644 index 0452f31..0000000 --- a/scripts/sidebar-align-verify.mjs +++ /dev/null @@ -1,328 +0,0 @@ -#!/usr/bin/env node -/** - * sidebar-align-verify.mjs — 侧栏「新会话 / 插件」行 ↔「工作区」下方行 的列对齐验收。 - * - * 为什么用夹具而不是截屏: - * DSH 桌面壳的 launch token 只活在宿主进程内存里(dsh-client-connection/lib/index.js:244-250), - * headless 打不开 19387;宿主又独占 profiles/desktop,起不了第二个实例。 - * 所以这里用**真实 shipped CSS + 按 shipped 渲染代码复刻的 DOM + 本插件真实 theme.css** 渲染夹具, - * 用 getBoundingClientRect 读几何 —— 不接受「看起来像」。 - * - * 样式来源:DSH 桌面壳的 app.asar(= 用户实际在跑的那一份)。 - * --source global 切到 npm 全局安装(0.1.7-rc.1,DOM 少一层 newSessionLabelMask),仅供回归对照。 - * - * 用法:node scripts/sidebar-align-verify.mjs [--source desktop|global] [--no-shot] - */ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { asarPath, chromePath, globalModules, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const EVID = join(WB, 'assets', 'screenshots'); -/** 夹具 HTML 是中间产物,落系统临时目录,不堆进 assets/screenshots/。 */ -const FIX = join(tmpdir(), 'codex-ui-fixtures'); -const THEME = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); - -const ASAR = asarPath(); -const GLOBAL = globalModules(); -const sourceArg = (() => { - const i = process.argv.indexOf('--source'); - return i >= 0 ? (process.argv[i + 1] ?? 'desktop') : 'desktop'; -})(); - -/* ── 取样式文本 ───────────────────────────────────────────────────────── */ - -/** 从引号开始扫一个 JS 字符串字面量,按反斜杠转义配对(CSS 里有转义引号,不能用 indexOf 找结尾)。 */ -function readStringLiteral(src, quoteIndex) { - let out = ''; - for (let i = quoteIndex + 1; i < src.length; i += 1) { - const ch = src[i]; - if (ch === '\\') { out += src[i + 1]; i += 1; continue; } - if (ch === '"') return out; - out += ch; - } - throw new Error('unterminated string literal at ' + quoteIndex); -} - -/** 取某个 .module.css 的内联样式串:按 tagId 回退到它前面最近的一条字符串字面量声明。 */ -function cssFor(src, moduleFile) { - const i = src.indexOf(moduleFile); - if (i < 0) throw new Error('tagId not found: ' + moduleFile); - const re = /(?:const|var)\s+[\w$]+\s*=\s*"/g; - let last = null, hit; - while ((hit = re.exec(src)) !== null && hit.index < i) { - const text = readStringLiteral(src, hit.index + hit[0].length - 1); - /* tagId 自己也是一条 `const tagId = "…"`,且排在样式串后面 —— 只认含花括号的那条。 */ - if (text.includes('{')) last = text; - } - if (last === null) throw new Error('css declaration not found for ' + moduleFile); - return last; -} - -/** 取某个 Module_css_default 类映射表。 */ -function mapFor(src, varName) { - const i = src.indexOf('var ' + varName + ' = {'); - if (i < 0) throw new Error('class map not found: ' + varName); - const start = src.indexOf('{', i); - const end = src.indexOf('};', start); - const out = {}; - for (const m of src.slice(start, end + 1).matchAll(/"([^"]+)":\s*"([^"]+)"/g)) out[m[1]] = m[2]; - return out; -} - -/* ── 从 app.asar 读文件(桌面壳的唯一真源) ────────────────────────────── */ -function asarReader(archive) { - const fd = fs.openSync(archive, 'r'); - const head = Buffer.alloc(16); - fs.readSync(fd, head, 0, 16, 0); - const headerSize = head.readUInt32LE(12); - const hb = Buffer.alloc(headerSize); - fs.readSync(fd, hb, 0, headerSize, 16); - const header = JSON.parse(hb.toString('utf8')); - const base = 16 + headerSize; - return (p) => { - let node = header; - for (const seg of p.split('/').filter(Boolean)) { - node = node.files?.[seg]; - if (node === undefined) return null; - } - const buf = Buffer.alloc(node.size); - fs.readSync(fd, buf, 0, node.size, base + Number(node.offset)); - return buf.toString('utf8'); - }; -} - -let readShipped; -let sourceLabel; -if (sourceArg === 'global') { - sourceLabel = 'npm 全局安装 0.1.7-rc.1'; - readShipped = (p) => { - const file = join(GLOBAL, p); - return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null; - }; -} else { - sourceLabel = 'DSH 桌面壳 app.asar'; - const read = asarReader(ASAR); - readShipped = (p) => read('/dsh/node_modules/' + p); -} - -const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar/lib/client.js'; -const WORKSPACE = '@deepseek-ai/dsh-client-ui-workspace/lib/client.js'; -const THEME_PKG = '@deepseek-ai/dsh-client-ui-theme/lib/client.js'; - -const sidebarSrc = readShipped(SIDEBAR); -const workspaceSrc = readShipped(WORKSPACE); -const themeSrc = readShipped(THEME_PKG); -if (sidebarSrc === null || workspaceSrc === null || themeSrc === null) { - throw new Error('shipped bundles not found via --source ' + sourceArg); -} - -/* ui-theme 令牌层:shipped 组件 CSS 消费 --dsw-radius-md / --dsw-alias-*,皮肤只覆盖其中一部分。 - 变量名跨版本会变(rc.1 与 rc.2 不同),故按「`*_css_default` + 含 --dsw- 令牌」全量收集,顺序即层叠顺序。 */ -const themeLayers = (() => { - const re = /(?:const|var|let)\s+([\w$]+_css_default)\s*=\s*"/g; - const parts = []; - let m; - while ((m = re.exec(themeSrc)) !== null) { - const text = readStringLiteral(themeSrc, m.index + m[0].length - 1); - if (text.includes('--dsw-')) parts.push(text); - } - if (parts.length === 0) throw new Error('no ui-theme token layers found in ' + THEME_PKG); - return parts.join('\n'); -})(); - -const sidebarCss = cssFor(sidebarSrc, '@deepseek-ai/dsh-client-ui-sidebar/SidebarRoot.module.css'); -const wsBrowserCss = cssFor(workspaceSrc, '@deepseek-ai/dsh-client-ui-workspace/WorkspaceBrowser.module.css'); -const rowsCss = cssFor(workspaceSrc, '@deepseek-ai/dsh-client-ui-workspace/Rows.module.css'); - -const S = mapFor(sidebarSrc, 'SidebarRoot_module_css_default'); -const W = mapFor(workspaceSrc, 'WorkspaceBrowser_module_css_default'); -const R = mapFor(workspaceSrc, 'Rows_module_css_default'); - -const hasMaskLayer = /newSessionLabelMask/.test(sidebarCss); -console.log('样式来源 : ' + sourceLabel); -console.log('New Session DOM : ' + (hasMaskLayer - ? 'rc.2 嵌套(button > mask > content > svg + label)' - : 'rc.1 扁平(button > svg + label)')); - -/* ── 复刻 DOM ──────────────────────────────────────────────────────────── */ -const svg = (size) => ''; -const NEWCHAT = ''; -const FOLDER = ''; - -/** 侧栏外壳 + 工作区浏览器;结构与 shipped 渲染代码一致,类名取自真实 CSS-Modules 映射。 */ -function stage() { - return '' - + '

' - + '
' - + '' - + '' + svg(24) + '' - + 'deepseek' - + '' - + '' - + '
' - + '' - + '' - + '
' - + '
' - + '
' - + '
' - + '工作区' - + '
' - + '
' - + '
' - + '
' - + group('PROJIECT', true) - + group('未分组', false) - + '
' - + '
' - + '
' - + '
' - + '
' - + '
'; -} - -function group(label, withSession) { - return '
' - + '
' - + '' + FOLDER + '' - + '' + svg(16) + '' - + '' + label + '' - + '' - + '
' - + (withSession - ? '
' - + '' - + '/ui-ux-pro-max dsh 模型选择器' - + '10分钟' - + '' - + '
' - : '') - + '
'; -} - -const clsMap = JSON.stringify({ S, W, R }).replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<'); -const page = '' - + '' - + '' - + '' - + '
' - + '

原生 shipped(无 codex-ui)

' + stage() + '
' - + '

+ codex-ui theme.css

' + stage() + '
' - + '
'; - -fs.mkdirSync(FIX, { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const htmlPath = join(FIX, 'sidebar-align-verify.html'); -fs.writeFileSync(htmlPath, page); - -/* 左联要「无 codex-ui」:插件样式表是最后一条 ' - + '' - + '' - + '' - + '
' + stage('A', false) + stage('At', true) + '
'; - -fs.mkdirSync(FIX, { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const htmlPath = join(FIX, 'sidebar-surface-verify.html'); -fs.writeFileSync(htmlPath, page); -const probe = fs.readFileSync(join(HERE, 'fixtures', 'sidebar-surface.probe.js'), 'utf8'); -/* ── PNG 解码(Chromium 出的是 8 位非隔行 PNG;只用 zlib + 标准反滤波) ──── */ - -function decodePng(buf) { - if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a png'); - let off = 8, w = 0, h = 0, bitDepth = 0, colorType = 0; - const idat = []; - while (off + 8 <= buf.length) { - const len = buf.readUInt32BE(off); - const type = buf.toString('ascii', off + 4, off + 8); - const data = buf.subarray(off + 8, off + 8 + len); - if (type === 'IHDR') { - w = data.readUInt32BE(0); h = data.readUInt32BE(4); - bitDepth = data[8]; colorType = data[9]; - if (data[12] !== 0) throw new Error('interlaced png unsupported'); - } else if (type === 'IDAT') idat.push(data); - else if (type === 'IEND') break; - off += 12 + len; - } - if (bitDepth !== 8) throw new Error('unsupported bit depth ' + bitDepth); - const ch = colorType === 6 ? 4 : colorType === 2 ? 3 : 0; - if (ch === 0) throw new Error('unsupported color type ' + colorType); - const raw = zlib.inflateSync(Buffer.concat(idat)); - const stride = w * ch; - const out = Buffer.alloc(h * stride); - let p = 0; - for (let y = 0; y < h; y += 1) { - const filter = raw[p]; p += 1; - const line = raw.subarray(p, p + stride); p += stride; - const prev = y === 0 ? null : out.subarray((y - 1) * stride, y * stride); - const cur = out.subarray(y * stride, (y + 1) * stride); - for (let x = 0; x < stride; x += 1) { - const a = x >= ch ? cur[x - ch] : 0; - const b = prev === null ? 0 : prev[x]; - const c = prev === null || x < ch ? 0 : prev[x - ch]; - const v = line[x]; - let r; - if (filter === 0) r = v; - else if (filter === 1) r = v + a; - else if (filter === 2) r = v + b; - else if (filter === 3) r = v + ((a + b) >> 1); - else if (filter === 4) { - const pp = a + b - c; - const pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); - r = v + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); - } else throw new Error('filter ' + filter); - cur[x] = r & 0xff; - } - } - return { w, h, ch, data: out }; -} - -/** 一行像素在某段 x 区间上的平均亮度(0–255,Rec.601)。 */ -function rowLum(img, y, x0, x1) { - let sum = 0, n = 0; - for (let x = x0; x < x1; x += 1) { - const o = (y * img.w + x) * img.ch; - sum += 0.299 * img.data[o] + 0.587 * img.data[o + 1] + 0.114 * img.data[o + 2]; - n += 1; - } - return n === 0 ? 0 : sum / n; -} - -/* ── 无头 Chromium + CDP ─────────────────────────────────────────────────── */ - -const CHROME = chromePath(); -const PORT = 9352; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const PROFILE = tempDir('dsh-cdp-sidebar-surface'); -fs.rmSync(PROFILE, { recursive: true, force: true }); -const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + PORT, - '--user-data-dir=' + PROFILE, - '--no-first-run', '--disable-gpu', '--window-size=900,760', 'about:blank'], { stdio: 'ignore' }); -let info; -for (let i = 0; i < 60 && info === undefined; i += 1) { - try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch { /* not up yet */ } - if (info === undefined) await sleep(250); -} -if (info === undefined) { child.kill(); throw new Error('headless chrome did not start'); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; -const pending = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }; -const send = (method, params = {}, sid) => new Promise((res, rej) => { - const id = ++seq; - pending.set(id, (m) => (m.error ? rej(new Error(method + ': ' + JSON.stringify(m.error))) : res(m.result))); - ws.send(JSON.stringify({ id, method, params, ...(sid ? { sessionId: sid } : {}) })); -}); -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 900, height: 760, deviceScaleFactor: 1, mobile: false }, sessionId); -await send('Page.navigate', { url: 'file:///' + htmlPath.replaceAll('\\', '/') + '?v=' + Date.now() }, sessionId); -await sleep(1200); - -const settle = async () => { - await send('Runtime.evaluate', { - expression: 'new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(1))))', - returnByValue: true, awaitPromise: true, - }, sessionId); -}; - -/** 在同一个页面上切 data-codex-ui,读一侧的探针 + 截一条底部横带。 */ -async function measure(skinOn, listBottom) { - await send('Runtime.evaluate', { - expression: skinOn - ? 'document.documentElement.setAttribute("data-codex-ui","")' - : 'document.documentElement.removeAttribute("data-codex-ui")', - returnByValue: true, - }, sessionId); - await settle(); - const r = await send('Runtime.evaluate', { expression: probe, returnByValue: true, awaitPromise: true }, sessionId); - if (r.result?.value === undefined) throw new Error('probe failed: ' + JSON.stringify(r).slice(0, 400)); - const styles = JSON.parse(r.result.value); - const clip = { x: 0, y: Math.round(listBottom - 60), width: 900, height: 60, scale: 1 }; - const shot = await send('Page.captureScreenshot', { format: 'png', clip }, sessionId); - return { styles, img: decodePng(Buffer.from(shot.data, 'base64')), clip }; -} - -/* 先用一次原生渲染拿到几何(所有列同高同位置)。 */ -const first = await measure(false, 0); -const LIST = first.styles.cols.A.list; -const BOTTOM = LIST.y + LIST.h; -const native = await measure(false, BOTTOM); -const skinned = await measure(true, BOTTOM); - -const bandFor = (img, side, column) => { - const c = (side === 'skin' ? skinned : native).styles.cols[column].list; - const x0 = Math.round(c.x + 60), x1 = Math.round(c.x + 120); - const sb0 = Math.round(c.right - 10), sb1 = Math.round(c.right - 2); - const rows = []; - for (let i = 0; i < 60; i += 1) { - rows.push({ - y: 60 - 1 - i, /* 距滚动视口底边的像素数 */ - lum: rowLum(img, i, x0, x1), - scrollbarLum: rowLum(img, i, sb0, sb1), - }); - } - return rows; -}; - -/** 由亮度反推遮罩 alpha:alpha = (bg - lum) / (bg - ink),bg/ink 用同一条曲线自标定。 */ -function alphaCurve(rows) { - const lums = rows.map((r) => r.lum); - const bg = Math.max(...lums); - const ink = Math.min(...lums); - const span = Math.max(1, bg - ink); - return { bg, ink, rows, alpha: rows.map((r) => ({ y: r.y, a: (bg - r.lum) / span, lum: r.lum })) }; -} - -const curves = { - A: alphaCurve(bandFor(native.img, 'native', 'A')), - At: alphaCurve(bandFor(native.img, 'native', 'At')), - B: alphaCurve(bandFor(skinned.img, 'skin', 'A')), - Bt: alphaCurve(bandFor(skinned.img, 'skin', 'At')), -}; -const at = (c, y) => c.alpha.find((p) => p.y === y).a; -const lumAt = (c, y) => c.rows.find((p) => p.y === y).lum; - -/* ── 报告 ────────────────────────────────────────────────────────────────── */ - -const S0 = skinned.styles.cols.A, S1 = native.styles.cols.A; -console.log(''); -console.log('① 机制(计算样式)'); -console.log(' 原生 A 滚动容器 mask-image : ' + S1.maskImage); -console.log(' codex-ui B 滚动容器 mask-image : ' + S0.maskImage); -console.log(' codex-ui B mask-size : ' + S0.maskSize); -console.log(' codex-ui B mask-position : ' + S0.maskPosition); -console.log(' codex-ui B mask-repeat : ' + S0.maskRepeat); -console.log(' 原生 A 宿主覆盖层 display : ' + S1.fadeDisplay + ' rect ' + JSON.stringify(S1.fadeRect)); -console.log(' codex-ui B 宿主覆盖层 display : ' + S0.fadeDisplay); -console.log(' 侧栏底(A / At) : ' + native.styles.cols.A.fill + ' / ' + native.styles.cols.At.fill); -console.log(' 自标定 bg / ink(A B At Bt) : ' - + Object.entries(curves).map(([k, v]) => k + ' ' + v.bg.toFixed(1) + '/' + v.ink.toFixed(1)).join(' ')); - -console.log(''); -console.log('② 遮罩 alpha 曲线(距滚动视口底边 y 像素处的可见度;1 = 全显,0 = 全隐)'); -console.log(' y A(原生) B(codex-ui) A−B | At(原生·半透明) Bt(codex-ui·半透明)'); -for (let y = 56; y >= 0; y -= 4) { - const a = at(curves.A, y), b = at(curves.B, y), a2 = at(curves.At, y), b2 = at(curves.Bt, y); - console.log(' ' + String(y).padStart(3) - + ' ' + a.toFixed(3) + ' ' + b.toFixed(3) + ' ' + (a - b).toFixed(3) - + ' | ' + a2.toFixed(3) + ' ' + b2.toFixed(3)); -} - -console.log(''); -console.log('④ 原始亮度(0–255,同一行内容 #111 = 17、侧栏底 ≈ 244):底部 0–4px 上「内容还看得见吗」'); -console.log(' y A B | At Bt ← 半透明底那两组才是本层的判据'); -for (const y of [0, 1, 2, 4, 8, 16, 24, 40]) { - console.log(' ' + String(y).padStart(3) - + ' ' + lumAt(curves.A, y).toFixed(1).padStart(6) + lumAt(curves.B, y).toFixed(1).padStart(7) - + ' | ' + lumAt(curves.At, y).toFixed(1).padStart(6) + lumAt(curves.Bt, y).toFixed(1).padStart(7)); -} -const bottomOpaque = Math.abs(lumAt(curves.A, 0) - lumAt(curves.B, 0)); -const bottomTranslucent = lumAt(curves.Bt, 0) - lumAt(curves.At, 0); -const fillInvariance = Math.max(...[0, 2, 4, 8, 16, 24, 40].map((y) => Math.abs(lumAt(curves.B, y) - lumAt(curves.Bt, y)))); - -const headDiff = Math.max(...[4, 8, 12, 16, 20, 24].map((y) => Math.abs(at(curves.A, y) - at(curves.B, y)))); -const tailDiff = Math.max(...[28, 32, 36, 40, 44].map((y) => Math.abs(at(curves.A, y) - at(curves.B, y)))); -const invarB = Math.max(...[0, 4, 8, 12, 16, 24, 40].map((y) => Math.abs(at(curves.B, y) - at(curves.Bt, y)))); -const invarA = Math.max(...[0, 4, 8, 12, 16, 24, 40].map((y) => Math.abs(at(curves.At, y) - at(curves.A, y)))); - -/* ── 断言 ────────────────────────────────────────────────────────────────── */ - -const checks = []; -const ok = (label, pass, got) => checks.push([label, pass, got]); - -ok('原生侧栏:滚动容器没有 mask', S1.maskImage === 'none', S1.maskImage); -ok('codex-ui:滚动容器有 2 层 mask', (S0.maskImage.match(/linear-gradient\(/g) ?? []).length === 2, S0.maskImage); -ok('两列几何一致(宽度/高度/列表底边)', - Math.abs(native.styles.cols.At.list.h - S1.list.h) < 0.5 && Math.abs(native.styles.cols.At.list.y - S1.list.y) < 0.5, - 'At h=' + native.styles.cols.At.list.h.toFixed(1) + ' A h=' + S1.list.h.toFixed(1)); -ok('codex-ui:第 2 层 mask 宽 = 12px(让开滚动条槽)', - /(^|[\s,])12px\s+100%(,|$)/.test(S0.maskSize.replace(/\s+/g, ' ')), S0.maskSize); -ok('codex-ui:第 1 层 mask 宽 = 100% − 12px', - S0.maskSize.replace(/\s+/g, ' ').startsWith('calc(100% - 12px) 100%'), S0.maskSize); -ok('原生侧栏:宿主覆盖层在渲染', S1.fadeDisplay !== 'none', S1.fadeDisplay); -ok('codex-ui:宿主覆盖层已让位', S0.fadeDisplay === 'none', S0.fadeDisplay); -/* e0 / 85 / 2e / 00 是 Codex 原文的十六进制 alpha;浏览器把它算成 0.88 / 0.52 / 0.18 / 0。 */ -ok('codex-ui:渐变里有 Codex 的四段 alpha 停点(e0/85/2e/00 → .88/.52/.18/0)', - /\(0, 0, 0, 0\.88\)/.test(S0.maskImage) && /\(0, 0, 0, 0\.52\)/.test(S0.maskImage) - && /\(0, 0, 0, 0\.18\)/.test(S0.maskImage) && /\(0, 0, 0, 0\) 100%/.test(S0.maskImage), S0.maskImage); -ok('不透明底下:前 24px 与宿主覆盖层逐点差 ≤ 0.13(如实记录:外观几乎等价)', headDiff <= 0.13, headDiff.toFixed(3)); -ok('不透明底下:24–40px 尾巴确有差别(这是本层唯一的形状差)', tailDiff > 0.02, tailDiff.toFixed(3)); -ok('不透明底:两种机制的底边结果一致(|A−B| ≤ 15)', bottomOpaque <= 15, bottomOpaque.toFixed(1)); -ok('半透明底:宿主覆盖层挡不住内容(At 底边比 Bt 暗 ≥ 40)', bottomTranslucent >= 40, bottomTranslucent.toFixed(1)); -ok('codex-ui 的 mask 对侧栏底色 alpha 免疫(|B−Bt| ≤ 15)', fillInvariance <= 15, fillInvariance.toFixed(1)); -void invarA; void invarB; - -console.log(''); -console.log('③ 断言'); -let failed = 0; -for (const [label, pass, got] of checks) { - if (!pass) failed += 1; - console.log(' ' + (pass ? 'PASS' : 'FAIL') + ' ' + label.padEnd(52) + ' 实测 ' + got); -} -console.log(''); -console.log(failed === 0 ? 'RESULT PASS ' + checks.length + '/' + checks.length - : 'RESULT FAIL ' + (checks.length - failed) + '/' + checks.length); - -if (!process.argv.includes('--no-shot')) { - await send('Runtime.evaluate', { expression: 'document.documentElement.setAttribute("data-codex-ui","")', returnByValue: true }, sessionId); - await settle(); - const shot = await send('Page.captureScreenshot', { - format: 'png', - clip: { x: 0, y: Math.round(BOTTOM - 120), width: 560, height: 130, scale: 2 }, - }, sessionId); - fs.writeFileSync(join(EVID, 'sidebar-surface-verify.png'), Buffer.from(shot.data, 'base64')); - console.log('WROTE ' + join(EVID, 'sidebar-surface-verify.png')); -} -ws.close(); child.kill(); -process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/specs/composer.mjs b/scripts/specs/composer.mjs new file mode 100644 index 0000000..fcc8100 --- /dev/null +++ b/scripts/specs/composer.mjs @@ -0,0 +1,337 @@ +/** + * 输入区:⑱ 输入卡阴影(composer-shadow)与 ⑬·3 顶栏瘦身 + ⑭ hero + ⑰ 底部控件(hero)。 + * 宿主样式取 dsh-client-ui-conversation 的 ConversationRoot / InputBar 与 agent-preset 的 AgentPresetLabel。 + */ +import { cssFor, mapFor } from '../lib/host.mjs'; + +const CONVERSATION = '@deepseek-ai/dsh-client-ui-conversation/lib/client.js'; + +/** 会话区与输入栏的宿主样式及类名映射。 */ +function conversation(host) { + const src = host.file(CONVERSATION); + return { + css: '' + + '', + R: mapFor(src, 'ConversationRoot_module_css_default'), + B: mapFor(src, 'InputBar_module_css_default'), + }; +} + +/* ── ⑱ 输入卡阴影 ─────────────────────────────────────────────────────── + 判据来源:Codex 桌面端 26.924.2738.0 的 resources/app.asar @67930943 逐字: + --elevation-composer: 0 0 0 1px #0000000a, 0 2px 8px 0 #0000000a, 0 4px 80px 8px #00000006; + --elevation-composer-dark: inset 0 0 1px 0 #fff3 + @media (width<40rem) 时远场 80px → 40px + 量四件事:① 亮色三层的几何与 alpha;② 暗色单层 inset 高光、卡外零投影;③ 窄屏远场 40px; + ④ 真实渲染像素:亮色衰减半径够长(远场真的画出来了)、暗色卡内顶边确实被点亮。 */ + +/** 深色令牌声明在 body[data-ds-dark-theme] 上(不是 :root),取令牌以 body 为先、html 兜底。 */ +function probeCard() { + const b = getComputedStyle(document.body); + const h = getComputedStyle(document.documentElement); + const tok = (n) => b.getPropertyValue(n).trim() || h.getPropertyValue(n).trim(); + const card = document.querySelector('[data-composer-card]'); + const cs = getComputedStyle(card); + const r = card.getBoundingClientRect(); + return { + shadow: cs.boxShadow, + bg: cs.backgroundColor, + varShadow: getComputedStyle(document.querySelector('[data-conversation-scroll]')).getPropertyValue('--dcu-composer-shadow').trim(), + canvasBase: tok('--dsw-alias-bg-base'), + surface: tok('--dsw-composer-surface'), + dark: document.body.hasAttribute('data-ds-dark-theme'), + rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }, + }; +} + +/** 把计算值拆成层:inset 标记、长度、alpha。 */ +const parseLayers = (s) => s.split(/,(?![^(]*\))/).map((x) => x.trim()).filter(Boolean).map((raw) => { + const col = raw.match(/rgba?\([^)]*\)/); + const parts = col === null ? [] : col[0].replace(/^rgba?\(|\)$/g, '').split(','); + return { + raw, + inset: /(^|\s)inset(\s|$)/.test(raw), + nums: [...raw.matchAll(/(-?\d+(?:\.\d+)?)px/g)].map((m) => parseFloat(m[1])).join(' '), + alpha: col === null ? null : parts.length === 4 ? parseFloat(parts[3]) : 1, + }; +}); +const near = (a, b, tol = 0.006) => Math.abs(a - b) <= tol; +/** 计算色 → 0..255 通道值:color-mix 算成 color(srgb r g b)(0..1),字面量是 rgb(r, g, b),两种都认。 */ +const chan = (c) => { + const m = String(c).match(/color\(srgb\s+([\d.]+)/); + if (m) return parseFloat(m[1]) * 255; + const r = String(c).match(/rgba?\(\s*([\d.]+)/); + return r ? parseFloat(r[1]) : NaN; +}; + +async function composerShadow(t) { + const DSF = 2; + const { css, R, B } = conversation(t.host); + const page = await t.page({ width: 1100, height: 700, dpr: DSF }); + /* 舞台底色不写死:交给皮肤自己的令牌,夹具只断言令牌值本身(写死会让画布色失配静默通过)。 + 留白放在滚动容器里面:宿主的 .scrollBody 是 overflow-y:auto,真 GUI 里卡片四周都还是滚动区, + 留白放在外面,远场阴影会被滚动容器裁掉(旧夹具只读到了半截宿主样式,才没碰上这一点)。 */ + await page.setContent(`${css} + +
+
+
+
`, 1200); + + /** 截一张图,返回「某设备像素的 RGB 均值」。 */ + const grab = async () => { + const img = await page.pixels(); + return (x, y) => { const [r, g, b] = img.rgb(Math.round(x), Math.round(y)); return (r + g + b) / 3; }; + }; + /** 卡片上沿往上 cssPx 的竖直扫描线:每个设备像素一格,相对页面底色的偏离。 */ + const scanAbove = (px, rect, cssPx) => { + const x = Math.round((rect.x + rect.w / 2) * DSF); + const yTop = Math.round(rect.y * DSF); + const base = px(x, 4); + const prof = []; + for (let d = 0; d < cssPx * DSF && yTop - d >= 0; d += 1) prof.push([d / DSF, base - px(x, yTop - d)]); + return prof; + }; + + /* ① 亮色 */ + const light = await page.evaluate(probeCard); + const L = parseLayers(light.shadow); + t.log('LIGHT var ' + light.varShadow + ' | calc ' + light.shadow); + t.check('亮色画布 = #ffffff', light.canvasBase.toLowerCase() === '#ffffff', light.canvasBase); + /* 亮色卡与页面同色:Codex 亮色靠阴影分层,不靠填充。0.5.7 及以前亮色也叠 5% 墨 = #f4f4f4,比 Codex 暗 11 级。 */ + t.check('亮色输入卡 = #ffffff(与页面同色,不叠罩)', light.bg === 'rgb(255, 255, 255)', light.bg); + t.check('亮色输入卡 ≠ #f4f4f4(旧值,暗 11 级)', light.bg !== 'rgb(244, 244, 244)', light.bg); + t.check('亮色阴影为三层', L.length === 3, L.length + ' 层'); + t.check('亮色三层皆非 inset', L.every((l) => !l.inset), L.map((l) => l.inset).join(',')); + /* 环取 10%(Codex --color-border = --alpha-10),不是源码路径 A 的 3.9%、也不是 0.5.8 的 12%: + 同图内比较与标定渲染两法都指向 ~10.4%。见 composer.css 头注。 */ + t.check('层1 环 = 0 0 0 1px @10%(Codex --color-border,双法标定)', L[0] !== undefined && !L[0].inset && L[0].nums === '0 0 0 1' && near(L[0].alpha, 0.1, 0.015), L[0]?.raw); + t.check('层2 近场 = 0 2px 8px 0 @4%', L[1] !== undefined && L[1].nums === '0 2 8 0' && near(L[1].alpha, 0.039), L[1]?.raw); + t.check('层3 远场 = 0 4px 80px 8px @2.4%(旧皮肤缺这一层)', L[2] !== undefined && L[2].nums === '0 4 80 8' && near(L[2].alpha, 0.024, 0.004), L[2]?.raw); + /* 渲染边缘:Codex 参考图与用户截图都落在 220~234;旧值 4% 渲出 241(偏亮)。 */ + const lightPx = await grab(); + const { rect } = light; + let edge = 255; + for (let d = 1; d <= 6; d += 1) edge = Math.min(edge, lightPx(Math.round(rect.x * DSF) - d, Math.round((rect.y + rect.h / 2) * DSF))); + edge = Math.round(edge); + t.check('亮色卡边缘落在 Codex 实测带内(210~234)', edge >= 210 && edge <= 234, String(edge)); + const lightScan = scanAbove(lightPx, rect, 60); + const extent = lightScan.filter(([, v]) => v >= 1).map(([d]) => d).pop() ?? 0; + /* 1px 处量到的其实是环本身(Codex 实测环像素 220,偏离 35),环的判据交给上面的边缘带断言,这里只留信息。 */ + t.log('LIGHT 1px 处(即环像素)偏离 ' + (lightScan.find(([d]) => d === 1)?.[1] ?? 0).toFixed(1) + ',衰减半径 ' + extent + 'px'); + t.check('亮色衰减半径 ≥ 35px(远场真的画出来了;旧皮肤仅 9px)', extent >= 35, extent + 'px'); + + /* ② 暗色 */ + await page.evaluate(() => document.body.setAttribute('data-ds-dark-theme', '')); + await t.sleep(400); + const dark = await page.evaluate(probeCard); + const D = parseLayers(dark.shadow); + t.log('DARK var ' + dark.varShadow + ' | calc ' + dark.shadow); + t.check('暗色画布 = #111111(Codex 主题面板的「背景」)', dark.canvasBase.toLowerCase() === '#111111', dark.canvasBase); + t.check('暗色表面 = #181818(卡片所坐的面,非窗口背景)', dark.surface.toLowerCase() === '#181818', dark.surface); + t.check('暗色属性确实生效(前提自检)', dark.dark === true, String(dark.dark)); + t.check('暗色阴影为单层', D.length === 1, D.length + ' 层'); + /* 暗色相反:靠填充分层 —— 5% 白叠 surface #181818 = 35.55(Codex 参考裁图实测 35)。 */ + t.check('暗色输入卡 ≈ 35.55(5% 白叠 #181818;Codex 实测 35)', Math.abs(chan(dark.bg) - 35.55) < 1.0, dark.bg + ' → ' + chan(dark.bg).toFixed(2)); + t.check('暗色输入卡 ≠ 亮色值(两套机制相反)', dark.bg !== light.bg, dark.bg); + t.check('暗色为 inset 内嵌高光(Codex --elevation-composer-dark)', D.length === 1 && D[0].inset === true, D[0]?.raw); + t.check('暗色几何 = 0 0 1px 0 @20%(#fff3)', D.length === 1 && D[0].nums === '0 0 1 0' && near(D[0].alpha, 0.2), D[0]?.raw); + const darkPx = await grab(); + const outer = scanAbove(darkPx, dark.rect, 60).filter(([d]) => d >= 2 && d <= 40).reduce((m, [, v]) => Math.max(m, Math.abs(v)), 0); + t.check('暗色卡外零投影(2..40px 偏离 ≤ 1)', outer <= 1, outer.toFixed(1)); + /* 卡内顶边应比卡体更亮 —— inset 高光的签名。取 +0px 与 +6px 对比。 */ + const cx = Math.round((dark.rect.x + dark.rect.w / 2) * DSF); + const top = Math.round(dark.rect.y * DSF); + const lit = darkPx(cx, top) - darkPx(cx, top + Math.round(6 * DSF)); + t.check('暗色卡内顶边被点亮(比卡体亮 ≥ 2;旧皮肤 36/36 全平)', lit >= 2, lit.toFixed(1)); + + /* ③ 窄屏:远场 80px → 40px(Codex 的 width<40rem 分支)。必须先摘掉深色属性: + 暗色分支的 inset 声明特异性更高,会盖掉窄屏覆盖值,不摘就测到暗色阴影、断言在错误的前提上「通过」。 */ + await page.evaluate(() => document.body.removeAttribute('data-ds-dark-theme')); + await page.viewport(600, 700, DSF); + await t.sleep(500); + const narrow = await page.evaluate(probeCard); + const N = parseLayers(narrow.shadow); + t.check('窄屏段确实回到亮色(前提自检)', narrow.dark === false, 'body 无 data-ds-dark-theme'); + t.check('窄屏(600px)远场收到 40px', N.some((l) => l.nums.split(' ')[2] === '40' && l.nums.split(' ').length === 4), narrow.shadow); + t.check('窄屏仍是三层', N.length === 3, N.length + ' 层'); + await page.viewport(1100, 700, DSF); + await t.sleep(400); + await t.shot(page, 'composer-shadow-verify.png'); +} + +/* ── ⑬·3 顶栏瘦身 + ⑭ hero + ⑰ 底部控件 ───────────────────────────────── */ + +/** 顶栏、hero 条、卡片与徽标的计算样式。 */ +function probeHero() { + const H = '[data-slot="conversation.session.header"]'; + const q = (s) => document.querySelector(s); + const vis = (el) => (el ? el.getClientRects().length > 0 : null); + const cs = (el, p) => (el ? getComputedStyle(el).getPropertyValue(p) : null); + const row = q('[data-phase=hero] [class$=_heroWorkspaceRow]'); + const card = q('[data-composer-card]'); + const label = q('[data-slot="conversation.session.header.actions"] span'); + const editor = q('[data-lexical-editor=true]'); + const foot = q('[data-input-scroll]')?.nextElementSibling ?? null; + return { + header: { + title: vis(q(H + ' [class$=_crumbs]')), + presetBadge: vis(label), + utilityItem: vis(q('[data-slot="conversation.session.header.utilities"] span')), + tabs: vis(q(H + ' [data-conversation-tabs]')), + corner: vis(q('[data-conversation-header-corner]')), + }, + presetLabel: { color: cs(label, 'color'), bg: cs(label, 'background-color'), radius: cs(label, 'border-top-left-radius'), h: label ? Math.round(label.getBoundingClientRect().height) : null }, + card: { radius: cs(card, 'border-top-left-radius'), token: cs(document.documentElement, '--dsw-radius-card').trim(), bg: cs(card, 'background-color'), padTop: cs(card, 'padding-top') }, + heroRow: { radius: cs(row, 'border-top-left-radius') }, + editor: { minH: cs(editor, 'min-height') }, + footPad: cs(foot, 'padding-bottom'), + }; +} + +/** 底部控件(加号 / 触发器)与卡外同款类名的对照件。锚点是哈希类名的后缀(_add / _trigger)。 */ +function probeControls() { + const read = (el) => (el ? { bg: getComputedStyle(el).backgroundColor, radius: getComputedStyle(el).borderTopLeftRadius } : null); + const pick = (suffix) => [...document.querySelectorAll('[data-composer-card] button')].find((b) => String(b.className).endsWith(suffix)); + return { + add: read(pick('_add')), + trig: read(pick('_trigger')), + trigRaw: read(document.querySelector('[data-host-baseline=trigger]')), + hoverFill: getComputedStyle(document.querySelector('[data-composer-card]')).getPropertyValue('--dsw-codex-hover-fill').trim(), + }; +} + +async function hero(t) { + const { css, R, B } = conversation(t.host); + /* ⑬·3c 放开顶栏两格后,槽里的条目用官方真实样式建模:预设徽标来自 agent-preset 的 AgentPresetLabel, + 它只用 --dsw-* 语义令牌取色,「配色跟着皮肤走」能被直接量到。 */ + const presetSrc = t.host.file('@deepseek-ai/dsh-client-ui-agent-preset/lib/client.js'); + const labelCss = cssFor(presetSrc, '@deepseek-ai/dsh-client-ui-agent-preset/AgentPresetLabel.module.css'); + const P = mapFor(presetSrc, 'AgentPresetLabel_module_css_default'); + /* 徽标圆角的宿主原值:rc.1 字面量 6px,rc.2 改成 var(--dsw-radius-xs)(ui-theme 给 4px)。 + 断言防的是「皮肤改写了它」,所以期望值从宿主源码现取。 */ + const labelRadius = (() => { + const decl = /border-radius:([^;]+)/.exec(new RegExp('\\.' + P.label + '\\{([^}]*)\\}').exec(labelCss)?.[1] ?? ''); + if (decl === null) throw new Error('宿主徽标规则里找不到 border-radius:' + P.label); + const token = /^var\((--[\w-]+)\)$/.exec(decl[1].trim()); + if (token === null) return decl[1].trim(); + const declared = new RegExp(token[1] + ':\\s*([^;}]+)').exec(t.host.file('@deepseek-ai/dsh-client-ui-theme/lib/client.js')); + if (declared === null) throw new Error('宿主 ui-theme 没有声明 ' + token[1]); + return declared[1].trim(); + })(); + + const chip = (label, glyph) => ''; + const page = await t.page({ width: 1100, height: 760, dpr: 2 }); + /* 宿主底样式复刻:加号常驻 --dsw-alias-bg-layer-2 圆底并全圆角;两个触发器默认透明。触发器圆角按宿主 rc.2 源码 + (model-selection 的 .u91W7W_trigger 与 permission-presets 的 ._5Tq8wa_trigger 都是 --dsw-radius-sm = 8px); + 本页不引宿主令牌层,所以写字面量。这条基线的意义是「皮肤必须显式压过它」。 */ + await page.setContent(`${css} + + +
+

⑬·3 会话分割线(会话名 + 槽位条目 + 右上角按钮;页签仍隐去)

+
+
+
+
+
+ +
+ ✳ Agent-Evo RSI +
+
+
工具
+
+
+
+ +
+
+
+

⑭ 输入框上方的卡片(hero:工作区 / 模式)

+
+
+
+
${chip('dsh-agency-agents', '📁')}${chip('Standard mode', '⚙️')}${chip('main', '⑂')}
+
+
+
+
Describe what you want to build... / commands, @ files or sessions
+
+
+
+
+
+

⑰ 宿主基线:同款哈希类名、但在 [data-composer-card] 之外(皮肤锚点不命中)

+
+
`, 1200); + + const hdr = await page.evaluate(probeHero); + /* 焦点环 = Codex --color-border-focus(亮色 #339cff)。先发一次 Tab 让浏览器进入键盘模态,再 focus(),才命中 :focus-visible。 */ + await page.key('Tab', 9); + await t.sleep(120); + const focusRing = await page.evaluate(() => { + const el = [...document.querySelectorAll('[data-composer-card] button')].find((b) => String(b.className).endsWith('_add')); + el.focus(); + const cs = getComputedStyle(el); + return cs.outlineColor + ' ' + cs.outlineWidth + ' ' + cs.outlineStyle; + }); + const idle = await page.evaluate(probeControls); + /* 悬停要等 :hover 算完、过渡跑完:过渡中 alpha 是小数(实测 0.92 / 0.992),所以要「非透明且连续两次取样相同」才算落定。 */ + const hoverUntil = async (selector, pick) => { + const [x, y] = await page.center(selector); + await page.move(x, y); + let prev = null; + for (let i = 0; i < 24; i += 1) { + await t.sleep(100); + const snap = await page.evaluate(probeControls); + if (pick(snap) !== 'rgba(0, 0, 0, 0)' && pick(snap) === prev) return snap; + prev = pick(snap); + } + return page.evaluate(probeControls); + }; + const addHover = await hoverUntil('[data-composer-card] button[class$="_add"]', (s) => s.add.bg); + const trigHover = await hoverUntil('[data-composer-card] button[class$="_trigger"]', (s) => s.trig.bg); + t.log('HEADER ' + JSON.stringify(hdr)); + t.log('IDLE ' + JSON.stringify(idle) + ' ADD-H ' + JSON.stringify(addHover.add) + ' TRIG-H ' + JSON.stringify(trigHover.trig)); + + /* ⑬·3c:两格放开后槽里的条目真的渲染出来;页签仍隐去(参考图没有页签)。 */ + t.check('会话名仍在', hdr.header.title === true); + t.check('预设徽标可见(顶栏两格已放开)', hdr.header.presetBadge === true); + t.check('右侧工具条目可见(顶栏两格已放开)', hdr.header.utilityItem === true); + t.check('页签仍隐去', hdr.header.tabs === false); + t.check('右上角按钮仍在', hdr.header.corner === true); + t.check('徽标配色 = 本皮肤 --dsw-alias-label-tertiary', hdr.presetLabel.color === 'rgb(118, 118, 118)', hdr.presetLabel.color); + t.check('徽标圆角保持宿主原值 ' + labelRadius + '(未被皮肤改写)', hdr.presetLabel.radius === labelRadius, hdr.presetLabel.radius); + t.check('徽标高 22px(官方控件高度,未改写)', hdr.presetLabel.h === 22, hdr.presetLabel.h); + t.check('徽标不填底色(令牌未定义即透明,不另配色)', hdr.presetLabel.bg === 'rgba(0, 0, 0, 0)', hdr.presetLabel.bg); + /* ⑭ 上栏条(hero 工作区行)只有上面两角是圆角,探在卡片背后与卡片同心叠放。2026-09-28 同屏对照:Codex 条/卡 = 1.03, + 旧值 0.79(条 16 / 卡 20)。现在条与卡片共用 --dsw-radius-card,比值恒为 1。 */ + t.check('上栏条与卡片同值(字面量)', hdr.heroRow.radius === hdr.card.radius, hdr.heroRow.radius + ' / ' + hdr.card.radius); + t.check('上栏条与卡片同值(比值 = 1)', parseFloat(hdr.heroRow.radius) === parseFloat(hdr.card.radius)); + t.check('上栏条取 --dsw-radius-card(条 = 卡 = 令牌)', hdr.heroRow.radius === hdr.card.token && hdr.heroRow.radius === hdr.card.radius, hdr.card.token); + /* ⑭·2 纵向留白:2026-09-28 同屏实测总高已对齐,差在卡片上内衬(DSH 22.4 vs Codex 29.25 设备像素)—— + 只把上内衬 8 → 12,编辑区与底衬回到宿主原值。 */ + t.check('输入区编辑区 min-height 44(宿主原值)', hdr.editor.minH === '44px', hdr.editor.minH); + t.check('卡片上内衬 12(Codex 实测)', hdr.card.padTop === '12px', hdr.card.padTop); + /* 输入卡必须不透明:DSH 的卡浮在会话滚动内容之上,透明会让身后的内容透出来。计算值里出现 "/" 即带 alpha。 */ + t.check('输入卡底不透明(不透视身后会话内容)', !hdr.card.bg.includes('/'), hdr.card.bg); + t.check('底栏下内衬 8(宿主原值)', hdr.footPad === '8px', hdr.footPad); + t.check('加号默认无底色框', idle.add.bg === 'rgba(0, 0, 0, 0)', idle.add.bg); + t.check('加号悬停才出现淡底', addHover.add.bg === 'rgb(242, 242, 243)', addHover.add.bg); + t.check('加号圆形(999px)', idle.add.radius === '999px', idle.add.radius); + t.check('触发器默认无底色框', idle.trig.bg === 'rgba(0, 0, 0, 0)', idle.trig.bg); + t.check('触发器悬停才出现淡底', trigHover.trig.bg === 'rgb(242, 242, 243)', trigHover.trig.bg); + /* 圆角不交给宿主:Codex 的 composer chip 是全圆角胶囊(参考图实测 R = h/2),卡外同款类名的对照证明这是皮肤写的。 */ + t.check('触发器全圆角胶囊(999px)', idle.trig.radius === '999px', idle.trig.radius); + t.check('原生对照:卡外同款触发器仍是宿主 8px', idle.trigRaw.radius === '8px', idle.trigRaw.radius); + t.check('悬停色标 = Codex 实测 #F2F2F3', idle.hoverFill === '#f2f2f3', idle.hoverFill); + t.check('焦点环 = Codex --color-border-focus', focusRing === 'rgb(51, 156, 255) 2px solid', focusRing); + + await page.move(10, 10); + await t.sleep(300); + await t.shot(page, 'hero-verify.png', { x: 0, y: 0, width: 1100, height: 760, scale: 2 }); +} + +export default { 'composer-shadow': composerShadow, hero }; diff --git a/scripts/specs/elevation.mjs b/scripts/specs/elevation.mjs new file mode 100644 index 0000000..a6d5661 --- /dev/null +++ b/scripts/specs/elevation.mjs @@ -0,0 +1,81 @@ +/** + * ⑲ --dsw-elevation-* 令牌与 Codex 源码对账。不需要宿主。 + * + * 判据来源:Codex 桌面端 26.924.2738.0 的 resources/app.asar @67930525 起逐字: + * --elevation-stroke: 0 0 0 .5px var(--color-border-strong) + * --elevation-stroke-subtle: 0 0 0 .5px rgb(from var(--color-border-strong) r g b / .04) (暗色 .06) + * --elevation-prominent: var(--elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d + * --elevation-sidebar: var(--elevation-stroke), 0 3px 7.5px #00000008, 0 0 16px #00000005 + * + * 量四件事:① 亮 / 暗两套下令牌都解析得出来;② --dsw-elevation-prominent 与 Codex 逐字一致(几何 + 两层 alpha); + * ③ 亮暗的 prominent 第 2、3 层必须相同 —— Codex 只在 :root 声明一次、没有暗色变体; + * ④ --dsw-elevation-panel 仍等于 stroke(本皮肤的分层选择,防误改)。 + */ + +/** 页面里读令牌与菜单面板的计算阴影。 */ +function readTokens() { + const cs = getComputedStyle(document.body); + const g = (n) => cs.getPropertyValue(n).trim(); + const menu = document.getElementById('m'); + return { + stroke: g('--dsw-elevation-stroke'), panel: g('--dsw-elevation-panel'), + prominent: g('--dsw-elevation-prominent'), soft: g('--dsw-elevation-soft'), + menuShadow: getComputedStyle(menu).boxShadow, menuFill: getComputedStyle(menu).backgroundColor, + }; +} + +const layers = (s) => s.split(/,(?![^(]*\))/).map((x) => x.trim()).filter(Boolean); +const hasGeom = (s, ...want) => { + const m = s.match(/(-?[\d.]+)px\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+(-?[\d.]+)px/); + return m !== null && want.every((v, i) => Math.abs(Number(m[i + 1]) - v) < 0.01); +}; +const alphaNear = (s, want, tol = 0.006) => { + const m = s.match(/rgba?\([^)]*?,\s*([\d.]+)\s*\)/); + return m !== null && Math.abs(parseFloat(m[1]) - want) <= tol; +}; + +async function elevation(t) { + const page = await t.page({ width: 460, height: 520, dpr: 2 }); + /* 舞台上放一个「菜单面板」,直接吃 --dsw-elevation-prominent。 */ + await page.setContent(` + + +
`, 1000); + + const light = await page.evaluate(readTokens); + await page.evaluate(() => document.body.setAttribute('data-ds-dark-theme', '')); + await t.sleep(400); + const dark = await page.evaluate(readTokens); + t.log('LIGHT prominent ' + light.prominent + ' → ' + light.menuShadow); + t.log('DARK prominent ' + dark.prominent + ' → ' + dark.menuShadow); + + /* 几何与 alpha 一律读**计算值**(menuShadow):自定义属性的字面值里 0 spread 会被省略、颜色是 hex, + 直接断言字面值会因记法差异误判。Codex 的两层 alpha #0a / #0d 归一为 0.039 / 0.051。 */ + for (const [tag, v] of [['亮', light], ['暗', dark]]) { + const L = layers(v.prominent); + const C = layers(v.menuShadow); + t.check(tag + '色 --dsw-elevation-prominent 为三层', L.length === 3, L.length + ' 层'); + t.check(tag + '色 层2 几何 = 0 3px 7.5px 0(Codex 逐字)', hasGeom(C[1] ?? '', 0, 3, 7.5, 0), (C[1] ?? '').slice(0, 60)); + t.check(tag + '色 层2 alpha = 3.92%(#0000000a)', alphaNear(C[1] ?? '', 0.039), (C[1] ?? '').slice(0, 60)); + t.check(tag + '色 层3 几何 = 0 0 20px 0(Codex 逐字)', hasGeom(C[2] ?? '', 0, 0, 20, 0), (C[2] ?? '').slice(0, 60)); + t.check(tag + '色 层3 alpha = 5.10%(#0000000d)', alphaNear(C[2] ?? '', 0.051), (C[2] ?? '').slice(0, 60)); + t.check(tag + '色 层1 = --dsw-elevation-stroke', L[0] === v.stroke, (L[0] ?? '').slice(0, 60)); + t.check(tag + '色 --dsw-elevation-panel 仍等于 stroke', v.panel === v.stroke, v.panel); + t.check(tag + '色 菜单面板确实吃到 prominent', v.menuShadow !== 'none' && v.menuShadow.length > 20, v.menuShadow.slice(0, 70)); + } + /* 第 1 层是 var(--elevation-stroke),本来就随主题反相,不参与亮暗同值的比较。 */ + const lit = layers(light.prominent); + const dk = layers(dark.prominent); + t.check('亮暗 层2 同值(Codex 无暗色变体)', lit[1] === dk[1], lit[1] + ' | ' + dk[1]); + t.check('亮暗 层3 同值(Codex 无暗色变体)', lit[2] === dk[2], lit[2] + ' | ' + dk[2]); + t.check('亮暗 stroke 不同值(描边随主题反相)', light.stroke !== dark.stroke, light.stroke + ' | ' + dark.stroke); + await t.shot(page, 'elevation-verify.png'); +} + +export default { elevation }; diff --git a/scripts/specs/model-picker.mjs b/scripts/specs/model-picker.mjs new file mode 100644 index 0000000..980180a --- /dev/null +++ b/scripts/specs/model-picker.mjs @@ -0,0 +1,384 @@ +/** + * 模型选择器的两面。 + * host-menu (A 面)⑫ 段对宿主原生菜单的皮肤:宿主真实样式(ui-theme 令牌 + model-selection + primitives + * MenuSurface / ShortcutKeys)+ 按渲染代码复刻的 DOM。before 页 = 皮肤去掉 ⑫ 段,只作原生对照。 + * power-rail (B 面)自建组件 src/client/model-picker/ + model-picker.css:不需要宿主, + * 配一个按宿主契约写的假 modelDirectories,select() 的往返故意放慢到 600ms + * (真宿主往返不到 60ms,pending 期间的行为来不及量)。指针与键盘都走 CDP 真输入事件。 + */ +import { join } from 'node:path'; +import { bundle, ROOT } from '../build.mjs'; +import { cssFor, mapFor, themeLayers } from '../lib/host.mjs'; + +/* ── A 面:宿主菜单 ────────────────────────────────────────────────────── */ + +/** 菜单挂在 body 下(⑫ 段的作用域选择器是 body > div[role=menu]),右列三块菜单:根、模型、busy 中的推理强度。 */ +function probeHostMenu() { + const cs = (el, p) => getComputedStyle(el, p); + const trig = document.querySelector('[data-slot="conversation.input.model"] button'); + const menu = document.querySelector('body > [role=menu]'); + const radio = document.querySelector('[role=menuitemradio]'); + const selected = document.querySelector('[role=menuitemradio][aria-checked=true]'); + const busyMenu = document.querySelector('body > [role=menu][aria-busy=true]'); + const busyRow = busyMenu.querySelector('[role=menuitemradio][aria-checked=true]'); + const busySpin = cs(busyRow.lastElementChild, '::after'); + const hostCheck = busyRow.lastElementChild.querySelector('svg'); + return { + trigger: { height: cs(trig).height, size: cs(trig).fontSize, chevron: cs(trig.querySelector('svg:last-child')).display }, + menu: { radius: cs(menu).borderTopLeftRadius, pad: cs(menu).paddingTop, footer: cs(menu, '::after').content, fill: cs(menu.querySelector('[aria-hidden]')).backgroundColor }, + row: { minH: cs(radio).minHeight, radius: cs(radio).borderTopLeftRadius, prefix: cs(radio, '::before').content }, + selected: { bg: cs(selected).backgroundColor, checkShown: cs(selected.lastElementChild).display }, + pending: { after: busySpin.content, w: busySpin.width, anim: busySpin.animationName + ' ' + busySpin.animationDuration, hostCheck: hostCheck ? cs(hostCheck).visibility : 'none', cursor: cs(busyRow).cursor }, + pendingBusyRows: busyMenu.querySelectorAll('[role=menuitemradio]:disabled').length, + disabledOpacity: cs(document.querySelector('[role=menuitemradio][disabled]')).opacity, + }; +} + +async function hostMenu(t) { + const modelSrc = t.host.file('@deepseek-ai/dsh-client-ui-model-selection/lib/client.js'); + const M = mapFor(modelSrc, 'ModelSelect_module_css_default'); + /* primitives 的样式是独立 .module.css 文件、类名不哈希:直接取原文(文件头有一个游离的 })。 */ + const prim = (f) => t.host.file('@deepseek-ai/dsh-client-ui-primitives/lib/' + f).replace(/^\s*}\s*/, ''); + const hostCss = ''; + + const svg = (d, size = 16) => ``; + const surface = (children, pos, busy = false) => ``; + const cell = (label, value) => ``; + /* 行结构照真实 DOM:勾选列是空 span,选中行里放宿主的勾选 svg。宿主没有 pending 行 —— busy 窗口内是 + 「整列 disabled + 勾选乐观前移到新值」,转圈由 ⑫ 段自己补。 */ + const option = (name, selected, disabled) => ``; + /* 真实 busy 快照(2026-09-26 headless 实测):点「Medium」后第 60ms 起,aria-busy=true、四行全 disabled、 + aria-checked 已挪到 Medium,约 1.1s 后菜单关闭。 */ + const body = `
+ +
+${surface(cell('模型', 'DeepSeek V4.1 Flash') + cell('推理强度', 'High'), 'position:fixed;left:40px;top:120px')} +${surface(`
DeepSeek
${option('DeepSeek V4.1 Flash', true, false)}${option('DeepSeek V4.1 Pro', false, false)}
`, 'position:fixed;left:340px;top:120px')} +${surface(option('Provider default', false, true) + option('High', false, true) + option('Medium', true, true) + option('Low', false, true), 'position:fixed;left:640px;top:120px', true)}`; + const html = (theme, label) => `${label}${hostCss} +

${label}

${body}`; + + const beforePage = await t.page({ width: 1000, height: 760, dpr: 2 }); + await beforePage.setContent(html(t.theme({ 'patches.css': [['⑫ Codex 模型选择器', '⑬ Codex 式输入区']] }), '原生 shipped(无 ⑫ 段)'), 700); + const before = await beforePage.evaluate(probeHostMenu); + await t.shot(beforePage, 'model-picker-before.png'); + const afterPage = await t.page({ width: 1000, height: 760, dpr: 2 }); + await afterPage.setContent(html(t.theme(), '+ codex-ui ⑫ 段'), 700); + const got = await afterPage.evaluate(probeHostMenu); + await t.shot(afterPage, 'model-picker-after.png'); + t.log('BEFORE ' + JSON.stringify(before)); + t.log('AFTER ' + JSON.stringify(got)); + + t.check('触发器高度 28(原生,不再压 24)', got.trigger.height === '28px', got.trigger.height); + t.check('触发器字号 13', got.trigger.size === '13px', got.trigger.size); + t.check('chevron 可见(不再隐藏)', got.trigger.chevron !== 'none', got.trigger.chevron); + t.check('菜单圆角 18(浮层刻度 --dsw-radius-menu)', got.menu.radius === '18px', got.menu.radius); + t.check('菜单内衬 5', got.menu.pad === '5px', got.menu.pad); + t.check('无 TUI 页脚', got.menu.footer === 'none', got.menu.footer); + t.check('菜单不透明白', got.menu.fill === 'rgb(255, 255, 255)', got.menu.fill); + t.check('行高 28', got.row.minH === '28px', got.row.minH); + t.check('行圆角 13(同心:菜单 18 − 内衬 5)', got.row.radius === '13px', got.row.radius); + /* 同心闭环:宿主原生就是同心的(16 − 4 = 12),防的是皮肤把行圆角写死成跟面板不同源的刻度。 */ + t.check('原生对照:宿主菜单 16 / 行 12', before.menu.radius === '16px' && before.row.radius === '12px', before.menu.radius + ' / ' + before.row.radius); + t.check('同心闭环:行圆角 = 菜单圆角 − 内衬', parseFloat(got.row.radius) === parseFloat(got.menu.radius) - parseFloat(got.menu.pad)); + t.check('无编号前缀', got.row.prefix === 'none', got.row.prefix); + t.check('选中行淡填充', got.selected.bg.includes('13, 13, 13'), got.selected.bg); + t.check('勾选列可见', got.selected.checkShown !== 'none', got.selected.checkShown); + t.check('busy 窗口四行全 disabled', got.pendingBusyRows === 4, got.pendingBusyRows); + t.check('pending 转圈已补位', got.pending.w === '12px' && got.pending.after === '""', got.pending.w + ' ' + got.pending.after); + t.check('pending 宿主导航勾选让位', got.pending.hostCheck === 'hidden', got.pending.hostCheck); + t.check('pending 动效在跑', got.pending.anim.startsWith('codex-ui-spin'), got.pending.anim); + t.check('pending 光标 progress', got.pending.cursor === 'progress', got.pending.cursor); + t.check('disabled 行不洗灰', got.disabledOpacity === '1', got.disabledOpacity); +} + +/* ── B 面:功率轨 ──────────────────────────────────────────────────────── */ + +/** 在页面里装上假目录并挂组件。字段、状态机与返回值照 dsh-client-ui-model-selection 0.1.7-rc.2 的 ModelDirectory.select()。 */ +function mountWithFakeDirectory(picker) { + const EFFORTS = [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }]; + const GROUPS = [{ id: 'deepseek-official', name: 'DeepSeek', models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', description: 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', reasoning: { defaultEffort: 'high', efforts: EFFORTS } }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', reasoning: { defaultEffort: 'max', efforts: EFFORTS } }, + { id: 'plain', name: 'Plain' }, + ] }]; + let state = { groups: GROUPS, current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' }, status: 'ready', pending: null, error: null }; + const subs = new Set(); + const store = { + getSnapshot: () => state, + subscribe: (fn) => { subs.add(fn); return () => subs.delete(fn); }, + update: (patch) => { state = { ...state, ...patch }; subs.forEach((fn) => fn()); }, + }; + const dir = { + store, calls: [], delay: 600, fail: null, + load: () => Promise.resolve(store.getSnapshot()), + select(selection) { + dir.calls.push(selection); + store.update({ status: 'selecting', pending: selection, error: null }); + return new Promise((resolve) => setTimeout(() => { + if (dir.fail !== null) { + const error = dir.fail; + dir.fail = null; + store.update({ status: 'error', pending: null, error: error.code + ': ' + error.message }); + resolve({ ok: false, error }); + return; + } + store.update({ status: 'ready', pending: null, current: { ...selection } }); + resolve({ ok: true }); + }, dir.delay)); + }, + }; + window.__dir = dir; + window.__picker = picker.mountModelPicker({ + models: { directoryFor: (id) => { if (id !== 'session-a') throw new Error('ui-model-selection: session "' + id + '" resolved no scope'); return dir; } }, + sessionFallback: () => null, + locale: null, + }); +} + +/** 一次取全弹层 / 轨的几何与状态。 */ +function railState() { + const q = (s) => document.querySelector(s); + const box = (e) => { if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height, r: b.right, b: b.bottom }; }; + const pop = q('.codex-mp-popover'); + const root = q('.codex-mp-root'); + const trig = q('.codex-mp-trigger'); + const error = q('.codex-mp-error'); + return { + open: pop !== null && !pop.hidden, + pop: box(pop), trig: box(trig), root: box(root), track: box(q('.codex-mp-track')), range: box(q('.codex-mp-range')), thumb: box(q('.codex-mp-thumb-scale')), + ticks: [...document.querySelectorAll('.codex-mp-tick')].map((el) => ({ ...box(el), sel: el.dataset.selected, color: getComputedStyle(el).backgroundColor })), + rows: document.querySelectorAll('.codex-mp-row').length, + checked: [...document.querySelectorAll('.codex-mp-row')].map((r) => r.getAttribute('aria-checked')), + now: root ? root.getAttribute('aria-valuenow') : null, + pos: root ? root.style.getPropertyValue('--codex-mp-pos') : null, + value: q('.codex-mp-effort-value')?.textContent ?? null, + spinner: q('.codex-mp-effort-value .codex-mp-spinner') !== null, + trigEffort: trig ? [...trig.querySelectorAll('.codex-mp-effort-text')].filter((e) => e.dataset.active === 'true').map((e) => e.textContent).join() : null, + trigModel: trig?.querySelector('.codex-mp-trigger-model').textContent ?? null, + expanded: trig?.getAttribute('aria-expanded') ?? null, + error: error && !error.hidden ? error.textContent : null, + calls: window.__dir.calls.length, lastCall: window.__dir.calls.at(-1) ?? null, status: window.__dir.store.getSnapshot().status, + focus: document.activeElement?.className ?? null, + kbd: root?.dataset.keyboardFocused ?? null, + thumbOutline: q('.codex-mp-thumb-scale') ? getComputedStyle(q('.codex-mp-thumb-scale')).outline : null, + }; +} + +/** 某个元素的一组计算样式。 */ +function computed(selector, props) { + const cs = getComputedStyle(document.querySelector(selector)); + return Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p)])); +} + +async function powerRail(t) { + const picker = bundle(join(ROOT, 'src', 'client', 'model-picker', 'component.js')); + const page = await t.page({ width: 900, height: 640, dpr: 2 }); + /* DPR 2:0.5px 的 border 在 DPR 1 下会被抬成 1 个设备像素,量不出发丝线。 */ + await page.media({ 'prefers-reduced-motion': 'no-preference' }); + await page.setContent(` + +
+
+
+
+
+ +`, 900); + /* 子代理会话(session-sub):宿主在那里返回 null(席位是空的),组件不许出头。 */ + + const near = (a, b, tol = 0.6) => Math.abs(a - b) <= tol; + const state = () => page.evaluate(railState); + const style = (sel, ...props) => page.evaluate(computed, sel, props); + + /* 1. 席位顶替 */ + const seat = await page.evaluate(() => { + const slot = document.querySelector('[data-conversation-session="session-a"] [data-slot="conversation.input.model"]'); + const ours = slot.querySelector('.codex-mp-trigger'); + const cs = ours ? getComputedStyle(ours) : null; + return { host: getComputedStyle(slot.querySelector('._7KE1Ra_root')).display, ours: ours !== null, h: ours ? ours.getBoundingClientRect().height : 0, + radius: cs?.borderTopLeftRadius ?? null, bg: cs?.backgroundColor ?? null, sub: document.getElementById('sub-seat').children.length, text: ours?.textContent ?? '' }; + }); + t.check('席位里有自建触发器', seat.ours, seat.text); + t.check('宿主那一格 display:none(仍在 DOM 里)', seat.host === 'none', seat.host); + t.check('宿主没渲染的席位(子代理会话)不接管', seat.sub === 0, seat.sub + ' 个子节点'); + t.check('触发器 28px 高、全圆角胶囊、默认透明', near(seat.h, 28) && seat.radius === '999px' && seat.bg === 'rgba(0, 0, 0, 0)', JSON.stringify(seat)); + + /* 2. 弹层与轨几何 */ + const [tx, ty] = await page.center('.codex-mp-trigger'); + await page.click(tx, ty); + await t.sleep(80); + const anim = await style('.codex-mp-popover', 'animation-name', 'animation-duration', 'animation-delay', 'animation-timing-function'); + await t.sleep(500); + let st = await state(); + const pop = await style('.codex-mp-popover', 'border-top-left-radius', 'padding-top', 'box-shadow'); + t.check('点触发器打开弹层(aria-expanded=true)', st.open && st.expanded === 'true', JSON.stringify({ open: st.open, expanded: st.expanded })); + t.check('弹层宽 254px(Codex spacing × 63.5)', near(st.pop.w, 254), st.pop.w); + t.check('弹层在触发器上方 8px、右沿对齐(宿主 place())', near(st.trig.y - st.pop.b, 8, 1) && near(st.trig.r, st.pop.r, 1), 'gap=' + (st.trig.y - st.pop.b) + ' right=' + (st.trig.r - st.pop.r)); + t.check('弹层 18px 圆角 + 内衬 5px + 菜单软影', pop['border-top-left-radius'] === '18px' && pop['padding-top'] === '5px' && /0px 8px 32px/.test(pop['box-shadow']), JSON.stringify(pop)); + t.check('入场 .32s cubic-bezier(.23,1,.32,1) 30ms,从右下角长出', anim['animation-name'] === 'codex-mp-enter' && anim['animation-duration'] === '0.32s' && anim['animation-delay'] === '0.03s' && /0\.23, 1, 0\.32, 1/.test(anim['animation-timing-function']), JSON.stringify(anim)); + t.check('列表三行、当前模型打勾', st.rows === 3 && st.checked[0] === 'true', JSON.stringify(st.checked)); + /* 宿主字典缺席(本夹具 locale 为 null)时,内置模型的说明要落回目录原文,不能把字典键名画出来。 */ + const desc = await page.evaluate(() => document.querySelector('.codex-mp-row-desc')?.textContent ?? null); + t.check('说明文字是目录原文、不是字典键名', typeof desc === 'string' && desc.startsWith('Fast, efficient') && !desc.startsWith('option.'), desc); + const track = await style('.codex-mp-track', 'border-top-left-radius', 'background-color', 'box-shadow'); + const thumb = await style('.codex-mp-thumb', 'background-color', 'box-shadow', 'border-top-width', 'border-top-style'); + const container = await style('.codex-mp-container', 'height', 'padding-top', 'padding-left'); + const range = await style('.codex-mp-range', 'border-top-left-radius', 'border-top-right-radius'); + const touch = (await style('.codex-mp-root', 'touch-action'))['touch-action']; + t.check('轨 24px 高、12px 圆角', near(st.track.h, 24) && track['border-top-left-radius'] === '12px', st.track.h + ' / ' + track['border-top-left-radius']); + t.check('轨底 = 前景 10%、0.5px 内描边', /0\.1\)$/.test(track['background-color']) && /inset/.test(track['box-shadow']) && /0\.5px/.test(track['box-shadow']), track['background-color'] + ' | ' + track['box-shadow']); + t.check('容器 32px、上下 2 / 左右 6;根 28px、touch-action none', container.height === '32px' && container['padding-top'] + ' ' + container['padding-left'] === '2px 6px' && near(st.root.h, 28) && touch === 'none', JSON.stringify({ ...container, touch })); + /* 描边断言声明值:Chromium 把 0.5px 的 border 在计算值里取整成 1px(DPR 2 也一样),Codex 源码同样写 .5px、 + 跑在同一个引擎上,渲染结果一致;计算值量不出「写的是不是 0.5」。 */ + const thumbDecl = await page.evaluate(() => { + for (const sheet of document.styleSheets) for (const rule of sheet.cssRules) if (/ \.codex-mp-thumb$/.test(rule.selectorText ?? '')) return rule.style.getPropertyValue('border'); + return null; + }); + t.check('拇指 28px 白圆片、声明 0.5px 描边、0 0 2px 微影', near(st.thumb.w, 28) && thumb['background-color'] === 'rgb(255, 255, 255)' && /^0\.5px solid/.test(thumbDecl ?? '') && /0px 0px 2px/.test(thumb['box-shadow']), JSON.stringify({ w: st.thumb.w, decl: thumbDecl, ...thumb })); + t.check('档位圆点 4px × 4 个', st.ticks.length === 4 && st.ticks.every((k) => near(k.w, 4) && near(k.h, 4)), st.ticks.length); + const expectX = (i) => st.root.x + 14 + (st.root.w - 28) * i / 3; + t.check('圆点等距落在拇指行程 [14, 宽 − 14] 上', st.ticks.every((k, i) => near(k.x + k.w / 2, expectX(i))), st.ticks.map((k) => Math.round((k.x + k.w / 2 - st.root.x) * 10) / 10).join(' / ')); + t.check('拇指中心 = 生效档(High = 第 3 档)', near(st.thumb.x + 14, expectX(2)) && st.now === '2', 'thumb=' + (st.thumb.x + 14 - st.root.x) + ' now=' + st.now); + t.check('强调色条止于拇指中线、只圆左侧两角', near(st.range.r, st.thumb.x + 14) && range['border-top-left-radius'] + ' ' + range['border-top-right-radius'] === '12px 0px', 'range.r=' + st.range.r + ' thumb=' + (st.thumb.x + 14)); + t.check('走过的圆点 30% 白、没走到的另一色', st.ticks[0].sel === 'true' && st.ticks[3].sel === 'false' && st.ticks[0].color === 'rgba(255, 255, 255, 0.3)' && st.ticks[3].color !== st.ticks[0].color, st.ticks.map((k) => k.sel + ':' + k.color).join(' | ')); + await t.shot(page, 'power-rail-verify.png', { x: st.pop.x - 20, y: st.pop.y - 20, width: st.pop.w + 40, height: st.trig.b - st.pop.y + 40, scale: 1 }); + + /* 3. 拖动:中途不提交、松手对齐提交一次;pending 不回弹、不清空、有转圈 */ + const thumbX = Math.round(st.thumb.x + 14); + const railY = Math.round(st.root.y + st.root.h / 2); + const outside = Math.round(st.root.x - 20); + await page.move(thumbX, railY); + await page.mouse('mousePressed', thumbX, railY, 1); + for (const x of [thumbX - 30, thumbX - 80, outside]) { await page.move(x, railY, 1); await t.sleep(30); } + st = await state(); + t.check('拖动中不提交(select 调用 0 次)', st.calls === 0, 'calls=' + st.calls); + t.check('拖动中拇指跟随指针(越界夹到 0)', st.pos === '0', 'pos=' + st.pos); + await page.mouse('mouseReleased', outside, railY, 0); + await t.sleep(120); + st = await state(); + t.check('松手提交一次、对齐到最左档 off', st.calls === 1 && st.lastCall?.reasoningEffort === 'off', JSON.stringify(st.lastCall)); + t.check('往返中(selecting)轨停在新档、不回弹', st.status === 'selecting' && st.now === '0' && st.pos === '0', 'status=' + st.status + ' now=' + st.now); + t.check('往返中列表不清空', st.rows === 3, 'rows=' + st.rows); + t.check('往返中档位名旁转圈、触发器已按新档显示', st.spinner && st.trigEffort === 'Off', 'spinner=' + st.spinner + ' trig=' + st.trigEffort); + await t.sleep(800); + st = await state(); + t.check('往返结束:转圈撤掉、弹层仍开着(改档不关)', !st.spinner && st.open && st.now === '0' && st.value === 'Off', JSON.stringify({ spinner: st.spinner, open: st.open, value: st.value })); + + /* 4. 键盘 */ + await page.evaluate(() => document.querySelector('.codex-mp-root').focus()); + await page.key('ArrowRight', 39); + await t.sleep(700); + st = await state(); + t.check('→ 进一档(low)', st.lastCall.reasoningEffort === 'low' && st.now === '1', JSON.stringify(st.lastCall)); + t.check('键盘焦点:拇指上 2px 焦点环(offset 0)', st.kbd === 'true' && /2px/.test(st.thumbOutline) && /solid/.test(st.thumbOutline), st.thumbOutline); + await page.key('End', 35); + await t.sleep(700); + st = await state(); + t.check('End 到最后一档(max)', st.lastCall.reasoningEffort === 'max' && st.now === '3', JSON.stringify(st.lastCall)); + const calls = st.calls; + await page.key('End', 35); + await t.sleep(200); + st = await state(); + t.check('同一档不重复提交', st.calls === calls, 'calls ' + calls + ' → ' + st.calls); + await page.key('Home', 36); + await t.sleep(700); + st = await state(); + t.check('Home 回第一档(off)', st.lastCall.reasoningEffort === 'off' && st.now === '0', JSON.stringify(st.lastCall)); + + /* 5. 失败:宿主把会话占用报回来 */ + await page.evaluate(() => { window.__dir.fail = { code: 'session/writer-held', message: 'held' }; }); + await page.key('ArrowRight', 39); + await t.sleep(800); + st = await state(); + t.check('提交失败:弹层顶上出现占用提示', st.error !== null && /(已被占用|already in use)/.test(st.error), st.error); + t.check('提交失败:轨退回生效档', st.now === '0', 'now=' + st.now); + + /* 6. Escape / 换模型 */ + await page.key('Escape', 27); + await t.sleep(150); + st = await state(); + t.check('Escape 关闭并把焦点还给触发器', !st.open && st.expanded === 'false' && st.focus === 'codex-mp-trigger', JSON.stringify({ open: st.open, focus: st.focus })); + await page.click(tx, ty); + await t.sleep(500); + const [rx, ry] = await page.evaluate(() => { + const r = document.querySelectorAll('.codex-mp-row')[1].getBoundingClientRect(); + return [Math.round(r.left + r.width / 2), Math.round(r.top + r.height / 2)]; + }); + await page.click(rx, ry); + await t.sleep(120); + const rowBusy = await page.evaluate(() => { + const r = document.querySelectorAll('.codex-mp-row')[1]; + return { busy: r.getAttribute('aria-busy'), spin: r.querySelector('.codex-mp-spinner') !== null }; + }); + t.check('换模型往返中:那一行行尾转圈', rowBusy.busy === 'true' && rowBusy.spin, JSON.stringify(rowBusy)); + await t.sleep(800); + st = await state(); + t.check('换模型连带该模型的默认档提交(pro → max)', st.lastCall.model === 'deepseek-v4-pro' && st.lastCall.reasoningEffort === 'max', JSON.stringify(st.lastCall)); + t.check('换模型成功后关弹层、触发器换名', !st.open && st.trigModel === 'DeepSeek-V4-Pro' && st.trigEffort === 'Max', st.trigModel + ' · ' + st.trigEffort); + + /* 7. reduced-motion */ + await page.media({ 'prefers-reduced-motion': 'reduce' }); + await page.click(tx, ty); + await t.sleep(300); + const rm = await page.evaluate(() => { + const p = document.querySelector('.codex-mp-popover'); + return { attr: p.dataset.reducedMotion, anim: getComputedStyle(p).animationName, thumb: getComputedStyle(document.querySelector('.codex-mp-thumb-scale')).transitionDuration }; + }); + t.check('reduced-motion:入场动画取消、拇指不过渡', rm.attr === 'true' && rm.anim === 'none' && /^0s|^1e-05s|0\.00001s/.test(rm.thumb), JSON.stringify(rm)); + /* 鼠标打开后焦点在触发器上:Escape 也得关(宿主的键盘处理挂在整格根上)。 */ + const focusBefore = await page.evaluate(() => document.activeElement?.className ?? null); + await page.key('Escape', 27); + await t.sleep(150); + st = await state(); + t.check('鼠标打开后(焦点在触发器)Escape 也能关', focusBefore === 'codex-mp-trigger' && !st.open, 'focus=' + focusBefore + ' open=' + st.open); + await page.media({ 'prefers-reduced-motion': 'no-preference' }); + + /* 8. 深色 */ + await page.evaluate(() => document.body.setAttribute('data-ds-dark-theme', '')); + await page.click(tx, ty); + await t.sleep(500); + const dark = await page.evaluate(() => { + const p = getComputedStyle(document.querySelector('.codex-mp-popover')); + const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); + return { shadow: p.boxShadow, bg: p.backgroundColor, thumb: th.backgroundColor, border: th.borderTopColor, track: getComputedStyle(document.querySelector('.codex-mp-track')).boxShadow }; + }); + st = await state(); + t.check('深色下弹层打开', st.open && near(st.pop.w, 254), 'open=' + st.open); + t.check('深色:菜单影 50% 黑 + 5% 亮环(暗色靠填充分层)', /rgba\(0, 0, 0, 0\.5\) 0px 8px 32px/.test(dark.shadow) && dark.bg === 'rgb(33, 33, 33)', JSON.stringify(dark)); + t.check('深色:拇指仍是白片、描边 20% 白、轨环 12% 白', dark.thumb === 'rgb(255, 255, 255)' && dark.border === 'rgba(255, 255, 255, 0.2)' && /rgba\(255, 255, 255, 0\.12\)/.test(dark.track), JSON.stringify(dark)); + await t.shot(page, 'power-rail-verify-dark.png', { x: st.pop.x - 20, y: st.pop.y - 20, width: st.pop.w + 40, height: st.trig.b - st.pop.y + 40, scale: 1 }); + await page.key('Escape', 27); + await page.evaluate(() => document.body.removeAttribute('data-ds-dark-theme')); + + /* 9. 开关 */ + const seatNow = () => page.evaluate(() => ({ ours: document.querySelectorAll('.codex-mp-trigger').length, host: getComputedStyle(document.querySelector('._7KE1Ra_root')).display })); + await page.evaluate(() => window.__picker.setEnabled(false)); + await t.sleep(100); + const off = await seatNow(); + t.check('关掉:自建触发器撤走、宿主那一格复原', off.ours === 0 && off.host !== 'none', JSON.stringify(off)); + await page.evaluate(() => window.__picker.setEnabled(true)); + await t.sleep(100); + const on = await seatNow(); + t.check('再打开:重新接管', on.ours === 1 && on.host === 'none', JSON.stringify(on)); + /* React 换掉宿主子节点:没有标记可丢,只看我们在不在席。 */ + await page.evaluate(() => { + const slot = document.querySelector('[data-conversation-session="session-a"] [data-slot="conversation.input.model"]'); + const fresh = document.createElement('div'); + fresh.className = '_7KE1Ra_root'; + fresh.innerHTML = ''; + slot.querySelector('._7KE1Ra_root').replaceWith(fresh); + }); + await t.sleep(50); + const swapped = await seatNow(); + t.check('宿主换掉自己的子节点后仍隐藏、不闪回', swapped.host === 'none' && swapped.ours === 1, JSON.stringify(swapped)); + t.check('页面没有未捕获异常', page.errors.length === 0, page.errors.join(' | ') || '0'); +} + +export default { 'host-menu': hostMenu, 'power-rail': powerRail }; diff --git a/scripts/specs/rightbar.mjs b/scripts/specs/rightbar.mjs new file mode 100644 index 0000000..e18968f --- /dev/null +++ b/scripts/specs/rightbar.mjs @@ -0,0 +1,214 @@ +/** + * 右栏(文件 / 浏览器 / 终端)三件套: + * ① 面板顶沿边框阴影(与中列同源); + * ② 左边框淡细(压掉宿主 dockkit 的 1px l4,边界只剩中列环)+ 分界线拖拽柄悬停:2px 纵向渐变; + * ③ 空栏 guide「展开选择组件」:平行(无胶囊)、图标 20、无描述、快捷键灰底 pill。 + * 宿主样式取 ui-theme / layout / sidebar-right / sidebar-terminal / primitives,dockkit 在前端主样式表里。 + * before 页 = 皮肤剥掉 ⑯ 段与 window-shadow 的右栏段,另把中列 / 面板阴影压成 none,只作对照。 + */ +import { cssAfter, cssFor, frontendCss, mapFor, themeLayers } from '../lib/host.mjs'; + +function probeRightbar() { + const cs = (el, p) => (el ? getComputedStyle(el, p) : null); + const q = (s) => document.querySelector(s); + const panel = q('[data-sidebar-right-panel]'); + const handle = q('[data-side="rightbar"]'); + const handleLeft = q('[data-side="sidebar"]'); + const entry = q('[data-sidebar-right-guide-entry]'); + const desc = entry.querySelectorAll('span:nth-child(2) > span'); + const keys = entry.lastElementChild; + const toggle = q('[data-sidebar-right-toggle]'); + const pane = q('[data-dockkit-host="dock"] [data-dockkit-pane]'); + const side = cs(q('[data-slot=sidebar]').parentElement); + const term = (() => { + const row = q('[data-sidebar-right-guide-entry="terminal"]'); + if (!row) return { missing: true }; + const icon = row.querySelector(':scope > span:nth-child(2)'); + const text = row.querySelector(':scope > span:nth-child(3)'); + const title = text?.querySelector(':scope > span:nth-child(1) > span:first-child'); + const chev = row.querySelector('button[aria-haspopup="menu"]'); + const r = row.getBoundingClientRect(); + const tr = title?.getBoundingClientRect(); + const files = q('[data-sidebar-right-guide-entry="workspace"]'); + return { + border: cs(row).borderTopWidth, bg: cs(row).backgroundColor, + iconBox: cs(icon).width, iconSvg: icon.querySelector('svg') ? cs(icon.querySelector('svg')).width : 'none', + desc: text?.querySelector(':scope > span:nth-child(2)') ? cs(text.querySelector(':scope > span:nth-child(2)')).display : 'absent', + chev: chev ? cs(chev).display + '/' + Math.round(chev.getBoundingClientRect().width) : 'missing', + titleCenterDelta: tr ? Math.round((tr.top + tr.height / 2 - (r.top + r.height / 2)) * 10) / 10 : 'n/a', + rowH: Math.round(r.height * 10) / 10, filesRowH: files ? Math.round(files.getBoundingClientRect().height * 10) / 10 : -1, + keysH: row.querySelector(':scope > span:has(kbd)') ? cs(row.querySelector(':scope > span:has(kbd)')).height : 'missing', + }; + })(); + const line = (el) => ({ opacity: cs(el, '::before').opacity, width: cs(el, '::before').width, grad: /linear-gradient/.test(cs(el, '::before').backgroundImage), bg: cs(el, '::before').backgroundImage, content: cs(el, '::before').content }); + return { + panelShadow: cs(panel).boxShadow !== 'none', + panelBox: cs(panel).boxShadow, + panelTop: Math.round(panel.getBoundingClientRect().top), + panelH: Math.round(panel.getBoundingClientRect().height), + centerBox: cs(q('[data-slot=main]').parentElement).boxShadow, + paneBorder: cs(pane).borderLeftWidth + ' ' + cs(pane).borderLeftColor, + handleBefore: line(handle), + handleLeftBefore: line(handleLeft), + hero: cs(q('[data-sidebar-right-guide] > span[aria-hidden]')).display, + after: cs(q('[data-sidebar-right-guide]'), '::after').display, + entry: { border: cs(entry).borderTopWidth, bg: cs(entry).backgroundColor, minH: cs(entry).minHeight, radius: cs(entry).borderTopLeftRadius, width: cs(entry).width }, + iconBox: cs(entry.querySelector('span:first-child')).width, + desc: desc.length > 1 ? cs(desc[1]).display : 'absent', + keys: { h: cs(keys).height, radius: cs(keys).borderTopLeftRadius, bg: cs(keys).backgroundColor }, + toggle: { bg: cs(toggle).backgroundColor, radius: cs(toggle).borderTopLeftRadius }, + sidebarBorder: side.borderRightColor + '|' + side.borderRightWidth + '|' + side.borderRightStyle, + term, + }; +} + +/** 宿主样式:令牌层 + AppFrame + SidebarRight + GuideBody + ShortcutKeys + dockkit + Button + TerminalGuide,外加类名映射。 */ +function hostParts(host) { + const layout = host.file('@deepseek-ai/dsh-client-ui-layout/lib/client.js'); + const right = host.file('@deepseek-ai/dsh-client-ui-sidebar-right/lib/client.js'); + const terminal = host.file('@deepseek-ai/dsh-client-ui-sidebar-terminal/lib/client.js'); + const prim = (f) => host.file('@deepseek-ai/dsh-client-ui-primitives/lib/' + f); + /* dockkit 的类名形如 _tabCell_<哈希>_<行号>,哈希随构建变:先从 tabCell 认出哈希,再按它收规则。 */ + const front = frontendCss(host); + const hash = /\._tabCell_(\w+?)_\d+/.exec(front)?.[1]; + if (hash === undefined) throw new Error('前端样式表里找不到 dockkit 的 _tabCell_ 类名'); + const dockCss = [...front.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => m[1].includes('_' + hash + '_')).map((m) => m[0]).join('\n'); + const dock = (key) => { + const m = new RegExp('\\._' + key + '_([\\w-]+)').exec(dockCss); + if (m === null) throw new Error('dockkit 里没有类名 ' + key); + return '_' + key + '_' + m[1]; + }; + const css = [ + themeLayers(host), + cssFor(layout, '@deepseek-ai/dsh-client-ui-layout/AppFrame.module.css'), + cssFor(right, '@deepseek-ai/dsh-client-ui-sidebar-right/SidebarRight.module.css'), + cssFor(right, '@deepseek-ai/dsh-client-ui-sidebar-right/GuideBody.module.css'), + prim('ShortcutKeys.module.css').replace(/^\s*}\s*/, ''), + dockCss, + prim('Button.module.css').replace(/^[\s\S]*?(?=\/\*|\.)/, ''), + cssAfter(terminal, 'TerminalGuide.module.css.mjs'), + ].map((text) => '').join(''); + return { + css, + F: mapFor(layout, 'AppFrame_module_css_default'), + P: mapFor(right, 'SidebarRight_module_css_default'), + G: mapFor(right, 'GuideBody_module_css_default'), + T: mapFor(terminal, 'TerminalGuide_module_css_default'), + D: Object.fromEntries(['tabCell', 'tabHost', 'tabHostHeader', 'tabHostBody', 'stripTabs', 'stripFill', 'stripChrome', 'pane'].map((k) => [k, dock(k)])), + }; +} + +async function rightbar(t) { + const { css, F, P, G, T, D } = hostParts(t.host); + const svg = (d, size = 16) => ``; + const entry = (icon, title, desc, combo, kind) => `
`; + /* TerminalGuide(terminal 插件注册的自定义卡):button.main 绝对定位点击层在前,子序 button→icon→text(titleRow+description)→keys。 */ + const termEntry = `
+ + + + +Ctrl+\`
`; + const stage = `
+
+
+
+
+
+
+
+
+
开始
+
+
+
+ +${entry('M1.5 4.5h4l1.2 1.5h7.8v7.5h-13z', '工作区文件', '浏览会话工作区的文件', 'Ctrl+P', 'workspace')} +${entry('M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13M1.5 8h13M8 1.5c2 1.8 3 4 3 6.5s-1 4.7-3 6.5c-2-1.8-3-4-3-6.5s1-4.7 3-6.5', '浏览器', '浏览网页', 'Ctrl+T', 'browser')} +${termEntry} +
+
+
+
`; + const html = (theme, titlebar = true) => `${css} + +${stage}`; + /* before 页:剥掉 ⑯ 段与 window-shadow 的右栏段,再把中列的发丝线 + 环境影压成 none —— 本组修复项在对照上必须有真实差异。 */ + const before = t.theme({ 'patches.css': [['⑯ 右栏「展开时选择组件」', '⑰ composer 底部控件']], 'window-shadow.css': [['② 右栏(push 模式)', null]] }) + .replace(/box-shadow:\s*0 0 0 0\.5px var\(--dsw-alias-border-l2\),\s*0 0 24px rgba\((13, 13, 13, 0\.05|0, 0, 0, 0\.5)\);/g, 'box-shadow: none;'); + + const open = async (theme, titlebar) => { + const page = await t.page({ width: 1000, height: 780, dpr: 2 }); + await page.setContent(html(theme, titlebar), 600); + return page; + }; + const pb = await open(before); + const pa = await open(t.theme()); + const was = await pb.evaluate(probeRightbar); + const idle = await pa.evaluate(probeRightbar); + /* 悬停左分界线柄(8px 骑跨 300 分界)→ 读 ::before;再悬停右分界线柄(骑跨 526,取柄内偏右一点避开中列右沿)。 */ + await pa.move(300, 390); + await t.sleep(500); + const hoverLeft = await pa.evaluate(probeRightbar); + await pa.move(528, 390); + await t.sleep(500); + const hovered = await pa.evaluate(probeRightbar); + /* web 页最后开:headless 单窗口只有前台页签处理 :hover,先开会抢走 after 页的前台态。 */ + const pw = await open(t.theme(), false); + const web = await pw.evaluate(probeRightbar); + const after = idle; + t.log('BEFORE ' + JSON.stringify(was)); + t.log('IDLE ' + JSON.stringify(idle)); + t.log('HOVER-L ' + JSON.stringify(hoverLeft.handleLeftBefore) + ' HOVER-R ' + JSON.stringify(hovered.handleBefore)); + + t.check('面板有边框阴影', after.panelShadow === true, after.panelBox); + t.check('面板左沿 0.5px l1 发丝线', /rgba\(13, 13, 13, 0\.07\) 0px 0px 0px 0\.5px/.test(after.panelBox), after.panelBox); + t.check('面板左沿不糊影(影只往上泄)', /rgba\(13, 13, 13, 0\.05\) 0px -12px 24px -12px/.test(after.panelBox), after.panelBox); + t.check('面板顶沿贴标题栏条下沿', after.panelTop === 36, after.panelTop); + t.check('面板撑满列高(塌高即回归)', after.panelH === 720, after.panelH); + t.check('中列 0.5px l2 发丝线', /rgba\(13, 13, 13, 0\.12\) 0px 0px 0px 0\.5px/.test(after.centerBox), after.centerBox); + t.check('中列全向环境影 24px', after.centerBox.includes('rgba(13, 13, 13, 0.05) 0px 0px 24px'), after.centerBox); + t.check('栏内 pane 的 1px l4 深边框被压掉', after.paneBorder === '0px rgb(0, 0, 0)' && was.paneBorder === '1px rgba(13, 13, 13, 0.24)', after.paneBorder + ' / 对照 ' + was.paneBorder); + t.check('右栏悬停线默认隐藏', idle.handleBefore.opacity === '0', idle.handleBefore.opacity); + t.check('右栏悬停线出现', hovered.handleBefore.opacity === '1', hovered.handleBefore.opacity); + t.check('右栏悬停线宽 2px', hovered.handleBefore.width === '2px', hovered.handleBefore.width); + t.check('右栏悬停线纵向渐变', hovered.handleBefore.grad === true); + t.check('左分界线无悬停线(静默)', idle.handleLeftBefore.content === 'none' && idle.handleLeftBefore.bg === 'none', idle.handleLeftBefore.content); + t.check('左分界线无悬停线(悬停仍是静默)', hoverLeft.handleLeftBefore.content === 'none' && hoverLeft.handleLeftBefore.bg === 'none', hoverLeft.handleLeftBefore.content); + t.check('罗盘 hero 隐藏', after.hero === 'none', after.hero); + t.check('底部占位移除', after.after === 'none', after.after); + t.check('条目无描边', after.entry.border === '0px', after.entry.border); + t.check('条目无底色', /rgba\(0, 0, 0, 0\)|transparent/.test(after.entry.bg), after.entry.bg); + t.check('条目行高 52', after.entry.minH === '52px', after.entry.minH); + t.check('条目圆角 12', after.entry.radius === '12px', after.entry.radius); + t.check('条目宽 380', after.entry.width === '380px', after.entry.width); + t.check('图标 20', after.iconBox === '20px', after.iconBox); + t.check('描述行隐藏', after.desc === 'none', after.desc); + t.check('快捷键 pill 高 24', after.keys.h === '24px', after.keys.h); + t.check('快捷键 pill 圆角 12', after.keys.radius === '12px', after.keys.radius); + t.check('快捷键 pill 灰底', after.keys.bg === 'rgb(241, 241, 239)', after.keys.bg); + t.check('收起按钮灰底', after.toggle.bg === 'rgb(241, 241, 239)', after.toggle.bg); + t.check('收起按钮圆角 12', after.toggle.radius === '12px', after.toggle.radius); + t.check('终端行无描边', after.term.border === '0px', after.term.border); + t.check('终端行无底色', /rgba\(0, 0, 0, 0\)|transparent/.test(after.term.bg), after.term.bg); + t.check('终端行小字隐藏', after.term.desc === 'none', after.term.desc); + t.check('终端图标 20', after.term.iconBox === '20px', after.term.iconBox); + t.check('终端图标 svg 20', after.term.iconSvg === '20px', after.term.iconSvg); + t.check('终端标题垂直居中', Math.abs(after.term.titleCenterDelta) <= 1, after.term.titleCenterDelta); + t.check('终端行高与标准行一致', after.term.rowH === after.term.filesRowH, after.term.rowH + ' / ' + after.term.filesRowH); + t.check('选 Shell chevron 保留', /^flex\/20|^block\/20|inline-flex\/20/.test(after.term.chev), after.term.chev); + t.check('终端快捷键 pill 高 24', after.term.keysH === '24px', after.term.keysH); + t.check('web 中列全向环境影', web.centerBox.includes('rgba(13, 13, 13, 0.05) 0px 0px 24px'), web.centerBox); + t.check('web 中列 0.5px l2 发丝线', /rgba\(13, 13, 13, 0\.12\) 0px 0px 0px 0\.5px/.test(web.centerBox), web.centerBox); + t.check('web 侧栏边界 l1 细线', /rgba\(13, 13, 13, 0\.07\)\|(0\.5|1)px\|solid/.test(web.sidebarBorder), web.sidebarBorder); + t.check('web 面板上沿影(负 spread)', /rgba\(13, 13, 13, 0\.05\) 0px -12px 24px -12px/.test(web.panelBox), web.panelBox); + t.check('web 面板撑满列高', web.panelH === 720, web.panelH); + + await t.shot(pb, 'rightbar-before.png'); + await t.shot(pa, 'rightbar-after.png'); + await t.shot(pw, 'rightbar-web.png'); +} + +export default { rightbar }; diff --git a/scripts/specs/sidebar.mjs b/scripts/specs/sidebar.mjs new file mode 100644 index 0000000..7bf0c61 --- /dev/null +++ b/scripts/specs/sidebar.mjs @@ -0,0 +1,219 @@ +/** + * 侧栏两件事,宿主样式都取 dsh-client-ui-sidebar 的 SidebarRoot 与 workspace 的 WorkspaceBrowser / Rows。 + * align 「新会话 / 插件」行 ↔「工作区」下方行的图标列 / 文字列对齐(getBoundingClientRect)。 + * surface 滚动渐隐从宿主的 24px 覆盖层换成 Codex 的 40px mask 斜坡:① 机制(mask-image / 覆盖层 display); + * ② 观感:把底部横带截下来逐像素解码,还原遮罩 alpha 曲线。同一份 DOM 上切 html[data-codex-ui] + * 得到原生 / 皮肤两态,每态再分不透明 / 半透明侧栏底两列。 + */ +import { cssFor, mapFor, themeLayers } from '../lib/host.mjs'; + +function sidebarHost(host) { + const sidebar = host.file('@deepseek-ai/dsh-client-ui-sidebar/lib/client.js'); + const workspace = host.file('@deepseek-ai/dsh-client-ui-workspace/lib/client.js'); + const sidebarCss = cssFor(sidebar, '@deepseek-ai/dsh-client-ui-sidebar/SidebarRoot.module.css'); + return { + sidebarCss, + css: '', + S: mapFor(sidebar, 'SidebarRoot_module_css_default'), + W: mapFor(workspace, 'WorkspaceBrowser_module_css_default'), + R: mapFor(workspace, 'Rows_module_css_default'), + }; +} + +const svg = (size) => ``; + +/* ── 列对齐 ────────────────────────────────────────────────────────────── */ + +/** 左列关掉插件样式表读一遍(原生),右列开着读一遍;位置相对侧栏根 div 左边缘。 */ +async function probeAlign(cls) { + const plugin = document.getElementById('codex-ui-theme'); + const q = (c) => '.' + CSS.escape(c); + const rect = (root, sel) => { + const el = root.querySelector(sel); + if (el === null) return null; + const r = el.getBoundingClientRect(); + const base = root.getBoundingClientRect(); + return { l: +(r.left - base.left).toFixed(2), r: +(r.right - base.left).toFixed(2) }; + }; + const read = (root) => ({ + newSessionIcon: rect(root, q(cls.S.newSession) + ' svg'), + newSessionText: rect(root, q(cls.S.newSessionLabel)), + panelIcon: rect(root, q(cls.S.panelGlyph)), + panelText: rect(root, q(cls.S.panelTitle)), + headerLabel: rect(root, q(cls.W.sectionLabel)), + projectIcon: rect(root, q(cls.R.projectRow) + ' ' + q(cls.R.folder)), + projectText: rect(root, q(cls.R.projectRow) + ' ' + q(cls.R.title)), + sessionText: rect(root, q(cls.R.sessionRow) + ' ' + q(cls.R.title)), + }); + const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + plugin.disabled = true; + await frame(); + const before = read(document.querySelector('.plain')); + plugin.disabled = false; + await frame(); + return { before, after: read(document.querySelectorAll('.col')[1]) }; +} + +async function align(t) { + const { css, sidebarCss, S, W, R } = sidebarHost(t.host); + /* rc.2 的新会话按钮多一层 newSessionLabelMask(button > mask > content > svg + label),rc.1 是扁平的。 */ + const masked = /newSessionLabelMask/.test(sidebarCss); + t.log('New Session DOM:' + (masked ? 'rc.2 嵌套' : 'rc.1 扁平')); + const NEWCHAT = ''; + const FOLDER = ''; + const label = NEWCHAT + `新会话`; + const group = (name, withSession) => `
+
${FOLDER}${svg(16)}${name}
+${withSession ? `
/ui-ux-pro-max dsh 模型选择器10分钟
` : ''} +
`; + /* 侧栏外壳 + 工作区浏览器,结构照 shipped 渲染代码,类名取自真实 CSS-Modules 映射。 */ + const stage = `
+
${svg(24)}deepseek
+ + +
+
工作区
+
${group('PROJIECT', true)}${group('未分组', false)}
+
+
+
`; + const page = await t.page({ width: 1300, height: 900, dpr: 2 }); + /* 左联要「无 codex-ui」:插件样式表加载后 disabled 掉即回到原生。 */ + await page.setContent(`${css} + +

原生 shipped(无 codex-ui)

${stage}

+ codex-ui theme.css

${stage}
`, 1200); + const { before, after } = await page.evaluate(probeAlign, { S, W, R }); + for (const key of Object.keys(after)) t.log(key.padEnd(16) + JSON.stringify(before[key]) + ' → ' + JSON.stringify(after[key])); + + /* 三行的图标列 / 文字列必须落在同一条竖线上。 */ + const ICON = 20; + const TEXT = 42; + const near = (v, want) => v !== null && Math.abs(v.l - want) <= 0.5; + t.check('新会话 图标列 = ' + ICON, near(after.newSessionIcon, ICON), after.newSessionIcon?.l); + t.check('插件 图标列 = ' + ICON, near(after.panelIcon, ICON), after.panelIcon?.l); + t.check('工作区 图标列 = ' + ICON, near(after.projectIcon, ICON), after.projectIcon?.l); + t.check('新会话 文字列 = ' + TEXT, near(after.newSessionText, TEXT), after.newSessionText?.l); + t.check('插件 文字列 = ' + TEXT, near(after.panelText, TEXT), after.panelText?.l); + t.check('工作区 文字列 = ' + TEXT, near(after.projectText, TEXT), after.projectText?.l); + await t.shot(page, 'sidebar-align-verify.png', { x: 0, y: 0, width: 660, height: 830, scale: 2 }); +} + +/* ── 滚动渐隐 ──────────────────────────────────────────────────────────── */ + +/** 两列(A 不透明底 / At 半透明底)的滚动容器遮罩、宿主覆盖层与几何。 */ +function probeSurface() { + const box = (el) => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, h: b.height, right: b.right }; }; + const out = {}; + for (const id of ['A', 'At']) { + const root = document.getElementById('col-' + id); + const list = root.querySelector('[class$="_list"]'); + const fade = root.querySelector('[class$="_fade"]'); + const cs = getComputedStyle(list); + out[id] = { + maskImage: cs.maskImage, maskSize: cs.maskSize, + fadeDisplay: fade === null ? 'missing' : getComputedStyle(fade).display, + list: box(list), + fill: getComputedStyle(root).getPropertyValue('--dsw-specific-sidebar-fill').trim(), + }; + } + return out; +} + +async function surface(t) { + const { css, S, W, R } = sidebarHost(t.host); + const TRANSLUCENT = 'rgba(238, 244, 249, 0.72)'; + /* 不透明那列不写侧栏底:吃宿主令牌 / 皮肤值,跟线上一致;半透明那列写死 rgba(…,0.72),正是设置卡开关 emit 的那一对声明。 + 只写一个 style 属性:HTML 里出现第二个会被整条丢弃,两列几何就不是一回事了。 */ + const column = (id, translucent) => `

${id}

+
+
deepseek
+ +
+
工作区
+
+${Array.from({ length: 20 }, (_, i) => `
row ${i}
`).join('')} +
+
+
`; + /* 夹具专用:行间距归零 + 滚动容器 padding-bottom 归零 + 滚动条常驻,让底部横带的像素读数只反映遮罩 alpha。 + 遮罩挂在滚动容器上、覆盖层挂在它的父盒上,两者几何都不受这几条影响。 */ + const fixtureCss = `.${W.groupSection} > * + * { margin-top: 0 } .${W.list} { padding-bottom: 0; overflow-y: scroll } +body{margin:0;background:#ffffff;font-family:"Segoe UI","Microsoft YaHei",sans-serif}.wrap{display:flex;gap:20px;padding:16px;align-items:flex-start} +.col > h4{margin:0 0 6px;font:600 12px/16px ui-monospace,Consolas,monospace;color:#8a8a8a}`; + /* html 上先不挂 data-codex-ui:皮肤全部规则都挂在这个属性下,摘掉即原生。 */ + const page = await t.page({ width: 900, height: 760, dpr: 1 }); + await page.setContent(`${css} +
${column('A', false)}${column('At', true)}
`, 900); + + /** 切皮肤开关,读探针,再截滚动视口底部 60px 的横带并解码。 */ + const measure = async (skinOn, bottom) => { + await page.evaluate((on) => document.documentElement.toggleAttribute('data-codex-ui', on), skinOn); + await page.frame(); + const styles = await page.evaluate(probeSurface); + return { styles, img: bottom === null ? null : await page.pixels({ x: 0, y: Math.round(bottom - 60), width: 900, height: 60, scale: 1 }) }; + }; + const first = await measure(false, null); + const BOTTOM = first.styles.A.list.y + first.styles.A.list.h; + const native = await measure(false, BOTTOM); + const skinned = await measure(true, BOTTOM); + + /** 横带里一列(内容区 x+60..x+120)逐行平均亮度(Rec.601),y = 距滚动视口底边的像素数。 */ + const band = (shot, column) => { + const { x } = shot.styles[column].list; + const rows = []; + for (let i = 0; i < 60; i += 1) { + let sum = 0; + for (let px = Math.round(x + 60); px < Math.round(x + 120); px += 1) { + const [r, g, b] = shot.img.rgb(px, i); + sum += 0.299 * r + 0.587 * g + 0.114 * b; + } + rows.push({ y: 59 - i, lum: sum / (Math.round(x + 120) - Math.round(x + 60)) }); + } + /* 由亮度反推遮罩 alpha:alpha = (bg − lum) / (bg − ink),bg / ink 用同一条曲线自标定。 */ + const bg = Math.max(...rows.map((r) => r.lum)); + const span = Math.max(1, bg - Math.min(...rows.map((r) => r.lum))); + return { lum: (y) => rows.find((r) => r.y === y).lum, alpha: (y) => (bg - rows.find((r) => r.y === y).lum) / span }; + }; + const A = band(native, 'A'); + const At = band(native, 'At'); + const B = band(skinned, 'A'); + const Bt = band(skinned, 'At'); + t.log('遮罩 alpha(y = 距视口底边) A(原生) B(codex-ui) | At(原生·半透明) Bt(codex-ui·半透明)'); + for (let y = 56; y >= 0; y -= 4) t.log(String(y).padStart(3) + ' ' + [A, B, At, Bt].map((c) => c.alpha(y).toFixed(3)).join(' ')); + + const maxOver = (ys, fn) => Math.max(...ys.map(fn)); + const headDiff = maxOver([4, 8, 12, 16, 20, 24], (y) => Math.abs(A.alpha(y) - B.alpha(y))); + const tailDiff = maxOver([28, 32, 36, 40, 44], (y) => Math.abs(A.alpha(y) - B.alpha(y))); + const bottomOpaque = Math.abs(A.lum(0) - B.lum(0)); + const bottomTranslucent = Bt.lum(0) - At.lum(0); + const fillInvariance = maxOver([0, 2, 4, 8, 16, 24, 40], (y) => Math.abs(B.lum(y) - Bt.lum(y))); + const on = skinned.styles.A; + const off = native.styles.A; + + t.check('原生侧栏:滚动容器没有 mask', off.maskImage === 'none', off.maskImage); + t.check('codex-ui:滚动容器有 2 层 mask', (on.maskImage.match(/linear-gradient\(/g) ?? []).length === 2, on.maskImage); + t.check('两列几何一致(宽度/高度/列表底边)', Math.abs(native.styles.At.list.h - off.list.h) < 0.5 && Math.abs(native.styles.At.list.y - off.list.y) < 0.5, + 'At h=' + native.styles.At.list.h.toFixed(1) + ' A h=' + off.list.h.toFixed(1)); + t.check('codex-ui:第 2 层 mask 宽 = 12px(让开滚动条槽)', /(^|[\s,])12px\s+100%(,|$)/.test(on.maskSize.replace(/\s+/g, ' ')), on.maskSize); + t.check('codex-ui:第 1 层 mask 宽 = 100% − 12px', on.maskSize.replace(/\s+/g, ' ').startsWith('calc(100% - 12px) 100%'), on.maskSize); + t.check('原生侧栏:宿主覆盖层在渲染', off.fadeDisplay !== 'none', off.fadeDisplay); + t.check('codex-ui:宿主覆盖层已让位', on.fadeDisplay === 'none', on.fadeDisplay); + /* e0 / 85 / 2e / 00 是 Codex 原文的十六进制 alpha;浏览器把它算成 0.88 / 0.52 / 0.18 / 0。 */ + t.check('codex-ui:渐变里有 Codex 的四段 alpha 停点(e0/85/2e/00 → .88/.52/.18/0)', + ['0.88)', '0.52)', '0.18)', '0) 100%'].every((stop) => on.maskImage.includes('(0, 0, 0, ' + stop)), on.maskImage); + /* 如实记录:不透明底下宿主那 24px 覆盖层与 Codex 前 24px 斜坡形状重合,本层不是为了外观而做的; + 差别在半透明底(设置卡的半透明侧栏)—— 覆盖层挡不住内容,mask 与底色无关。 */ + t.check('不透明底下:前 24px 与宿主覆盖层逐点差 ≤ 0.13(如实记录:外观几乎等价)', headDiff <= 0.13, headDiff.toFixed(3)); + t.check('不透明底下:24–40px 尾巴确有差别(这是本层唯一的形状差)', tailDiff > 0.02, tailDiff.toFixed(3)); + t.check('不透明底:两种机制的底边结果一致(|A−B| ≤ 15)', bottomOpaque <= 15, bottomOpaque.toFixed(1)); + t.check('半透明底:宿主覆盖层挡不住内容(At 底边比 Bt 暗 ≥ 40)', bottomTranslucent >= 40, bottomTranslucent.toFixed(1)); + t.check('codex-ui 的 mask 对侧栏底色 alpha 免疫(|B−Bt| ≤ 15)', fillInvariance <= 15, fillInvariance.toFixed(1)); + + await page.evaluate(() => document.documentElement.setAttribute('data-codex-ui', '')); + await page.frame(); + await t.shot(page, 'sidebar-surface-verify.png', { x: 0, y: Math.round(BOTTOM - 120), width: 560, height: 130, scale: 2 }); +} + +export default { align, surface }; diff --git a/scripts/theme-flash-probe.mjs b/scripts/theme-flash-probe.mjs deleted file mode 100644 index c9299b2..0000000 --- a/scripts/theme-flash-probe.mjs +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -/** - * theme-flash-probe.mjs — 抓主题切换时那一帧「闪」。 - * - * 为什么要有它:主题切换的闪屏是 1–3 帧的事(~16–50ms),肉眼看到、截图拍不到、 - * 断言也抓不住。这里在页面里挂一个 requestAnimationFrame 采样器,按帧记录 - * 画布相关的计算色与主题标记,再在 Node 侧找出「既不是起点色、也不是终点色」的中间帧。 - * - * node scripts/theme-flash-probe.mjs --url "http://127.0.0.1:3098/?token=..." [--frames 180] - */ -import { spawn } from 'node:child_process'; -import http from 'node:http'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromePath, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const arg = (name, fallback) => { - const i = process.argv.indexOf('--' + name); - return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : fallback; -}; -const TOKEN_URL = arg('url', ''); -const CHROME = arg('chrome', '') || chromePath(); -const PORT = Number(arg('cdp-port', '9342')); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -if (!TOKEN_URL) { console.error('缺少 --url'); process.exit(2); } - -const origin = new URL(TOKEN_URL).origin; -const grab = (url) => new Promise((resolve, reject) => { - const u = new URL(url); - http.get({ hostname: u.hostname, port: u.port, path: u.pathname + u.search }, (res) => { resolve((res.headers['set-cookie'] || [''])[0]); res.resume(); }).on('error', reject); -}); -const cookie = (await grab(TOKEN_URL)).split(';')[0]; -const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + tempDir('dsh-cdp-flash'), - '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--window-size=1280,900', '--hide-scrollbars', 'about:blank'], { stdio: 'ignore' }); -let info; -for (let i = 0; i < 60 && !info; i += 1) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch { /* 等 */ } if (!info) await sleep(250); } -if (!info) { console.error('Chromium 没起来'); process.exit(2); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const waiting = new Map(); -/** 主题发布序列:宿主每次 theme/change 都会被本插件打一行日志(临时埋点),这里全部收集起来。 */ -const themeLog = []; -ws.onmessage = (ev) => { - const m = JSON.parse(ev.data); - if (m.method === 'Runtime.consoleAPICalled') { - const text = m.params.args.map((a) => String(a.value ?? a.description ?? '')).join(' '); - if (text.includes('[codex-ui][probe]')) themeLog.push(text.replace('[codex-ui][probe] ', '')); - } - const fn = waiting.get(m.id); - if (fn) { waiting.delete(m.id); fn(m); } -}; -const send = (method, params = {}, sessionId) => new Promise((res, rej) => { - const id = ++seq; - waiting.set(id, (m) => (m.error ? rej(new Error(method + ': ' + JSON.stringify(m.error))) : res(m.result))); - ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); -}); -const evaluate = async (expression, sessionId) => (await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId)).result.value; -const click = async (x, y, sessionId) => { - await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, sessionId); - await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, sessionId); - await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, sessionId); -}; -const clickByText = async (pattern, sessionId) => evaluate('(() => {' - + 'const re = new RegExp(' + JSON.stringify(pattern) + ');' - + 'const els = [...document.querySelectorAll("button, a, [role=tab], [role=menuitem], [role=button]")];' - + 'const el = els.find((e) => (re.test(e.getAttribute("aria-label") || "") || re.test(e.textContent.trim())) && e.offsetParent !== null);' - + 'if (!el) return null; const r = el.getBoundingClientRect();' - + 'return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), text: (el.getAttribute("aria-label") || el.textContent.trim()).slice(0, 30) }; })()', sessionId); - -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Runtime.enable', {}, sessionId); -await send('Network.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }, sessionId); -const eq = cookie.indexOf('='); -await send('Network.setCookie', { name: cookie.slice(0, eq), value: cookie.slice(eq + 1), domain: new URL(origin).hostname, path: '/', httpOnly: true }, sessionId); -await send('Page.navigate', { url: origin + '/' }, sessionId); -await sleep(11000); -for (let i = 0; i < 4; i += 1) { const d = await clickByText('^(继续|稍后配置|跳过|知道了)$', sessionId); if (d === null) break; await click(d.x, d.y, sessionId); await sleep(1200); } -const entry = await clickByText('^插件$', sessionId); -if (entry !== null) { await click(entry.x, entry.y, sessionId); await sleep(2000); } -const open = await clickByText('^查看 codex-ui$', sessionId); -if (open === null) { console.error('找不到 codex-ui 入口'); process.exit(1); } -await click(open.x, open.y, sessionId); -await sleep(3000); - -/** - * 采样器:每帧记录「**实际画在屏幕上的那一层**」。 - * 只看元素的 backgroundColor 不够 —— 大多数元素是透明的,真正被看到的颜色要沿祖先往上 - * 找第一个不透明的底色;主题切换的闪,正是这种「有效底色」在某一帧翻错。 - */ -const SAMPLER = `(() => { - /** 沿祖先找第一个不透明底色;返回 { color, from } 便于定位是哪一层。 */ - const backdrop = (x, y) => { - let el = document.elementFromPoint(x, y); - while (el !== null && el !== document.documentElement.parentElement) { - const cs = getComputedStyle(el); - const bg = cs.backgroundColor; - const m = /rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)(?:,\\s*([\\d.]+))?/.exec(bg); - if (m !== null && (m[4] === undefined || Number(m[4]) > 0.99)) { - return m[1] + ',' + m[2] + ',' + m[3] + '@' + (el.getAttribute('data-slot') || el.className.toString().slice(0, 20) || el.tagName); - } - el = el.parentElement; - } - return 'none'; - }; - const pts = [[640, 450], [1400, 450], [140, 700]]; - window.__flash = []; - const loop = () => { - const frame = { - t: Math.round(performance.now()), - dark: document.body.hasAttribute('data-ds-dark-theme'), - scheme: getComputedStyle(document.documentElement).colorScheme, - override: document.documentElement.hasAttribute('data-codex-ui-theme'), - preview: document.documentElement.getAttribute('data-codex-ui-preview') || '', - skin: document.documentElement.hasAttribute('data-codex-ui'), - body: getComputedStyle(document.body).backgroundColor, - html: getComputedStyle(document.documentElement).backgroundColor, - card: document.querySelectorAll('.cx-form').length, - p0: backdrop(pts[0][0], pts[0][1]), - p1: backdrop(pts[1][0], pts[1][1]), - p2: backdrop(pts[2][0], pts[2][1]), - }; - window.__flash.push(frame); - if (window.__flash.length < 20000) window.__flashRaf = requestAnimationFrame(loop); - }; - loop(); - return true; -})()`; -await evaluate(SAMPLER, sessionId); - -/** 一次操作:清空采样 → 点 → 等落定 → 回收这一段(连同点击时刻一起带走)。 */ -const capture = async (act, settle = 1300) => { - await evaluate('window.__flash = []', sessionId); - const box = await act(); - if (box === null) return null; - const t0 = await evaluate('performance.now()', sessionId); - await click(box.x, box.y, sessionId); - await sleep(settle); - const frames = await evaluate('(() => { const f = window.__flash; window.__flash = []; return f; })()', sessionId); - return { frames, t0 }; -}; -/** 点击 → 屏幕上真的变色之间的毫秒数(以第一帧 dark 标记翻转计)。 */ -const latencyOf = ({ frames, t0 }) => { - if (frames === undefined || frames.length === 0) return null; - const before = frames[0].dark; - const flip = frames.find((f) => f.dark !== before); - return flip === undefined ? null : Math.round(flip.t - t0); -}; -const tap = (label) => async () => clickByText('^' + label + '$', sessionId); -const spot = (f) => f.p0 + ' | ' + f.p1 + ' | ' + f.p2; -/** - * 异常帧 = 有效底色既不是切换前的值、也不是切换后的稳定值。 - * 这正是肉眼看到的「闪」:某一帧画出来的东西不属于任何一端。 - */ -/** 把逐帧的深浅状态压成连续段,例:L×12 → D×68 —— 段数多于 2 就是「回打」(黑→白→黑)。 */ -const runsOf = (frames) => { - const runs = []; - for (const f of frames) { - const key = f.dark ? 'D' : 'L'; - if (runs.length > 0 && runs[runs.length - 1][0] === key) runs[runs.length - 1][1] += 1; - else runs.push([key, 1]); - } - return runs; -}; -const analyse = (name, frames) => { - if (frames === null || frames.length === 0) { console.log(name + ':没采到帧'); return 0; } - const runs = runsOf(frames); - console.log(' ' + name + ' 深浅序列 ' + runs.map(([k, n]) => k + '×' + n).join(' → ') + (runs.length > 2 ? ' ← 回打!' : '')); - const head = frames[0]; - const last = frames[frames.length - 1]; - const bad = frames.filter((f, i) => i > 0 && i < frames.length - 1 && spot(f) !== spot(head) && spot(f) !== spot(last)); - console.log(name + ':帧 ' + String(frames.length).padStart(3) + ' 异常 ' + bad.length + (bad.length === 0 ? '' : ' ' + spot(head) + ' → ' + spot(last))); - for (const f of bad.slice(0, 5)) console.log(' FLASH t=' + f.t + ' dark=' + f.dark + ' scheme=' + f.scheme + ' ' + spot(f)); - return bad.length; -}; - -/* 起始态固定亮色,然后来回切 4 趟:偶发闪屏要靠重复切换才抓得到。 */ -const seg = await evaluate('(() => { const t = document.querySelector("[role=tab][aria-selected=true]"); return t === null ? null : t.textContent.trim(); })()', sessionId); -if (seg !== '亮色') { const b = await clickByText('^亮色$', sessionId); if (b !== null) { await click(b.x, b.y, sessionId); await sleep(1500); } } - -let anomalies = 0; -let captured = 0; -/* 来回切:偶发闪屏要靠重复切换才抓得到;--switches N 可只跑前 N 趟。 */ -const all = [['深色', tap('深色')], ['亮色', tap('亮色')], ['深色', tap('深色')], ['亮色', tap('亮色')], ['深色', tap('深色')], ['亮色', tap('亮色')]]; -const want = Number(arg('switches', '6')); -const plan = all.slice(0, Number.isInteger(want) && want > 0 ? Math.min(want, all.length) : all.length); -const latencies = []; -for (const [label, act] of plan) { - themeLog.push('--- 点 ' + label + ' ---'); - const shot2 = await capture(act); - if (shot2 === null) { console.log('切换 ' + label + ':找不到分段'); break; } - captured += 1; - const ms = latencyOf(shot2); - if (ms !== null) latencies.push(ms); - anomalies += analyse('主题 → ' + label, shot2.frames); - console.log(' ↑ 点击 → 变色 ' + (ms === null ? '未观测到' : ms + 'ms')); -} -if (latencies.length > 0) { - console.log('\n变色延迟:样本 ' + latencies.join(' / ') + ' ms,中位 ' + latencies.slice().sort((a, b) => a - b)[Math.floor(latencies.length / 2)] + 'ms'); -} - -/* 页面切换也采一遍:设置页 ↔ 会话页来回走。 */ -const pageShot = await capture(async () => clickByText('^新会话$', sessionId), 1600); -if (pageShot !== null) anomalies += analyse('切到会话页', pageShot.frames); -const backShot = await capture(async () => clickByText('^插件$', sessionId), 1800); -if (backShot !== null) anomalies += analyse('切回插件页', backShot.frames); -const reopenShot = await capture(async () => clickByText('^查看 codex-ui$', sessionId), 2000); -if (reopenShot !== null) anomalies += analyse('打开组合包页', reopenShot.frames); - -const total = await evaluate('(() => { cancelAnimationFrame(window.__flashRaf); return window.__flash.length; })()', sessionId); -console.log('\n主题发布序列(宿主 theme/change 逐次):'); -for (const line of themeLog) console.log(' ' + line); -console.log('\n采样段 ' + (captured + 3) + ' 段,异常帧合计 ' + anomalies + ',探测器仍在跑的帧 ' + total); -console.log(anomalies === 0 ? 'RESULT 未捕捉到中间态帧' : 'RESULT 捕捉到 ' + anomalies + ' 个中间态帧'); -ws.close(); child.kill(); diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000..03384b0 --- /dev/null +++ b/scripts/verify.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * verify.mjs — 夹具验收:宿主真实 shipped 样式 + 按宿主渲染代码复刻的 DOM + 本插件样式 / 组件, + * 在无头 Chromium 里读计算样式、几何与像素,逐条断言。 + * + * node scripts/verify.mjs [spec…] [--host ] [--shots <目录>] [--verbose] + * + * spec 即 scripts/specs/<名>.mjs,不写就全跑。每个 spec 默认导出 { 小节名: async (t) => … }, + * 每个小节独占一个浏览器进程(悬停态只在前台页签里算,小节之间不能共用)。 + * 宿主来源与浏览器见 lib/host.mjs;截图默认写进系统临时目录,--shots 指定别处(例如 assets/screenshots)。 + * + * 为什么是夹具而不是截真 GUI:launch token 只在宿主进程内存里,桌面壳的 profile 又被 Electron 独占。 + * 真 GUI 的验收在 scripts/live/。 + */ +import fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { SKIN_DIR, SKIN_PARTS, scopeCss, stripComments } from './build.mjs'; +import { launch, sleep } from './lib/cdp.mjs'; +import { checklist, summarize } from './lib/checks.mjs'; +import { openHost } from './lib/host.mjs'; + +const SPEC_DIR = join(dirname(fileURLToPath(import.meta.url)), 'specs'); + +/** + * 夹具用的皮肤样式:与 build.mjs 同一条管线(去注释 → 作用域化)。before 对照页要「没有某一段的皮肤」, + * cuts 按**源文件**里的注释标记剪(产物已去注释):{ 'patches.css': [['⑫ …', '⑬ …']] }, + * 从 from 所在注释的起点剪到 to 所在注释的起点;to 为 null 剪到文件尾。 + */ +function themeCss(cuts = {}) { + return SKIN_PARTS.map((file) => { + let src = fs.readFileSync(join(SKIN_DIR, file), 'utf8'); + for (const [from, to] of cuts[file] ?? []) { + const at = src.indexOf(from); + const until = to === null ? src.length : src.indexOf(to); + if (at < 0 || until < 0) throw new Error(file + ' 里找不到标记:' + (at < 0 ? from : to)); + src = src.slice(0, src.lastIndexOf('/*', at)) + src.slice(to === null ? until : src.lastIndexOf('/*', until)); + } + return scopeCss(stripComments(src)); + }).join('\n'); +} + +const opts = { host: undefined, shots: join(tmpdir(), 'codex-ui-shots'), verbose: false }; +const named = []; +for (let i = 2; i < process.argv.length; i += 1) { + const a = process.argv[i]; + if (a === '--verbose') opts.verbose = true; + else if (a === '--host' || a === '--shots') opts[a.slice(2)] = process.argv[++i]; + else named.push(a); +} +const shots = resolve(opts.shots); +const available = fs.readdirSync(SPEC_DIR).filter((f) => f.endsWith('.mjs')).map((f) => basename(f, '.mjs')).sort(); +const wanted = named.length === 0 ? available : named; +const unknown = wanted.filter((name) => !available.includes(name)); +if (unknown.length > 0) { + console.error('没有这个 spec:' + unknown.join(' ') + '。可选:' + available.join(' ')); + process.exit(2); +} +fs.mkdirSync(shots, { recursive: true }); + +let host = null; +const all = []; +const table = []; +for (const spec of wanted) { + const sections = (await import(pathToFileURL(join(SPEC_DIR, spec + '.mjs')).href)).default; + for (const [section, run] of Object.entries(sections)) { + console.log('\n━━ ' + spec + ' / ' + section); + const { results, check } = checklist(); + let browser = null; + const t = { + check, + sleep, + theme: themeCss, + log: (...args) => { if (opts.verbose) console.log(' ', ...args); }, + /** 宿主只在用到时才打开(elevation / power-rail 不需要宿主)。 */ + get host() { + if (host === null) { + host = openHost(opts.host); + console.log('宿主 ' + host.label); + } + return host; + }, + async page(viewport) { + browser ??= await launch(); + return browser.newPage(viewport); + }, + async shot(page, file, clip) { + const path = join(shots, file); + await page.screenshot({ path, clip }); + t.log('截图 ' + path); + }, + }; + try { + await run(t); + } catch (e) { + check(section + ' 运行出错', false, e.stack ?? e.message); + } finally { + await browser?.close(); + } + all.push(...results); + table.push([spec, section, results.filter((r) => r.ok).length, results.length]); + } +} + +console.log('\n━━ 汇总(截图在 ' + shots + ')'); +for (const [spec, section, pass, total] of table) { + console.log(' ' + (pass === total ? 'PASS' : 'FAIL') + ' ' + (spec + ' / ' + section).padEnd(32) + pass + '/' + total); +} +summarize(all); From 370cf2836e1de780473d9e5d0be4139e63a08cfe Mon Sep 17 00:00:00 2001 From: wushi2333 <837145630@qq.com> Date: Mon, 28 Sep 2026 20:38:05 +0800 Subject: [PATCH 6/9] =?UTF-8?q?docs:=200.6.1=20=E2=80=94=E2=80=94=20README?= =?UTF-8?q?=20/=20=E6=9B=B4=E6=96=B0=E6=97=A5=E5=BF=97=20/=20=E7=9A=AE?= =?UTF-8?q?=E8=82=A4=E8=AF=B4=E6=98=8E=E6=8C=89=E6=96=B0=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E9=87=8D=E5=86=99=EF=BC=9B=E5=AE=89=E8=A3=85=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20dsh=20plugin=20add=20link:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README(中英):安装走标准 link 安装(含试用 profile、桌面端入口与从 0.6.0 迁移);新增「开发」一节 (构建、加功能的方式、inject 与 ctx.inject 的分工);目录、验收(check / verify 分 spec / live 三件)、 宿主与浏览器、边界(席位标记、类名锚点计数、:has 纪律)、CI 全部对齐现状。 - skins/codex-ink/README、docs/ 两份计划、样式与 spec 注释里的旧脚本名换成新入口。 - 版本 0.6.1(client.js 只有头注释的版本号变化)。 --- CHANGELOG.md | 76 +++++++++++++ CHANGELOG.zh-CN.md | 56 ++++++++++ README.md | 178 ++++++++++++++++++------------- README.zh-CN.md | 167 ++++++++++++++++------------- client.js | 2 +- docs/next-components.zh-CN.md | 12 +-- docs/plan-model-picker.zh-CN.md | 3 + package.json | 2 +- scripts/specs/model-picker.mjs | 2 +- skins/codex-ink/README.md | 18 ++-- skins/codex-ink/README.zh-CN.md | 16 +-- skins/codex-ink/model-picker.css | 2 +- 12 files changed, 358 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e5b63..7107d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,82 @@ [简体中文](CHANGELOG.zh-CN.md) +## 0.6.1 - 2026-09-28 + +An architecture cleanup: no feature changes, much faster while a reply streams in, and the engineering scripts reduced +to three entry points. Before and after the refactor, every element's computed style was compared across 14 UI states on +a real `dsh web`: zero differences apart from the one intended change under "Fixes". + +### Performance + +- **No more `:has()` in ancestor positions**: 9 rules in sidebar alignment, 2 in the sidebar fade, 1 for the model + picker seat; the composer hero rule became a fixed-depth child `:has()`. An ancestor-position `:has()` makes Chromium + re-match the whole subtree under every affected ancestor each time the conversation inserts a node carrying `data-slot`. + Simulated streaming (200 frames, each 30 spans plus one `data-slot` node), median of 5 runs: + + | Mode | Style recalc | Wall time | + |---|---|---| + | web | 4264 → 267 ms | 6.3 → 2.3 s | + | Windows desktop shell (title bar) | 9518 → 442 ms | 11.7 → 2.5 s | + + In the desktop-shell mode the skin's extra recalc over a bare host is now within noise. Every new anchor keeps the + old selector's specificity, so the cascade against host rules is unchanged. +- **Model picker seat**: the host control used to be hidden by `seat:has(> .codex-mp-trigger) > :not(trigger)`; now the + **seat itself** gets `data-codex-ui-seated` when our trigger goes in, and loses it when the trigger is removed. The mark + sits on the slot outlet, not on the host's child, so React replacing that child does not drop it (0.6.0's reason for + avoiding a mark no longer applies; the fixture "still hidden after the host replaces its own child" passes). +- **Narrower observer**: the model picker's MutationObserver rescans everything only on attribute changes and on seats + being added or removed; otherwise it re-syncs just the touched seats and the ones not settled yet. Script time while + streaming: 47.5 → 10.1 ms. + +### Fixes + +- **The dark `html` background never applied**: `skin.css`'s `:root:has(body[data-ds-dark-theme])` was scoped to + `html[data-codex-ui] :root:has(…)` (a descendant selector that never matches), so in dark mode `html` stayed light + `#fff`, normally hidden behind `body`. The scoper now maps compound selectors starting with `:root` to the root itself. + This is the release's only visible difference: in dark mode the `html` background goes `#fff` → `#111111` (the + intended design; see the last item under "Settings page" in the README). It is its own commit (249e6d1) and can be + reverted on its own. + +### Structure + +- The browser half is split into ES modules under `src/client/`: `index.js` (`inject` and `apply`), `stylesheet.js`, + `settings.js`, `theme-preview.js`, `settings-card.js`, `override.js`, `model-picker/{index,component,view}.js`, + `constants.js`, `host.js`. The string-splicing template `src/client.template.js` and `src/build.mjs` are gone. A new + feature is one module exporting `installXxx(ctx)` plus one line in `apply`. +- `scripts/build.mjs` is the only build: a zero-dependency bundler turns the modules into IIFEs in dependency order + (relative imports become destructuring, host packages go through the loader's `require`), with the stylesheet + inlined as the virtual module `codex-ui:theme.css`; unknown import or export forms fail the build. The CSS scoper + understands comments and strings, and comments are stripped from the output. +- Unreachable code removed: the settings card's summary branch, the no-primitives fallback and theme fallback table, + and the matching `.cx-row--stack` and fallback styles in `settings.css`. +- Deduplicated styles: repeated shadows and fills became 4 tokens (`--dsw-codex-ambient`, `--dsw-codex-menu-shadow`, + `--dsw-codex-suggest-shadow`, `--dsw-codex-suggest-fill`), dark declarations identical to light ones were dropped, + comments were slimmed; the eight stylesheets went from 2491 to about 1820 lines. + +### Engineering + +- Scripts went from 25 files / 4886 lines to 15 / 3356, with three entry points and shared code in `scripts/lib/` + (host / cdp / checks): + - `scripts/check.mjs`: the former `check-repo.mjs` plus `audit-codex-ink.mjs`, 60 checks, the CI entry point; new are a + DSH client plugin contract check on `client.js` (executed once in isolation: loader id = package name, `inject` is + exactly the three required services, the inlined stylesheet equals `theme.css`) and a `peerDependencies` check. + - `scripts/verify.mjs` + `scripts/specs/`: the eight former `*-verify.mjs` fixtures with the same assertion names and + counts, 195 in total; `npm run verify`. + - `scripts/live/`: `gui.mjs`, `settings.mjs` (absorbing `theme-flash-probe.mjs`: no intermediate frame while switching + theme) and the new `parity.mjs` (per-element computed-style comparison before and after a change). +- **Install goes through the standard `dsh plugin add link:`**: removed `install-plugin.mjs`, `make-verify-profile.mjs`, + `pack-host-asar.mjs` (an npm-installed `node_modules` works as a fixture host directly), `make-preview.mjs` and + `scripts/fixtures/`; `package.json` drops `install:*`, adds `verify`, and declares `@deepseek-ai/schemastery` in + `peerDependencies` (`index.js` imports it; the host provides it). See "Install" in the README for **migrating**. +- Fixture fixes: the old hero / composer-shadow fixtures truncated the host's conversation root styles at the first + `content:""` (2672 of 6717 characters read); with the full CSS the scroller clips the shadow, so the fixture now puts + the padding inside the scroller as the real app does, with unchanged readings (edge 224, 42px falloff). The face A + menu fixture and the right-bar fixture, already broken on 0.6.0, work again. The settings check resets every override + when it finishes, so later comparisons are not polluted. +- Removed `docs/plan-settings-page.zh-CN.md` (an executed plan whose conclusions are in the changelog) and the + verification screenshots the READMEs no longer reference. + ## 0.6.0 - 2026-09-28 Face B of the model picker (the Codex reasoning power rail) lands, together with a full-repository review against the diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 4d7739f..04f86ab 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,62 @@ [English](CHANGELOG.md) +## 0.6.1 - 2026-09-28 + +架构整理:功能不变,流式输出时明显更快,工程脚本收拢成三个入口。重构前后在真 `dsh web` 上把 14 个界面状态逐元素比对计算样式: +除「修正」一节那一处有意的变化外,零差异。 + +### 性能 + +- **去掉落在祖先位置的 `:has()`**:侧栏对齐 9 条、侧栏渐隐 2 条、模型选择器席位 1 条;输入区 hero 那条改成固定深度的子代 `:has()`。 + 祖先位置的 `:has()` 让会话区每插入一个带 `data-slot` 的节点,Chromium 都要把受影响祖先下的整片子树重配一遍样式。 + 模拟流式输出(200 帧,每帧 30 个 span 加 1 个 `data-slot` 节点),5 次取中位: + + | 形态 | 样式重算 | 整段耗时 | + |---|---|---| + | web | 4264 → 267 ms | 6.3 → 2.3 s | + | Windows 桌面壳(标题栏形态) | 9518 → 442 ms | 11.7 → 2.5 s | + + 桌面壳形态下皮肤相对裸宿主的额外重算已在噪声以内。新锚点的特异性与旧写法逐条相同,与宿主规则的先后关系不变。 +- **模型选择器席位**:原先靠 `席位:has(> .codex-mp-trigger) > :not(触发器)` 隐藏宿主那一格;现在触发器挂进席位时给**席位本身** + 打 `data-codex-ui-seated`,撤走时一起摘掉。标记在席位出口上、不在宿主子节点上,React 换掉宿主子节点不影响它 + (0.6.0 不打标记的顾虑因此不成立;夹具「宿主换掉自己的子节点后仍隐藏」照常通过)。 +- **观察器收窄**:模型选择器的 MutationObserver 只在属性变化、席位增删时全量扫描,其余只重同步被触及的席位与尚未落定的席位。 + 流式时脚本耗时 47.5 → 10.1 ms。 + +### 修正 + +- **深色下 `html` 底色从未生效**:`skin.css` 的 `:root:has(body[data-ds-dark-theme])` 被作用域化成了 + `html[data-codex-ui] :root:has(…)`(后代选择器,永远匹配不到),深色时 `html` 一直是浅色的 `#fff`,只是平时被 `body` 盖住。 + 作用域器现在把以 `:root` 开头的复合选择器映射到根本身。这是本版唯一的可见差异:深色状态下 `html` 的底色 `#fff` → `#111111` + (本来就是这么设计的,见 README「设置页」最后一条)。它单独一个提交(249e6d1),要回退可以单独 revert。 + +### 结构 + +- 浏览器半拆成 `src/client/` 下的 ES 模块:`index.js`(`inject` 与 `apply`)、`stylesheet.js`、`settings.js`、`theme-preview.js`、 + `settings-card.js`、`override.js`、`model-picker/{index,component,view}.js`、`constants.js`、`host.js`。 + 旧的字符串拼接模板 `src/client.template.js` 与 `src/build.mjs` 删除。加功能 = 一个模块导出 `installXxx(ctx)` + `apply` 里一行。 +- `scripts/build.mjs` 是唯一的构建:零依赖打包器把模块按依赖顺序打成 IIFE(相对导入 → 解构,宿主包 → loader 的 `require`), + 样式作为虚拟模块 `codex-ui:theme.css` 内联;不认识的 import / export 写法直接报错。CSS 作用域器认得注释与字符串,产物去掉注释。 +- 删掉走不到的代码:设置卡的摘要分支、无 primitives 时的降级与主题回退表,`settings.css` 里对应的 `.cx-row--stack` 与回退样式。 +- 样式去重:重复的阴影与底色收成 4 个令牌(`--dsw-codex-ambient`、`--dsw-codex-menu-shadow`、`--dsw-codex-suggest-shadow`、 + `--dsw-codex-suggest-fill`),去掉深色里与浅色同值的重复声明,注释瘦身;八份 CSS 2491 → 约 1820 行。 + +### 工程 + +- 脚本从 25 个文件 / 4886 行收成 15 个 / 3356 行,三个入口,公共部分在 `scripts/lib/`(host / cdp / checks): + - `scripts/check.mjs`:原 `check-repo.mjs` + `audit-codex-ink.mjs`,60 项,CI 入口;新增 `client.js` 的 DSH 插件契约检查 + (隔离执行一遍:loader id = 包名、`inject` 恰为三个必需服务、内嵌样式与 `theme.css` 一致)与 `peerDependencies` 检查。 + - `scripts/verify.mjs` + `scripts/specs/`:原 8 个 `*-verify.mjs` 夹具,断言名与项数不变,共 195 项;`npm run verify`。 + - `scripts/live/`:`gui.mjs`、`settings.mjs`(并入原 `theme-flash-probe.mjs`:主题切换逐帧无中间帧),新增 `parity.mjs`(改动前后逐元素计算样式对账)。 +- **安装改走 DSH 标准的 `dsh plugin add link:`**:删掉 `install-plugin.mjs`、`make-verify-profile.mjs`、`pack-host-asar.mjs` + (npm 装出来的 `node_modules` 直接就能当夹具宿主)、`make-preview.mjs` 与 `scripts/fixtures/`;`package.json` 去掉 `install:*`、 + 加 `verify`,并在 `peerDependencies` 声明 `@deepseek-ai/schemastery`(`index.js` 导入它,由宿主提供)。**迁移**见 README「安装」。 +- 夹具修正:旧 hero / composer-shadow 夹具在第一个 `content:""` 处截断了宿主会话根样式(6717 字符只读到 2672);按全文读后阴影会被 + 滚动容器裁掉,夹具改成像真应用一样把留白放在滚动容器里,读数不变(边缘 224、衰减 42px)。0.6.0 上已跑不通的 A 面菜单夹具与 + 右栏夹具修好。settings 验收结束时重置全部覆盖,不再污染之后的比对。 +- 删掉 `docs/plan-settings-page.zh-CN.md`(已执行的旧计划,结论已在更新日志里)与 README 不再引用的验收截图。 + ## 0.6.0 - 2026-09-28 模型选择器的 B 面(Codex 推理等级功率轨)落地;外加一轮对照复刻规格的全仓审查,修掉其中查实的问题。 diff --git a/README.md b/README.md index e3316ab..ae2c160 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,9 @@ images are in the sections below and in `assets/reference/`. ## Host compatibility Developed against DSH `0.1.7-rc.1` (npm global install) and `0.1.7-rc.2` (Windows desktop shell `app.asar`); -the full 0.6.0 verification ran on the npm release `@deepseek-ai/dsh@0.1.7-rc.2` (a real `dsh web` instance plus an asar packed from it, see "Host paths"). -The verification scripts read shipped CSS straight out of `app.asar`; a host upgrade that changes structure fails their assertions. +the full 0.6.1 verification ran on the npm release `@deepseek-ai/dsh@0.1.7-rc.2` (fixtures against both its +`node_modules` and an `app.asar` packed from it, the live checks on a `dsh web` booted from it; see "Host and browser"). +Fixtures read the shipped host CSS and render code directly, so a structural host change fails their assertions. ## Features @@ -67,27 +68,33 @@ With face B switched off, the host menu (face A) while a reasoning effort is bei ## Install +A standard DSH plugin install; none of this repository's scripts are involved: + ```powershell -npm run install:web # node scripts/install-plugin.mjs --write -npm run install:desktop # desktop shell; restart the app afterwards -npm run build # regenerate theme.css and client.js from skins/codex-ink +dsh plugin --profile web add link: # adds a link dependency and puts the package into dsh.profile.bundles +dsh web +dsh plugin --profile web remove codex-ui # uninstall: removes the dependency and the bundles entry +``` -node scripts/install-plugin.mjs # dry run (web profile by default) -node scripts/install-plugin.mjs --bundle --write # register through dsh.profile.bundles and drop the redundant insert -node scripts/build.mjs --check # report stale artifacts without writing +To keep your everyday profile untouched, try it in a new profile first: + +```powershell +dsh codex --from-default-profile web --dump-config # creates the codex profile from the web template +dsh plugin --profile codex add link: +dsh codex --port 3099 --no-open ``` -The installer scopes the eight stylesheets in `skins/codex-ink/` to `html[data-codex-ui]`, combines them with -`src/client.template.js` (which inlines `src/override.js`, `src/model-picker.js` and `src/settings-card.js`) into `client.js`, copies the -result to `profiles//vendor/codex-ui`, creates the `node_modules/codex-ui` junction, and keeps exactly one -registration path. +The desktop app owns its profile and the CLI refuses to write it: open "Plugins" → "Add plugin" in the app and enter +the absolute repo path (the dialog accepts a package name, a Git URL, a tarball or a local absolute path). +`@deepseek-ai/schemastery` is declared in `peerDependencies` and provided by the host. -Never use both registration paths at once — two rows share the id and the market check disables the plugin: +`link:` points at the repository itself: after changing a stylesheet or a component, run `npm run build` and reload the +page. A change to the `Config` in `index.js` needs a `dsh web` restart (the host half loads at boot). -| Path | How | Used by | -|---|---|---| -| bundle | the package name goes into the profile `package.json` `dsh.profile.bundles`; the package's own `cordis.patch.yml` inserts the entry | the desktop shell profile and the web profile (`--bundle`) | -| insert | a hand-written `- insert:` in the profile `cordis.patch.yml` | throwaway verification profiles that skip pnpm install | +**Migrating from 0.6.0 or earlier**: `install-plugin.mjs` is gone. It installed a copy at +`profiles//vendor/codex-ui` plus a `node_modules/codex-ui` junction, registered either through the package name in +`dsh.profile.bundles` or a hand-written `- insert:` in the profile `cordis.patch.yml`. Remove those before the standard +install — two registrations of the same id fail the market check and the plugin is disabled. The same stylesheets can be picked up by a skin loader: @@ -96,81 +103,97 @@ node scripts/install-skin.mjs # per-file SHA256 check against $DSH_HOME node scripts/install-skin.mjs --write # overwrite on drift ``` +## Development + +```powershell +npm run build # regenerate theme.css and client.js from skins/codex-ink and src/client +node scripts/build.mjs --check # report stale artifacts without writing +``` + +The build has no dependencies and one implementation, `scripts/build.mjs`: the eight stylesheets are stripped of +comments and scoped to `html[data-codex-ui]` into `theme.css`; the ES modules in `src/client/` are bundled in dependency +order into one classic script, `client.js` (the DSH `__ModuleLoader__.load` shape, stylesheet inlined, host packages such +as `react` resolved through the loader's `require`). It accepts two import forms (`import { a, b as c } from '…'`, +`import * as ns from '…'`) and three export forms (`export const | function | class`); anything else fails the build +instead of bundling wrongly. + +Adding a feature: a module in `src/client/` exporting `installXxx(ctx)` plus one line in `apply` in +`src/client/index.js`; new styles go into a new file in `skins/codex-ink/`, registered in `SKIN_PARTS` in +`scripts/build.mjs`. Required host services go into `inject` (one missing service keeps the plugin inactive); optional +ones go through a `ctx.inject([...], cb)` child scope, as the model picker does. + ## Layout | Path | Content | |---|---| -| `index.js` `cordis.patch.yml` `package.json` | Host half and manifest | -| `src/client.template.js` | Browser half template (stylesheet, override layer, settings seat) | -| `src/override.js` | Override-layer pure functions (no DOM; unit-tested by the repo checks) | -| `src/settings-card.js` | The config card on the bundle page (inlined into `client.js` at build time) | -| `src/model-picker.js` | Model picker face B: seat takeover, popover, power rail (its pure functions are unit-tested by `check-repo`) | -| `src/build.mjs` | Scoping and artifact generation; the only implementation | -| `theme.css` `client.js` | Generated from `skins/codex-ink/` by `src/build.mjs` | -| `skins/codex-ink/` | Stylesheet sources (skin.css / patches.css / model-picker.css / sidebar-align.css / sidebar-surface.css / window-shadow.css / composer.css / settings.css) | +| `index.js` `cordis.patch.yml` `package.json` | Host half (the `Config` schema) and manifest | +| `src/client/index.js` | Browser half entry: `inject` and `apply`, wiring up the modules below in order | +| `src/client/stylesheet.js` | Injects the skin (`style[data-plugin]`, which the host removes on unload and hot reload) | +| `src/client/settings.js` `theme-preview.js` `settings-card.js` | Settings: the override `
\ No newline at end of file diff --git a/scripts/model-picker-verify.mjs b/scripts/model-picker-verify.mjs index 8f5cf03..3a65b38 100644 --- a/scripts/model-picker-verify.mjs +++ b/scripts/model-picker-verify.mjs @@ -166,7 +166,7 @@ const page = (theme, label) => '' + label + '' + '' + '' - + '' + '

' + label + '

' diff --git a/scripts/power-rail-verify.mjs b/scripts/power-rail-verify.mjs index d3cf693..ad53872 100644 --- a/scripts/power-rail-verify.mjs +++ b/scripts/power-rail-verify.mjs @@ -3,12 +3,14 @@ * power-rail-verify.mjs — 模型选择器 B 面(src/model-picker.js + model-picker.css)的夹具验收。 * * 不需要宿主:真实的 theme.css + 真实的组件模块(构建期同一条 stripExports 路径包成 IIFE), - * 配一个按宿主契约写的假 modelDirectories —— select() 的往返**故意放慢到 600ms**, - * 本机真宿主往返不到 60ms,pending 期间的行为在真 GUI 上根本来不及量。 + * 配一个按**安装中的**宿主契约写的假 modelDirectories —— select() 的往返**故意放慢到 600ms**, + * 本机真宿主往返不到 60ms,往返期间的行为在真 GUI 上根本来不及量。 + * 假快照**故意不给 pending**(真实 ModelDirectoryState 没有这个字段,见 directory.d.ts:13-32): + * 0.6.1 之前夹具给了它,于是这三条判据测的是一条线上永不执行的分支,真机上照旧回弹。 * 指针与键盘都走 CDP 的真输入事件(setPointerCapture 只认真指针)。 * * 覆盖:席位顶替与复原、触发器几何、弹层几何与入场动画、功率轨 Codex 逐字几何、 - * 拖动中不提交 / 松手对齐提交一次、pending 不回弹不清空且有转圈、键盘四键、 + * 拖动中不提交 / 松手对齐提交一次、往返不回弹不清空且有转圈、底部触发器同步换档、键盘四键、 * 焦点环、Escape、换模型带默认档、失败提示、reduced-motion、深色、开关。 * * 用法:node scripts/power-rail-verify.mjs [--no-shot] @@ -29,7 +31,10 @@ const theme = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); const pickerIife = '(() => {\n' + stripExports(fs.readFileSync(MODEL_PICKER_FILE, 'utf8'), 'model-picker.js') + '\nreturn { ' + MODEL_PICKER_EXPORTS.join(', ') + ' };\n})()'; -/* 假目录:字段、状态机与返回值照 dsh-client-ui-model-selection 0.1.7-rc.2 的 ModelDirectory.select()。 */ +/* 假目录:字段、状态机与返回值照**安装中的** dsh-client-ui-model-selection 0.1.7-rc.1 的 + ModelDirectory.select()。快照就是 ModelDirectoryState 那六个字段 —— **故意不提供 pending**: + 真实宿主没有这个字段(directory.d.ts:13-32),夹具给了就等于测一条线上永不执行的分支。 + 乐观显示因此必须由席位自己的 seat.pending 撑起来,这条夹具就是它的判据。 */ const fake = ` const EFFORTS = [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }, { id: 'max', name: 'Max' }]; const GROUPS = [{ id: 'deepseek-official', name: 'DeepSeek', models: [ @@ -43,19 +48,19 @@ const makeStore = (initial) => { update: (patch) => { state = { ...state, ...patch }; subs.forEach((fn) => fn()); } }; }; const dir = { - store: makeStore({ groups: GROUPS, current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' }, status: 'ready', pending: null, error: null }), + store: makeStore({ groups: GROUPS, current: { provider: 'deepseek-official', model: 'deepseek-v4-flash', reasoningEffort: 'high' }, status: 'ready', error: null }), calls: [], delay: 600, fail: null, load() { return Promise.resolve(this.store.getSnapshot()); }, select(selection) { this.calls.push(selection); - this.store.update({ status: 'selecting', pending: selection, error: null }); + this.store.update({ status: 'selecting', error: null }); return new Promise((resolve) => setTimeout(() => { if (this.fail !== null) { const error = this.fail; this.fail = null; - this.store.update({ status: 'error', pending: null, error: error.code + ': ' + error.message }); + this.store.update({ status: 'error', error: error.code + ': ' + error.message }); resolve({ ok: false, error }); return; } - this.store.update({ status: 'ready', pending: null, current: { ...selection } }); + this.store.update({ status: 'ready', current: { ...selection } }); resolve({ ok: true }); }, this.delay)); }, @@ -153,7 +158,7 @@ const STATE = `(() => { checked: [...document.querySelectorAll('.codex-mp-row')].map((r) => r.getAttribute('aria-checked')), now: root ? root.getAttribute('aria-valuenow') : null, pos: root ? root.style.getPropertyValue('--codex-mp-pos') : null, - value: q('.codex-mp-effort-value') ? q('.codex-mp-effort-value').textContent : null, + value: q('.codex-mp-value-text') ? q('.codex-mp-value-text').textContent : null, spinner: !!q('.codex-mp-effort-value .codex-mp-spinner'), trigEffort: trig ? [...trig.querySelectorAll('.codex-mp-effort-text')].filter((e) => e.dataset.active === 'true').map((e) => e.textContent).join() : null, trigModel: trig ? trig.querySelector('.codex-mp-trigger-model').textContent : null, @@ -198,33 +203,38 @@ check('列表三行、当前模型打勾', st.rows === 3 && st.checked[0] === 't /* 宿主字典缺席(本夹具 locale 为 null)时,内置模型的说明要落回目录原文,不能把字典键名画出来。 */ const desc = await evalJs('(document.querySelector(".codex-mp-row-desc") || {}).textContent || null'); check('说明文字是目录原文、不是字典键名', typeof desc === 'string' && desc.startsWith('Fast, efficient') && !desc.startsWith('option.'), desc); -const railStyle = await J(`(() => { const t = getComputedStyle(document.querySelector('.codex-mp-track')); const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); +const railStyle = await J(`(() => { + const t = document.querySelector('.codex-mp-track'); const tb = getComputedStyle(t, '::before'); + const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); const c = getComputedStyle(document.querySelector('.codex-mp-container')); const rg = getComputedStyle(document.querySelector('.codex-mp-range')); - return { tRadius: t.borderTopLeftRadius, tBg: t.backgroundColor, tRing: t.boxShadow, thBg: th.backgroundColor, thBorder: th.borderTopWidth + ' ' + th.borderTopStyle, thShadow: th.boxShadow, - cH: c.height, cPad: c.paddingTop + ' ' + c.paddingLeft, rRadius: rg.borderTopLeftRadius + ' ' + rg.borderTopRightRadius, touch: getComputedStyle(document.querySelector('.codex-mp-root')).touchAction }; })()`); -check('轨 24px 高、12px 圆角', near(st.track.h, 24) && railStyle.tRadius === '12px', st.track.h + ' / ' + railStyle.tRadius); -check('轨底 = 前景 10%、0.5px 内描边', /0\.1\)$/.test(railStyle.tBg) && /inset/.test(railStyle.tRing) && /0\.5px/.test(railStyle.tRing), railStyle.tBg + ' | ' + railStyle.tRing); -check('容器 32px、上下 2 / 左右 6;根 28px、touch-action none', railStyle.cH === '32px' && railStyle.cPad === '2px 6px' && near(st.root.h, 28) && railStyle.touch === 'none', JSON.stringify(railStyle)); -/* 描边断言**声明值**:Chromium 153 把 0.5px 的 border 在计算值里取整成 1px(DPR 2 也一样), - Codex 源码同样写的是 border .5px、跑在同一个引擎上,渲染结果一致;计算值量不出「写的是不是 0.5」。 */ -const thumbDecl = await evalJs(`(() => { for (const sheet of document.styleSheets) for (const rule of sheet.cssRules) { - if (rule.selectorText && / \\.codex-mp-thumb$/.test(rule.selectorText)) return rule.style.getPropertyValue('border'); } - return null; })()`); -check('拇指 28px 白圆片、声明 0.5px 描边、0 0 2px 微影', near(st.thumb.w, 28) && railStyle.thBg === 'rgb(255, 255, 255)' && /^0\.5px solid/.test(thumbDecl ?? '') && /0px 0px 2px/.test(railStyle.thShadow), JSON.stringify({ w: st.thumb.w, bg: railStyle.thBg, decl: thumbDecl, computed: railStyle.thBorder })); + return { grooveH: tb.height, grooveR: tb.borderTopLeftRadius, grooveBg: tb.backgroundColor, hitH: getComputedStyle(t).height, + thBg: th.backgroundColor, thR: th.borderTopLeftRadius, thShadow: th.boxShadow, + cH: c.height, cPad: c.paddingTop + ' ' + c.paddingLeft, + rRadius: rg.borderTopLeftRadius + ' ' + rg.borderTopRightRadius, rBg: rg.backgroundColor, + touch: getComputedStyle(document.querySelector('.codex-mp-root')).touchAction, + ends: [...document.querySelectorAll('.codex-mp-ends span')].map((e) => e.textContent), + endsSize: getComputedStyle(document.querySelector('.codex-mp-ends')).fontSize }; })()`); +/* 形态基准 = dsh-claude-style 的 .dsh-claude-effort-*(它自己注明对标 Claude Desktop 的 Effort slider)。 */ +check('槽 26px 厚、8px 圆角(圆角矩形,不是胶囊)', railStyle.grooveH === '26px' && railStyle.grooveR === '8px', railStyle.grooveH + ' / ' + railStyle.grooveR); +check('槽底走主题变量(--dsw-alias-interactive-bg-hover)', railStyle.grooveBg !== 'rgba(0, 0, 0, 0)', railStyle.grooveBg); +check('热区 = 旋钮高 30px;根 30px、touch-action none', railStyle.hitH === '30px' && near(st.root.h, 30) && railStyle.touch === 'none', railStyle.hitH + ' / ' + st.root.h + ' / ' + railStyle.touch); +check('填充 = 主文本墨 26%、只圆左侧两角', /0\.26\)$/.test(railStyle.rBg) && railStyle.rRadius === '8px 0px', railStyle.rBg + ' | ' + railStyle.rRadius); +check('旋钮 16×30 圆角矩形、白片、软影(不是圆珠)', near(st.thumb.w, 16) && near(st.thumb.h, 30) && railStyle.thR === '5px' && railStyle.thBg === 'rgb(255, 255, 255)' && /0px 1px 3px/.test(railStyle.thShadow), JSON.stringify({ w: st.thumb.w, h: st.thumb.h, r: railStyle.thR, bg: railStyle.thBg })); +check('两端刻度名 更快 / 更强、11px caption 色', railStyle.ends.join('|') === '更快|更强' && railStyle.endsSize === '11px', railStyle.ends.join('|') + ' ' + railStyle.endsSize); check('档位圆点 4px × 4 个', st.ticks.length === 4 && st.ticks.every((t) => near(t.w, 4) && near(t.h, 4)), st.ticks.length); const W = st.root.w; -const expectX = (i) => st.root.x + 14 + (W - 28) * i / 3; -check('圆点等距落在拇指行程 [14, 宽 − 14] 上', st.ticks.every((t, i) => near(t.x + t.w / 2, expectX(i))), st.ticks.map((t) => Math.round((t.x + t.w / 2 - st.root.x) * 10) / 10).join(' / ')); -check('拇指中心 = 生效档(High = 第 3 档)', near(st.thumb.x + 14, expectX(2)) && st.now === '2', 'thumb=' + (st.thumb.x + 14 - st.root.x) + ' now=' + st.now); -check('强调色条止于拇指中线、只圆左侧两角', near(st.range.r, st.thumb.x + 14) && railStyle.rRadius === '12px 0px', 'range.r=' + st.range.r + ' thumb=' + (st.thumb.x + 14)); -check('走过的圆点 30% 白、没走到的另一色', st.ticks[0].sel === 'true' && st.ticks[3].sel === 'false' && st.ticks[0].color === 'rgba(255, 255, 255, 0.3)' && st.ticks[3].color !== st.ticks[0].color, st.ticks.map((t) => t.sel + ':' + t.color).join(' | ')); +const expectX = (i) => st.root.x + 8 + (W - 16) * i / 3; +check('圆点等距落在旋钮行程 [8, 宽 − 8] 上', st.ticks.every((t, i) => near(t.x + t.w / 2, expectX(i))), st.ticks.map((t) => Math.round((t.x + t.w / 2 - st.root.x) * 10) / 10).join(' / ')); +check('旋钮中心 = 生效档(High = 第 3 档)', near(st.thumb.x + 8, expectX(2)) && st.now === '2', 'thumb=' + (st.thumb.x + 8 - st.root.x) + ' now=' + st.now); +check('填充止于旋钮中线', near(st.range.r, st.thumb.x + 8), 'range.r=' + st.range.r + ' thumb=' + (st.thumb.x + 8)); +check('圆点与填充同墨(走过去的被填充吞掉,不再分两色)', st.ticks[0].color === st.ticks[3].color && /0\.26\)$/.test(st.ticks[0].color), st.ticks.map((t) => t.color).join(' | ')); if (SHOT) { const s = await S('Page.captureScreenshot', { format: 'png', clip: { x: st.pop.x - 20, y: st.pop.y - 20, width: st.pop.w + 40, height: st.trig.b - st.pop.y + 40, scale: 1 } }); fs.writeFileSync(join(EVID, 'power-rail-verify.png'), Buffer.from(s.data, 'base64')); } /* ── 3. 拖动:中途不提交、松手对齐提交一次;pending 不回弹、不清空、有转圈 ───── */ -const thumbX = Math.round(st.thumb.x + 14), railY = Math.round(st.root.y + st.root.h / 2); +const thumbX = Math.round(st.thumb.x + 8), railY = Math.round(st.root.y + st.root.h / 2); await mouse('mouseMoved', thumbX, railY); await mouse('mousePressed', thumbX, railY, 1); for (const x of [thumbX - 30, thumbX - 80, Math.round(st.root.x - 20)]) { await mouse('mouseMoved', x, railY, 1); await sleep(30); } @@ -288,12 +298,81 @@ st = await state(); check('换模型连带该模型的默认档提交(pro → max)', st.lastCall.model === 'deepseek-v4-pro' && st.lastCall.reasoningEffort === 'max', JSON.stringify(st.lastCall)); check('换模型成功后关弹层、触发器换名', !st.open && st.trigModel === 'DeepSeek-V4-Pro' && st.trigEffort === 'Max', st.trigModel + ' · ' + st.trigEffort); +/* ── 6.5 顶档点阵(形态基准里的 apex)──────────────────────────────────── */ +await click(tx, ty); +await sleep(400); +const MATRIX = `(() => { + const cs = (s, p) => { const e = document.querySelector(s); return e ? getComputedStyle(e).getPropertyValue(p) : null; }; + const root = document.querySelector('.codex-mp-root'); + const sqs = [...document.querySelectorAll('.codex-mp-matrix-sq')]; + const opac = sqs.map((e) => Number(e.style.opacity)); + return { tier: root.dataset.tier ?? null, boxTier: document.querySelector('.codex-mp-effort').dataset.tier ?? null, + display: cs('.codex-mp-matrix', 'display'), cells: document.querySelectorAll('.codex-mp-matrix-cell').length, sqs: sqs.length, + tones: new Set(sqs.map((e) => e.dataset.tone)).size, delays: new Set(sqs.map((e) => e.style.animationDelay)).size, + fadeL: Math.min(...opac), fadeR: Math.max(...opac), + flash: cs('.codex-mp-matrix-sq', 'animation-name'), flashDur: cs('.codex-mp-matrix-sq', 'animation-duration'), + cellIn: cs('.codex-mp-matrix-cell', 'animation-name'), + fillOpacity: cs('.codex-mp-range', 'opacity'), tickOpacity: cs('.codex-mp-tick', 'opacity'), + thumbBg: cs('.codex-mp-thumb', 'background-color'), thumbShadow: cs('.codex-mp-thumb', 'box-shadow'), + valueColor: cs('.codex-mp-value-text', 'color') }; })()`; +const mx = await J(MATRIX); +check('顶档:点阵亮起(display:grid、5 行 × N 格)', mx.tier === 'max' && mx.boxTier === 'max' && mx.display === 'grid' && mx.cells > 40 && mx.cells % 5 === 0, JSON.stringify({ tier: mx.tier, display: mx.display, cells: mx.cells })); +check('顶档:填充与刻度让位给点阵', mx.fillOpacity === '0' && mx.tickOpacity === '0', mx.fillOpacity + ' / ' + mx.tickOpacity); +check('顶档:每格相位 hash 散开、色调 8 档', mx.delays > 20 && mx.tones === 8, 'delays=' + mx.delays + ' tones=' + mx.tones); +check('顶档:左端溶回裸槽、右端实心(羽化)', mx.fadeL < 0.2 && mx.fadeR === 1, mx.fadeL + ' → ' + mx.fadeR); +/* 每块的周期由 hash 散开(1.45s × [0.92, 1.08]),所以量的是散相带而不是一个定值。 */ +check('顶档:方块闪动 + 入场(周期在 1.45s 的散相带里)', mx.flash === 'codex-mp-flash' && mx.cellIn === 'codex-mp-cell-in' && Number.parseFloat(mx.flashDur) >= 1.33 && Number.parseFloat(mx.flashDur) <= 1.57, JSON.stringify({ flash: mx.flash, dur: mx.flashDur, in: mx.cellIn })); +check('顶档:旋钮染紫 + 光晕', /10px/.test(mx.thumbShadow) && mx.thumbBg !== 'rgb(255, 255, 255)', mx.thumbBg + ' | ' + mx.thumbShadow); +check('顶档:档位名转紫(--codex-mp-apex-ink #5f51b5)', mx.valueColor === 'rgb(95, 81, 181)', mx.valueColor); +const rb = await J('(() => { const b = document.querySelector(".codex-mp-root").getBoundingClientRect(); return { left: b.left + 8, right: b.right - 8, cy: b.top + b.height / 2 }; })()'); +await mouse('mousePressed', rb.left, rb.cy, 1); +await sleep(60); +const atLeft = await J('(() => { const r = document.querySelector(".codex-mp-root"); return { tier: r.dataset.tier ?? null, display: getComputedStyle(document.querySelector(".codex-mp-matrix")).display }; })()'); +await mouse('mouseMoved', rb.right, rb.cy, 1); +await sleep(60); +const atRight = await J('(() => { const r = document.querySelector(".codex-mp-root"); return { tier: r.dataset.tier ?? null, drag: r.dataset.dragging ?? null, display: getComputedStyle(document.querySelector(".codex-mp-matrix")).display }; })()'); +await mouse('mouseReleased', rb.right, rb.cy, 0); +await sleep(700); +check('拖到最左端:点阵收起(判据是连续比例,不是生效档)', atLeft.tier === null && atLeft.display === 'none', JSON.stringify(atLeft)); +check('拖到最右端:松手前点阵就已亮', atRight.tier === 'max' && atRight.drag === 'true' && atRight.display === 'grid', JSON.stringify(atRight)); +/* 收尾把弹层关掉:下一节假定弹层是关着的。 */ +await key('Escape', 27); +await sleep(200); + +/* ── 6.6 往返窗口内切回原档:全程不回弹 ────────────────────────────────── + 用户报的现象:high→max 之后有一段加载期,这期间怎么滑都回弹,必须等加载完。 + 这里把窗口拉长到夹具的 600ms,先在窗口内切走、再切回来,逐帧看生效档有没有被拽回。 */ +await click(tx, ty); +await sleep(400); +const rb2 = await J('(() => { const b = document.querySelector(".codex-mp-root").getBoundingClientRect(); return { left: b.left + 8, right: b.right - 8, cy: b.top + b.height / 2 }; })()'); +const midX = rb2.left + (rb2.right - rb2.left) / 3; +/* 第一次:切到 low(往返 600ms 开始) */ +await mouse('mousePressed', midX, rb2.cy, 1); +await mouse('mouseReleased', midX, rb2.cy, 0); +await sleep(80); +/* 窗口内:切回 max */ +await mouse('mousePressed', rb2.right, rb2.cy, 1); +await mouse('mouseMoved', rb2.right, rb2.cy, 1); +await mouse('mouseReleased', rb2.right, rb2.cy, 0); +const trace = []; +for (let i = 0; i < 22; i += 1) { + await sleep(50); + trace.push(await J('(() => { const r = document.querySelector(".codex-mp-root"); const v = document.querySelector(".codex-mp-value-text"); return (r.getAttribute("aria-valuenow") ?? "-") + ":" + (v ? v.textContent : "?"); })()')); +} +const strayed = trace.filter((t) => !t.startsWith("3:")); +check('往返窗口内切回原档:全程停在目标档(不回弹)', strayed.length === 0, trace.join(' ')); +await key('Escape', 27); +await sleep(200); + /* ── 7. reduced-motion ─────────────────────────────────────────────────── */ await media(true); await click(tx, ty); await sleep(300); -const rm = await J(`(() => { const p = document.querySelector('.codex-mp-popover'); return { attr: p.dataset.reducedMotion, anim: getComputedStyle(p).animationName, thumb: getComputedStyle(document.querySelector('.codex-mp-thumb-scale')).transitionDuration }; })()`); +const rm = await J(`(() => { const p = document.querySelector('.codex-mp-popover'); const root = document.querySelector('.codex-mp-root'); + return { attr: p.dataset.reducedMotion, anim: getComputedStyle(p).animationName, thumb: getComputedStyle(document.querySelector('.codex-mp-thumb-scale')).transitionDuration, + tier: root.dataset.tier ?? null, matrix: getComputedStyle(document.querySelector('.codex-mp-matrix-sq')).animationName, cell: getComputedStyle(document.querySelector('.codex-mp-matrix-cell')).animationName }; })()`); check('reduced-motion:入场动画取消、拇指不过渡', rm.attr === 'true' && rm.anim === 'none' && /^0s|^1e-05s|0\.00001s/.test(rm.thumb), JSON.stringify(rm)); +check('reduced-motion:顶档点阵与入场都停(两条口子都接上了)', rm.tier === 'max' && rm.matrix === 'none' && rm.cell === 'none', JSON.stringify({ tier: rm.tier, matrix: rm.matrix, cell: rm.cell })); /* 鼠标打开后焦点在触发器上:Escape 也得关(宿主的键盘处理挂在整格根上)。 */ const focusBefore = await evalJs('document.activeElement ? document.activeElement.className : null'); await key('Escape', 27); @@ -307,11 +386,18 @@ await evalJs('document.body.setAttribute("data-ds-dark-theme", ""); "ok"'); await click(tx, ty); await sleep(500); const dark = await J(`(() => { const p = getComputedStyle(document.querySelector('.codex-mp-popover')); const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); - return { shadow: p.boxShadow, bg: p.backgroundColor, thumb: th.backgroundColor, border: th.borderTopColor, track: getComputedStyle(document.querySelector('.codex-mp-track')).boxShadow }; })()`); + const root = document.querySelector('.codex-mp-root'); const rs = getComputedStyle(root); + const groove = getComputedStyle(document.querySelector('.codex-mp-track'), '::before'); + const sq = document.querySelector('.codex-mp-matrix-sq'); + return { shadow: p.boxShadow, bg: p.backgroundColor, thumb: th.backgroundColor, thumbShadow: th.boxShadow, + grooveBg: groove.backgroundColor, grooveH: groove.height, apex: rs.getPropertyValue('--codex-mp-apex').trim(), + sqBg: sq ? getComputedStyle(sq).backgroundColor : null }; })()`); st = await state(); check('深色下弹层打开', st.open && near(st.pop.w, 254), 'open=' + st.open); check('深色:菜单影 50% 黑 + 5% 亮环(暗色靠填充分层)', /rgba\(0, 0, 0, 0\.5\) 0px 8px 32px/.test(dark.shadow) && dark.bg === 'rgb(33, 33, 33)', JSON.stringify(dark)); -check('深色:拇指仍是白片、描边 20% 白、轨环 12% 白', dark.thumb === 'rgb(255, 255, 255)' && dark.border === 'rgba(255, 255, 255, 0.2)' && /rgba\(255, 255, 255, 0\.12\)/.test(dark.track), JSON.stringify(dark)); +check('深色:槽仍是 26px/8px 圆角矩形、底色走主题变量', dark.grooveH === '26px' && dark.grooveBg !== 'rgba(0, 0, 0, 0)', dark.grooveH + ' / ' + dark.grooveBg); +check('深色:顶档紫换成暗色那一套 #9d8ce0,方块吃到它', dark.apex === '#9d8ce0' && /157, 140, 224|0\.6/.test(dark.sqBg ?? ''), dark.apex + ' | ' + dark.sqBg); +check('深色:顶档旋钮是紫色染片 + 光晕(不是白片)', dark.thumb !== 'rgb(255, 255, 255)' && /10px/.test(dark.thumbShadow), dark.thumb + ' | ' + dark.thumbShadow); if (SHOT) { const p = await state(); const s = await S('Page.captureScreenshot', { format: 'png', clip: { x: p.pop.x - 20, y: p.pop.y - 20, width: p.pop.w + 40, height: p.trig.b - p.pop.y + 40, scale: 1 } }); diff --git a/scripts/rightbar-verify.mjs b/scripts/rightbar-verify.mjs index 5176859..42cc2da 100644 --- a/scripts/rightbar-verify.mjs +++ b/scripts/rightbar-verify.mjs @@ -329,7 +329,10 @@ const checks = [ ['面板顶沿贴标题栏条下沿', after.panelTop === 36], ['面板撑满列高(塌高即回归)', after.panelH === 720], ['中列 0.5px l2 发丝线', /rgba\(13, 13, 13, 0\.12\) 0px 0px 0px 0\.5px/.test(after.centerBox)], - ['中列全向环境影 24px', after.centerBox.includes('rgba(13, 13, 13, 0.05) 0px 0px 24px')], + /* 0.6.3 起环境影按 Codex 实测拟合(SSE 47 -> 22),判据随之改写: + 旧值 0 0 24px @5% 是照抄源码 token 的那一支,实测偏散偏浅。 */ + ['中列全向环境影 13px @7%(0.6.3 实测拟合;旧值 24px @5%)', + after.centerBox.includes('rgba(13, 13, 13, 0.07) 0px 0px 13px')], ['栏内 pane 的 1px l4 深边框被压掉', after.paneBorder === '0px rgb(0, 0, 0)' && before.paneBorder === '1px rgba(13, 13, 13, 0.24)'], ['右栏悬停线默认隐藏', idle.handleBefore.opacity === '0'], ['右栏悬停线出现', hovered.handleBefore.opacity === '1'], @@ -363,7 +366,8 @@ const checks = [ ]; const web = await probe(sw); checks.push( - ['web 中列全向环境影', web.centerBox.includes('rgba(13, 13, 13, 0.05) 0px 0px 24px')], + ['web 中列全向环境影 13px @7%(与桌面壳同值)', + web.centerBox.includes('rgba(13, 13, 13, 0.07) 0px 0px 13px')], ['web 中列 0.5px l2 发丝线', /rgba\(13, 13, 13, 0\.12\) 0px 0px 0px 0\.5px/.test(web.centerBox)], ['web 侧栏边界 l1 细线', /rgba\(13, 13, 13, 0\.07\)\|(0\.5|1)px\|solid/.test(web.sidebarBorder)], ['web 面板上沿影(负 spread)', /rgba\(13, 13, 13, 0\.05\) 0px -12px 24px -12px/.test(web.panelBox)], diff --git a/scripts/sidebar-color-verify.mjs b/scripts/sidebar-color-verify.mjs new file mode 100644 index 0000000..3268d86 --- /dev/null +++ b/scripts/sidebar-color-verify.mjs @@ -0,0 +1,254 @@ +#!/usr/bin/env node +/** + * sidebar-color-verify.mjs — 侧栏底色的像素验收(侧栏 <-> 主区那一档),亮暗两套都量。 + * + * 判据不是本皮肤自己定的,来自 Codex 应用的样式表 + 同屏实测: + * + * Codex **没有**「侧栏色」令牌。侧栏(左侧板 + 标题栏条 + frame 底)= --color-surface-tertiary + * 以 70% 不透明度叠在窗口底上(app-shared-*.css,electron 窗口、左侧板外观非 content-surface): + * .app-shell-left-panel:not([data-app-shell-left-panel-appearance=content-surface]) + * { background: color-mix(in srgb, var(--color-surface-tertiary) 70%, transparent) } + * 那 30% 透明意味着渲染值随窗口背后的底而变 —— 所以只认同屏实测值,不认推导值。 + * + * 同屏实测(避开文字取样): + * 亮色 侧栏 246,246,246 · 选中行 233,234,234 · 主区 255,255,255 + * 暗色 侧栏 15, 15, 15 · 选中行 31, 31, 31 · 主区 17, 17, 17 + * 两套里侧栏都**比主区低一档**(亮 255→246、暗 17→15),方向一致。 + * + * 夹具在同一页上并排渲染「新值 / 旧值」两组,同一台无头 Chromium、同一 DPR、同一采样点, + * 亮暗各截一次(只切 body[data-ds-dark-theme],不重排版)。旧值那组是反例对照: + * 它必须复现出旧读数,否则说明夹具分辨不出差别,正面断言就没有意义。 + * + * 用法:node scripts/sidebar-color-verify.mjs + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import zlib from 'node:zlib'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromePath, tempDir } from './host-paths.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const WB = dirname(HERE); +const FIX = join(WB, 'scripts', 'fixtures'); +const EVID = join(WB, 'assets', 'screenshots'); +const THEME = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); + +/* Codex 同屏实测(判据;改这里等于改判据,必须同时改 CHANGELOG 与 README 的表)。 */ +const CODEX = { + light: { sidebar: 246, active: 233, content: 255 }, + dark: { sidebar: 15, active: 31, content: 17 }, +}; +/* 旧值:亮色 #eef4f9 是某次蓝底截图取样,暗色 #181818 是把侧栏当成「表面」那一层。 + 两组都留作反例。 */ +const OLD = { + light: { sidebar: '#eef4f9', active: '#e2e9ed' }, + dark: { sidebar: '#181818', active: '#282828' }, +}; + +const BOX = 200; +const AT = { sidebar: [20, 20], content: [240, 20], active: [460, 20] }; +const OLD_AT = { sidebar: [20, 240], content: [240, 240], active: [460, 240] }; +const PAGE = { w: 680, h: 460 }; + +const block = (id, x, y, decl) => '
'; + +const page = '' + + 'sidebar-color' + + '' + + block('new-sidebar', AT.sidebar[0], AT.sidebar[1], '') + + block('new-content', AT.content[0], AT.content[1], 'background:var(--dsw-alias-bg-base)') + + block('new-active', AT.active[0], AT.active[1], 'background:var(--dsw-specific-sidebar-nav-item-active)') + + '
' + + block('old-sidebar', OLD_AT.sidebar[0], OLD_AT.sidebar[1], '') + + block('old-content', OLD_AT.content[0], OLD_AT.content[1], 'background:var(--dsw-alias-bg-base)') + + block('old-active', OLD_AT.active[0], OLD_AT.active[1], 'background:var(--dsw-specific-sidebar-nav-item-active)') + + '
' + + '