diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b2e1f..f731d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ [简体中文](CHANGELOG.zh-CN.md) +## 0.7.0 - 2026-09-28 + +The architecture cleanup (originally PR #3) and the full-page settings work (originally PR #1) land together on top of +0.6.4. Nothing from 0.6.2–0.6.4 is dropped: the structure is the new one, and every assertion that used to live in the +old flat scripts was re-expressed in the new ones. + +### Structure (the PR #3 refactor) + +- **Client side is modular**: `src/client.template.js` is gone. `src/client/index.js` is the entry and pulls in + `settings.js`, `settings-card.js`, `override.js`, `stylesheet.js`, `theme-preview.js`, `host.js`, + `constants.js` and `model-picker/`. The bundler in `scripts/build.mjs` is zero-dependency and produces the same + classic `window.__ModuleLoader__.load(...)` script. A new feature is one module plus one line in the entry. +- **Tooling collapsed into three entries**: `npm run check` (host-free), `npm run verify` (fixtures), + `scripts/live/*` (real GUI). Shared code lives in `scripts/lib/`; fixtures are `scripts/specs/*.mjs`. + The old `check-repo.mjs` / `*-verify.mjs` / `live-gui-probe.mjs` / `theme-flash-probe.mjs` are gone; their + assertions were moved, not deleted. +- **No `:has()` in ancestor positions** and a narrower MutationObserver: style recalc while a reply streams in went + 4264 → 267 ms (web) and 9518 → 442 ms (desktop shell). +- **The dark `html` background never applied**: the scoper turned `:root:has(body[data-ds-dark-theme])` into a + descendant selector that could not match. Compound selectors starting with `:root` now map to the root itself. + +### Settings modal (the PR #1 work) + +- **㉑ The settings dialog gets a full Codex pass**: a grouped sidebar with a "← Back to app" row and a search box that + filters host items, group headers, a page header in the content area, white cards with hairlines on every sub-page and + sticky save bars. Structure lives in `src/client/settings-modal.js`, looks in `skins/codex-ink/settings-modal.css`. +- **Host anchors first**: the panel is found by `[data-shortcut-modal="settings"]` (the host's own attribute) and only + falls back to the third-party skin-center adapter's `[data-dsh-surface="settings"]`. The visual layer hangs off the + plugin's own `[data-cx-sm-panel]` only, so a host rename moves one constant, not a stylesheet. When the settings + dialog is open but no anchor matches, the layer **warns** instead of failing silently. +- **Hiding is attribute-first**: the host rewrites a nav item's whole `className` when the selection moves away, so the + durable marker is `data-cx-sm-hidden`; the class is only for humans. The list observer runs with `subtree` — without + it the class rewrite produces zero mutation records (measured 0 vs 5). +- **Never takes over host nodes**: unknown items are only grouped visually, host nodes are never moved, cloned or + deleted. Reordering uses `style.order`; the known cost is that Tab order stays DOM order. + +### Ported into the new structure + +- The 0.6.2–0.6.4 model-picker work (seat-local `pending`, top-rung violet dot matrix, the 16×30 knob and the + Faster/Smarter row) lives in `src/client/model-picker/{component,view}.js`; the fixture's fake directory no longer + pretends the snapshot has a `pending` field, which is what the installed host actually looks like. +- The 0.6.3 measured-pixel values (sidebar `#f6f6f6`, centre-column ambient shadow 13px @7%, two-layer composer + shadow) are in, and the dark centre-column rule is kept separate: the light fit does not hold for dark, so a single + token cannot express both. +- `scripts/live/settings-modal.mjs` and `scripts/live/settings-sweep.mjs` are the live-GUI checks for the modal. The + sweep treats "found zero settings pages" as a hard failure — it used to report PASS while scanning nothing. +- 0.6.4's sidebar-colour verification comes across as the `sidebar-color` fixture spec (16 assertions: both themes on + one page, measured pixels, the hierarchy direction, plus a per-theme negative control). + +### Verification + +`npm run check` 67/67 · `node scripts/verify.mjs` 224/224 (9 specs). + ## 0.6.4 - 2026-09-28 Both sidebars were rebased. Codex has **no sidebar color token**; the left panel is painted with a translucent diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 8002fbd..85f8de0 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,54 @@ [English](CHANGELOG.md) +## 0.7.0 - 2026-09-28 + +架构整理(原 PR #3)与设置界面全页化(原 PR #1)一起落在 0.6.4 之上。0.6.2–0.6.4 的东西一样没丢: +结构用新的那一套,原来散在扁平脚本里的判据逐条搬进新脚本,不是删掉。 + +### 结构(原 PR #3 的重构) + +- **客户端模块化**:`src/client.template.js` 删除。入口是 `src/client/index.js`,下面挂 `settings.js`、 + `settings-card.js`、`override.js`、`stylesheet.js`、`theme-preview.js`、`host.js`、`constants.js` + 与 `model-picker/`。`scripts/build.mjs` 里的打包器零依赖,产出的仍是宿主要求的经典脚本 + `window.__ModuleLoader__.load(...)`。加一个功能 = 加一个模块 + 入口一行。 +- **工具链收拢成三个入口**:`npm run check`(不依赖宿主)、`npm run verify`(夹具)、`scripts/live/*`(真 GUI)。 + 公共部分在 `scripts/lib/`,夹具在 `scripts/specs/*.mjs`。旧的 `check-repo.mjs` / `*-verify.mjs` / + `live-gui-probe.mjs` / `theme-flash-probe.mjs` 删除;它们的判据是**搬家**,不是删除。 +- **祖先位置的 `:has()` 清零**,MutationObserver 收窄:流式输出时的样式重算 4264 → 267 ms(web)、 + 9518 → 442 ms(桌面壳)。 +- **暗色 `html` 底色从未生效**:作用域化把 `:root:has(body[data-ds-dark-theme])` 变成了永远不匹配的后代选择器。 + 现在以 `:root` 开头的复合选择器映射到根自身。 + +### 设置模态框(原 PR #1 的工作) + +- **㉑ 设置对话框全页 Codex 化**:分组侧栏(「← 返回应用」一行、按文字过滤宿主条目的搜索框)、分组标题、 + 内容区页头、每个子页面的白卡细边与贴底保存栏。结构在 `src/client/settings-modal.js`,外观在 + `skins/codex-ink/settings-modal.css`。 +- **宿主锚点优先**:面板先认宿主自己的 `[data-shortcut-modal="settings"]`,只有找不到才退到第三方 + skin-center 适配器补打的 `[data-dsh-surface="settings"]`。视觉层只挂插件自有的 `[data-cx-sm-panel]` —— + 宿主改锚点只动一个常量,不动样式表。设置界面确实开着却一个锚点都没匹配到时,这一层**发警告**,不静默失效。 +- **隐藏态以属性为准**:宿主在选中态搬走时会把导航项的 `className` 整条重写,所以耐久标记是 + `data-cx-sm-hidden`,类名只是给人看的。列表观察器必须开 `subtree` —— 不开时那次 class 重写一条 mutation + 记录都不产生(实测 0 条 vs 5 条)。 +- **绝不接管宿主节点**:未知项只做视觉分组,宿主节点不移动、不克隆、不删除。排序走 `style.order`; + 已知代价是键盘 Tab 顺序仍是 DOM 顺序。 + +### 搬进新结构的部分 + +- 0.6.2–0.6.4 的模型选择器改动(席位级 `pending`、顶档紫色点阵、16×30 旋钮与「更快/更强」一行)落在 + `src/client/model-picker/{component,view}.js`;夹具的假目录不再假装快照里有 `pending` 字段 —— + 安装中的宿主本来就没有。 +- 0.6.3 的实测像素值(侧栏 `#f6f6f6`、中列环境影 13px @7%、输入卡阴影两层)都在,且暗色中列规则**单独保留**: + 亮色那支拟合对暗色不成立,一个令牌表达不了两档。 +- `scripts/live/settings-modal.mjs` 与 `scripts/live/settings-sweep.mjs` 是模态框的真 GUI 验收。 + 扫掠脚本把「一个设置页都没扫到」当硬故障 —— 旧版在这种情况下报 PASS。 +- 0.6.4 的侧栏底色验收搬成 `sidebar-color` 夹具 spec(16 项:亮暗同页、实测像素、层级方向,外加每套一条反例对照)。 + +### 验收 + +`npm run check` 67/67 · `node scripts/verify.mjs` 224/224(9 个 spec)。 + ## 0.6.4 - 2026-09-28 亮暗两套侧栏都换了基准。Codex **没有「侧栏色」令牌**;那一面是半透明遮罩,渲染出来的颜色取决于窗口背后是什么。 diff --git a/README.md b/README.md index f7b8f01..0aac547 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ 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.1 verification ran on the npm release `@deepseek-ai/dsh@0.1.7-rc.2` (fixtures against both its +the full 0.7.0 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. @@ -47,6 +47,7 @@ Fixtures read the shipped host CSS and render code directly, so a structural hos | ②d | Right panel: hairline only on its left edge, shadow bleeds upward only; the dockkit 1px border is removed | | ②e | Right divider handle: center-darkest gradient on hover | | ⑱ | Settings card on the plugin manager's codex-ui bundle page: theme / accent / background / foreground / UI font / code font / translucent sidebar / Codex model picker / contrast | +| ㉑ | **Settings modal, full-page Codex pass**: the settings dialog gets a grouped sidebar (a "← Back to app" row, a search box that filters host items, group headers), a page header in the content area, white cards with hairlines on every sub-page and sticky save bars. Structure comes from `src/client/settings-modal.js` (host anchors only, host nodes never moved or cloned, hiding driven by a durable `data-*` attribute), looks from `skins/codex-ink/settings-modal.css`. Any step that does not match the host layout warns and skips — the layer degrades, it never throws | ## Screenshots @@ -133,7 +134,7 @@ ones go through a `ctx.inject([...], cb)` child scope, as the model picker does. | `src/client/override.js` | Override-layer pure functions (no DOM; unit-tested by `check.mjs`) | | `src/client/model-picker/` | Model picker face B: `index.js` waits for the `modelDirectories` service, `component.js` is the seat takeover, popover and power rail, `view.js` holds the pure functions (unit-tested by `check.mjs`) | | `src/client/constants.js` `host.js` | Shared names and small helpers for reading host services | -| `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) and the Skin v2 manifest | +| `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 / settings-modal.css) and the Skin v2 manifest | | `theme.css` `client.js` | Generated by `scripts/build.mjs` and committed (DSH loads `client.js`) | | `scripts/build.mjs` | Scoping and bundling | | `scripts/check.mjs` | Host-free repository checks; the CI entry point | @@ -150,21 +151,24 @@ ones go through a `ctx.inject([...], cb)` child scope, as the model picker does. | Command | Coverage | Requirement | |---|---|---| -| `npm run check` | Syntax, JSON, manifest and `peerDependencies`, artifacts in sync with sources, the DSH client plugin contract of `client.js` (executed once in isolation), scoping, override-layer and power-rail pure functions, 36 WCAG pairs, color whitelist, encoding, docs pairing, machine-specific paths — 60 checks | none | -| `npm run verify` | All fixtures, 195 assertions (table below) | host packages + Chromium | +| `npm run check` | Syntax, JSON, manifest and `peerDependencies`, artifacts in sync with sources, the DSH client plugin contract of `client.js` (executed once in isolation), scoping, override-layer and power-rail pure functions, 36 WCAG pairs, color whitelist, encoding, docs pairing, machine-specific paths, the settings-modal contract (sources / scoping / host-first anchor / assembly) — 67 checks | none | +| `npm run verify` | All fixtures, 224 assertions (table below) | host packages + Chromium | | `node scripts/live/gui.mjs --url ` | Real GUI: shadows and both dividers, plus the model seat — 14 assertions with face B on (takeover, geometry, a keyboard change written into the host store and reverted), 10 with it off (the face A pending window; `--latency` adds 800ms to that round trip by default, since locally it takes <60ms and cannot be sampled) | a running `dsh web` | | `node scripts/live/settings.mjs --url <…>` | Real GUI: the card on the bundle page, its 9 rows, no override at defaults, switch and accent writes, the host seat coming back when the model picker is off, survival across a reload, no intermediate frame while switching theme; resets everything at the end — 30 assertions | same, with the plugin manager enabled | +| `node scripts/live/settings-modal.mjs --url <…>` | Real GUI: the ㉑ structure layer and its visual layer together — the grouped sidebar, the "← Back to app" row, the search filter, group headers, host node identity (nothing moved or cloned), and the attribute-first hiding that survives the host rewriting `className`. Needs a live `dsh web` with the plugin manager enabled; `--explore` dumps the structure without asserting | same | +| `node scripts/live/settings-sweep.mjs --url <…> --out --prefix c1` | Opens every settings page in light and dark, screenshots each and measures layout health. **Zero pages found is a hard failure** (non-zero exit): a sweep that scans nothing must never report PASS | same | | `node scripts/live/parity.mjs snap --url <…> --out `
`node scripts/live/parity.mjs diff ` | Stores every element's computed style across 14 UI states and compares them; exits 0 on zero differences. This is how a refactor proves the look did not change. `--ignore` skips given properties or newly added `--variables` | same | Fixtures: `node scripts/verify.mjs [spec…]`; without a spec, all of them run. | Spec | Sections | Coverage | Assertions | |---|---|---|---| -| `composer` | composer-shadow · hero | ⑱ composer shadow aligned to Codex's `--elevation-composer` (per-layer geometry and alpha, dark inset, narrow-viewport 80→40px, rendered pixels); ⑬ ⑭ ⑰, the focus ring and the released header slots | 23 + 25 | +| `composer` | composer-shadow · hero | ⑱ composer shadow **fitted to measured pixels** (two layers — ring + near field; per-layer geometry and alpha, dark inset, narrow and wide viewports agreeing, rendered pixels); ⑬ ⑭ ⑰, the focus ring, the released header slots and the badge colour / radius read from the tokens themselves | 21 + 25 | | `elevation` | elevation | ⑲ `--dsw-elevation-*` against Codex's source, plus a rendered menu panel | 19 | -| `model-picker` | host-menu · power-rail | ⑫ face A (the host menu) and the pending indicator; ⑳ face B: seat takeover and hand-back, geometry, no commit while dragging / one snapped commit on release, no snap-back and a spinner during a slow round trip, the four keys, focus ring, Escape, model change carrying its default effort, failure notice, reduced motion, dark, the switch | 20 + 47 | +| `model-picker` | host-menu · power-rail | ⑫ face A (the host menu) and the pending indicator; ⑳ face B: seat takeover and hand-back, geometry, no commit while dragging / one snapped commit on release, no snap-back and a spinner during a slow round trip, **the top-rung violet dot matrix** (5 rows, 8 tone buckets, hash-scattered phases, feathering, both reduced-motion switches), the four keys, focus ring, Escape, model change carrying its default effort, failure notice, reduced motion, dark, the switch. The fake directory is shaped like the **installed** host (no `pending` in the snapshot) | 20 + 62 | | `rightbar` | rightbar | Shadow layer, right panel, both dividers | 42 | | `sidebar` | align · surface | Sidebar column alignment; the sidebar scroll fade (Codex mask ramp), mechanism plus per-pixel alpha | 6 + 13 | +| `sidebar-color` | sidebar-color | The sidebar base against Codex's **measured** pixels, both themes on one page: light 246/233/255 and dark 15/31/17 (all neutral, R=G=B), the sidebar-to-content step in each, the hierarchy direction, plus a per-theme negative control | 16 | Options: `--host ` picks the host, `--shots ` the screenshot directory (default `codex-ui-shots/` under the system temp directory), `--verbose` prints the readings. Fixtures read the shipped host CSS @@ -348,7 +352,7 @@ thing from the sidebar. The composer card over it measures 35, matching Codex. - The session row text column is 40px, 2px shorter than the workspace, new session and plugin rows, because the shipped `Rows.module.css` gives `.sessionRow .title` its own margin. Left as is. - `composer.css`, `patches.css`, `sidebar-align.css` and `sidebar-surface.css` use hash-class suffix anchors (`[class$=…]`, - `[class*=…]`) where the host exposes no `data-*` (0.6.1: composer 10 · patches 10 · sidebar-align 9 · sidebar-surface 2). + `[class*=…]`) where the host exposes no `data-*` (0.7.0 raw occurrences: composer 9 · patches 9 · sidebar-align 9 · sidebar-surface 2 · settings-modal 3). The 9 in sidebar-align are one anchor, `_collapsed` (the collapsed sidebar), replacing an ancestor-position `:has()`. `model-picker.css` has none. - Selectors keep `:has()` out of ancestor positions: while the conversation streams nodes in, Chromium re-matches the diff --git a/README.zh-CN.md b/README.zh-CN.md index 25512ca..2c32be0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -24,7 +24,7 @@ DSH Web 的界面元素按 Codex 复刻:窗口边缘的阴影与发丝线、 ## 宿主兼容性 本工作区对照 DSH `0.1.7-rc.1`(npm 全局安装)与 `0.1.7-rc.2`(Windows 桌面壳的 `app.asar`)开发; -0.6.1 的全套验收在 npm 发布的 `@deepseek-ai/dsh@0.1.7-rc.2` 上跑(夹具读它的 `node_modules` 与由它打包的 `app.asar` 各一遍, +0.7.0 的全套验收在 npm 发布的 `@deepseek-ai/dsh@0.1.7-rc.2` 上跑(夹具读它的 `node_modules` 与由它打包的 `app.asar` 各一遍, 真 GUI 用它起的 `dsh web`,见「宿主与浏览器」)。夹具直接读宿主 shipped 的 CSS 与渲染代码,宿主升级后若结构变化,夹具断言会失败。 ## 功能 @@ -43,6 +43,7 @@ DSH Web 的界面元素按 Codex 复刻:窗口边缘的阴影与发丝线、 | ②d | 右栏面板:左沿只留发丝线、影只往上泄;压掉 dockkit 的 1px 深边框 | | ②e | 右分界线拖拽柄:悬停时中段最深、两端淡出的渐变 | | ⑱ | 插件管理 → codex-ui 组合包页的设置卡:主题 / 强调色 / 背景 / 前景 / UI 字体 / 代码字体 / 半透明侧边栏 / Codex 模型选择器 / 对比度 | +| ㉑ | **设置模态框全页 Codex 化**:设置对话框换成分组侧栏(「← 返回应用」一行、按文字过滤宿主条目的搜索框、分组标题)、内容区页头、每个子页面的白卡细边与贴底保存栏。结构在 `src/client/settings-modal.js`(只用宿主锚点,宿主节点绝不移动或克隆;隐藏态靠耐久的 `data-*` 属性),外观在 `skins/codex-ink/settings-modal.css`。任何一步与宿主结构不符都只 warn 并跳过 —— 这一层会降级,绝不抛 | ## 界面 @@ -104,7 +105,7 @@ npm run build # 由 skins/codex-ink 与 src/client 重新生 node scripts/build.mjs --check # 只比对产物是否过期,不落盘 ``` -构建零依赖,只有 `scripts/build.mjs` 一份实现:八份样式去注释、作用域化到 `html[data-codex-ui]`,写出 `theme.css`; +构建零依赖,只有 `scripts/build.mjs` 一份实现:九份样式去注释、作用域化到 `html[data-codex-ui]`,写出 `theme.css`; `src/client/` 的 ES 模块按依赖顺序打成一个经典脚本 `client.js`(DSH 的 `__ModuleLoader__.load` 形态,样式内联在里面, `react` 等宿主包走 loader 给的 `require`)。只认两种 import(`import { a, b as c } from '…'`、`import * as ns from '…'`) 和三种 export(`export const | function | class`),其余写法构建直接报错,不会悄悄打错。 @@ -142,7 +143,7 @@ node scripts/build.mjs --check # 只比对产物是否过期,不落盘 | 命令 | 覆盖 | 前置 | |---|---|---| | `npm run check` | 语法、JSON、清单与 `peerDependencies`、产物与源码同源、`client.js` 的 DSH 插件契约(隔离执行一遍)、作用域化、覆盖层与功率轨纯函数、36 组 WCAG、彩色白名单、编码、双语文档成对、机器专属路径,60 项 | 无 | -| `npm run verify` | 全部夹具,195 项(见下表) | 宿主包 + Chromium | +| `npm run verify` | 全部夹具,224 项(见下表) | 宿主包 + Chromium | | `node scripts/live/gui.mjs --url <带 token 的 URL>` | 真 GUI:阴影与两条分界线,加模型位 —— B 面开着时 14 项(顶替、几何、键盘改档写进宿主 store 并改回),关着时 10 项(A 面 pending 窗口;`--latency` 默认给往返加 800ms,本机往返 <60ms 采不到) | `dsh web` 实例 | | `node scripts/live/settings.mjs --url <…>` | 真 GUI:组合包页设置卡、9 行结构、默认不覆盖、开关与强调色写入、模型选择器关掉后宿主那一格复原、刷新后仍在、主题切换逐帧无中间帧;结束时全部重置,30 项 | 同上(profile 需启用插件管理) | | `node scripts/live/parity.mjs snap --url <…> --out <目录>`
`node scripts/live/parity.mjs diff <改前> <改后>` | 14 个界面状态逐元素存下全部计算样式再逐项比,0 差异时退出码 0;重构靠它证明外观没变。`--ignore` 可跳过指定属性或新加的 `--变量` | 同上 | @@ -151,11 +152,12 @@ node scripts/build.mjs --check # 只比对产物是否过期,不落盘 | spec | 小节 | 覆盖 | 项数 | |---|---|---|---| -| `composer` | composer-shadow · hero | ⑱ 输入卡阴影对齐 Codex `--elevation-composer`(逐层几何与 alpha、暗色 inset、窄屏 80→40px、渲染像素);⑬⑭⑰ 与焦点环、顶栏两格放开 | 23 + 25 | +| `composer` | composer-shadow · hero | ⑱ 输入卡阴影**按实测像素拟合**(两层:环 + 近场;逐层几何与 alpha、暗色 inset、宽窄屏一致、渲染像素);⑬⑭⑰ 与焦点环、顶栏两格放开、徽标配色与圆角改读令牌计算值 | 21 + 25 | | `elevation` | elevation | ⑲ `--dsw-elevation-*` 与 Codex 源码对账,外加渲染出的菜单面板 | 19 | -| `model-picker` | host-menu · power-rail | ⑫ A 面宿主菜单与 pending 指示器;⑳ B 面:席位顶替与复原、几何、拖动中不提交 / 松手对齐提交一次、慢往返不回弹且转圈、键盘四键、焦点环、Escape、换模型带默认档、失败提示、reduced-motion、深色、开关 | 20 + 47 | +| `model-picker` | host-menu · power-rail | ⑫ A 面宿主菜单与 pending 指示器;⑳ B 面:席位顶替与复原、几何、拖动中不提交 / 松手对齐提交一次、慢往返不回弹且转圈、键盘四键、焦点环、Escape、换模型带默认档、失败提示、**顶档紫色点阵**(5 行、8 档色调桶、相位 hash 散开、羽化、两条关动效口子)、reduced-motion、深色、开关。假目录按**安装中的**宿主形状写(快照里没有 `pending`) | 20 + 62 | | `rightbar` | rightbar | 阴影层、右栏三件套、两条分界线 | 42 | | `sidebar` | align · surface | 侧栏列对齐;侧栏滚动渐隐(Codex mask 斜坡)的机制与逐像素 alpha | 6 + 13 | +| `sidebar-color` | sidebar-color | 侧栏底色对 Codex **实测**像素,亮暗同页:亮 246/233/255、暗 15/31/17(全中性,R=G=B)、各自与主区的档差、层级方向,外加每套一条反例对照 | 16 | 选项:`--host ` 指定宿主,`--shots <目录>` 指定截图位置(默认系统临时目录下的 `codex-ui-shots/`), `--verbose` 打印读数。夹具取宿主 shipped CSS 加按渲染代码复刻的 DOM,用 `getComputedStyle` 读值;它没有标题栏条、真实 @@ -317,7 +319,7 @@ npm run verify - 顶栏两格自 0.3.0 起放开(⑬·3c),因此**会话顶栏会比参考图多出条目**:只有当会话真有子代理 / 后台 job / 预设 / 工作目录时才出现,此时它同时是"子代理在跑"的唯一可见面。取舍写在 CHANGELOG。 - `composer.css`、`patches.css`、`sidebar-align.css` 与 `sidebar-surface.css` 使用哈希类名后缀锚点(`[class$=…]`、`[class*=…]`), - 宿主没有对应 `data-*` 的位置只能如此(0.6.1:composer 10 · patches 10 · sidebar-align 9 · sidebar-surface 2)。sidebar-align 的 9 处 + 宿主没有对应 `data-*` 的位置只能如此(0.7.0 原始出现次数:composer 9 · patches 9 · sidebar-align 9 · sidebar-surface 2 · settings-modal 3)。sidebar-align 的 9 处 是同一个锚点 `_collapsed`(侧栏折叠态),替掉原先落在祖先位置的 `:has()`。`model-picker.css` 为 0。 - 选择器不把 `:has()` 放在祖先位置:会话区流式插入节点时,Chromium 要为每个受影响的祖先重配整片子树, 实测样式重算从 ~0.3s 涨到 4s 以上。剩下的 `:has()` 都在主语位置或只看直接子代;`model-picker.css` 由 `check.mjs` 强制为 0。 diff --git a/assets/screenshots/settings-page-accent.png b/assets/screenshots/settings-page-accent.png index b8b34af..6631047 100644 Binary files a/assets/screenshots/settings-page-accent.png and b/assets/screenshots/settings-page-accent.png differ diff --git a/assets/screenshots/settings-page-dark.png b/assets/screenshots/settings-page-dark.png index 5673289..f35d56e 100644 Binary files a/assets/screenshots/settings-page-dark.png and b/assets/screenshots/settings-page-dark.png differ diff --git a/assets/screenshots/settings-page-default.png b/assets/screenshots/settings-page-default.png new file mode 100644 index 0000000..df76488 Binary files /dev/null and b/assets/screenshots/settings-page-default.png differ diff --git a/assets/screenshots/settings-page-translucent.png b/assets/screenshots/settings-page-translucent.png new file mode 100644 index 0000000..92da72c Binary files /dev/null and b/assets/screenshots/settings-page-translucent.png differ diff --git a/client.js b/client.js index 1e10096..ecd0cc7 100644 --- a/client.js +++ b/client.js @@ -1902,9 +1902,694 @@ function installOverride(ctx, form) { return { installSettings }; })(); +/* src/client/settings-modal.js */ +const __src_client_settings_modal = (() => { +/** + * settings-modal.js — 设置模态框的 Codex 化「结构层」(纯 DOM 操作、幂等、可降级)。 + * + * 补上宿主没有的四件东西:侧栏顶部的「← 返回应用」、搜索框、按组插入的分组标题、 + * 以及内容区顶部的页头大标题;并保证关闭重开后完整重放。 + * **视觉一律不在这里做**:新样式挂在 html[data-codex-ui] 下, + * 由 skins/codex-ink/settings-modal.css 提供,本文件只负责结构与行为。 + * + * 三条纪律(都是实测踩出来的,别改回去): + * 1. **只加不接管**。宿主导航是数据驱动的:往 DOM 里塞一个新按钮**不会**让它成为设置页 + * (侦察 §i 实测:克隆项存活、点击不崩、但既不切换选中态也不渲染内容)。 + * 所以对未知项一律只做视觉分组,绝不代它处理点击;宿主节点只打属性,不移动、不克隆、不删。 + * 2. **选择器不碰哈希类名**。宿主类名是 CSS-Modules 哈希(VOzbGW_* / oY77xG_* …,每个设置页 + * 还各有一套前缀),只用宿主自带属性(data-shortcut-modal / data-slot)/ tag / 位置索引这些稳定锚点。 + * 3. **任何一步结构不符预期就跳过并 warn,绝不抛**。宿主改版只会让这一层失效, + * 不会拖垮插件其余功能(皮肤、覆盖层、组合包页配置卡)。 + * + * 幂等性来自两处(侦察 §i 实测:**切设置项不重建 DOM,关闭再打开整棵 navList 重建**): + * · 每个注入节点都带 data-codex-ui-injected="settings-modal",创建前先查标记; + * · 观察 document.body 的 childList,overlay 被卸载/重建一次就重放一次。 + * + * ⚠ 取舍一:**顺序调整走 CSS flex order,不移动宿主节点**(任务书二选一里的前者)。 + * 侦察 §j 实测:宿主按 order 排序后渲染,重排 DOM 顺序会与 React 的 key 协调冲突、重渲染时被还原。 + * 本次探针复核:13 个 navCell 的 inline style 全为空、computed order 全为 0 —— order 是一个 + * **没有人用过**的布局槽位,所以给宿主 cell 写 style.order 是安全的(React 不认它、也不会覆盖它)。 + * 收益:宿主节点零移动,点击 / 渲染 / 第三方项进出全部原样;代价:键盘 Tab 顺序仍是 DOM 顺序, + * 视觉顺序 ≠ 焦点顺序,属已知可及性瑕疵(分组本身不改变焦点可达性,只是顺序不同)。 + * + * ⚠ 取舍二:**隐藏项用「属性 + 类名」双写**。 + * 本次探针实测(关键,比侦察报告的措辞更具体):宿主在**选中态从某一项搬走时**会把那一项的 + * className **整条重写**(oldValue "VOzbGW_navCell VOzbGW_active" → "VOzbGW_navCell"), + * 而 data-* 属性原样保留。所以: + * · 耐久标记是**属性** data-cx-sm-hidden —— CSS 必须以它为准([data-cx-sm-hidden]); + * · 类名 cx-sm-hidden 只是方便人读的冗余,**不可依赖**。 + * 补回逻辑由下面的列表观察器负责,**它必须开 subtree**(见 observeList 的注释: + * 不开 subtree 时观察器只上报被观察节点自身的属性变化,子节点的 class 改写一条都收不到, + * 实测 0 条 vs 5 条)。 + * + * ⚠ 结论(C1 与 B2 交叉验证后的定论):宿主重写 class **确实会产生 mutation 记录**, + * 只是**必须开 subtree 才收得到**。C1 早先「那次重写不产生任何 mutation 记录」的观测, + * 是 attributes 观察漏了 subtree 造成的假象,已由 probe4 双向对照证伪(同一时刻: + * 不开 subtree → 0 条;开 subtree → 5 条,含 class oldValue + * "VOzbGW_navCell VOzbGW_active cx-sm-hidden" → "VOzbGW_navCell")。 + * 因此两道保险同时成立、互为兜底: + * · CSS 以**属性** [data-cx-sm-hidden] 为准(耐久,类名被抹也不影响隐藏); + * · JS 在下一帧把类名补回(subtree 观察器驱动),供只认 .cx-sm-hidden 的样式使用。 + */ + +/* ── 稳定锚点 ─────────────────────────────────────────────────────── */ + +/** + * 设置模态框面板锚点(两条都查,前者优先): + * 1. [data-shortcut-modal="settings"] —— **宿主自己**盖在面板上的属性 + * (dsh-client-ui-primitives 的 Dialog 原语统一行为,跨包约定); + * 2. [data-dsh-surface="settings"] —— 第三方 skin-center 兼容适配器补盖的语义属性, + * 其契约文档自声明「非永久公共契约」,且**没装适配器的环境里它根本不存在** + * (整层静默失效曾在这条上翻车:PR #1 一审 C 项)。 + * 找到面板后统一补盖 PANEL_ATTR,CSS 只挂这个自有锚点 —— 以后锚点再变只动本文件。 + */ +const PANEL_SELECTOR = '[data-shortcut-modal="settings"], [data-dsh-surface="settings"]'; +/** 找到面板后补盖的自有锚点:settings-modal.css 里所有面板规则的唯一挂载点。 */ +const PANEL_ATTR = 'data-cx-sm-panel'; +/** 注入节点统一标记属性;卸载时按它一次性清干净。 */ +const ATTR = 'data-codex-ui-injected'; +/** 注入节点统一标记值。 */ +const MARK = 'settings-modal'; +/** 注入节点自报「我是哪一件」——重放时用它找回自己的节点,不靠类名。 */ +const PART_ATTR = 'data-cx-sm-part'; +/** 被搜索过滤掉的宿主项:属性是耐久标记(React 会重写 className,属性不会)。 */ +const HIDDEN_ATTR = 'data-cx-sm-hidden'; +/** 同一个隐藏态的类名,给 CSS 的现成钩子;被宿主抹掉后会被补回。 */ +const HIDDEN_CLASS = 'cx-sm-hidden'; +/** 宿主项上按组打的属性,CSS 与重放共用。 */ +const GROUP_ATTR = 'data-cx-sm-group'; +/** 兜底组 / 空结果提示的排序位:必须比任何组都大,否则 flex 默认 order:0 会把它顶到最前。 */ +const TAIL_ORDER = 999999; +/** 每个分组占据的 order 槽位数。 */ +const ORDER_STEP = 1000; + +/* ── 分组映射(两个子代理共用的唯一权威) ─────────────────────────────── */ + +const GROUPS = [ + { id: 'personal', label: '个人', items: ['账号与余额', '通用设置', '皮肤', '宠物', '使用统计'] }, + { id: 'integrations', label: '集成', items: ['内置插件', 'Web 插件', '订阅服务', '记忆系统', '创意工坊'] }, + { id: 'coding', label: '编码', items: ['模型', 'Agent 预设', 'LLM Verifier'] }, + { id: 'archive', label: '已归档', items: ['已归档会话'] }, +]; +/** + * 兜底组:映射里没有的项(第三方插件新注册的设置页、宿主将来新增的页)动态归入, + * 排在所有已知组之后;该组为空则不渲染。**绝不做硬编码白名单**——这正是用户那条硬约束。 + */ +const OTHER_GROUP = { id: 'other', label: '其他' }; +/** 标签(小写、去空白)→ 组 id。 */ +const GROUP_OF_LABEL = new Map(); +/** + * 组 id → { 标签 → 组内名次 }。 + * **组内顺序以映射表的 items 数组为准,不沿用宿主的注册顺序** —— 映射表是唯一权威。 + * 实测差异就在「集成」组:宿主按 order 渲染出来是 + * 内置插件 / 记忆系统 / 订阅服务 / Web 插件 / 创意工坊,映射表要的是 + * 内置插件 / Web 插件 / 订阅服务 / 记忆系统 / 创意工坊。 + * 兜底组没有名次表,保持宿主原序(未知项只做视觉归拢,不替它决定位置)。 + */ +const GROUP_RANK = new Map(); +for (const group of GROUPS) { + const rank = new Map(); + group.items.forEach((item, index) => rank.set(item.trim().toLowerCase(), index)); + GROUP_RANK.set(group.id, rank); + for (const item of group.items) GROUP_OF_LABEL.set(item.trim().toLowerCase(), group.id); +} +/** 渲染顺序:已知组按 GROUPS 顺序,兜底组永远最后。 */ +const GROUP_SEQUENCE = [...GROUPS, OTHER_GROUP]; + +/* ── 小工具 ───────────────────────────────────────────────────────── */ + +/** 归一化标签:去掉首尾空白并压掉内部连续空白后小写。 */ +const normalizeLabel = (text) => String(text).replace(/\s+/g, ' ').trim().toLowerCase(); + +/** 只警告一次,避免宿主改版时把控制台刷满。 */ +const warnOnce = (() => { + const seen = new Set(); + return (key, ...rest) => { + if (seen.has(key)) return; + seen.add(key); + console.warn('[codex-ui] 设置模态框:' + key, ...rest); + }; +})(); + +/** 写属性,值没变就不写(避免自己把自己的观察器喂成死循环)。 */ +function setAttr(el, name, value) { + if (el.getAttribute(name) !== value) el.setAttribute(name, value); +} + +/** 摘属性,本来就没有就不写。 */ +function dropAttr(el, name) { + if (el.hasAttribute(name)) el.removeAttribute(name); +} + +/** 设置 inline order,值没变就不写。 */ +function setOrder(el, value) { + const next = String(value); + if (el.style.order !== next) el.style.order = next; +} + +/** 按 PART_ATTR 在直接子节点里找回自己注入的那一件。 */ +const findPart = (parent, part) => + Array.prototype.find.call(parent.children, (el) => el !== null && el.getAttribute(PART_ATTR) === part) ?? null; + +/** 造一个带标记的注入节点。 */ +function makeNode(tag, className, part, text) { + const el = document.createElement(tag); + el.className = className; + el.setAttribute(ATTR, MARK); + if (part !== null && part !== undefined) el.setAttribute(PART_ATTR, part); + if (text !== undefined && text !== null) el.textContent = text; + return el; +} + +/** navList 里哪些是宿主项:没带我们标记的直接子节点。 */ +const hostChildren = (list) => Array.prototype.filter.call(list.children, (el) => el.getAttribute(ATTR) !== MARK); + +/** + * 取宿主项的可见文本。 + * 固定结构是 (侦察 §b 实测 13 项无例外); + * 但**不把它当硬契约**:拿不到就退到最后一个 span,再不行退到 textContent。 + */ +function cellLabel(cell) { + const kids = cell.children; + if (kids.length === 2 && kids[0].tagName === 'SVG' && kids[1].tagName === 'SPAN') return kids[1].textContent.trim(); + const last = kids.length > 0 ? kids[kids.length - 1] : null; + if (last !== null && last.tagName === 'SPAN') return last.textContent.trim(); + return cell.textContent.trim(); +} + +/** 标签 → 组 id;匹配不上就是兜底组。 */ +const groupOfLabel = (label) => GROUP_OF_LABEL.get(normalizeLabel(label)) ?? OTHER_GROUP.id; + +/* ── 面板定位(全部走稳定锚点,找不到就返回 null) ────────────────────── */ + +/** 找设置面板并补盖自有锚点(幂等);不在 DOM 里返回 null。 */ +function findPanel() { + const panel = document.querySelector(PANEL_SELECTOR); + if (panel !== null && panel !== undefined) { + setAttr(panel, PANEL_ATTR, ''); + return panel; + } + /* 设置界面确实开着(设置分区槽已在 DOM 里)却一个锚点都匹配不到 —— 这是整层静默失效的 + 唯一路径(宿主改版 / 锚点改名),必须显式警告。设置关着时锚点不在是正常的,不警告。 */ + if (document.querySelector('[data-slot="settings.section"]') !== null) { + warnOnce('设置界面开着,但两个面板锚点(data-shortcut-modal / data-dsh-surface)都没匹配到 —— 结构层本次失效,宿主可能改版了'); + } + return null; +} + +/** 面板的直接子节点里的侧栏 nav。 */ +function findNav(panel) { + return Array.prototype.find.call(panel.children, (el) => el.tagName === 'NAV') ?? null; +} + +/** + * 侧栏里的设置项列表容器。 + * 侦察 §a 实测 nav.children = [navTitle, navList],且 navList 恒为最后一个子节点; + * 我们往 nav 里插的都是**前置**节点,所以「最后一个元素子节点」这条判据在我们注入后依然成立。 + */ +function findList(nav) { + const kids = Array.prototype.filter.call(nav.children, (el) => el.getAttribute(ATTR) !== MARK); + const last = kids.length > 0 ? kids[kids.length - 1] : null; + if (last === null || last.tagName !== 'DIV') { + warnOnce('找不到侧栏列表容器(nav 的最后一个子节点不是 div),本次跳过注入', nav); + return null; + } + return last; +} + +/* ── 主流程 ───────────────────────────────────────────────────────── */ + +/** 当前那一次安装的会话;热重载重复安装时先撤旧的。 */ +let active = null; + +/** + * 装上设置模态框的结构层。 + * @param ctx - 客户端上下文(只用它的 effect 做卸载回收,没有也能跑,只是不回收)。 + * @returns {() => void} 卸载函数(幂等;重复调用无副作用)。 + */ +function installSettingsModal(ctx) { + if (typeof document === 'undefined' || document === null) return () => {}; + if (active !== null) { + try { active.dispose(); } catch { /* 撤不干净也不能阻断新的一次安装 */ } + active = null; + } + + const rootNode = document.body !== null && document.body !== undefined ? document.body : document.documentElement; + /** 当前挂着的 navList;换了一棵就重新挂列表观察器。 */ + let observedList = null; + /** 当前 navList 上的轻量观察器(只跟直接子节点,不深扫)。 */ + let listObserver = null; + /** 一个待执行的 rAF 句柄。 */ + let pending = null; + let disposed = false; + + /** 下一帧跑一次;已经有了就不重复排。 */ + const schedule = (fn) => { + if (pending !== null || disposed) return; + pending = requestAnimationFrame(() => { + pending = null; + if (disposed) return; + try { fn(); } catch (error) { warnOnce('重放时出错(已吞掉,不影响宿主)', error); } + }); + }; + + /* ── 侧栏顶部:「← 返回应用」+ 搜索框 ─────────────────────────────── */ + + /** 派发一次 Escape,走宿主自己的关闭路径。 */ + function pressEscape() { + const target = document.activeElement !== null && document.activeElement !== document.body + ? document.activeElement + : document.body; + const init = { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true, cancelable: true }; + let event; + try { + event = new KeyboardEvent('keydown', init); + } catch (error) { + /* 老宿主没有 KeyboardEvent 构造器:造一个普通的、把 key 挂上去。 */ + event = new Event('keydown', { bubbles: true, cancelable: true }); + event.key = 'Escape'; + } + target.dispatchEvent(event); + } + + /** 结构上的关闭按钮:侦察 §d 实测 [data-slot="settings.close"] 嵌在按钮内部的 span 里。 */ + function closeButton(panel) { + const seat = panel.querySelector('[data-slot="settings.close"]'); + const button = seat === null || seat === undefined ? null : seat.closest('button'); + return button; + } + + /** + * 关闭设置:优先派发 Escape(侦察实测可用),没关掉再兜底点宿主的关闭按钮。 + * 绝不自己 display:none 藏模态 —— 那会让宿主的状态与界面脱节。 + */ + function requestClose(panel) { + pressEscape(); + requestAnimationFrame(() => requestAnimationFrame(() => { + if (disposed) return; + if (findPanel() !== null) return; /* Escape 生效了 */ + const button = closeButton(panel); + if (button === null) { + warnOnce('Escape 没关掉设置,也找不到结构上的关闭按钮,放弃关闭', panel); + return; + } + button.click(); + })); + } + + /** 建「← 返回应用」。 */ + function makeBack(panel) { + const back = makeNode('div', 'cx-sm-back', 'back'); + back.setAttribute('role', 'button'); + back.setAttribute('tabindex', '0'); + back.setAttribute('aria-label', '返回应用'); + const arrow = makeNode('span', 'cx-sm-back-icon', null, '\u2190'); + arrow.setAttribute('aria-hidden', 'true'); + const text = makeNode('span', 'cx-sm-back-text', null, '返回应用'); + back.appendChild(arrow); + back.appendChild(text); + back.addEventListener('click', () => requestClose(panel)); + back.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Spacebar') return; + event.preventDefault(); + requestClose(panel); + }); + return back; + } + + /** 建搜索框。只过滤,不拦事件:input 上的监听器只服务它自己。 */ + function makeSearch(makeFilter) { + const box = makeNode('div', 'cx-sm-search', 'search'); + const input = makeNode('input', 'cx-sm-search-input', 'search-input'); + input.setAttribute('type', 'search'); + input.setAttribute('placeholder', '搜索设置...'); + input.setAttribute('aria-label', '搜索设置'); + input.setAttribute('autocomplete', 'off'); + input.setAttribute('spellcheck', 'false'); + const run = () => makeFilter(input.value); + input.addEventListener('input', run); + input.addEventListener('search', run); /* type=search 的原生清除按钮走这条 */ + input.addEventListener('change', run); + box.appendChild(input); + return box; + } + + /** 重新取一次宿主项(列表变了之后 cells 会过期)。 */ + const cellsFrom = (list) => Array.prototype.filter.call( + list.children, + (el) => el.getAttribute(ATTR) !== MARK && el.tagName === 'BUTTON', + ); + + /* ── 分组:按组插标题 + 用 flex order 排顺序 ──────────────────────── */ + + /** + * 同步分组标题与顺序。**只写属性与 inline order,不移动任何宿主节点。** + * @param list - navList。 + * @param cells - 宿主项(保持 DOM 顺序)。 + */ + function syncGroups(list, cells) { + if (cells.length === 0) return; + const buckets = new Map([[OTHER_GROUP.id, []]]); + for (const group of GROUPS) buckets.set(group.id, []); + cells.forEach((cell, domIndex) => { + const label = cellLabel(cell); + const id = groupOfLabel(label); + setAttr(cell, GROUP_ATTR, id); + buckets.get(id).push({ cell, domIndex, label }); + }); + /* 已知组按映射表名次排;表里没写到的(不该有,防御性)排在该组末尾,再按宿主原序。 */ + for (const group of GROUPS) { + const rank = GROUP_RANK.get(group.id); + const members = buckets.get(group.id); + members.sort((a, b) => { + const ra = rank.get(normalizeLabel(a.label)) ?? Number.MAX_SAFE_INTEGER; + const rb = rank.get(normalizeLabel(b.label)) ?? Number.MAX_SAFE_INTEGER; + return ra - rb || a.domIndex - b.domIndex; + }); + } + /* 空组不渲染 —— 「账号与余额」未登录时缺位,组里其余项照常。 */ + const rendered = GROUP_SEQUENCE.filter((group) => buckets.get(group.id).length > 0); + + let slot = ORDER_STEP; + const liveHeaders = new Set(); + for (const group of rendered) { + const members = buckets.get(group.id); + const first = members[0].cell; + let header = findPart(list, 'group:' + group.id); + if (header === null) { + header = makeNode('div', 'cx-sm-group', 'group:' + group.id, group.label); + header.setAttribute(GROUP_ATTR, group.id); + header.setAttribute('aria-hidden', 'true'); + list.insertBefore(header, first); + } else { + /* 标题是自己的节点,可以自由移动:只在位置不对时才动。 + 宿主项一个都不动 —— 组内顺序全靠下面的 flex order 实现。 */ + if (header.nextElementSibling !== first) list.insertBefore(header, first); + if (header.textContent !== header.getAttribute('data-cx-sm-label')) header.textContent = group.label; + } + setAttr(header, 'data-cx-sm-label', group.label); + liveHeaders.add(header); + setAttr(header, GROUP_ATTR, group.id); + setOrder(header, slot); + slot += 1; + for (const member of members) setOrder(member.cell, slot++); + slot = Math.ceil(slot / ORDER_STEP) * ORDER_STEP; + } + /* 已经不该渲染的组标题(组内项被插件摘走了)撤掉。 */ + for (const el of Array.prototype.slice.call(list.children)) { + if (el.getAttribute(ATTR) !== MARK) continue; + if (!String(el.getAttribute(PART_ATTR)).startsWith('group:')) continue; + if (!liveHeaders.has(el)) el.remove(); + } + } + + /* ── 页头大标题:Codex 的「大标题 + 页级操作」───────────────────── */ + + /** + * 内容列:面板里除侧栏之外的最后一个直接子节点。 + * 侦察 §a 实测面板 children = [nav, content],content 恒为最后一个。 + */ + function findContent(panel) { + const kids = Array.prototype.filter.call( + panel.children, + (el) => el.tagName !== 'NAV' && el.getAttribute(ATTR) !== MARK, + ); + const last = kids.length > 0 ? kids[kids.length - 1] : null; + if (last === null || last.tagName !== 'DIV') { + warnOnce('找不到内容列(面板最后一个子节点不是 div),本次跳过页头', panel); + return null; + } + return last; + } + + /** + * 内容列顶部的 header 行。 + * 判据用**语义座位**而不是位置:里面装着 [data-slot="settings.action"](宿主唯一稳定的动作座位,侦察 §d)。 + * 判据不成立就返回 null,调用方降级到「插在 options 之前」。 + */ + function findHeader(content) { + return Array.prototype.find.call( + content.children, + (el) => el.getAttribute(ATTR) !== MARK && el.querySelector('[data-slot="settings.action"]') !== null, + ) ?? null; + } + + /** 设置页正文容器(滚动区):装着设置页座位 [data-slot="settings.section"] 的那个子节点。 */ + function findOptions(content, header) { + const rest = Array.prototype.filter.call( + content.children, + (el) => el !== header && el.getAttribute(ATTR) !== MARK, + ); + return rest.find((el) => el.querySelector('[data-slot="settings.section"]') !== null) ?? rest[0] ?? null; + } + + /** + * 当前激活项的标签。 + * 选中态 = aria-current="true":宿主自己的语义属性(侦察 §b 实测),比哈希类名稳。 + * 兜底顺序:aria-current → data-modal-autofocus → 第一项。 + */ + function activeLabel(cells) { + const pick = (pred) => { + for (const cell of cells) if (pred(cell)) return cellLabel(cell); + return null; + }; + return pick((cell) => cell.getAttribute('aria-current') === 'true') + ?? pick((cell) => cell.hasAttribute('data-modal-autofocus')) + ?? (cells.length > 0 ? cellLabel(cells[0]) : null); + } + + /** 建页头:div.cx-sm-pagehead > div.cx-sm-title(形态对齐 settings-modal.css §3.2)。 */ + function makePagehead() { + const head = makeNode('div', 'cx-sm-pagehead', 'pagehead'); + head.appendChild(makeNode('div', 'cx-sm-title', 'pagehead-title')); + return head; + } + + /** + * 同步页头:位置 + 标题文本。 + * 首选挂在 header 行的**最左侧**(与右上角「打开配置文件 ×」同一行,正对 Codex 的 + * 「大标题 + 页级操作」);header 判据不成立时降级为插在 options 之前;两者都不成立就 warn 跳过。 + */ + function syncPagehead(panel, content, cells) { + const header = findHeader(content); + let target = null; + let before = null; + if (header !== null) { + target = header; + before = header.firstChild; + } else { + const options = findOptions(content, null); + if (options !== null) { + warnOnce('设置面板的 header 行判据不成立,页头降级插在 options 之前', content); + target = content; + before = options; + } + } + if (target === null) { + warnOnce('找不到能挂页头的位置,本次跳过(只是没有大标题,其余注入照常)', content); + return; + } + let head = panel.querySelector('[' + PART_ATTR + '="pagehead"]'); + if (head === null) { + head = makePagehead(); + target.insertBefore(head, before); + } else if (head.parentNode !== target) { + /* 只搬自己的节点,宿主节点一个不动。 */ + target.insertBefore(head, before); + } + const title = findPart(head, 'pagehead-title'); + const label = activeLabel(cells); + if (title !== null && label !== null && title.textContent !== label) title.textContent = label; + } + + /* ── 搜索过滤:只加显隐,不移除/替换宿主节点 ──────────────────────── */ + + /** 写隐藏态;值没变就不写(否则会把自己的观察器喂成死循环)。 */ + function setHidden(el, hidden) { + if (hidden) { + setAttr(el, HIDDEN_ATTR, ''); + if (!el.classList.contains(HIDDEN_CLASS)) el.classList.add(HIDDEN_CLASS); + } else { + dropAttr(el, HIDDEN_ATTR); + if (el.classList.contains(HIDDEN_CLASS)) el.classList.remove(HIDDEN_CLASS); + } + } + + /** + * 按 query 过滤侧栏项。空查询 = 全部恢复。 + * @param list - navList。 + * @param query - 搜索框当前值。 + */ + function applyFilter(list, query) { + const cells = cellsFrom(list); + const needle = normalizeLabel(query); + const visible = new Map(); + for (const cell of cells) { + const hit = needle === '' || cellLabel(cell).toLowerCase().includes(needle); + setHidden(cell, !hit); + if (hit) { + const id = cell.getAttribute(GROUP_ATTR) ?? OTHER_GROUP.id; + visible.set(id, (visible.get(id) ?? 0) + 1); + } + } + /* 空分组标题也隐藏:标题的可见性只跟「这一组还有没有命中项」有关。 */ + for (const el of Array.prototype.slice.call(list.children)) { + if (el.getAttribute(ATTR) !== MARK) continue; + if (!String(el.getAttribute(PART_ATTR)).startsWith('group:')) continue; + setHidden(el, (visible.get(el.getAttribute(GROUP_ATTR)) ?? 0) === 0); + } + /* 无结果提示:.cx-sm-empty 只在「有查询且一项都没命中」时露出来; + 顺序上也必须钉在最后——flex 默认 order:0,不写这一条它会被排到分组前面去。 */ + let total = 0; + for (const count of visible.values()) total += count; + const needed = needle !== '' && total === 0; + let empty = findPart(list, 'empty'); + if (needed && empty === null) { + empty = makeNode('div', 'cx-sm-empty', 'empty', '没有匹配的设置项'); + empty.setAttribute('role', 'status'); + list.appendChild(empty); + } + if (empty !== null) { + setOrder(empty, TAIL_ORDER); + setHidden(empty, !needed); + } + } + + /* ── 注入 / 重放 ─────────────────────────────────────────────────── */ + + /** + * 把当前 navList 挂上观察器。它一处扛三件事: + * ① 列表增删 —— 插件热插拔设置项时重跑分组与「其他」兜底组; + * ② 补回被宿主抹掉的 cx-sm-hidden 类名(耐久标记仍是属性,类名只是给 CSS 的便利钩子); + * ③ **切项时刷新页头标题** —— 宿主把选中态从一项搬到另一项时写的就是 class + aria-current。 + * + * ⚠ **subtree 是必需项,不是优化**(probe4 双向对照实测): + * MutationObserver 不开 subtree 时,只上报被观察节点自己的属性变化; + * navCell 是 navList 的子节点,它们的 class / aria-current 改写一条都收不到 —— + * 同一时刻不开 subtree 收 0 条、开 subtree 收 5 条。 + * 漏掉它的表现是静默失效:aria-current 变了、页头标题纹丝不动,其余功能全部正常。 + * + * 不会自激:本文件写自己的属性一律「值没变就不写」(setAttr / setOrder / setHidden), + * 且 attributeFilter 只认 class 与 aria-current —— 我们自己从不写 aria-current, + * 也只在隐藏态真的翻转时才动 class,第一轮之后没有新 mutation 可喂给自己(实测收敛)。 + */ + function observeList(list) { + if (observedList === list && listObserver !== null) return; + if (listObserver !== null) listObserver.disconnect(); + observedList = list; + listObserver = new MutationObserver(() => schedule(inject)); + listObserver.observe(list, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'aria-current'], + }); + } + + /** + * 重放:找面板 → 补侧栏顶部两件 → 同步分组 → 重挂过滤。 + * 每一步都自带「已经在了就跳过」,所以重复跑不会叠加节点。 + */ + function inject() { + const panel = findPanel(); + if (panel === null) { + /* 面板没了:列表观察器跟着失效,断开等下一次重放。 */ + if (listObserver !== null) { listObserver.disconnect(); listObserver = null; observedList = null; } + return; + } + const nav = findNav(panel); + if (nav === null) { + warnOnce('设置面板里没有侧栏 nav,本次跳过注入', panel); + return; + } + const list = findList(nav); + if (list === null) return; + const cells = cellsFrom(list); + if (cells.length === 0) { + warnOnce('侧栏列表里一个设置项都没有,本次跳过分组', list); + return; + } + /* 结构自检:宿主项本该都是 button。出现别的就跳过它,只 warn 不抛。 */ + for (const el of hostChildren(list)) { + if (el.tagName !== 'BUTTON') warnOnce('侧栏列表里出现了非 button 的子节点,已跳过它(宿主可能改版了)', el); + } + + /* 返回按钮与搜索框:都放在 nav 的最前面(返回在最上,搜索在它下面)。 + 它们排在宿主的「设置」标题之前 —— 只加不挪,宿主原有节点一个不动。 */ + let back = findPart(nav, 'back'); + if (back === null) { + back = makeBack(panel); + nav.insertBefore(back, nav.firstChild); + } + let search = findPart(nav, 'search'); + if (search === null) { + search = makeSearch((value) => { + const current = findList(nav); + if (current !== null) applyFilter(current, value); + }); + nav.insertBefore(search, back.nextSibling); + } + + syncGroups(list, cells); + const content = findContent(panel); + if (content !== null) syncPagehead(panel, content, cells); + const input = findPart(search, 'search-input'); + applyFilter(list, input !== null ? input.value : ''); + observeList(list); + } + + /* ── 观察 body:面板被卸载/重建时重放 ─────────────────────────────── */ + + /* subtree 按任务书开着(防宿主将来给 overlay 套一层壳),但回调里只认 target 就是 body 的 + childList 记录 —— 宿主 overlay 是 body 的直接子节点(侦察 §a + 本次探针复核), + 所以这一条 O(1) 过滤会把页面上其它所有 mutation 全部丢掉,不做任何 DOM 查询。 */ + const bodyObserver = new MutationObserver((records) => { + for (const record of records) { + if (record.type !== 'childList') continue; + if (record.target !== rootNode) continue; + schedule(inject); + return; + } + }); + bodyObserver.observe(rootNode, { childList: true, subtree: true }); + + inject(); + + const dispose = () => { + if (disposed) return; + disposed = true; + if (pending !== null) { cancelAnimationFrame(pending); pending = null; } + if (listObserver !== null) { listObserver.disconnect(); listObserver = null; } + bodyObserver.disconnect(); + observedList = null; + /* 注入节点一律带标记,一次清干净;宿主节点只把「我们写上去的那几个属性」摘掉。 */ + for (const el of Array.prototype.slice.call(document.querySelectorAll('[' + ATTR + '="' + MARK + '"]'))) el.remove(); + for (const el of Array.prototype.slice.call(document.querySelectorAll('[' + GROUP_ATTR + ']'))) { + dropAttr(el, GROUP_ATTR); + el.style.removeProperty('order'); + } + for (const el of Array.prototype.slice.call(document.querySelectorAll('[' + HIDDEN_ATTR + ']'))) { + dropAttr(el, HIDDEN_ATTR); + el.classList.remove(HIDDEN_CLASS); + } + if (active !== null && active.dispose === dispose) active = null; + }; + + if (typeof ctx.effect === 'function') { + ctx.effect(() => dispose, 'codex-ui: settings modal'); + } else { + console.warn('[codex-ui] ctx.effect 不可用:设置模态框结构层已挂上,但不会随 fiber 卸载回收。'); + } + + active = { dispose }; + return dispose; +} +return { installSettingsModal }; +})(); + /* codex-ui:theme.css */ const __codex_ui_theme_css = (() => { -const THEME_CSS = "html[data-codex-ui],\nhtml[data-codex-ui] body {\n color-scheme: light;\n\n background-color: #ffffff;\n\n --dsw-alias-bg-base: #ffffff;\n\n --dsw-alias-bg-sidebar: #f6f6f6;\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 --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 --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 --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 --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 --dsw-alias-interactive-bg-hover: rgba(13, 13, 13, 0.04);\n --dsw-codex-hover-fill: #f2f2f3;\n --dsw-codex-focus: #339cff;\n\n --dsw-focus-ring-color: var(--dsw-codex-focus);\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 --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 --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 --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 --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 --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 --dsw-specific-sidebar-fill: #f6f6f6;\n --dsw-specific-sidebar-nav-item-hover: #f0f0f0;\n --dsw-specific-sidebar-nav-item-active: #e9e9e9;\n --dsw-specific-sidebar-nav-item-active-accent: rgba(13, 13, 13, 0.1);\n --dsw-specific-input-major: #ffffff;\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 --dsw-codex-ambient: rgba(13, 13, 13, 0.05);\n --dsw-codex-menu-shadow: 0 8px 32px rgba(13, 13, 13, 0.13), 0 0 0 1px rgba(13, 13, 13, 0.04);\n --dsw-codex-suggest-shadow: 0 8px 32px #0002, 0 0 0 1px #0000000a;\n --dsw-codex-suggest-fill: #fff;\n}\n\nhtml[data-codex-ui-switching] *,\nhtml[data-codex-ui-switching] *::before,\nhtml[data-codex-ui-switching] *::after {\n transition: none !important;\n}\n\nhtml[data-codex-ui]:has(body[data-ds-dark-theme]) {\n background-color: #111111;\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] {\n color-scheme: dark;\n background-color: #111111;\n\n --dsw-alias-bg-base: #111111;\n --dsw-alias-bg-sidebar: #0f0f0f;\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;\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 --dsw-codex-hover-fill: rgba(255, 255, 255, 0.08);\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: #0f0f0f;\n --dsw-specific-sidebar-nav-item-hover: #171717;\n --dsw-specific-sidebar-nav-item-active: #1f1f1f;\n --dsw-specific-sidebar-nav-item-active-accent: rgba(255, 255, 255, 0.14);\n --dsw-specific-input-major: #212121;\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 --dsw-codex-ambient: rgba(0, 0, 0, 0.5);\n --dsw-codex-menu-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(236, 236, 236, 0.05);\n --dsw-codex-suggest-shadow: 0 8px 32px #0003, 0 0 0 1px #ffffff08;\n --dsw-codex-suggest-fill: #292929;\n}\n\nhtml[data-codex-ui],\nhtml[data-codex-ui] body {\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 --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 --dsw-radius-pill: 999px;\n\n --dsw-content-max-width: 768px;\n\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 --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 --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 --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 --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@supports (corner-shape: superellipse(1.5)) {\n html[data-codex-ui],\nhtml[data-codex-ui] body {\n --dsw-radius-card: var(--dsw-radius-2xl);\n }\n}\n\nhtml[data-codex-ui] body,\nhtml[data-codex-ui] body * {\n --dsw-elevation-stroke-color: rgba(13, 13, 13, 0.08);\n --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color);\n --dsw-elevation-panel: var(--dsw-elevation-stroke);\n\n --dsw-elevation-prominent: var(--dsw-elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d;\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme],\nhtml[data-codex-ui] body[data-ds-dark-theme] * {\n --dsw-elevation-stroke-color: rgba(255, 255, 255, 0.09);\n}\n\nhtml[data-codex-ui],\nhtml[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}\n\nhtml[data-codex-ui] :focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 2px;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] a:hover,\nhtml[data-codex-ui] a:focus-visible {\n text-decoration: underline;\n}\n\nhtml[data-codex-ui] button,\nhtml[data-codex-ui] a,\nhtml[data-codex-ui] summary,\nhtml[data-codex-ui] [role=\"button\"],\nhtml[data-codex-ui] [role=\"tab\"],\nhtml[data-codex-ui] [role=\"menuitem\"],\nhtml[data-codex-ui] [role=\"option\"] {\n cursor: pointer;\n}\n\nhtml[data-codex-ui] button,\nhtml[data-codex-ui] a,\nhtml[data-codex-ui] summary,\nhtml[data-codex-ui] [role=\"button\"],\nhtml[data-codex-ui] [role=\"tab\"],\nhtml[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}\n\nhtml[data-codex-ui] button:disabled,\nhtml[data-codex-ui] button[aria-disabled=\"true\"],\nhtml[data-codex-ui] [aria-disabled=\"true\"] {\n cursor: not-allowed;\n opacity: 0.45;\n}\n\nhtml[data-codex-ui] [data-slot=\"web-ui.plugin.item\"] > *,\nhtml[data-codex-ui] [data-dsh-part=\"card\"],\nhtml[data-codex-ui] [data-dsh-part=\"column\"],\nhtml[data-codex-ui] [data-dsh-part=\"status\"],\nhtml[data-codex-ui] [data-dsh-part=\"today-card\"],\nhtml[data-codex-ui] [data-dsh-part=\"balance-card\"],\nhtml[data-codex-ui] [data-dsh-part=\"trend-card\"],\nhtml[data-codex-ui] [data-dsh-part=\"plan-card\"],\nhtml[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\nhtml[data-codex-ui] [role=\"dialog\"],\nhtml[data-codex-ui] [data-dsh-surface=\"overlay\"] [data-dsh-part=\"panel\"] {\n border-radius: var(--dsw-radius-l);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"composer-chip\"],\nhtml[data-codex-ui] [data-dsh-part=\"chip\"],\nhtml[data-codex-ui] [data-dsh-part=\"ref\"],\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"],\nhtml[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\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"0\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"1\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"2\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"3\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"4\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"5\"],\nhtml[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\nhtml[data-codex-ui] [data-dsh-surface=\"sidebar\"],\nhtml[data-codex-ui] [data-slot=\"sidebar.workspaces\"] {\n background-color: var(--dsw-alias-bg-sidebar);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"sidebar-entry\"],\nhtml[data-codex-ui] [data-dsh-surface=\"sidebar\"] [role=\"button\"] {\n border-radius: var(--dsw-radius-s);\n}\n\nhtml[data-codex-ui] [data-dsh-surface=\"composer\"] [data-dsh-part=\"composer-input\"] {\n border-radius: var(--dsw-radius-l);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"composer-input\"] {\n font-family: var(--dsw-font-family);\n line-height: var(--dsw-leading-base);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"turn-tail\"],\nhtml[data-codex-ui] [data-dsh-part=\"queue-dock\"],\nhtml[data-codex-ui] [data-dsh-part=\"usage-chart\"],\nhtml[data-codex-ui] [data-dsh-part=\"voucher-preview\"] {\n font-family: var(--dsw-font-meta);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"composer-chip\"] {\n text-transform: uppercase;\n letter-spacing: var(--dsw-meta-tracking);\n}\n\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"composer-chip\"],\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"chip\"],\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"ref\"],\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"tag-chip\"],\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"tag-badge\"] {\n letter-spacing: 0;\n text-transform: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-codex-ui] *,\nhtml[data-codex-ui] *::before,\nhtml[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\nhtml[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\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] {\n\n --dsw-menu-surface-fill: var(--dsw-alias-bg-layer-1);\n --dsw-menu-backdrop-filter: none;\n\n background: var(--dsw-alias-bg-layer-1);\n padding: 5px;\n border-radius: var(--dsw-radius-menu);\n box-shadow: var(--dsw-codex-menu-shadow);\n color: var(--dsw-alias-label-primary);\n}\n\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"],\nhtml[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}\n\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"]:hover,\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"]:hover:not(:disabled),\nhtml[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\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"]:focus-visible,\nhtml[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\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"]:disabled {\n opacity: 1;\n}\n\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy=\"true\"] button[role=\"menuitemradio\"][aria-checked=\"true\"] > span:last-child {\n position: relative;\n}\n\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy=\"true\"] button[role=\"menuitemradio\"][aria-checked=\"true\"] > span:last-child > svg {\n visibility: hidden;\n}\n\nhtml[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 animation: codex-ui-spin 1s linear infinite;\n}\n\nhtml[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\nhtml[data-codex-ui] [data-slot=\"conversation.session.header\"] > [data-conversation-tabs] {\n display: none;\n}\n\nhtml[data-codex-ui] header:has([data-conversation-header-leading]):has([data-conversation-tabs]) {\n min-height: 0;\n padding-bottom: 10px;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"new-session\"]:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] [data-slot=\"conversation.composer.dock\"] {\n display: none;\n}\n\nhtml[data-codex-ui] [data-conversation-header-corner] {\n margin-left: 0;\n margin-right: 0;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-expand] svg {\n transform: scaleX(-1);\n}\n\nhtml[data-codex-ui] [data-animating] {\n transition: grid-template-columns 500ms cubic-bezier(.16, 1, .3, 1);\n}\n\nhtml[data-codex-ui] [data-animating] [data-side] {\n transition: left 500ms cubic-bezier(.16, 1, .3, 1);\n}\n\nhtml[data-codex-ui] [data-dragging],\nhtml[data-codex-ui] [data-rightbar-instant],\nhtml[data-codex-ui] [data-rightbar-fullscreen],\nhtml[data-codex-ui] [data-dragging] [data-side],\nhtml[data-codex-ui] [data-rightbar-instant] [data-side],\nhtml[data-codex-ui] [data-rightbar-fullscreen] [data-side] {\n transition: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-codex-ui] [data-animating],\nhtml[data-codex-ui] [data-animating] [data-side] {\n transition: none;\n }\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide] > span[aria-hidden=\"true\"] {\n display: none;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide]::after {\n display: none;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry]:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry] > span:first-child {\n width: 20px;\n height: 20px;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry] > span:first-child svg {\n width: 20px;\n height: 20px;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry] > span:nth-child(2) {\n gap: 0;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry] > span:nth-child(2) > span:nth-child(2) {\n display: none;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(2) {\n width: 20px;\n height: 20px;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(2) svg {\n width: 20px;\n height: 20px;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(3) > span:nth-child(2) {\n display: none;\n}\n\nhtml[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}\n\nhtml[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\nhtml[data-codex-ui] [data-sidebar-right-toggle] {\n background: var(--dsw-alias-bg-layer-2);\n border-radius: var(--dsw-radius-m);\n}\n\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_add\"],\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"] {\n transition: background-color var(--dsw-motion-fast) var(--dsw-ease);\n border-radius: var(--dsw-radius-pill);\n}\n\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_add\"] {\n background: transparent;\n}\n\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_add\"]:hover,\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_add\"][aria-expanded=\"true\"],\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"]:hover,\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"][aria-expanded=\"true\"] {\n background: var(--dsw-codex-hover-fill);\n}\n\nhtml[data-codex-ui] [data-slot=\"conversation.input.model\"][data-codex-ui-seated] > :not(.codex-mp-trigger) {\n display: none;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-trigger:hover,\nhtml[data-codex-ui] .codex-mp-trigger[aria-expanded=\"true\"] {\n background: var(--dsw-codex-hover-fill);\n}\n\nhtml[data-codex-ui] .codex-mp-trigger-model {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-effort-layers[hidden] {\n display: none;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-effort-text[data-active=\"true\"] {\n opacity: 1;\n filter: none;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-trigger[aria-expanded=\"true\"] .codex-mp-trigger-chevron {\n transform: rotate(180deg);\n}\n\nhtml[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: var(--dsw-codex-menu-shadow);\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}\n\nhtml[data-codex-ui] .codex-mp-popover[hidden] {\n display: none;\n}\n\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-error[hidden] {\n display: none;\n}\n\nhtml[data-codex-ui] .codex-mp-list {\n min-height: 0;\n overflow-y: auto;\n scrollbar-width: thin;\n}\n\nhtml[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}\n\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-group:not(:first-child) {\n margin-top: 3px;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-row:hover,\nhtml[data-codex-ui] .codex-mp-row[aria-checked=\"true\"] {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-row[aria-busy=\"true\"] {\n cursor: progress;\n}\n\nhtml[data-codex-ui] .codex-mp-row-copy {\n display: flex;\n flex: 1;\n flex-direction: column;\n min-width: 0;\n}\n\nhtml[data-codex-ui] .codex-mp-row-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: 500;\n}\n\nhtml[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}\n\nhtml[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\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-effort[hidden] {\n display: none;\n}\n\nhtml[data-codex-ui] .codex-mp-effort {\n --codex-mp-apex: #8b7ad0;\n --codex-mp-apex-flash: #d9d2f5;\n --codex-mp-apex-ink: #5f51b5;\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] .codex-mp-effort {\n --codex-mp-apex: #9d8ce0;\n --codex-mp-apex-flash: #e6e0fb;\n --codex-mp-apex-ink: #b9adf0;\n}\n\nhtml[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}\n\nhtml[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\nhtml[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\nhtml[data-codex-ui] .codex-mp-root {\n --codex-mp-motion-duration: 0.18s;\n --codex-mp-motion-easing: cubic-bezier(0.22, 0.61, 0.36, 1);\n --codex-mp-tick-transform-duration: 0.12s;\n --codex-mp-thumb-motion-duration: 0s;\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n height: 30px;\n outline: none;\n touch-action: none;\n cursor: pointer;\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-armed=\"true\"] {\n --codex-mp-thumb-motion-duration: var(--codex-mp-motion-duration);\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-dragging=\"true\"],\nhtml[data-codex-ui] .codex-mp-root[data-dragging=\"true\"] .codex-mp-tick {\n cursor: grabbing;\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-disabled=\"true\"] {\n opacity: 0.6;\n}\n\nhtml[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\nhtml[data-codex-ui] .codex-mp-track {\n position: relative;\n flex-grow: 1;\n height: 30px;\n}\n\nhtml[data-codex-ui] .codex-mp-track::before {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n top: 50%;\n height: 26px;\n margin-top: -13px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] .codex-mp-range {\n position: absolute;\n left: 0;\n top: 50%;\n height: 26px;\n margin-top: -13px;\n width: calc(8px + (100% - 16px) * var(--codex-mp-pos, 0));\n border-radius: 8px 0 0 8px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 26%, transparent);\n transition: width var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing);\n pointer-events: none;\n}\n\nhtml[data-codex-ui] .codex-mp-matrix {\n position: absolute;\n left: 0;\n right: 0;\n top: 50%;\n height: 26px;\n margin-top: -13px;\n border-radius: 8px;\n overflow: hidden;\n box-sizing: border-box;\n display: none;\n pointer-events: none;\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-tier=\"max\"] .codex-mp-matrix {\n display: grid;\n grid-auto-flow: row;\n}\n\nhtml[data-codex-ui] .codex-mp-matrix-cell {\n animation: codex-mp-cell-in 0.15s ease both;\n}\n\n@keyframes codex-mp-cell-in {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\nhtml[data-codex-ui] .codex-mp-matrix-sq {\n width: 100%;\n height: 100%;\n border-radius: 1px;\n background-color: var(--codex-mp-apex);\n --codex-mp-flash-light: var(--codex-mp-apex-flash);\n animation: codex-mp-flash 1.45s infinite ease-in-out;\n}\n\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"0\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 62%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"1\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 54%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"2\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 46%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"3\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 38%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"4\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 30%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"5\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 22%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"6\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 14%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"7\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 6%, var(--codex-mp-apex-flash)); }\n\n@keyframes codex-mp-flash {\n 0%,\n 17.24% {\n background-color: var(--codex-mp-apex);\n }\n\n 17.25%,\n 50.34% {\n background-color: var(--codex-mp-flash-light);\n }\n\n 100% {\n background-color: var(--codex-mp-apex);\n }\n}\n\nhtml[data-codex-ui] .codex-mp-tick {\n position: absolute;\n top: 50%;\n width: 4px;\n height: 4px;\n margin-top: -2px;\n border-radius: 50%;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 26%, transparent);\n transform: translateX(-50%);\n pointer-events: none;\n}\n\nhtml[data-codex-ui] .codex-mp-tick::before {\n content: \"\";\n position: absolute;\n inset: -6px;\n}\n\nhtml[data-codex-ui] .codex-mp-thumb-scale {\n position: absolute;\n top: 50%;\n left: calc(8px + (100% - 16px) * var(--codex-mp-pos, 0));\n width: 16px;\n height: 30px;\n margin-top: -15px;\n border-radius: 5px;\n transform: translateX(-50%);\n pointer-events: none;\n will-change: left;\n transition: left var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing);\n}\n\nhtml[data-codex-ui] .codex-mp-thumb {\n position: absolute;\n inset: 0;\n box-sizing: border-box;\n display: block;\n border-radius: 5px;\n background: var(--dsw-static-neutral-bluish-00, #ffffff);\n box-shadow:\n 0 1px 3px rgb(20 20 19 / 28%),\n 0 0 0 1px rgb(20 20 19 / 6%);\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] .codex-mp-thumb {\n box-shadow:\n 0 1px 3px rgb(0 0 0 / 55%),\n 0 0 0 1px rgb(0 0 0 / 40%);\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-tier=\"max\"] .codex-mp-range,\nhtml[data-codex-ui] .codex-mp-root[data-tier=\"max\"] .codex-mp-tick {\n opacity: 0;\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-tier=\"max\"] .codex-mp-thumb {\n background: color-mix(in srgb, var(--codex-mp-apex) 22%, #ffffff);\n box-shadow:\n 0 0 10px color-mix(in srgb, var(--codex-mp-apex) 80%, transparent),\n 0 0 0 1px color-mix(in srgb, var(--codex-mp-apex) 35%, transparent);\n}\n\nhtml[data-codex-ui] .codex-mp-effort[data-tier=\"max\"] .codex-mp-effort-value {\n color: var(--codex-mp-apex-ink);\n}\n\nhtml[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\nhtml[data-codex-ui] .codex-mp-root[data-unset] .codex-mp-thumb-scale,\nhtml[data-codex-ui] .codex-mp-root[data-unset] .codex-mp-range {\n visibility: hidden;\n}\n\nhtml[data-codex-ui] .codex-mp-ends {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 0 9px 2px;\n font-size: 11px;\n line-height: 14px;\n color: var(--dsw-alias-label-caption);\n}\n\nhtml[data-codex-ui] .codex-mp-value-text {\n animation: codex-mp-value-in 0.28s ease;\n}\n\nhtml[data-codex-ui] .codex-mp-effort-ghost {\n position: absolute;\n left: 0;\n top: 0;\n white-space: nowrap;\n pointer-events: none;\n color: inherit;\n animation: codex-mp-value-out 0.28s ease forwards;\n}\n\n@keyframes codex-mp-value-in {\n from {\n filter: blur(4px);\n opacity: 0;\n transform: translateY(5px);\n }\n\n to {\n filter: blur(0);\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes codex-mp-value-out {\n from {\n filter: blur(0);\n opacity: 1;\n transform: translateY(0);\n }\n\n to {\n filter: blur(4px);\n opacity: 0;\n transform: translateY(-5px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-codex-ui] .codex-mp-matrix-cell,\nhtml[data-codex-ui] .codex-mp-matrix-sq,\nhtml[data-codex-ui] .codex-mp-value-text,\nhtml[data-codex-ui] .codex-mp-effort-ghost {\n animation: none;\n }\n}\n\nhtml[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-matrix-cell,\nhtml[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-matrix-sq,\nhtml[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-value-text,\nhtml[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-effort-ghost {\n animation: none;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button {\n box-sizing: border-box;\n height: auto;\n min-height: 36px;\n flex: none;\n background: 0 0;\n border: none;\n border-radius: var(--dsw-radius-s);\n justify-content: flex-start;\n align-items: center;\n gap: 6px;\n\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}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button:focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: -2px;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > svg {\n width: 16px;\n height: 16px;\n flex: none;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > span {\n max-width: none;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > span > span:has(> svg) {\n justify-content: flex-start;\n width: auto;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > span > span > svg {\n width: 16px;\n height: 16px;\n flex: none;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > span > span > span {\n max-width: none;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > nav > button {\n gap: 6px;\n margin-left: 0;\n}\n\nhtml[data-codex-ui] div > [data-slot=\"sidebar.workspaces\"] [class$=\"_fade\"] {\n display: none;\n}\n\nhtml[data-codex-ui] div > [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\nhtml[data-codex-ui][data-windows-titlebar] div:has(> [data-slot=\"main\"]),\nhtml[data-codex-ui][data-windows-titlebar] div:has(> [data-slot=\"sidebar\"]) + div,\nhtml[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) div:has(> [data-slot=\"main\"]),\nhtml[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 13px rgba(13, 13, 13, 0.07);\n}\n\nhtml[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] div:has(> [data-slot=\"main\"]),\nhtml[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] div:has(> [data-slot=\"sidebar\"]) + div,\nhtml[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) body[data-ds-dark-theme] div:has(> [data-slot=\"main\"]),\nhtml[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\nhtml[data-codex-ui][data-windows-titlebar] [data-sidebar-right-panel=\"push\"][data-sidebar-right-open],\nhtml[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 var(--dsw-codex-ambient);\n}\n\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] [data-side=\"rightbar\"]:hover::before {\n opacity: 1;\n scale: 1 1;\n}\n\nhtml[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\nhtml[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\nhtml[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\n --dcu-composer-bg: var(--dsw-composer-surface);\n\n --dcu-composer-shadow: 0 0 0 0.5px rgba(13, 13, 13, 0.1), 0 2px 12px 0 rgba(0, 0, 0, 0.09);\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] [data-conversation-scroll] {\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}\n\nhtml[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)) {\n html[data-codex-ui] [data-conversation-scroll] [data-composer-card] {\n corner-shape: superellipse(1.5);\n }\n}\n\n@media (forced-colors: active) {\n html[data-codex-ui] [data-conversation-scroll] [data-composer-card] {\n outline: 1px solid CanvasText;\n }\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] [data-input-scroll] {\n margin-right: 0;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-input-scroll] [data-lexical-editor=true] {\n min-height: 44px;\n padding: 0 12px;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-placeholder] {\n inset: 0 12px auto;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div {\n padding: 0 8px 8px;\n gap: 5px;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div > div {\n gap: 4px;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div > div > div {\n gap: 4px;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div button:not([role]),\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div select:not([role]) {\n min-height: 28px;\n height: 28px;\n}\n\nhtml[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}\n\nhtml[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}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div button:focus-visible,\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div select:focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 2px;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] [class*=\"_heroWorkspaceRow\"] button:hover,\nhtml[data-codex-ui] [class*=\"_heroWorkspaceRow\"] button[aria-expanded=true] {\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\nhtml[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: var(--dsw-codex-suggest-fill);\n box-shadow: var(--dsw-codex-suggest-shadow);\n}\n\nhtml[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\nhtml[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}\n\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] [data-phase=hero] [data-composer-seat] {\n flex: 1 0 auto;\n min-height: 100%;\n}\n\nhtml[data-codex-ui] [data-phase=hero] [data-composer-seat] > :has(> * > [class*=\"_composerHero\"]) {\n display: flex;\n flex: 1;\n flex-direction: column;\n}\n\nhtml[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}\n\nhtml[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 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}\n\nhtml[data-codex-ui] [data-phase=hero] [class*=\"_heroWorkspaceRow\"] > [class$=\"_workspace\"] {\n min-width: 0;\n max-width: 100%;\n font-weight: 400;\n}\n\nhtml[data-codex-ui] .cx-form {\n display: flex;\n flex-direction: column;\n margin: 0;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .cx-row:last-child {\n border-bottom: none;\n}\n\nhtml[data-codex-ui] .cx-row__text {\n min-width: 0;\n}\n\nhtml[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}\n\nhtml[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}\n\nhtml[data-codex-ui] .cx-row__control {\n display: flex;\n align-items: center;\n gap: 8px;\n justify-self: end;\n}\n\nhtml[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);\n background: none;\n cursor: pointer;\n}\n\nhtml[data-codex-ui] .cx-swatch::-webkit-color-swatch-wrapper {\n padding: 2px;\n}\n\nhtml[data-codex-ui] .cx-swatch::-webkit-color-swatch {\n border: none;\n border-radius: calc(var(--dsw-radius-s) - 2px);\n}\n\nhtml[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);\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}\n\nhtml[data-codex-ui] .cx-hex:focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 1px;\n}\n\nhtml[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);\n background: var(--dsw-alias-bg-base);\n color: var(--dsw-alias-label-primary);\n font-size: 12px;\n}\n\nhtml[data-codex-ui] .cx-text:focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 1px;\n}\n\nhtml[data-codex-ui] .cx-range {\n width: 160px;\n accent-color: var(--dsw-alias-link);\n}\n\nhtml[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}\n\nhtml[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}\n\nhtml[data-codex-ui] .cx-error {\n margin: 8px 0 0;\n color: var(--dsw-alias-state-error-primary);\n font-size: 11px;\n line-height: 16px;\n}\n"; +const THEME_CSS = "html[data-codex-ui],\nhtml[data-codex-ui] body {\n color-scheme: light;\n\n background-color: #ffffff;\n\n --dsw-alias-bg-base: #ffffff;\n\n --dsw-alias-bg-sidebar: #f6f6f6;\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 --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 --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 --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 --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 --dsw-alias-interactive-bg-hover: rgba(13, 13, 13, 0.04);\n --dsw-codex-hover-fill: #f2f2f3;\n --dsw-codex-focus: #339cff;\n\n --dsw-focus-ring-color: var(--dsw-codex-focus);\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 --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 --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 --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 --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 --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 --dsw-specific-sidebar-fill: #f6f6f6;\n --dsw-specific-sidebar-nav-item-hover: #f0f0f0;\n --dsw-specific-sidebar-nav-item-active: #e9e9e9;\n --dsw-specific-sidebar-nav-item-active-accent: rgba(13, 13, 13, 0.1);\n --dsw-specific-input-major: #ffffff;\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 --dsw-codex-ambient: rgba(13, 13, 13, 0.05);\n --dsw-codex-menu-shadow: 0 8px 32px rgba(13, 13, 13, 0.13), 0 0 0 1px rgba(13, 13, 13, 0.04);\n --dsw-codex-suggest-shadow: 0 8px 32px #0002, 0 0 0 1px #0000000a;\n --dsw-codex-suggest-fill: #fff;\n}\n\nhtml[data-codex-ui-switching] *,\nhtml[data-codex-ui-switching] *::before,\nhtml[data-codex-ui-switching] *::after {\n transition: none !important;\n}\n\nhtml[data-codex-ui]:has(body[data-ds-dark-theme]) {\n background-color: #111111;\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] {\n color-scheme: dark;\n background-color: #111111;\n\n --dsw-alias-bg-base: #111111;\n --dsw-alias-bg-sidebar: #0f0f0f;\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;\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 --dsw-codex-hover-fill: rgba(255, 255, 255, 0.08);\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: #0f0f0f;\n --dsw-specific-sidebar-nav-item-hover: #171717;\n --dsw-specific-sidebar-nav-item-active: #1f1f1f;\n --dsw-specific-sidebar-nav-item-active-accent: rgba(255, 255, 255, 0.14);\n --dsw-specific-input-major: #212121;\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 --dsw-codex-ambient: rgba(0, 0, 0, 0.5);\n --dsw-codex-menu-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(236, 236, 236, 0.05);\n --dsw-codex-suggest-shadow: 0 8px 32px #0003, 0 0 0 1px #ffffff08;\n --dsw-codex-suggest-fill: #292929;\n}\n\nhtml[data-codex-ui],\nhtml[data-codex-ui] body {\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 --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 --dsw-radius-pill: 999px;\n\n --dsw-content-max-width: 768px;\n\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 --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 --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 --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 --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@supports (corner-shape: superellipse(1.5)) {\n html[data-codex-ui],\nhtml[data-codex-ui] body {\n --dsw-radius-card: var(--dsw-radius-2xl);\n }\n}\n\nhtml[data-codex-ui] body,\nhtml[data-codex-ui] body * {\n --dsw-elevation-stroke-color: rgba(13, 13, 13, 0.08);\n --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color);\n --dsw-elevation-panel: var(--dsw-elevation-stroke);\n\n --dsw-elevation-prominent: var(--dsw-elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d;\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme],\nhtml[data-codex-ui] body[data-ds-dark-theme] * {\n --dsw-elevation-stroke-color: rgba(255, 255, 255, 0.09);\n}\n\nhtml[data-codex-ui],\nhtml[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}\n\nhtml[data-codex-ui] :focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 2px;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] a:hover,\nhtml[data-codex-ui] a:focus-visible {\n text-decoration: underline;\n}\n\nhtml[data-codex-ui] button,\nhtml[data-codex-ui] a,\nhtml[data-codex-ui] summary,\nhtml[data-codex-ui] [role=\"button\"],\nhtml[data-codex-ui] [role=\"tab\"],\nhtml[data-codex-ui] [role=\"menuitem\"],\nhtml[data-codex-ui] [role=\"option\"] {\n cursor: pointer;\n}\n\nhtml[data-codex-ui] button,\nhtml[data-codex-ui] a,\nhtml[data-codex-ui] summary,\nhtml[data-codex-ui] [role=\"button\"],\nhtml[data-codex-ui] [role=\"tab\"],\nhtml[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}\n\nhtml[data-codex-ui] button:disabled,\nhtml[data-codex-ui] button[aria-disabled=\"true\"],\nhtml[data-codex-ui] [aria-disabled=\"true\"] {\n cursor: not-allowed;\n opacity: 0.45;\n}\n\nhtml[data-codex-ui] [data-slot=\"web-ui.plugin.item\"] > *,\nhtml[data-codex-ui] [data-dsh-part=\"card\"],\nhtml[data-codex-ui] [data-dsh-part=\"column\"],\nhtml[data-codex-ui] [data-dsh-part=\"status\"],\nhtml[data-codex-ui] [data-dsh-part=\"today-card\"],\nhtml[data-codex-ui] [data-dsh-part=\"balance-card\"],\nhtml[data-codex-ui] [data-dsh-part=\"trend-card\"],\nhtml[data-codex-ui] [data-dsh-part=\"plan-card\"],\nhtml[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\nhtml[data-codex-ui] [role=\"dialog\"],\nhtml[data-codex-ui] [data-dsh-surface=\"overlay\"] [data-dsh-part=\"panel\"] {\n border-radius: var(--dsw-radius-l);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"composer-chip\"],\nhtml[data-codex-ui] [data-dsh-part=\"chip\"],\nhtml[data-codex-ui] [data-dsh-part=\"ref\"],\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"],\nhtml[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\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"0\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"1\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"2\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"3\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"4\"],\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"tag-chip\"][data-tag-tone=\"5\"],\nhtml[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\nhtml[data-codex-ui] [data-dsh-surface=\"sidebar\"],\nhtml[data-codex-ui] [data-slot=\"sidebar.workspaces\"] {\n background-color: var(--dsw-alias-bg-sidebar);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"sidebar-entry\"],\nhtml[data-codex-ui] [data-dsh-surface=\"sidebar\"] [role=\"button\"] {\n border-radius: var(--dsw-radius-s);\n}\n\nhtml[data-codex-ui] [data-dsh-surface=\"composer\"] [data-dsh-part=\"composer-input\"] {\n border-radius: var(--dsw-radius-l);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"composer-input\"] {\n font-family: var(--dsw-font-family);\n line-height: var(--dsw-leading-base);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"turn-tail\"],\nhtml[data-codex-ui] [data-dsh-part=\"queue-dock\"],\nhtml[data-codex-ui] [data-dsh-part=\"usage-chart\"],\nhtml[data-codex-ui] [data-dsh-part=\"voucher-preview\"] {\n font-family: var(--dsw-font-meta);\n}\n\nhtml[data-codex-ui] [data-dsh-part=\"composer-chip\"] {\n text-transform: uppercase;\n letter-spacing: var(--dsw-meta-tracking);\n}\n\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"composer-chip\"],\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"chip\"],\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"ref\"],\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"tag-chip\"],\nhtml[data-codex-ui] :lang(zh) [data-dsh-part=\"tag-badge\"] {\n letter-spacing: 0;\n text-transform: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-codex-ui] *,\nhtml[data-codex-ui] *::before,\nhtml[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\nhtml[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\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] {\n\n --dsw-menu-surface-fill: var(--dsw-alias-bg-layer-1);\n --dsw-menu-backdrop-filter: none;\n\n background: var(--dsw-alias-bg-layer-1);\n padding: 5px;\n border-radius: var(--dsw-radius-menu);\n box-shadow: var(--dsw-codex-menu-shadow);\n color: var(--dsw-alias-label-primary);\n}\n\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"],\nhtml[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}\n\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"]:hover,\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"]:hover:not(:disabled),\nhtml[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\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitem\"]:focus-visible,\nhtml[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\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy] button[role=\"menuitemradio\"]:disabled {\n opacity: 1;\n}\n\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy=\"true\"] button[role=\"menuitemradio\"][aria-checked=\"true\"] > span:last-child {\n position: relative;\n}\n\nhtml[data-codex-ui] body > div[role=\"menu\"][aria-busy=\"true\"] button[role=\"menuitemradio\"][aria-checked=\"true\"] > span:last-child > svg {\n visibility: hidden;\n}\n\nhtml[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 animation: codex-ui-spin 1s linear infinite;\n}\n\nhtml[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\nhtml[data-codex-ui] [data-slot=\"conversation.session.header\"] > [data-conversation-tabs] {\n display: none;\n}\n\nhtml[data-codex-ui] header:has([data-conversation-header-leading]):has([data-conversation-tabs]) {\n min-height: 0;\n padding-bottom: 10px;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] [data-dsh-part=\"new-session\"]:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] [data-slot=\"conversation.composer.dock\"] {\n display: none;\n}\n\nhtml[data-codex-ui] [data-conversation-header-corner] {\n margin-left: 0;\n margin-right: 0;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-expand] svg {\n transform: scaleX(-1);\n}\n\nhtml[data-codex-ui] [data-animating] {\n transition: grid-template-columns 500ms cubic-bezier(.16, 1, .3, 1);\n}\n\nhtml[data-codex-ui] [data-animating] [data-side] {\n transition: left 500ms cubic-bezier(.16, 1, .3, 1);\n}\n\nhtml[data-codex-ui] [data-dragging],\nhtml[data-codex-ui] [data-rightbar-instant],\nhtml[data-codex-ui] [data-rightbar-fullscreen],\nhtml[data-codex-ui] [data-dragging] [data-side],\nhtml[data-codex-ui] [data-rightbar-instant] [data-side],\nhtml[data-codex-ui] [data-rightbar-fullscreen] [data-side] {\n transition: none;\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-codex-ui] [data-animating],\nhtml[data-codex-ui] [data-animating] [data-side] {\n transition: none;\n }\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide] > span[aria-hidden=\"true\"] {\n display: none;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide]::after {\n display: none;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry]:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry] > span:first-child {\n width: 20px;\n height: 20px;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry] > span:first-child svg {\n width: 20px;\n height: 20px;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry] > span:nth-child(2) {\n gap: 0;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry] > span:nth-child(2) > span:nth-child(2) {\n display: none;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(2) {\n width: 20px;\n height: 20px;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(2) svg {\n width: 20px;\n height: 20px;\n}\n\nhtml[data-codex-ui] [data-sidebar-right-guide-entry=\"terminal\"] > span:nth-child(3) > span:nth-child(2) {\n display: none;\n}\n\nhtml[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}\n\nhtml[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\nhtml[data-codex-ui] [data-sidebar-right-toggle] {\n background: var(--dsw-alias-bg-layer-2);\n border-radius: var(--dsw-radius-m);\n}\n\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_add\"],\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"] {\n transition: background-color var(--dsw-motion-fast) var(--dsw-ease);\n border-radius: var(--dsw-radius-pill);\n}\n\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_add\"] {\n background: transparent;\n}\n\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_add\"]:hover,\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_add\"][aria-expanded=\"true\"],\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"]:hover,\nhtml[data-codex-ui] [data-composer-card] button[class$=\"_trigger\"][aria-expanded=\"true\"] {\n background: var(--dsw-codex-hover-fill);\n}\n\nhtml[data-codex-ui] [data-slot=\"conversation.input.model\"][data-codex-ui-seated] > :not(.codex-mp-trigger) {\n display: none;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-trigger:hover,\nhtml[data-codex-ui] .codex-mp-trigger[aria-expanded=\"true\"] {\n background: var(--dsw-codex-hover-fill);\n}\n\nhtml[data-codex-ui] .codex-mp-trigger-model {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-effort-layers[hidden] {\n display: none;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-effort-text[data-active=\"true\"] {\n opacity: 1;\n filter: none;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-trigger[aria-expanded=\"true\"] .codex-mp-trigger-chevron {\n transform: rotate(180deg);\n}\n\nhtml[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: var(--dsw-codex-menu-shadow);\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}\n\nhtml[data-codex-ui] .codex-mp-popover[hidden] {\n display: none;\n}\n\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-error[hidden] {\n display: none;\n}\n\nhtml[data-codex-ui] .codex-mp-list {\n min-height: 0;\n overflow-y: auto;\n scrollbar-width: thin;\n}\n\nhtml[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}\n\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-group:not(:first-child) {\n margin-top: 3px;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-row:hover,\nhtml[data-codex-ui] .codex-mp-row[aria-checked=\"true\"] {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-row[aria-busy=\"true\"] {\n cursor: progress;\n}\n\nhtml[data-codex-ui] .codex-mp-row-copy {\n display: flex;\n flex: 1;\n flex-direction: column;\n min-width: 0;\n}\n\nhtml[data-codex-ui] .codex-mp-row-name {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: 500;\n}\n\nhtml[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}\n\nhtml[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\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] .codex-mp-effort[hidden] {\n display: none;\n}\n\nhtml[data-codex-ui] .codex-mp-effort {\n --codex-mp-apex: #8b7ad0;\n --codex-mp-apex-flash: #d9d2f5;\n --codex-mp-apex-ink: #5f51b5;\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] .codex-mp-effort {\n --codex-mp-apex: #9d8ce0;\n --codex-mp-apex-flash: #e6e0fb;\n --codex-mp-apex-ink: #b9adf0;\n}\n\nhtml[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}\n\nhtml[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\nhtml[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\nhtml[data-codex-ui] .codex-mp-root {\n --codex-mp-motion-duration: 0.18s;\n --codex-mp-motion-easing: cubic-bezier(0.22, 0.61, 0.36, 1);\n --codex-mp-tick-transform-duration: 0.12s;\n --codex-mp-thumb-motion-duration: 0s;\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n height: 30px;\n outline: none;\n touch-action: none;\n cursor: pointer;\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-armed=\"true\"] {\n --codex-mp-thumb-motion-duration: var(--codex-mp-motion-duration);\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-dragging=\"true\"],\nhtml[data-codex-ui] .codex-mp-root[data-dragging=\"true\"] .codex-mp-tick {\n cursor: grabbing;\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-disabled=\"true\"] {\n opacity: 0.6;\n}\n\nhtml[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\nhtml[data-codex-ui] .codex-mp-track {\n position: relative;\n flex-grow: 1;\n height: 30px;\n}\n\nhtml[data-codex-ui] .codex-mp-track::before {\n content: \"\";\n position: absolute;\n left: 0;\n right: 0;\n top: 50%;\n height: 26px;\n margin-top: -13px;\n border-radius: 8px;\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] .codex-mp-range {\n position: absolute;\n left: 0;\n top: 50%;\n height: 26px;\n margin-top: -13px;\n width: calc(8px + (100% - 16px) * var(--codex-mp-pos, 0));\n border-radius: 8px 0 0 8px;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 26%, transparent);\n transition: width var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing);\n pointer-events: none;\n}\n\nhtml[data-codex-ui] .codex-mp-matrix {\n position: absolute;\n left: 0;\n right: 0;\n top: 50%;\n height: 26px;\n margin-top: -13px;\n border-radius: 8px;\n overflow: hidden;\n box-sizing: border-box;\n display: none;\n pointer-events: none;\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-tier=\"max\"] .codex-mp-matrix {\n display: grid;\n grid-auto-flow: row;\n}\n\nhtml[data-codex-ui] .codex-mp-matrix-cell {\n animation: codex-mp-cell-in 0.15s ease both;\n}\n\n@keyframes codex-mp-cell-in {\n from {\n opacity: 0;\n }\n\n to {\n opacity: 1;\n }\n}\n\nhtml[data-codex-ui] .codex-mp-matrix-sq {\n width: 100%;\n height: 100%;\n border-radius: 1px;\n background-color: var(--codex-mp-apex);\n --codex-mp-flash-light: var(--codex-mp-apex-flash);\n animation: codex-mp-flash 1.45s infinite ease-in-out;\n}\n\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"0\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 62%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"1\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 54%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"2\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 46%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"3\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 38%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"4\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 30%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"5\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 22%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"6\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 14%, var(--codex-mp-apex-flash)); }\nhtml[data-codex-ui] .codex-mp-matrix-sq[data-tone=\"7\"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 6%, var(--codex-mp-apex-flash)); }\n\n@keyframes codex-mp-flash {\n 0%,\n 17.24% {\n background-color: var(--codex-mp-apex);\n }\n\n 17.25%,\n 50.34% {\n background-color: var(--codex-mp-flash-light);\n }\n\n 100% {\n background-color: var(--codex-mp-apex);\n }\n}\n\nhtml[data-codex-ui] .codex-mp-tick {\n position: absolute;\n top: 50%;\n width: 4px;\n height: 4px;\n margin-top: -2px;\n border-radius: 50%;\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 26%, transparent);\n transform: translateX(-50%);\n pointer-events: none;\n}\n\nhtml[data-codex-ui] .codex-mp-tick::before {\n content: \"\";\n position: absolute;\n inset: -6px;\n}\n\nhtml[data-codex-ui] .codex-mp-thumb-scale {\n position: absolute;\n top: 50%;\n left: calc(8px + (100% - 16px) * var(--codex-mp-pos, 0));\n width: 16px;\n height: 30px;\n margin-top: -15px;\n border-radius: 5px;\n transform: translateX(-50%);\n pointer-events: none;\n will-change: left;\n transition: left var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing);\n}\n\nhtml[data-codex-ui] .codex-mp-thumb {\n position: absolute;\n inset: 0;\n box-sizing: border-box;\n display: block;\n border-radius: 5px;\n background: var(--dsw-static-neutral-bluish-00, #ffffff);\n box-shadow:\n 0 1px 3px rgb(20 20 19 / 28%),\n 0 0 0 1px rgb(20 20 19 / 6%);\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] .codex-mp-thumb {\n box-shadow:\n 0 1px 3px rgb(0 0 0 / 55%),\n 0 0 0 1px rgb(0 0 0 / 40%);\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-tier=\"max\"] .codex-mp-range,\nhtml[data-codex-ui] .codex-mp-root[data-tier=\"max\"] .codex-mp-tick {\n opacity: 0;\n}\n\nhtml[data-codex-ui] .codex-mp-root[data-tier=\"max\"] .codex-mp-thumb {\n background: color-mix(in srgb, var(--codex-mp-apex) 22%, #ffffff);\n box-shadow:\n 0 0 10px color-mix(in srgb, var(--codex-mp-apex) 80%, transparent),\n 0 0 0 1px color-mix(in srgb, var(--codex-mp-apex) 35%, transparent);\n}\n\nhtml[data-codex-ui] .codex-mp-effort[data-tier=\"max\"] .codex-mp-effort-value {\n color: var(--codex-mp-apex-ink);\n}\n\nhtml[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\nhtml[data-codex-ui] .codex-mp-root[data-unset] .codex-mp-thumb-scale,\nhtml[data-codex-ui] .codex-mp-root[data-unset] .codex-mp-range {\n visibility: hidden;\n}\n\nhtml[data-codex-ui] .codex-mp-ends {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 8px;\n padding: 0 9px 2px;\n font-size: 11px;\n line-height: 14px;\n color: var(--dsw-alias-label-caption);\n}\n\nhtml[data-codex-ui] .codex-mp-value-text {\n animation: codex-mp-value-in 0.28s ease;\n}\n\nhtml[data-codex-ui] .codex-mp-effort-ghost {\n position: absolute;\n left: 0;\n top: 0;\n white-space: nowrap;\n pointer-events: none;\n color: inherit;\n animation: codex-mp-value-out 0.28s ease forwards;\n}\n\n@keyframes codex-mp-value-in {\n from {\n filter: blur(4px);\n opacity: 0;\n transform: translateY(5px);\n }\n\n to {\n filter: blur(0);\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n@keyframes codex-mp-value-out {\n from {\n filter: blur(0);\n opacity: 1;\n transform: translateY(0);\n }\n\n to {\n filter: blur(4px);\n opacity: 0;\n transform: translateY(-5px);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-codex-ui] .codex-mp-matrix-cell,\nhtml[data-codex-ui] .codex-mp-matrix-sq,\nhtml[data-codex-ui] .codex-mp-value-text,\nhtml[data-codex-ui] .codex-mp-effort-ghost {\n animation: none;\n }\n}\n\nhtml[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-matrix-cell,\nhtml[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-matrix-sq,\nhtml[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-value-text,\nhtml[data-codex-ui] .codex-mp-popover[data-reduced-motion=\"true\"] .codex-mp-effort-ghost {\n animation: none;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button {\n box-sizing: border-box;\n height: auto;\n min-height: 36px;\n flex: none;\n background: 0 0;\n border: none;\n border-radius: var(--dsw-radius-s);\n justify-content: flex-start;\n align-items: center;\n gap: 6px;\n\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}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button:focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: -2px;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > svg {\n width: 16px;\n height: 16px;\n flex: none;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > span {\n max-width: none;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > span > span:has(> svg) {\n justify-content: flex-start;\n width: auto;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > span > span > svg {\n width: 16px;\n height: 16px;\n flex: none;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > button > span > span > span {\n max-width: none;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\nhtml[data-codex-ui] [data-slot=\"sidebar\"] > div:not([class*=\"_collapsed\"]) > nav > button {\n gap: 6px;\n margin-left: 0;\n}\n\nhtml[data-codex-ui] div > [data-slot=\"sidebar.workspaces\"] [class$=\"_fade\"] {\n display: none;\n}\n\nhtml[data-codex-ui] div > [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\nhtml[data-codex-ui][data-windows-titlebar] div:has(> [data-slot=\"main\"]),\nhtml[data-codex-ui][data-windows-titlebar] div:has(> [data-slot=\"sidebar\"]) + div,\nhtml[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) div:has(> [data-slot=\"main\"]),\nhtml[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 13px rgba(13, 13, 13, 0.07);\n}\n\nhtml[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] div:has(> [data-slot=\"main\"]),\nhtml[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] div:has(> [data-slot=\"sidebar\"]) + div,\nhtml[data-codex-ui][data-platform=\"win32\"]:not([data-windows-titlebar]) body[data-ds-dark-theme] div:has(> [data-slot=\"main\"]),\nhtml[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\nhtml[data-codex-ui][data-windows-titlebar] [data-sidebar-right-panel=\"push\"][data-sidebar-right-open],\nhtml[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 var(--dsw-codex-ambient);\n}\n\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] [data-side=\"rightbar\"]:hover::before {\n opacity: 1;\n scale: 1 1;\n}\n\nhtml[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\nhtml[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\nhtml[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\n --dcu-composer-bg: var(--dsw-composer-surface);\n\n --dcu-composer-shadow: 0 0 0 0.5px rgba(13, 13, 13, 0.1), 0 2px 12px 0 rgba(0, 0, 0, 0.09);\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] [data-conversation-scroll] {\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}\n\nhtml[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)) {\n html[data-codex-ui] [data-conversation-scroll] [data-composer-card] {\n corner-shape: superellipse(1.5);\n }\n}\n\n@media (forced-colors: active) {\n html[data-codex-ui] [data-conversation-scroll] [data-composer-card] {\n outline: 1px solid CanvasText;\n }\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] [data-input-scroll] {\n margin-right: 0;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-input-scroll] [data-lexical-editor=true] {\n min-height: 44px;\n padding: 0 12px;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-placeholder] {\n inset: 0 12px auto;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div {\n padding: 0 8px 8px;\n gap: 5px;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div > div {\n gap: 4px;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div > div > div {\n gap: 4px;\n}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div button:not([role]),\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div select:not([role]) {\n min-height: 28px;\n height: 28px;\n}\n\nhtml[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}\n\nhtml[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}\n\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div button:focus-visible,\nhtml[data-codex-ui] [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div select:focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 2px;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] [class*=\"_heroWorkspaceRow\"] button:hover,\nhtml[data-codex-ui] [class*=\"_heroWorkspaceRow\"] button[aria-expanded=true] {\n background: color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent);\n}\n\nhtml[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: var(--dsw-codex-suggest-fill);\n box-shadow: var(--dsw-codex-suggest-shadow);\n}\n\nhtml[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\nhtml[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}\n\nhtml[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\nhtml[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}\n\nhtml[data-codex-ui] [data-phase=hero] [data-composer-seat] {\n flex: 1 0 auto;\n min-height: 100%;\n}\n\nhtml[data-codex-ui] [data-phase=hero] [data-composer-seat] > :has(> * > [class*=\"_composerHero\"]) {\n display: flex;\n flex: 1;\n flex-direction: column;\n}\n\nhtml[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}\n\nhtml[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 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}\n\nhtml[data-codex-ui] [data-phase=hero] [class*=\"_heroWorkspaceRow\"] > [class$=\"_workspace\"] {\n min-width: 0;\n max-width: 100%;\n font-weight: 400;\n}\n\nhtml[data-codex-ui] .cx-form {\n display: flex;\n flex-direction: column;\n margin: 0;\n}\n\nhtml[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}\n\nhtml[data-codex-ui] .cx-row:last-child {\n border-bottom: none;\n}\n\nhtml[data-codex-ui] .cx-row__text {\n min-width: 0;\n}\n\nhtml[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}\n\nhtml[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}\n\nhtml[data-codex-ui] .cx-row__control {\n display: flex;\n align-items: center;\n gap: 8px;\n justify-self: end;\n}\n\nhtml[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);\n background: none;\n cursor: pointer;\n}\n\nhtml[data-codex-ui] .cx-swatch::-webkit-color-swatch-wrapper {\n padding: 2px;\n}\n\nhtml[data-codex-ui] .cx-swatch::-webkit-color-swatch {\n border: none;\n border-radius: calc(var(--dsw-radius-s) - 2px);\n}\n\nhtml[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);\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}\n\nhtml[data-codex-ui] .cx-hex:focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 1px;\n}\n\nhtml[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);\n background: var(--dsw-alias-bg-base);\n color: var(--dsw-alias-label-primary);\n font-size: 12px;\n}\n\nhtml[data-codex-ui] .cx-text:focus-visible {\n outline: 2px solid var(--dsw-focus-ring-color);\n outline-offset: 1px;\n}\n\nhtml[data-codex-ui] .cx-range {\n width: 160px;\n accent-color: var(--dsw-alias-link);\n}\n\nhtml[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}\n\nhtml[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}\n\nhtml[data-codex-ui] .cx-error {\n margin: 8px 0 0;\n color: var(--dsw-alias-state-error-primary);\n font-size: 11px;\n line-height: 16px;\n}\n\nhtml[data-codex-ui] {\n\n --cx-sm-sidebar-w: 236px;\n --cx-sm-row-h: 46px;\n --cx-sm-panel-w: 760px;\n --cx-sm-gutter: 24px;\n --cx-sm-radius-card: var(--dsw-radius-m, 12px);\n\n --cx-sm-radius-field: 10px;\n --cx-sm-radius-pill: 999px;\n\n}\n\n@media (max-width: 900px) {\n html[data-codex-ui] {\n --cx-sm-sidebar-w: 196px;\n --cx-sm-gutter: 14px;\n }\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] {\n --cx-sm-surface-backdrop: var(--dsw-alias-bg-sidebar);\n\n --cx-sm-card-edge: var(--dsw-alias-bg-sidebar);\n\n --dsw-alias-bg-layer-2: var(--dsw-alias-bg-layer-1);\n --dsw-alias-bg-layer-3: var(--dsw-alias-bg-layer-1);\n}\n\nhtml[data-codex-ui] body[data-ds-dark-theme] div[data-cx-sm-panel] {\n --cx-sm-card-edge: var(--dsw-alias-border-l1);\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] {\n position: fixed;\n inset: 0;\n box-sizing: border-box;\n width: auto;\n height: auto;\n max-width: none;\n max-height: none;\n border-radius: 0;\n box-shadow: none;\n overflow: hidden;\n\n background: var(--cx-sm-surface-backdrop);\n}\n\nhtml[data-codex-ui][data-windows-titlebar] div[data-cx-sm-panel] {\n padding-top: var(--dsh-windows-titlebar-height, 40px);\n}\n\nhtml[data-codex-ui][data-windows-titlebar] div[data-cx-sm-panel] button,\nhtml[data-codex-ui][data-windows-titlebar] div[data-cx-sm-panel] input,\nhtml[data-codex-ui][data-windows-titlebar] div[data-cx-sm-panel] a {\n -webkit-app-region: no-drag;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav {\n position: relative;\n box-sizing: border-box;\n flex: 0 0 var(--cx-sm-sidebar-w);\n width: var(--cx-sm-sidebar-w);\n min-height: 0;\n padding: 12px 12px 16px;\n gap: 8px;\n background: none;\n overflow: hidden;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:not([data-codex-ui-injected]):not(:has(> button)) {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n padding: 0;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button),\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:last-child {\n box-sizing: border-box;\n min-height: 0;\n flex: 1 1 auto;\n gap: 2px;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button) > button {\n box-sizing: border-box;\n width: 100%;\n min-height: 32px;\n padding: 6px 10px;\n gap: 9px;\n border: 0;\n border-radius: var(--dsw-radius-s, 8px);\n background: none;\n color: var(--dsw-alias-label-secondary);\n font-size: 14px;\n line-height: 20px;\n text-align: left;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button) > button:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button) > button[aria-current=\"true\"] {\n background: var(--dsw-alias-interactive-bg-active);\n color: var(--dsw-alias-label-primary);\n font-weight: 500;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button) > button[aria-current=\"true\"]:hover {\n background: var(--dsw-alias-interactive-bg-active);\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button) > button:focus-visible {\n outline-offset: -2px;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button) > button svg {\n width: 16px;\n height: 16px;\n flex: 0 0 auto;\n stroke-width: 1.4;\n opacity: 0.72;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button) > button:hover svg,\nhtml[data-codex-ui] div[data-cx-sm-panel] > nav > div:has(> button) > button[aria-current=\"true\"] svg {\n opacity: 1;\n}\n\nhtml[data-codex-ui] .cx-sm-group {\n margin: 12px 0 4px;\n padding: 0 10px;\n color: var(--dsw-alias-label-caption);\n font-size: 12px;\n line-height: 18px;\n font-weight: 500;\n letter-spacing: 0.01em;\n user-select: none;\n}\n\nhtml[data-codex-ui] :lang(zh) .cx-sm-group {\n letter-spacing: 0;\n}\n\nhtml[data-codex-ui] .cx-sm-search + .cx-sm-group {\n margin-top: 4px;\n}\n\nhtml[data-codex-ui] .cx-sm-back {\n display: flex;\n align-items: center;\n gap: 6px;\n box-sizing: border-box;\n width: fit-content;\n min-height: 28px;\n padding: 2px 6px 2px 4px;\n border-radius: var(--dsw-radius-s, 8px);\n color: var(--dsw-alias-label-secondary);\n font-size: 13px;\n line-height: 20px;\n cursor: pointer;\n user-select: none;\n}\n\nhtml[data-codex-ui] .cx-sm-back:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n color: var(--dsw-alias-label-primary);\n}\n\nhtml[data-codex-ui] .cx-sm-back svg {\n width: 14px;\n height: 14px;\n flex: 0 0 auto;\n stroke-width: 1.6;\n opacity: 0.8;\n}\n\nhtml[data-codex-ui] .cx-sm-search {\n display: flex;\n align-items: center;\n gap: 8px;\n box-sizing: border-box;\n width: 100%;\n height: 32px;\n\n padding: 0 14px;\n border: 0;\n border-radius: var(--cx-sm-radius-pill);\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-tertiary);\n}\n\nhtml[data-codex-ui] .cx-sm-search:focus-within {\n box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l3);\n}\n\nhtml[data-codex-ui] .cx-sm-search svg {\n width: 14px;\n height: 14px;\n flex: 0 0 auto;\n stroke-width: 1.6;\n opacity: 0.7;\n}\n\nhtml[data-codex-ui] .cx-sm-search input {\n box-sizing: border-box;\n flex: 1 1 auto;\n min-width: 0;\n height: 100%;\n padding: 0;\n border: 0;\n outline: 0;\n background: none;\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 13px;\n line-height: 20px;\n}\n\nhtml[data-codex-ui] .cx-sm-search input::placeholder {\n color: var(--dsw-alias-label-caption);\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > div:last-child {\n box-sizing: border-box;\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n\n background: var(--dsw-alias-bg-base);\n\n overflow: hidden;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > div:last-child > div:first-child {\n box-sizing: border-box;\n flex: 0 0 auto;\n align-items: center;\n min-height: 52px;\n padding: 12px var(--cx-sm-gutter) 8px;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > div:last-child > div:last-child {\n box-sizing: border-box;\n flex: 1 1 auto;\n min-height: 0;\n padding: 2px var(--cx-sm-gutter) 0;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] > div:last-child > div:last-child [data-slot=\"settings.section\"] > * {\n box-sizing: border-box;\n width: 100%;\n max-width: var(--cx-sm-panel-w);\n margin-left: auto;\n margin-right: auto;\n\n padding-bottom: 40px;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.action\"] button {\n box-sizing: border-box;\n height: 32px;\n padding: 0 12px;\n\n white-space: nowrap;\n border: 0.5px solid var(--dsw-alias-border-l2);\n border-radius: var(--dsw-radius-s, 8px);\n background: var(--dsw-alias-button-elevated-fill);\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n line-height: 1;\n cursor: pointer;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.action\"] button:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n border-color: var(--dsw-alias-border-l3);\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] button:has([data-slot=\"settings.close\"]) {\n display: none;\n}\n\nhtml[data-codex-ui] .cx-sm-pagehead {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n box-sizing: border-box;\n width: 100%;\n min-height: 36px;\n margin: 0 0 12px;\n}\n\nhtml[data-codex-ui] .cx-sm-title,\nhtml[data-codex-ui] .cx-sm-pagehead > h1,\nhtml[data-codex-ui] .cx-sm-pagehead > h2 {\n margin: 0;\n padding: 0;\n color: var(--dsw-alias-label-primary);\n\n font-size: 20px;\n line-height: 26px;\n font-weight: 600;\n letter-spacing: -0.01em;\n}\n\nhtml[data-codex-ui] :lang(zh) .cx-sm-title,\nhtml[data-codex-ui] :lang(zh) .cx-sm-pagehead > h1,\nhtml[data-codex-ui] :lang(zh) .cx-sm-pagehead > h2 {\n letter-spacing: 0;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.section\"] > * {\n box-sizing: border-box;\n width: 100%;\n gap: 0;\n padding-top: 2px;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.general.item\"] > * {\n box-sizing: border-box;\n min-height: var(--cx-sm-row-h);\n padding: 8px 16px;\n border-style: solid;\n border-color: var(--cx-sm-card-edge);\n border-width: 0.5px 0;\n\n background: var(--dsw-alias-bg-layer-1);\n transition: background-color var(--dsw-motion-fast, 150ms) var(--dsw-ease, ease);\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.section\"] > :first-child > [data-slot=\"settings.general.item\"] > *:first-child {\n border-top-width: 0.5px;\n border-left-width: 0.5px;\n border-right-width: 0.5px;\n border-radius: var(--cx-sm-radius-card) var(--cx-sm-radius-card) 0 0;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.section\"] > :first-child > [data-slot=\"settings.general.item\"] > *:last-child {\n border-bottom-width: 0.5px;\n border-left-width: 0.5px;\n border-right-width: 0.5px;\n border-radius: 0 0 var(--cx-sm-radius-card) var(--cx-sm-radius-card);\n}\n\n html[data-codex-ui] ._card { background: var(--dsw-alias-settings-card-fill) }\n html[data-codex-ui] ._cardActive { background: var(--dsw-alias-bg-module-platform) }\n html[data-codex-ui] —— 它们**吃令牌**,不是硬编码灰。该令牌由宿主主题包 dsh-client-ui-theme 在\n **body** 上声明为 var(--dsw-alias-settings-card-fill: var(--dsw-alias-bg-layer-2));\n 本机 app.asar 全文扫描里,该令牌的**声明**只有这一处,另 4 处都是 background 消费点。\n 皮肤把它同名重定义成 layer-1(skin.css:html[data-codex-ui],\nhtml[data-codex-ui] body),\n 特异性与顺序都压过宿主的 body 声明 —— 白卡 / 深色 #212121 因此**自动生效**。\n\n 旧注释怎么错的(一并记下,免得后人再被同一个坑骗):\n 旧文写「#f3f3f3 硬编码、换色试验证明不走任何令牌」。那个换色试验是在 §4.3 规则**还在**的\n 前提下做的 —— §4.3 以特异性 (0,3,3) 压过宿主的 (0,1,0),无论令牌被改成什么,卡都会保持\n §4.3 写死的 layer-1,于是「0 个元素跟随」是这条规则生效的**必然结果**,推不出「卡不读令牌」。\n 定案靠的是宿主源码里那两条 background 声明,不是那个试验。\n (二审结论见 docs/recon/second-pass-review.md 的 M1/M2。)\n\n **删除理由**(三条,任一条都够):\n ① 同值重复:皮肤已把该令牌定成 layer-1,§1 的 layer-2/3→layer-1 重定向同样落上去,\n 这条规则写的是**同一个值**,三层路径同值,将来改一处根本无法判断谁在生效;\n ② 覆盖面无意义:它靠特异性 (0,3,3) 压过宿主 .rowCard/.card 的 (0,1,0),\n 而值相同 —— 赢了也没有任何视觉差异;\n ③ **会漂**:[class$=\"_card\"] 匹配的是「整条 class 属性以该后缀结尾」,\n 覆盖面随宿主状态类的书写顺序漂移。实测 Agent 预设的选中卡是\n class=\"rtSEdW_card rtSEdW_cardActive\"(以 _cardActive 结尾)→ 今天**不命中**、\n 靠宿主自己的 .cardActive {background: var(--dsw-alias-bg-module-platform)} html[data-codex-ui] 保住选中底色,\n 纯属侥幸;一旦宿主把状态类挪到前面,选中卡会被压成 layer-1,**选中态视觉当场丢失,\n 而没有任何断言会红**。删除即消除这条会随宿主改版静默劣化的路径。\n\n 删除后的着色机制**唯一**:--dsw-alias-settings-card-fill(skin.css 定义)\n → 未选中卡 = layer-1(浅 #ffffff / 深 #212121)。选中卡保持宿主自己的\n bg-module-platform(浅 #f9f9f9)—— 与未选中卡不同色,选中态仍可区分。\n 这条路径由 scripts/settings-modal-verify.mjs 的两条集成断言把守\n (未选中卡 == var(--dsw-alias-bg-layer-1) 的计算值;选中卡背景 ≠ 未选中卡背景)。 */\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.section\"] > :first-child > [data-slot=\"settings.general.item\"] > *:not(:first-child):not(:last-child) {\n border-radius: 0;\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.general.item\"] > *:hover {\n background: var(--dsw-alias-interactive-bg-hover);\n}\n\nhtml[data-codex-ui] div[data-cx-sm-panel] [data-slot=\"settings.general.item\"] > * > :first-child > div:not(:first-child) {\n color: var(--dsw-alias-label-caption);\n}\n\nhtml[data-codex-ui] [data-cx-sm-hidden],\nhtml[data-codex-ui] .cx-sm-hidden {\n display: none !important;\n}\n\nhtml[data-codex-ui] .cx-sm-empty {\n box-sizing: border-box;\n padding: 32px 8px;\n color: var(--dsw-alias-label-tertiary);\n font-size: 13px;\n line-height: 20px;\n text-align: center;\n}\n\nhtml[data-codex-ui] [data-codex-ui-injected=\"settings-modal\"],\nhtml[data-codex-ui] [data-codex-ui-injected=\"settings-modal\"] * {\n box-sizing: border-box;\n}\n"; return { THEME_CSS }; })(); @@ -1939,11 +2624,12 @@ return { installStylesheet }; const __src_client_index = (() => { const { installModelPicker } = __src_client_model_picker_index; const { installSettings } = __src_client_settings; +const { installSettingsModal } = __src_client_settings_modal; const { installStylesheet } = __src_client_stylesheet; /** * codex-ui 浏览器半入口(DSH 客户端插件:导出 inject 与 apply,由 scripts/build.mjs 打成 client.js)。 * - * 功能按顺序装配:皮肤样式 → 设置(覆盖层、主题预览、配置卡)→ 模型选择器。 + * 功能按顺序装配:皮肤样式 → 设置(覆盖层、主题预览、配置卡)→ 模型选择器 → 设置模态框结构层。 * 样式先挂且不包 try:它是插件本体;其余任何一项失败都只降级,不连累皮肤。 * 新功能 = src/client/ 下一个模块导出 install(ctx) + 这里一行。 */ @@ -1951,6 +2637,7 @@ const { installStylesheet } = __src_client_stylesheet; + /** * cordis **服务名**(不是包名):loader 逐个等它们就绪,缺一个本插件就不激活。 * 只放必需的;可有可无的服务(modelDirectories)走 ctx.inject 子作用域。 @@ -1970,6 +2657,12 @@ function apply(ctx) { } catch (error) { console.warn('[codex-ui] 模型选择器挂载失败,宿主原生菜单照常:', error); } + /* 设置模态框的结构层(返回按钮 / 搜索 / 分组):与设置表单彼此独立,谁挂不上都不连累对方。 */ + try { + installSettingsModal(ctx); + } catch (error) { + console.warn('[codex-ui] 设置模态框结构层挂载失败,其余功能照常:', error); + } } return { inject, apply }; })(); diff --git a/docs/dom-recon-settings-layout.md b/docs/dom-recon-settings-layout.md new file mode 100644 index 0000000..e44bb0f --- /dev/null +++ b/docs/dom-recon-settings-layout.md @@ -0,0 +1,624 @@ +# 设置界面 DOM 侦察报告(codex-ui 改造基线) + +> 侦察对象:`E:\codex-ui` @ `d590248699a6a2708035af639596ab33cd6caf91`,装在一个一次性验证 profile 上 +> 验证实例:`http://127.0.0.1:3199`,DSH `0.1.7-rc.2`,窗口 1440×960 @ DPR 1.5 +> 侦察方式:Python Playwright(headless Chromium)+ 直接 `Runtime.evaluate` 取证。**所有 selector 与尺寸均为实测值**,推断项单独标注。 + +--- + +## 0. 结论速览(给改造定调用) + +| 判断 | 结论 | 依据 | +|---|---|---| +| 侧栏项渲染方式 | 每项都是 ` +``` + +- 单元格计算样式:`display:flex; gap:8px; padding:9px 16px 9px 12px; border-radius:12px; font-size:14px` +- 选中态:额外加 `VOzbGW_active` 类 + `aria-current="true"` +- **每项都是 2 个直接子节点:`svg` + `span`** —— 这一点对注入很关键(见 §i) + +### ⚠️ 任务书清单与实际不符(必须记录) + +任务书给的 10 项里,**「账号与余额」在本验证实例上不存在**,另有 3 项任务书未列出(已归档会话 / 宠物 / 创意工坊 / 使用统计)。 + +- **「账号与余额」缺的原因(已定位到源码级)**:`@deepseek-ai/dsh-client-ui-settings-account` 只在**已存有 DeepSeek 凭据**时才注册这个 section: + ```js + // lib/client.js:4397-4414 + if (snapshot.view?.status === "credential-stored") ctx.slots.register({ + name: "settings.section", id: "account", order: -10, label: () => t("nav") … + ``` + 未登录时它 `unregister`,所以侧栏里根本没有这一项。**这不是环境缺陷,是设计如此。** +- 其余 3 项(记忆系统 / LLM Verifier / 订阅服务)在我把 `dsh-mnemon` / `dsh-llm-verifier` / `@eddyskywalker/dsh-chatgpt-subscription` 正规装进验证 profile 后**已全部出现**(见 §验收证据)。 +- **宠物**项来自 `dsh-web-all` 的宠物模块,是本次 A2 正规重装 `@linxin666/dsh-web-all` 之后新出现的。 + +> 结论:任务书的 10 项清单应更新为「以实际渲染为准,动态列举」。**这恰好印证了用户那条「不做硬编码白名单、未知项进兜底分组」的硬约束是对的。** + +--- + +## c) 侧栏容器与内容容器的定位方式 + +两者**都不是** `fixed`/`absolute`/`grid`,而是 `display:flex` 的直接子项: + +```css +/* 面板是横向 flex */ +.VOzbGW_panel { display:flex; flex-direction:row; width:800px; height:800px; } +/* 侧栏是纵向 flex,固定 188px */ +.VOzbGW_nav { display:flex; flex-direction:column; width:188px; padding:22px 12px 0; gap:18px; } +/* 内容列纵向 flex,吃掉剩余宽度 */ +.VOzbGW_content{ display:flex; flex-direction:column; flex:1; } +``` + +- 侧栏**没有**背景色(`transparent`),面板底色 `rgb(233,237,247)` 透上来 +- 侧栏**没有**右边框 —— 分隔完全靠空间,**这是 Codex 化改造可以发力的一点** +- `nav` 的 `gap:18px` 是 navTitle 与 navList 之间;navList 内部是 `gap:4px` + +--- + +## d) 遮罩层与关闭按钮结构 + +**遮罩**:`div.VOzbGW_mask[aria-hidden="true"]`,`position:absolute; inset:0`,`background: rgba(28,37,70,0.4)`,无 `backdrop-filter`。 + +**关闭按钮**:位于 `content > header`,**是 header 的第 2 个也是最后一个子节点**: + +```html + +``` + +> **注意**:关闭按钮上的 `data-slot` 是**嵌在 `span.VOzbGW_hiddenLabel` 内部**的,不是按钮本身的属性。写 CSS 时要用 `button:has([data-slot="settings.close"])` 或直接 `.VOzbGW_close`(后者是哈希类名,不推荐)。 + +**header 结构**: + +```html +
+
+
+
+ +
+
+
+ +
+``` + +- `VOzbGW_actions` 实测 `margin-left:458px`(即 `margin-left:auto` 的效果),把操作推到右侧 +- `[data-slot="settings.action"]` 是**唯一稳定的宿主座位锚点**,改造「右上角操作按钮」应挂它 + +--- + +## e) codex-ui 插件在设置模态上已注入的内容 + +**已注入的样式标签**(实测 `document.querySelectorAll('style[data-plugin="codex-ui"]')`): + +| `data-plugin-css` | 长度 | 作用域 | +|---|---|---| +| `codex-ui/theme.css` | 65280 B | `html[data-codex-ui]` | +| `codex-ui/settings-override.css` | 704 B | `html[data-codex-ui][data-codex-ui-theme]` | + +**html 上的属性**(实测):`data-codex-ui`, `data-codex-ui-theme`(有覆盖时), `data-dsh-backdrop-active`, `data-input-modality` + +### 哪些选择器命中了设置模态? + +theme.css 共 143 条选择器。对 9 个模态元素逐一跑 `Element.matches()` 的结果: + +| 元素 | 命中的 codex-ui 规则 | 判定 | +|---|---|---| +| `.VOzbGW_overlay` | `html[data-codex-ui] *`(通配)、`body, body *`(eleveation 令牌覆盖) | **仅继承性命中** | +| `.VOzbGW_mask` | 同上 | **仅继承性命中** | +| `.VOzbGW_panel` | **`html[data-codex-ui] [role="dialog"]`** ← 唯一一条真正的结构样式 | **实命中** | +| `nav.VOzbGW_nav` | 通配 + 令牌 | 仅继承 | +| `.VOzbGW_navList` | 通配 + 令牌 | 仅继承 | +| `button.VOzbGW_navCell` | 通配、令牌、`:focus-visible`、`button, a, summary, [role=button]…`(cursor/过渡) | 仅泛型命中 | +| `.VOzbGW_content` | 通配 + 令牌 | 仅继承 | +| `.VOzbGW_header` | 通配 + 令牌 | 仅继承 | +| `.VOzbGW_options` | 通配 + 令牌 | 仅继承 | + +**结论:codex-ui 目前对设置模态的针对性样式只有一条** —— `skins/codex-ink/patches.css` 里的: + +```css +html[data-codex-ui] [role="dialog"], +html[data-codex-ui] [data-dsh-surface="overlay"] [data-dsh-part="panel"] { + /* 大圆角 + 一点点软影 */ +} +``` + +其余全部是通过 `*` 通配继承的令牌(`--dsw-alias-*`)。**设置界面的 Codex 化基本等于从零开始**,但基础设施(作用域属性、令牌、样式注入通道)已就绪。 + +> 另注:`skins/codex-ink/settings.css` 里的 `.cx-*` 系列选择器**在设置模态上一条都不命中**——它们服务的是官方插件管理页的组合包配置卡(座位 `plugins.bundle.config`),是**另一套 UI**,不要混淆。 + +--- + +## f) 内容区代表页(通用设置)内部结构 + +``` +div.VOzbGW_options +└─ div[data-slot="settings.section"] style="display:contents" + └─ div._WvWnq_section ← 每个设置页的根容器 + ├─ div[data-slot="settings.general.item"] style="display:contents" ← 每一行的座位 + │ └─ div.oY77xG_row ← 行:权限 + │ ├─ div.oY77xG_rowText + │ │ ├─ div.oY77xG_title 「权限」 + │ │ └─ div.oY77xG_desc 「选择新会话的默认权限模式」 + │ └─ span._root_gzo7u_1 → button.oY77xG_selector ← 控件 + ├─ div.hVGvvW_row … (语言) + ├─ div._8HJdBW_group ← **分组**!外观 + │ ├─ div._8HJdBW_title 「外观」 + │ └─ div._8HJdBW_cubeRow + │ └─ button._8HJdBW_themeCube ×3 (浅色/深色/跟随系统) + ├─ div.bVCLcG_row … (字号大小,带 stepper) + ├─ div._2XZxNq_row ×2 (工作步骤展示 / 性能与用量) + ├─ div.Pt1bsG_row … (代码工作工具,带 switch) + ├─ div.nhfO0a_setting …(快捷键) + ├─ div.T1PP_q_row … (繁忙时的发送行为) + └─ div.yIbyla_row … +``` + +**关键的坏消息:行的类名是「一页一套」的 CSS-Modules 哈希前缀**—— +`oY77xG_row` / `hVGvvW_row` / `bVCLcG_row` / `_2XZxNq_row` / `Pt1bsG_row` / `nhfO0a_setting` / `T1PP_q_row` / `yIbyla_row`, +**每行前缀都不同,且是随构建变化的哈希**。绝不能拿它们写 CSS。 + +**可用作 CSS 钩子的稳定结构**: + +| 目标 | 推荐 selector | +|---|---| +| 设置页根容器 | `[data-cx-sm-panel] [data-slot="settings.section"] > *`(自有锚点;宿主锚点见 §a 的「锚点结论」) | +| 通用设置的每一行 | `[data-slot="settings.general.item"] > *` | +| 行内标题 | `[data-slot="settings.general.item"] * > div:first-child`(位置取,无类名) | +| 整页所有行 | 无统一类名 → **只能用属性选择器** `[class$="_row"]` / `[class*="_row_"]`,但这踩 CSS-Modules 哈希纪律,**不推荐** | + +> **这是本次侦察最重要的改造风险**:宿主没有为「设置行」提供语义锚点。若要 Codex 化行样式,可行路径只有两条: +> ① 用 `[data-slot="settings.general.item"]` 给通用页行做样式(覆盖有限,只管一页); +> ② 走 JS 注入层,在运行时给行打上自有的 `data-codex-ui-row` 属性(幂等重放,见 §i)。 +> **(推荐 ②)** + +### 控件类型清单(实测,通用设置页) + +| 控件 | 类名 | tag | 尺寸 | 计算样式 | +|---|---|---|---|---| +| 下拉选择 | `oY77xG_selector` 等 | `button` | 138×36 / 82×36 | `bg:rgb(233,237,247); radius:12px; fs:14px` | +| 主题方块 | `_8HJdBW_themeCube` | `button` | 183×84 | 选中 `border:1px rgb(173,178,184)`;未选 `border:1px rgba(44,58,115,.42)`;`radius:20px` | +| 开关 | `_switch_15ung_5` | `button` | 36×20 | `bg:rgb(74,95,168); radius:999px`,`aria-label="代码工作工具"` | +| 数字步进 | `bVCLcG_stepper` | `div` | 72×36 | `bg:rgb(233,237,247); radius:12px` | +| 步进箭头 | `bVCLcG_arrow` | `button` | 17×12 | `aria-label="增大字号"/"减小字号"` | +| 输入框 | `zGbnIq_input` 等 | `input` | — | 模型页:`type=password`(API 密钥)、`text`(API 地址/模型 ID) | +| 复选框 | (无类名) | `input[type=checkbox]` | — | 皮肤 / 使用统计页 | + +--- + +## g) 侧栏图标:实现方式与注入位置 + +**结论:每一项都已经自带图标,且是内联 SVG。后续「Codex 式侧栏图标」不需要从零注入,只需做样式与替换。** + +实测(以「模型」项为例): + +```json +{ "tag": "svg", "cls": "VOzbGW_navIcon", "width": "16", "height": "16", + "viewBox": "0 0 16 16", "fill": "none", "stroke-width": "1.3", "aria-hidden": "true", + "pathCount": 2, + "computed": { "width": "16px", "height": "16px", "flexShrink": "0", "color": "rgb(29,37,57)" } } +``` + +- **实现方式:内联 ``,描边图标,`stroke="currentColor"`,随文字色变色。** +- **不是** icon font,**不是** sprite/``,**不是** `background-image`。 + (探测脚本里 `iconFontUsed: true` 是误报——它扫到了页面无关样式表里含 "font-family" 的规则;`anyFontIconClass: false` 与 `useCount: 0`、`imgCount: 0` 才是实情。) +- 图标 **16×16**,`flex-shrink:0`,与 `span.VOzbGW_navLabel` 之间 `gap:8px`。 +- 每一项的 `path` 内容各不相同(通用设置=齿轮、模型=层叠、内置插件=滑块…),共 13 个不同图标。 + +**若要替换成 Codex 风格图标,注入位置(实测可行)**: + +```js +// cell = button.VOzbGW_navCell;直接子节点固定为 [svg, span] +cell.querySelector('svg.VOzbGW_navIcon') // ← 替换或改属性都直接命中 +cell.children[0] // ← 位置取,等价且不依赖类名 +cell.children[1] // ← span.VOzbGW_navLabel(文本在文本节点里) +``` + +> `span.VOzbGW_navLabel` 内部是**纯文本节点**,若要在文本前插图标,`insertBefore(node, span.firstChild)` 即可; +> 但鉴于 `svg` 已是第一个子节点,**更稳的做法是替换 `children[0]`**。 + +`span.VOzbGW_navLabel` 计算样式:`font-size:14px; line-height:22px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap` +→ 长文本会省略号截断,**注入图标时要留意 188px 的侧栏宽度余量**(实际可用内容宽 164px)。 + +--- + +## h) 含主操作 / 危险操作的设置页 + +### 危险操作:已归档会话(index 5) + +**标题区**(页面顶部): +```html +会话归档管理 +``` +**注意:这不是 `

`/`

`,是一个 ``。** Codex 风格要的「大标题」在当前 DOM 里不存在层级语义。 + +**右上角操作区**:位于 `content > header > .VOzbGW_actions`,由 `[data-slot="settings.action"]` 提供。 +但**页面级的操作(刷新)并不在 header,而在内容区顶部**:`button.yoSR5W_button`(「刷新」,53×27,`y=134`)。 + +**危险按钮**:`button.yoSR5W_dangerButton`(「批量删除」)——**宿主有专门的 danger 类名**,这是唯一一处语义化的按钮类型标记。 +配套操作按钮:`yoSR5W_button`(批量归档 / 批量取消归档 / 全选当前结果 / 清空选择 / 按当前配置预览)。 + +实测按钮计算样式(普通): + +```json +{ "cls": "yoSR5W_button", "color": "rgb(29,37,57)", "bg": "rgb(243,245,251)", + "borderColor": "rgba(44,58,115,0.22)", "borderWidth": "1px", + "radius": "8px", "fontSize": "13.3333px", "padding": "4px 12px" } +``` + +### 主操作:模型页(index 1) + +```html + + + +``` +**宿主有 `primaryButton` / `secondaryButton` / `addButton` 三种语义按钮类**(同样带哈希前缀)。 + +### 皮肤页(index 9) +`button.TJMolG_buttonPrimary`(「试穿」)为主操作。 + +### 对 Codex 风格改造的影响 + +Codex 要的是「**大标题 + 右上角操作按钮 + 卡片列表**」。实测现状: + +| Codex 结构 | 宿主现状 | 差距 | +|---|---|---| +| 大标题 | 内容区**没有独立标题元素**,页面首行文本就是普通 span | 需新建标题节点或提升样式 | +| 右上角操作按钮 | `[data-slot="settings.action"]` **存在且可用**,但当前只有全局的「打开配置文件」 | 座位可用,但页面级操作不在里面(在 options 内) | +| 卡片列表 | 行是 `[data-slot="settings.general.item"]`(仅通用页)或裸 div | **无统一卡片容器语义** | + +> **改造建议锚点**:`[data-slot="settings.action"]` 做右上角操作;内容区做标题需靠注入(因为宿主没留标题座位)。 + +--- + +## i) React 重渲染对注入节点的影响(实测,非推断) + +**方法**:给 `navList` 挂 `MutationObserver({childList,subtree,attributes})`,给第 1、5 项打 `data-recon-mark` 属性并往第 1 项 `appendChild` 一个 ``,持有全部 cell 的 DOM 引用做身份比对,然后跑三组操作。 + +### 结果 + +| 操作 | 注入属性存活 | 注入子节点存活 | DOM 节点身份 | MutationObserver 观测 | +|---|---|---|---|---| +| 注入后 | ✔ | ✔ | 同一节点 | 3 条(我们的写入) | +| 切到「内置插件」 | ✔ | ✔ | **sameNodeIdx1 = true** | +2 条,**全是 `class` 属性变化** | +| 切到「皮肤」 | ✔ | ✔ | **true** | +2 条 class | +| 切回「通用设置」 | ✔ | ✔ | **true** | +2 条 class | +| **关闭(Esc)→ 重新打开** | ✘ **全部丢失** | ✘ **全部丢失** | **sameNodeIdx0/1 = false** | — | + +**关键观测**:从注入到关闭前,共记录 9 条 mutation,`added=1`(我们自己的)、`removed=0`。 +即**切换设置项时宿主完全没有增删 DOM 节点,只改了 `class`**(选中态 `VOzbGW_active` 在两个 cell 之间来回搬)。 + +**关闭再打开时**:`document.querySelector('.VOzbGW_overlay').parentElement === document.body`, +body 层面观测到 **4 条 childList mutation** —— 整个 overlay 被卸载再重建,**navList 及其全部 navCell 都是新节点**。 + +### 结论(直接决定注入架构) + +1. **一次性注入是不够的。必须做幂等重放。** +2. **重放触发点**:观察 `document.body` 的 `childList` 即可(subtree 非必需)—— + overlay 是 body 的直接子节点,开关一次产生 2 次 childList(移除 + 添加)。 +3. **切页不需要重放**(节点不重建),所以重放逻辑要**幂等**——重复跑不能叠加节点,须先检查标记属性。 +4. **推荐观察容器:`document.body`**(而非 `.VOzbGW_navList`)—— + 因为观察 navList 本身会在它被移除后失效。观察 body 才能在重建后重新发现新的 navList。 + 若要更省,可观察 `#root`;但 body 最稳。 + +**参考实现骨架**: + +```js +const MARK = 'data-codex-ui-nav'; +function replay() { + const list = document.querySelector('[data-cx-sm-panel] nav > div:last-child'); + if (!list) return; + for (const cell of list.children) { + if (cell.hasAttribute(MARK)) continue; // 幂等:已打过就跳过 + cell.setAttribute(MARK, ''); + // …注入图标 / 分组包裹… + } +} +new MutationObserver(replay).observe(document.body, { childList: true, subtree: true }); +replay(); +``` + +### 模拟第三方侧栏项(实测) + +克隆第 6 项(「皮肤」)改文本为「第三方测试项」,`className` 重置为 `VOzbGW_navCell` 后 `appendChild` 到 navList: + +| 观测 | 结果 | +|---|---| +| 节点是否存活 | **✔ 存活**(`stillThere: true`,在 list 内) | +| 点击是否崩溃 | **✘ 未崩溃**,模态保持打开,内容区保持原样 | +| 是否切换选中态 | **✘ 否** —— `activeIdx` 仍是 0(「通用设置」) | +| 是否渲染对应内容 | **✘ 否** —— 内容区仍显示通用设置 | + +**结论**:宿主的导航是**数据驱动**的(`settings.section` 座位注册 + `id`),DOM 里塞一个新按钮**不会**让它成为一个真正的设置页。 + +> 这直接支撑了派发者那条判断:**兼容第三方插件,正确姿势是「不破坏宿主对未知项的处理」,而不是「接管未知项」。** +> 任何试图靠 DOM 克隆/patch 来「补出」第三方设置页的方案都会失败——第三方插件应当通过 +> `ctx.slots.register({ name: 'settings.section', id, order, label }, Component)` 正常注册(宿主契约见 `dsh-client-ui-settings/lib/types/client/contract/slots.d.ts`)。 + +--- + +## j) 分组映射建议的技术可行性注记 + +任务书给的分组:**个人** = 账号与余额 / 通用设置 / 皮肤;**编码** = 模型 / Agent 预设 / LLM Verifier;**集成** = 内置插件 / Web 插件 / 订阅服务 / 记忆系统。 + +### 可行性判定 + +| 维度 | 判定 | 说明 | +|---|---|---| +| 能否按文本识别 | **✔ 可以** | 13 项的 `span.VOzbGW_navLabel` 文本可读,可直接匹配 | +| 能否插入分组标题 | **✔ 可以** | navList 是普通 flex column,`insertBefore` 插标题 div 即可 | +| 能否视觉分组(间隙/线) | **✔ 可以,推荐** | 纯 CSS:给目标 cell 加 `margin-top` / `::before` 分隔线。**零 DOM 结构风险** | +| 能否重排顺序 | **✘ 不建议** | 顺序由宿主 `order` 决定,改 DOM 顺序会与 React 的 key 协调冲突,重渲染时被还原 | +| 分组是否持久 | **✘ 不持久** | 关闭重开就没了,**必须走 §i 的幂等重放** | +| 未列出的项怎么办 | **必须兜底** | 实测有 4 项(已归档会话/宠物/创意工坊/使用统计)**不在任务书任何分组里** | + +### 关键约束(用户的硬性要求:不做硬编码白名单) + +任务书的分组表**覆盖不全**:实测 13 项里有 4 项没有任何归属。 +按「未知项归入兜底分组」的要求,建议分组定义改为: + +``` +个人:账号与余额(条件出现)、通用设置、皮肤、宠物、创意工坊 +编码:模型、Agent 预设、LLM Verifier +集成:内置插件、Web 插件、订阅服务、记忆系统 +兜底:已归档会话、使用统计、以及任何未来新增的未知项 +``` + +> 但注意「宠物」和「创意工坊」归入「个人」是我的**推断**,任务书未指定——**请派发者确认**。 + +### 实施建议排序(按风险从低到高) + +1. **纯 CSS 视觉分组**(加 margin / 分隔线)——零 DOM 风险,但无法加分组标题文字。 +2. **JS 注入分组标题**(插 `div` 到 navList)+ §i 幂等重放——可加文字,中等风险。 +3. **改造宿主注册机制**——超出插件边界(`settings.section` 是宿主契约,插件只能注册自己的页)。 + +--- + +## k) 可复用的 Playwright 操作序列(供后续验证脚本) + +以下序列已在本次侦察中反复跑通(Python Playwright,headless Chromium): + +```python +from playwright.sync_api import sync_playwright +import re + +TOKEN_URL = "http://127.0.0.1:3199/?token=" +ORIGIN = "http://127.0.0.1:3199" + +def dismiss_onboarding(page): + """关掉启动引导(内测声明 / 配置 API Key)。重复点直到没有为止。""" + for _ in range(8): + done = True + for txt in ["稍后配置", "继续", "知道了", "跳过"]: + try: + loc = page.get_by_role("button", name=re.compile("^" + txt + "$")) + if loc.count() > 0 and loc.first.is_visible(): + loc.first.click(); page.wait_for_timeout(900) + done = False; break + except Exception: + pass + if done: + return + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + ctx = browser.new_context(viewport={"width":1440,"height":960}, device_scale_factor=1.5) + page = ctx.new_page() + + # ① 先用 token URL 建会话(建立 dsh-auth cookie),再跳裸 origin + page.goto(TOKEN_URL, wait_until="domcontentloaded"); page.wait_for_timeout(3000) + page.goto(ORIGIN + "/", wait_until="domcontentloaded") + try: page.wait_for_load_state("networkidle", timeout=20000) + except Exception: pass + page.wait_for_timeout(8000) # SPA 启动需要 ~6-8s + dismiss_onboarding(page) + page.wait_for_timeout(800) + + page.screenshot(path="closed.png") # 设置关闭态 + + # ② 打开设置:用 JS click 绕过引导遮罩拦截 + page.evaluate("""() => document.querySelector("button[aria-label='设置']").click()""") + page.wait_for_timeout(3000) + page.screenshot(path="open.png") # 设置打开态 + + # ③ 切换设置项 + def click_nav(i): + page.evaluate("(i) => document.querySelectorAll('.VOzbGW_navList .VOzbGW_navCell')[i].click()", i) + page.wait_for_timeout(1600) + + click_nav(0) # 通用设置 + + # ④ 关闭:Escape 或点关闭按钮 + page.keyboard.press("Escape"); page.wait_for_timeout(1500) + + # ⑤ 内容区特写截图(按面板实际位置裁剪) + clip = page.evaluate("""() => { const d = document.querySelector('[data-cx-sm-panel]'); + const r = d.getBoundingClientRect(); + return { x: Math.round(r.x), y: Math.round(r.y), + width: Math.round(r.width), height: Math.round(r.height) }; }""") + page.screenshot(path="content.png", clip=clip) + + browser.close() +``` + +**关键坑位(都是本次踩过的)**: + +1. **必须用 `page.evaluate` 里的 JS `.click()` 打开设置**——用 Playwright 的 `.click()` 会被引导遮罩 + `div._mask_17i0t_18[aria-hidden="true"]` 拦截(`intercepts pointer events`),30s 超时。 +2. **引导弹层必须用 `get_by_role("button", name=...)` 精确匹配**,不能用 `has_text`——后者会误伤。 +3. **选择器里的引号**:`[data-slot="settings.close"]` 用单引号包会报 + `is not a valid selector`,必须写成 `[data-slot="settings.close"]`(双引号在 JSON 里转义)。 +4. **主题/暗色标记在 `body` 上**(`body[data-ds-dark-theme]`),不在 `html`。 +5. **等 8 秒**再操作——这个 SPA 冷启动比一般应用慢(实测引导弹层之后还要 ~4s 才完全就绪)。 + +--- + +## l) 风险点清单 + +| # | 风险 | 严重度 | 缓解 | +|---|---|---|---| +| 1 | **宿主大量使用 CSS-Modules 哈希类名**(`VOzbGW_*`, `oY77xG_*`, `_8HJdBW_*`…),且**每个设置页前缀都不同** | 🔴 高 | 面板锚点走 §a「锚点结论」的三层(宿主 `data-shortcut-modal` 优先 → 自有 `data-cx-sm-panel` 作 CSS 挂载点 → 第三方 `data-dsh-surface` 仅兜底);行内用 `data-slot="*"` / `role="dialog"` / 位置索引;哈希类名一律不用。codex-ui 现有 `patches.css` 已立此纪律,应继续遵守 | +| 2 | **`settings.navList` 与设置行都没有语义锚点** | 🔴 高 | 靠 JS 注入打自有属性(`data-codex-ui-nav`),并做幂等重放 | +| 3 | **关闭重开整棵 navList 重建** | 🔴 高 | §i 的幂等重放,观察 `document.body` | +| 4 | **设置项数量是动态的**(实测 13 项;账号与余额随登录态出现/消失;插件增删会改列表) | 🟠 中 | 按文本/位置动态匹配,**绝不自名单**;必须兜底分组 | +| 5 | **内容区无统一行容器语义**,改造行样式覆盖面有限 | 🟠 中 | 优先 `[data-slot="settings.general.item"]`(通用页);其余页靠 JS 打标或放弃统一 | +| 6 | **Codex 要的「大标题」在宿主 DOM 里不存在**(页面首行只是普通 `span`) | 🟠 中 | 需 JS 注入标题节点;或只用 CSS 提升首个文本节点样式(易误伤) | +| 7 | **导航是数据驱动的,DOM 克隆项不会路由** | 🟡 低(但认知上重要) | 第三方兼容只做「不破坏」,不做「接管」 | +| 8 | **本次验证实例的 pnpm store 曾损坏**(`E:\.pnpm-store\v11\files\ec`),后经用户修复 | 🟡 低 | 已验证修复:干净 profile 默认 store 安装成功(见验收证据) | +| 9 | 暗色主题下未做侦察(本次全部在亮色下) | 🟡 低 | 若改造涉及暗色,需补一轮 | +| 10 | 未在 Windows 桌面端(Electron)验证 | 🟡 低 | 桌面 profile 由 Electron 独占,本次未触及 | + +--- + +## m) 验收证据附录 + +### 基线构建(`E:\codex-ui`,git `d590248699a6a2708035af639596ab33cd6caf91`) + +``` +$ npm run build +源样式: 61456 B → theme.css 65280 B + skin.css 17781 B, patches.css 21317 B, sidebar-align.css 5918 B, + window-shadow.css 5883 B, composer.css 7629 B, settings.css 2928 B +client.js: 102393 B +OK theme.css / OK client.js +BUILD_EXIT=0 + +$ npm run check +ok Node >= 22 v24.19.0 ok 语法 23 个 JS 文件 +ok theme.css 与源样式一致 65280 B ok client.js 与源样式一致 102393 B +ok theme.css 全部作用域化 65280 B ok 覆盖层:默认值不产生任何 CSS +ok 覆盖层与皮肤对账 37 条 ok 产物里确实带上了设置页与覆盖层 +PASS:全部通过 +CHECK_EXIT=0 +``` + +### 验证实例 + +| 项 | 值 | +|---|---| +| profile 路径 | `E:\codex-ui-verify\profiles\verify`(DSH_HOME=`E:\codex-ui-verify`) | +| 端口 | **3199** | +| 启动命令 | `$env:DSH_HOME='E:\codex-ui-verify'; dsh --profile verify --port 3199 --no-open` | +| 进程 | PID **39688**,`node …/dsh/lib/bin.js --profile verify --port 3199 --no-open` | +| 当前 URL | `http://127.0.0.1:3199/?token=GOso7Xcdg2G-TvTak8FuzyKG7Cwlz-easAr9k9zMmAE` | +| 停止命令 | `Stop-Process -Id 39688 -Force` | +| 插件加载 | **0 条 degraded 警告** | + +### 已装插件(verify profile bundles) +`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@linxin666/dsh-web-all`, `codex-ui`, `@eddyskywalker/dsh-chatgpt-subscription`, `dsh-llm-verifier`, `dsh-mnemon` + +### pnpm store 修复验证 +在干净的一次性 profile 上用**默认 store**(无 `--store-dir` 旁路)安装: +``` +Content-addressable store is at: E:\.pnpm-store\v11 +Done in 3.8s using pnpm v11.21.0 +CLEAN_DEFAULT_STORE_EXIT=0 +``` +→ **E 盘 store 修复确认有效。** + +### 截图(`E:\codex-ui\docs\recon\`) +| 文件 | 内容 | +|---|---| +| `01-settings-closed.png` | 设置关闭态(含侧栏与主界面) | +| `02-settings-open.png` | 设置打开态(侧栏 13 项 + 通用设置内容) | +| `03-general-content.png` | 通用设置内容区特写 | +| `04-injection-probe.png` | React 重渲染注入探针现场 | +| `05-page-4.png` / `05-page-5.png` / `05-page-6.png` | 已归档会话 / Web 插件 / 皮肤 三页 | +| `06-archived-danger.png` | 危险操作页特写 | diff --git a/docs/references/ui-ux-pro-max-quick-reference.md b/docs/references/ui-ux-pro-max-quick-reference.md new file mode 100644 index 0000000..ef90070 --- /dev/null +++ b/docs/references/ui-ux-pro-max-quick-reference.md @@ -0,0 +1,42 @@ +# UI/UX Quick Reference(验收检查清单) + +> 来源:ChenYiming-aaa/dsh-ui-ux-pro-max(references/quick-reference.md,基于 nextlevelbuilder/ui-ux-pro-max-skill)。 +> 用途:设置界面 Codex 化改造的交付前自查与验收基准。与本仓库契约(零硬编码颜色、tokens only)一致。 + +## §1 视觉质量(CRITICAL) +- 禁止 emoji 作结构图标;图标同一家族、统一描边与尺寸令牌 +- 按压态不改变布局边界、不抖动 +- 全程语义令牌,禁止逐屏硬编码 hex +- 图标与文字基线对齐;小元素对比度 ≥4.5:1,大图形 ≥3:1 + +## §2 交互(CRITICAL) +- 可点击元素 80–150ms 内有按压反馈;cursor:pointer +- 微交互 150–300ms,原生缓动;禁止 >500ms 慢动画 +- 禁用态语义清晰(disabled + 视觉弱化) +- ⚠️ 焦点顺序与视觉顺序一致 —— 与本插件 flex order 视觉重排的已知权衡冲突(见下) +- 键盘焦点可见(focus-visible) + +## §3 深色/浅色模式(HIGH) +- 浅色正文对比度 ≥4.5:1;深色正文 ≥4.5:1、次要文字 ≥3:1(独立实测,不推断) +- 发丝分隔线/边框在两种主题下都可见 +- 按压/聚焦/禁用态在深浅色下可区分度一致 + +## §4 布局与间距(HIGH) +- 4/8 间距节奏;纵向层级 16/24/32/48 +- 滚动内容不被固定栏遮挡;窄屏(<900px)不破碎 +- 长文行长 45–75 字符 + +## §5 无障碍(HIGH) +- 颜色不是唯一信息指示方式 +- 支持 reduced-motion +- 角色/状态(selected/disabled/expanded)正确 + +## 与本实现的已知张力(验收时显式处理,不静默放过) +1. **Tab 焦点顺序 ≠ 视觉顺序**:分组与排序用 flex order 实现(DOM 重排会与 React key 调和冲突)。 + 缓解:搜索过滤时隐藏项同时移出 Tab 序(display:none 天然满足);组内顺序与 DOM 一致。 + 残余风险:跨组 Tab 顺序与视觉不一致,属于已接受权衡,PR 描述中需声明。 +2. **不要用毛玻璃背板**(本条 2026-09-28 订正,原文为「毛玻璃用 backdrop-filter 近似」)。 + 本改造的设置面板是**全页铺满**的 overlay,它下面压的是主窗口内容:78% alpha + blur 会把底下的壁纸色 + 揉进设置页的灰,整页偏深偏色且**随用户壁纸变化**(真机实测,见 `skins/codex-ink/settings-modal.css` 头部 + 「取色纪律」与 §1)。铺满全窗的 overlay 从模糊里拿不到任何视觉受益,只赔确定性,所以背板取**不透明** + `--dsw-alias-bg-sidebar` 一条声明、不需要 `@supports` 兜底。毛玻璃只在「居中浮层」形态下才是合理诉求。 diff --git a/scripts/build.mjs b/scripts/build.mjs index 9ab4840..ad40ad9 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -30,6 +30,7 @@ export const SKIN_PARTS = [ 'window-shadow.css', // L3 窗口边缘 'composer.css', // L3 输入区 'settings.css', // L3 插件设置卡 + 'settings-modal.css', // L3 设置模态框 Codex 化层 ]; /** 虚拟模块:客户端代码从这里取内联样式(DSH 只下发 client.js,样式必须在 JS 里)。 */ const THEME_MODULE = 'codex-ui:theme.css'; diff --git a/scripts/check.mjs b/scripts/check.mjs index b428766..b02b992 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -316,6 +316,129 @@ attempt('模型选择器样式只画自建节点,不碰宿主菜单', () => { return selectors.length + ' 组选择器'; }); +/* ── 7b. 设置模态框契约(结构层 + 视觉层 + 装配)─────────────────────────── + 这一层由两个文件组成(结构层 + 视觉层),且靠构建期装配 —— 任一环断了,浏览器里都只表现为 + 「设置界面没变化」,很难从现象反推原因。仓库体检必须在这里就把它抓住。 + 结构层现在是一个普通 ES 模块(src/client/settings-modal.js),由 src/client/index.js 导入 —— + PR #1 原版的占位符拼装(__CODEX_UI_MODAL__)在模块化打包器下不再需要。 */ +const MODAL_FILE = join(ROOT, 'src', 'client', 'settings-modal.js'); +const MODAL_CSS = join(SKIN_DIR, 'settings-modal.css'); + +/** + * 扫一遍 CSS 的每条规则,挑出选择器里带 .cx-sm- 却没挂作用域根的。 + * 逐规则扫而不是正则捞:正则会被注释里的花括号带偏,而作用域漏挂是本层最致命的一类错 + * (漏了前缀 = 规则在宿主默认态下裸奔)。 + * @param css - 已作用域化的样式文本。 + * @returns 越界的选择器片段数组。 + */ +const unscopedCxSm = (css) => { + const bad = []; + let i = 0; + while (i < css.length) { + const open = css.indexOf('{', i); + if (open < 0) break; + const prelude = css.slice(i, open); + let depth = 1; + let j = open + 1; + while (j < css.length && depth > 0) { + if (css[j] === '{') depth += 1; + else if (css[j] === '}') depth -= 1; + j += 1; + } + const body = css.slice(open + 1, j - 1); + const close = prelude.lastIndexOf('*/'); + const selText = close >= 0 ? prelude.slice(close + 2) : prelude; + if (selText.includes('.cx-sm-') && !selText.includes(SCOPE)) bad.push(selText.trim().slice(0, 80)); + if (selText.trim().startsWith('@') && body.includes('{')) bad.push(...unscopedCxSm(body)); + i = j; + } + return bad; +}; + +attempt('设置模态框:两个源文件存在且非空', () => { + for (const [what, p] of [['结构层', MODAL_FILE], ['视觉层', MODAL_CSS]]) { + assert(fs.existsSync(p), what + ' 源文件不存在:' + rel(p)); + assert(fs.statSync(p).size > 0, what + ' 源文件是空的:' + rel(p)); + } + return rel(MODAL_FILE) + ' ' + fs.statSync(MODAL_FILE).size + ' B · ' + + rel(MODAL_CSS) + ' ' + fs.statSync(MODAL_CSS).size + ' B'; +}); +attempt('设置模态框:结构层被装配进 client.js 且已挂到 apply 上', () => { + /* 定义与调用缺一不可:模块拼进去了但没人调用,界面同样不会有任何变化。 */ + assert(built.clientJs.includes('function installSettingsModal('), + 'client.js 里没有 installSettingsModal 的定义 —— src/client/index.js 没导入它?'); + assert(/installSettingsModal\(ctx\)/.test(built.clientJs), + 'client.js 里没有 installSettingsModal(ctx) 的调用 —— 模块装配了却没挂到 apply 上,界面不会变'); + assert(/from '\.\/settings-modal\.js'/.test(read('src', 'client', 'index.js')), + 'src/client/index.js 里没有 settings-modal.js 的 import'); + return '定义 + 调用都在'; +}); +attempt('设置模态框:视觉层进了 theme.css、全部作用域化、隐藏态有属性兜底', () => { + assert(themeCss.includes('.cx-sm-'), 'theme.css 里一条 .cx-sm-* 规则都没有,视觉层等于没进去'); + const unscoped = unscopedCxSm(themeCss); + assert(unscoped.length === 0, + '有 ' + unscoped.length + ' 条 .cx-sm-* 规则没挂作用域根 ' + SCOPE + ':' + unscoped.slice(0, 2).join(' | ')); + /* 隐藏态必须以**属性选择器**为准:宿主在选中态搬家时会把那一项的 className 整条重写, + 类名会被抹掉,只有 data-* 属性留得下来。只写 .cx-sm-hidden 的话, + 筛选态下点一下别的设置项,被隐藏项就会当场复现并永久留在侧栏(集成验证批实测的回归)。 */ + assert(themeCss.includes('[data-cx-sm-hidden]'), + '隐藏态缺少属性选择器 [data-cx-sm-hidden] —— 宿主重写 className 后类名会丢,被隐藏项会当场复现'); + return '隐藏态含属性兜底'; +}); +/* 面板锚点契约:形态规则**只**认插件自有锚点 [data-cx-sm-panel], + 宿主 data-shortcut-modal / 适配器 data-dsh-surface 都只在 JS 的 PANEL_SELECTOR 里出现。 + 两层分开断言,是为了让「视觉层写回宿主/适配器锚点」与「结构层丢掉宿主锚点」这两种退化 + 各自指向明确 —— 前者是这里,后者在下面那条。 */ +attempt('设置模态框:形态规则只挂自有锚点 [data-cx-sm-panel]', () => { + assert(themeCss.includes('[data-cx-sm-panel]'), + 'theme.css 里找不到 [data-cx-sm-panel] —— 视觉层没挂到插件自有锚点上,面板一改名就整层失效'); + /* 只看本层的选择器文本(theme.css 已去注释):patches.css 对 [data-dsh-surface="sidebar"|…] + 的规则是它自己的锚点契约,与设置面板无关。 */ + const legacy = []; + let i = 0; + while (i < themeCss.length) { + const open = themeCss.indexOf('{', i); + if (open < 0) break; + const selText = themeCss.slice(i, open).trim(); + let depth = 1; + let j = open + 1; + while (j < themeCss.length && depth > 0) { + if (themeCss[j] === '{') depth += 1; + else if (themeCss[j] === '}') depth -= 1; + j += 1; + } + if (selText.includes('.cx-sm-') && /data-dsh-surface|data-shortcut-modal/.test(selText)) legacy.push(selText.slice(0, 90)); + i = j; + } + assert(legacy.length === 0, + '设置层里有 ' + legacy.length + ' 条规则还挂在宿主/适配器锚点上(应改挂 [data-cx-sm-panel]):' + legacy.slice(0, 2).join(' | ')); + const scoped = (themeCss.match(/\[data-cx-sm-panel\]/g) ?? []).length; + assert(scoped > 0, '设置层里没有一条规则挂 [data-cx-sm-panel]'); + return '自有锚点 ' + scoped + ' 处 + 0 条回退锚点规则'; +}); +attempt('设置模态框:结构层的面板选择器 = 宿主锚点优先、适配器兜底', () => { + const src = read('src', 'client', 'settings-modal.js'); + const m = /const PANEL_SELECTOR = '([^']+)'/.exec(src); + assert(m !== null, 'settings-modal.js 里找不到 PANEL_SELECTOR'); + assert(m[1] === '[data-shortcut-modal="settings"], [data-dsh-surface="settings"]', + 'PANEL_SELECTOR 变了(当前 ' + m[1] + ')—— data-shortcut-modal 是宿主自己的属性,必须排在前;' + + 'data-dsh-surface 是第三方 adapter 补打的兜底,不能升为首选'); + assert(/setAttribute\(panel, PANEL_ATTR, ''\)/.test(src) || /setAttr\(panel, PANEL_ATTR, ''\)/.test(src), + 'findPanel() 没有盖印自有锚点 PANEL_ATTR(data-cx-sm-panel)—— 视觉层的全部形态规则都挂它,盖不上就整层失效'); + assert(src.includes('const PANEL_ATTR'), '找不到 PANEL_ATTR 声明'); + /* 面板锚点失配必须**显式报警**,不能静默失效:设置界面确实开着 + (settings.section 槽在 DOM 里)却一个锚点都没匹配到时,整层注入会无声跳过。 */ + assert(src.includes('[data-slot="settings.section"]'), + 'findPanel() 里找不到「设置界面开着」的判据 —— 锚点全失配时会静默失效,不发警告'); + return m[1]; +}); +attempt('设置模态框:产物里不残留任何构建占位符', () => { + const left = ['__CODEX_UI_CSS__', '__CODEX_UI_OVERRIDE__', '__CODEX_UI_SETTINGS__', '__CODEX_UI_MODAL__'] + .filter((ph) => built.clientJs.includes(ph)); + assert(left.length === 0, 'client.js 里残留占位符 ' + left.join(' ') + '(构建没跑,或拼装漏了一环)'); + return '0 个残留占位符(模块化打包器下不再需要模板占位符)'; +}); + /* ── 8. 皮肤配色:WCAG 对比度与彩色白名单 ──────────────────────────────── */ /** 颜色解析:#rgb / #rrggbb / #rrggbbaa / rgb() / rgba()。 */ function parseColor(input) { diff --git a/scripts/live/settings-modal.mjs b/scripts/live/settings-modal.mjs new file mode 100644 index 0000000..05109e8 --- /dev/null +++ b/scripts/live/settings-modal.mjs @@ -0,0 +1,1092 @@ +#!/usr/bin/env node +/** + * settings-modal-verify.mjs — 设置模态框「结构层 + 视觉层」的真 GUI 验收。 + * + * 覆盖两件交付物的集成结果: + * · src/settings-modal.js 结构层(返回按钮 / 搜索框 / 分组标题 / 排序 / 过滤) + * · skins/codex-ink/settings-modal.css 视觉层(注入节点的样式是否真的命中) + * + * 为什么必须真 GUI:分组与过滤改的是宿主 navList 的真实子节点,而宿主的 React 重渲染行为 + * (关闭重开整棵重建、选中态搬家时整条重写 className)只有在真宿主里才成立,夹具复刻不出来。 + * 夹具能测纯函数,测不了这一层。 + * + * 用法: + * 1) DSH_HOME=<临时家> dsh --profile

--port 3199 --no-open (终端打印带 token 的 URL) + * 2) node scripts/live/settings-modal.mjs --url "http://127.0.0.1:3199/?token=..." + * + * 可选: + * --out

截图目录(默认 docs/recon) + * --cdp-port CDP 调试端口(默认 9343) + * --dpr 设备像素比(默认 1.5) + * --explore 只把现场结构打出来,不做断言 + * + * 幂等:脚本自己收尾(清搜索框、摘暗色、关模态),反复跑结果一致。 + * 退出码:非 0 = 有断言未通过。 + */ +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 { tmpdir } from 'node:os'; +import { chromePath } from '../lib/host.mjs'; +/* lib/host.mjs 不导出 tempDir:这里就地生成一个唯一临时 profile 目录。 */ +const tempDir = (name) => fs.mkdtempSync(join(tmpdir(), name + '-')); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const WB = dirname(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_DIR = arg('out', join(WB, 'docs', 'recon')); +const CHROME = arg('chrome', '') || chromePath(); +const PORT = Number(arg('cdp-port', '9343')); +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); } + +/* ── 契约快照:分组映射表 ───────────────────────────────────────────────── + 这是 src/settings-modal.js 里 GROUPS 的期望值。下面有一道「与源文件对账」的前置检查: + 源文件改了映射而这里没跟着改,脚本会**先报错**而不是拿旧快照去测新实现。 */ +const EXPECT_GROUPS = [ + { id: 'personal', label: '个人', items: ['账号与余额', '通用设置', '皮肤', '宠物', '使用统计'] }, + { id: 'integrations', label: '集成', items: ['内置插件', 'Web 插件', '订阅服务', '记忆系统', '创意工坊'] }, + { id: 'coding', label: '编码', items: ['模型', 'Agent 预设', 'LLM Verifier'] }, + { id: 'archive', label: '已归档', items: ['已归档会话'] }, +]; +/* 环境相关缺位按可选处理:「账号与余额」只在已存凭据时才注册;「皮肤」是第三方 skin-center + 的设置页,没装/禁用该适配器的环境里整个条目不存在(无适配器负向验证的实测)。 */ +const OPTIONAL_ITEMS = new Set(['账号与余额', '皮肤']); + +{ + const src = fs.readFileSync(join(WB, 'src', 'settings-modal.js'), 'utf8'); + const block = /const GROUPS = \[([\s\S]*?)\n\];/.exec(src); + if (block === null) { + console.error('前置检查失败:src/settings-modal.js 里找不到 GROUPS 字面量,契约快照无法对账'); + process.exit(2); + } + const actual = [...block[1].matchAll(/\{ id: '([a-z]+)', label: '([^']+)', items: \[([^\]]*)\] \}/g)] + .map((m) => ({ id: m[1], label: m[2], items: m[3].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter(Boolean) })); + if (JSON.stringify(actual) !== JSON.stringify(EXPECT_GROUPS)) { + console.error('前置检查失败:脚本里的 EXPECT_GROUPS 与 src/settings-modal.js 的 GROUPS 已经不一致。'); + console.error(' 源文件:' + JSON.stringify(actual)); + console.error(' 脚本内:' + JSON.stringify(EXPECT_GROUPS)); + console.error(' 改映射是行为变更 —— 请同步更新本脚本顶部的 EXPECT_GROUPS。'); + 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); } + +fs.mkdirSync(OUT_DIR, { recursive: true }); + +const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + PORT, + '--user-data-dir=' + tempDir('dsh-cdp-settings-modal'), '--no-first-run', + '--no-default-browser-check', '--disable-gpu', '--window-size=1440,960', '--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(); +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 = {}, sid) => 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, ...(sid ? { sessionId: sid } : {}) })); +}); +const evaluate = async (expression) => { + const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId); + if (r.exceptionDetails !== undefined && r.exceptionDetails !== null) { + throw new Error('页面内求值抛错:' + String(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text).slice(0, 400)); + } + return r.result.value; +}; +const click = async (x, y) => { + 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: 1440, height: 960, 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); + +/* ── 页面内的取值 API ──────────────────────────────────────────────────── + 整段作为**一个自包含闭包**注进页面,挂到 window.__cxs 上。 + 必须自包含:这些函数在浏览器里跑,引用不到本文件的任何作用域。 + selector 一律走稳定锚点 —— **宿主优先**:data-shortcut-modal="settings"(宿主自己盖在面板上的 + 属性)、data-slot / aria-current / role / tag / 位置索引;适配器的 data-dsh-surface="settings" + 只作兜底(第三方 skin-center 补打,契约自称非永久公共契约,不是唯一锚点、也不是首选)。 + 面板定位的**唯一权威来源**是 src/settings-modal.js 的 PANEL_SELECTOR,本文件不重复实现它, + 只断言它盖出来的自有锚点 [data-cx-sm-panel] 确实在。 + 不碰宿主哈希类名(CSS-Modules,每次构建都变)。 */ + + +function cxsInstall() { + /** 侧栏列表容器:nav 里最后一个「不是我们注入的」子节点。 */ + const list = () => { + const panel = document.querySelector('[data-shortcut-modal="settings"]'); + if (panel === null) return null; + const nav = Array.from(panel.children).find((el) => el.tagName === 'NAV'); + if (nav === undefined) return null; + const hit = Array.from(nav.children) + .filter((el) => el.getAttribute('data-codex-ui-injected') !== 'settings-modal').pop(); + return hit === undefined ? null : hit; + }; + /** + * 一个宿主项的「真实可见性」——读 **computed display**,不是读属性也不是读类名。 + * 只认属性的断言会漏掉「类名被宿主抹掉、属性还在但没人认」这一类故障(C1 实测踩过)。 + */ + const visible = (el) => getComputedStyle(el).display !== 'none'; + /** 视觉顺序:inline order 优先,没有就取 computed。 */ + const order = (el) => (el.style.order !== '' ? Number(el.style.order) : Number(getComputedStyle(el).order)); + const hostCells = (l) => Array.from(l.children).filter((el) => el.tagName === 'BUTTON'); + const byText = (l, text) => hostCells(l).find((el) => el.textContent.trim() === text) || null; + + const snapshot = () => { + const l = list(); + if (l === null) return null; + const nav = l.parentElement; + const items = Array.from(l.children).map((el, i) => ({ + i: i, + tag: el.tagName, + part: el.getAttribute('data-cx-sm-part'), + group: el.getAttribute('data-cx-sm-group'), + text: (el.textContent || '').trim(), + order: order(el), + injected: el.getAttribute('data-codex-ui-injected') === 'settings-modal', + hidden: !visible(el), + display: getComputedStyle(el).display, + hasHiddenAttr: el.hasAttribute('data-cx-sm-hidden'), + hasHiddenClass: el.classList.contains('cx-sm-hidden'), + })); + const flow = items.slice().sort((a, b) => a.order - b.order || a.i - b.i); + const back = nav.querySelector('[data-cx-sm-part="back"]'); + const input = nav.querySelector('[data-cx-sm-part="search-input"]'); + return { + items: items, + flow: flow, + hostItems: items.filter((r) => !r.injected && r.tag === 'BUTTON'), + headers: items.filter((r) => r.injected && String(r.part).indexOf('group:') === 0), + hasBack: back !== null, + backText: back === null ? null : back.textContent.trim(), + backAria: back === null ? null : back.getAttribute('aria-label'), + backIndex: Array.from(nav.children).findIndex((el) => el.getAttribute('data-cx-sm-part') === 'back'), + searchIndex: Array.from(nav.children).findIndex((el) => el.getAttribute('data-cx-sm-part') === 'search'), + titleIndex: Array.from(nav.children).findIndex((el) => el.tagName === 'DIV' + && el.getAttribute('data-codex-ui-injected') !== 'settings-modal' && el.querySelector('button') === null), + placeholder: input === null ? null : input.placeholder, + /* 「属性说该隐藏、但实际渲染出来了」= 隐藏态失效,单独点名。 */ + leaked: items.filter((r) => r.hasHiddenAttr && r.display !== 'none').map((r) => r.text), + /* 每个注入件在整份文档里的出现次数(重复 / 孤儿都靠它抓)。 */ + uniq: Array.from(document.querySelectorAll('[data-cx-sm-part]')).reduce((acc, el) => { + const k = el.getAttribute('data-cx-sm-part'); + acc[k] = (acc[k] || 0) + 1; + return acc; + }, {}), + injectedCount: document.querySelectorAll('[data-codex-ui-injected="settings-modal"]').length, + activeLabel: (() => { + const el = hostCells(l).find((c) => c.getAttribute('aria-current') === 'true'); + return el === undefined ? null : el.textContent.trim(); + })(), + }; + }; + + /** 注入节点的样式命中(视觉层的真凭据,不是读 CSS 源码)。 */ + const styleHits = () => { + const pick = (sel, props) => { + const el = document.querySelector(sel); + if (el === null) return { present: false }; + const cs = getComputedStyle(el); + const out = { present: true }; + for (const p of props) out[p] = cs[p]; + return out; + }; + return { + back: pick('[data-cx-sm-part="back"]', ['display', 'fontSize', 'cursor', 'color']), + group: pick('[data-cx-sm-part^="group:"]', ['display', 'fontSize', 'color']), + search: pick('[data-cx-sm-part="search"]', ['display', 'height', 'borderTopLeftRadius', 'paddingLeft']), + searchInput: pick('[data-cx-sm-part="search-input"]', ['fontSize', 'height']), + hasAttrSelector: Array.from(document.querySelectorAll('style[data-plugin="codex-ui"]')) + .map((s) => s.textContent).join('').indexOf('[data-cx-sm-hidden]') >= 0, + }; + }; + + /** + * 内容列里页头的形态。 + * 注入形态(B2 契约):header 行 > 最左侧 div.cx-sm-pagehead[data-cx-sm-part="pagehead"] + * > div.cx-sm-title[data-cx-sm-part="pagehead-title"] + * 判据同样读**计算样式**:标题必须真的比正文大,否则「注入了但没有视觉」也算不合格。 + */ + const pagehead = () => { + const panel = document.querySelector('[data-shortcut-modal="settings"]'); + if (panel === null) return null; + const content = Array.from(panel.children).filter((e) => e.tagName !== 'NAV').pop() || null; + if (content === null) return null; + const head = document.querySelector('[data-cx-sm-part="pagehead"]'); + const title = document.querySelector('[data-cx-sm-part="pagehead-title"]'); + if (head === null || title === null) return { present: false, headCount: document.querySelectorAll('[data-cx-sm-part="pagehead"]').length }; + const parent = head.parentElement; + const hs = getComputedStyle(title); + const rowCs = getComputedStyle(parent); + const firstInRow = parent.firstElementChild === head; + const rowIsHeader = parent.querySelector('[data-slot="settings.action"]') !== null; + /* 内容列里所有非注入子节点的首行文本 —— 用来证明「页头没有把宿主原有结构顶掉」。 */ + return { + present: true, + headCount: document.querySelectorAll('[data-cx-sm-part="pagehead"]').length, + titleCount: document.querySelectorAll('[data-cx-sm-part="pagehead-title"]').length, + text: title.textContent.trim(), + fontSize: hs.fontSize, + fontWeight: hs.fontWeight, + display: hs.display, + parentIsDirectChildOfContent: parent.parentElement === content, + parentHasActionSeat: rowIsHeader, + firstInRow: firstInRow, + rowIsFlex: rowCs.display === 'flex', + headClass: head.className, + titleClass: title.className, + tag: title.tagName, + }; + }; + + /** 某个宿主项的屏幕中心点(点它用真实鼠标事件,不用 el.click())。 */ + const itemBox = (text) => { + const l = list(); + if (l === null) return null; + const el = byText(l, text); + if (el === null) return null; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; + }; + + /** 设置搜索框的值,并派发真实 input 事件(等价于用户输入)。 */ + const setQuery = (value) => { + const el = document.querySelector('[data-cx-sm-part="search-input"]'); + if (el === null) return false; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + return true; + }; + + /** 往列表里塞一个「第三方」项(结构克隆自真实宿主项),返回是否成功。 */ + const injectUnknown = (text) => { + const l = list(); + if (l === null) return false; + const proto = hostCells(l)[0]; + if (proto === undefined) return false; + const clone = proto.cloneNode(true); + const span = clone.querySelector('span'); + if (span !== null) span.textContent = text; + clone.removeAttribute('aria-current'); + l.appendChild(clone); + return true; + }; + const removeUnknown = (text) => { + const l = list(); + if (l === null) return false; + const el = byText(l, text); + if (el === null) return false; + el.remove(); + return true; + }; + + /** + * 行内文字层级:标题必须仍是 label-primary/14px,说明才是 caption 灰。 + * + * 这个探针是为一个**真实回归**补的:早先 §4.4 写成「行内所有 div」, + * 把标题一起染成 caption 灰并压到 12px,整页标题层级塌掉,而当时的 69 项断言 + * 没有任何一条覆盖标题层级 —— 全绿也没拦住。所以这里同时取 + * 「令牌基准值」(把 var() 解析成具体色)与实际计算值对比, + * 而不是硬编码 rgb,主题切换后依然成立。 + */ + const rowType = () => { + const proxy = document.createElement('div'); + proxy.style.cssText = 'position:absolute;left:-9999px'; + document.body.appendChild(proxy); + const val = (n) => { proxy.style.color = 'rgb(1, 2, 3)'; proxy.style.color = 'var(' + n + ')'; return getComputedStyle(proxy).color; }; + const primary = val('--dsw-alias-label-primary'); + const caption = val('--dsw-alias-label-caption'); + proxy.remove(); + const rows = [...document.querySelectorAll('[data-slot="settings.general.item"] > *')]; + const titles = []; + const descs = []; + const stray = []; + for (const row of rows) { + const textEl = [...row.children].find((c) => [...c.children].some((k) => k.tagName === 'DIV')); + if (textEl === undefined) continue; + const divs = [...textEl.children].filter((k) => k.tagName === 'DIV'); + if (divs[0] !== undefined) { + const cs = getComputedStyle(divs[0]); + titles.push({ text: divs[0].textContent.trim().slice(0, 10), fs: cs.fontSize, color: cs.color }); + } + if (divs[1] !== undefined) { + const cs = getComputedStyle(divs[1]); + descs.push({ text: divs[1].textContent.trim().slice(0, 10), fs: cs.fontSize, color: cs.color }); + } + } + /* 控件区的纯数字(如字号步进的 14)不该被文字层级规则顺手染灰压小 */ + for (const el of document.querySelectorAll('[data-slot="settings.general.item"] *')) { + if (el.children.length === 0 && /^\d+$/.test((el.textContent || '').trim())) { + const cs = getComputedStyle(el); + stray.push({ text: el.textContent.trim(), fs: cs.fontSize, color: cs.color }); + } + } + return { primary, caption, titleCount: titles.length, descCount: descs.length, + titles, descs, stray }; + }; + + /** 打开设置:点宿主那个 aria-label=设置 的按钮。 */ + const openSettings = () => { + const b = document.querySelector('button[aria-label="设置"]'); + if (b === null) return false; + b.click(); + return true; + }; + /** + * 面板的**自有锚点**盖印状态(本轮锚点迁移的集成证据)。 + * data-cx-sm-panel 是 src/settings-modal.js 的 findPanel() 找到面板后盖的, + * 视觉层全部形态规则都挂在它下面 —— 它没盖上的话,整份 settings-modal.css 不命中任何元素。 + */ + const panelStamp = () => { + const panels = [...document.querySelectorAll('[data-cx-sm-panel]')]; + const host = [...document.querySelectorAll('[data-shortcut-modal="settings"], [data-dsh-surface="settings"]')]; + return { + count: panels.length, + attr: panels.length > 0 ? panels[0].getAttribute('data-cx-sm-panel') : null, + hostCount: host.length, + sameNode: panels.length > 0 && host.length > 0 && panels[0] === host[0], + shortcutModal: panels.length > 0 ? panels[0].getAttribute('data-shortcut-modal') : null, + surface: panels.length > 0 ? panels[0].getAttribute('data-dsh-surface') : null, + }; + }; + + /** + * 令牌的**计算值**(临时元素法,同 rowType 那条的老办法):挂一个探针在 body 上, + * 用 color 读 var(--token) 的解析结果 —— 不写死 rgb,换主题/换皮肤都不假失败。 + * 为什么用 color 读:自定义属性不能自引用(在探针上把 --x 设成 var(--x) 会变成无效值), + * 但把**别的**属性设成 var(--x) 是安全的,读回来的就是该令牌在当前上下文里的真实取值。 + */ + const tokenValue = (name) => { + const el = document.createElement('div'); + el.style.cssText = 'position:absolute;left:-9999px'; + document.body.appendChild(el); + const read = (n) => { + el.style.color = 'var(' + n + ')'; + const v = getComputedStyle(el).color; + el.style.removeProperty('color'); + return v; + }; + const out = { layer1: read('--dsw-alias-bg-layer-1'), layer2: read('--dsw-alias-bg-layer-2') }; + out[name] = read(name); + el.remove(); + return out; + }; + + /** + * 设置页里的行卡片(「模型」页 rowCard / 「Agent 预设」页 card)的着色状态。 + * 这两页**没有**通用语义锚点,宿主类名是哈希,所以取元素按类令牌(_rowCard / _card 后缀令牌)匹配 + * —— 注意:settings-modal.css §4.3 那条同名规则**已删除**(见该处删除记录), + * 这里的选择器只用来**读**计算样式,不用来写样式。 + * 选中判定:aria-selected / aria-checked,或 class 里带 Active / Selected / Checked + * (实测 Agent 预设页的选中卡在 class 里带一个 *_cardActive 状态类; + * 宿主哈希前缀不写进本文件 —— 它每次构建都变,写下来只会误导后人)。 + */ + const cardState = (token) => { + /* 类**令牌**匹配,不是 [class$=…] 后缀匹配:选中卡的 class 是 "_card _cardActive", + 以 _cardActive 结尾 —— 后缀匹配会把它整条漏掉(实测:4 张卡只命中 3+1 张未选中的, + selected=null 假失败)。这正是 settings-modal.css §4.3 删除记录里那条漂移陷阱, + 读样式时同样会咬人。token 传 '_rowCard' / '_card'。 */ + const re = new RegExp('(^|\\s)[\\w-]+' + token + '(?=\\s|$)'); + const cards = [...document.querySelectorAll('li')].filter((el) => typeof el.className === 'string' && re.test(el.className)); + const isSel = (el) => el.matches('[aria-selected="true"], [aria-checked="true"], [aria-current="true"]') + || /(Active|Selected|Checked)/.test(el.getAttribute('class') || ''); + const info = (el) => ({ + cls: String(el.getAttribute('class') || '').slice(0, 70), + bg: getComputedStyle(el).backgroundColor, + selected: isSel(el), + }); + const visible = cards.filter((el) => el.getBoundingClientRect().width > 0); + const pick = visible.find((el) => !isSel(el)) || null; + const selected = visible.find((el) => isSel(el)) || null; + return { total: cards.length, visible: visible.length, + pick: pick === null ? null : info(pick), + selected: selected === null ? null : info(selected), + all: visible.map(info) }; + }; + + const panelCount = () => document.querySelectorAll('[data-shortcut-modal="settings"]').length; + const orphanCount = () => document.querySelectorAll('[data-codex-ui-injected="settings-modal"]').length; + const setDark = (on) => { + if (on) document.body.setAttribute('data-ds-dark-theme', ''); + else document.body.removeAttribute('data-ds-dark-theme'); + }; + window.__cxs = { + list: list, visible: visible, snapshot: snapshot, styleHits: styleHits, itemBox: itemBox, + pagehead: pagehead, + setQuery: setQuery, injectUnknown: injectUnknown, removeUnknown: removeUnknown, + openSettings: openSettings, panelCount: panelCount, rowType: rowType, orphanCount: orphanCount, setDark: setDark, + panelStamp: panelStamp, tokenValue: tokenValue, cardState: cardState, + }; +} +await evaluate('(' + cxsInstall.toString() + ')()'); + +/** 引导弹层:内测声明 / 配置 API Key,重复点直到没有。 */ +const dismissOnboarding = async () => { + for (let round = 0; round < 8; round += 1) { + const box = await evaluate('(() => {' + + 'const re = /^(继续|稍后配置|跳过|知道了)$/;' + + 'const el = [...document.querySelectorAll("button")].find((e) =>' + + ' re.test((e.getAttribute("aria-label") || 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) }; })()'); + if (box === null) return; + await click(box.x, box.y); + await sleep(1200); + } +}; + +const openSettings = async () => { await evaluate('window.__cxs.openSettings()'); await sleep(2200); }; +const clickItem = async (label) => { + const box = await evaluate('window.__cxs.itemBox(' + JSON.stringify(label) + ')'); + if (box === null) return null; + await click(box.x, box.y); + await sleep(1500); + return box; +}; +const setQuery = async (value) => { await evaluate('window.__cxs.setQuery(' + JSON.stringify(value) + ')'); await sleep(900); }; +const screenshot = async (file) => { + const s = await send('Page.captureScreenshot', { format: 'png' }, sessionId); + fs.writeFileSync(join(OUT_DIR, file), Buffer.from(s.data, 'base64')); + return file; +}; + +/* ── 断言基建 ─────────────────────────────────────────────────────────── */ +let failed = 0; +let passed = 0; +const ok = (name, extra = '') => { passed += 1; 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 checkEqual = (name, got, want) => { + const a = JSON.stringify(got); + const b = JSON.stringify(want); + check(name, a === b, a === b ? '' : 'got = ' + a + '\n want = ' + b); +}; + +await dismissOnboarding(); +await sleep(800); +await openSettings(); +await sleep(1500); + +const snap0 = await evaluate('window.__cxs.snapshot()'); +if (snap0 === null || snap0 === undefined) { + console.error('FAIL 设置面板或侧栏列表没找到:结构层可能整体没生效(看下面 console 里的 [codex-ui] warn)'); + console.log('\nPAGE LOGS:\n ' + pageLogs.slice(0, 15).join('\n ')); + ws.close(); child.kill(); + process.exit(1); +} + +/** 期望的可见视觉流:按分组映射表展开,再挖掉本环境缺位的可选项。 */ +const EXPECT_FLOW = EXPECT_GROUPS.flatMap((g) => [[g.id, g.label]].concat(g.items.map((t) => [g.id, t]))) + .filter((row) => !OPTIONAL_ITEMS.has(row[1]) || snap0.hostItems.some((r) => r.text === row[1])); + +if (EXPLORE) { + console.log('SNAPSHOT: ' + JSON.stringify(snap0, null, 1)); + console.log('STYLE HITS: ' + JSON.stringify(await evaluate('window.__cxs.styleHits()'), null, 1)); + await screenshot('c1-explore.png'); + ws.close(); child.kill(); + process.exit(0); +} + +const flowOf = (snap) => snap.flow.filter((r) => !r.hidden).map((r) => [r.group, r.text]); +const headerFlowOf = (snap) => snap.flow + .filter((r) => r.injected && String(r.part).indexOf('group:') === 0 && !r.hidden).map((r) => r.text); + +/* ── 1. 侧栏骨架 ───────────────────────────────────────────────────────── */ +console.log('\n── 1 侧栏骨架 ──'); +console.log(' 宿主项 ' + snap0.hostItems.length + ' 个,注入件 ' + snap0.injectedCount + ' 个'); +check('返回按钮存在、文案与无障碍名正确', + snap0.hasBack && snap0.backText.indexOf('返回应用') >= 0 && snap0.backAria === '返回应用', + 'text=' + snap0.backText + ' aria=' + snap0.backAria); +check('顺序:返回在最上 → 搜索框 → 宿主标题 → 列表', + snap0.backIndex === 0 && snap0.searchIndex === 1 && snap0.titleIndex === 2, + 'back=' + snap0.backIndex + ' search=' + snap0.searchIndex + ' title=' + snap0.titleIndex); +check('搜索框占位符正确', snap0.placeholder === '搜索设置...', 'placeholder=' + snap0.placeholder); +checkEqual('分组标题按 个人/集成/编码/已归档 顺序出现', headerFlowOf(snap0), ['个人', '集成', '编码', '已归档']); + +/* ── 2. 分组内项序与映射表一致 ─────────────────────────────────────────── */ +console.log('\n── 2 分组与项序 ──'); +const gotFlow = flowOf(snap0); +if (JSON.stringify(gotFlow) !== JSON.stringify(EXPECT_FLOW)) { + /* 只报第一处不一致,否则一次错位会把后面全刷成「错」。 */ + const at = gotFlow.findIndex((row, i) => JSON.stringify(row) !== JSON.stringify(EXPECT_FLOW[i])); + console.error(' 第 ' + at + ' 项起不一致:got=' + JSON.stringify(gotFlow.slice(at, at + 3)) + + ' want=' + JSON.stringify(EXPECT_FLOW.slice(at, at + 3))); +} +checkEqual('可见视觉流与分组映射表逐项一致', gotFlow, EXPECT_FLOW); + +const personalItems = gotFlow.filter((r) => r[0] === 'personal' && r[1] !== '个人').map((r) => r[1]); +const hasAccount = snap0.hostItems.some((r) => r.text === '账号与余额'); +check('「账号与余额」缺位时,个人组其余项正常、组标题仍在', + personalItems.length >= 2 && headerFlowOf(snap0)[0] === '个人', + 'hasAccount=' + hasAccount + ' 个人组=' + JSON.stringify(personalItems)); +console.log(' (本实例「账号与余额」' + (hasAccount ? '存在' : '缺位') + ';两种都算通过,验的是组内其余项不受影响)'); + +/* ── 3. 视觉层:注入节点的样式真的命中 ─────────────────────────────────── */ +console.log('\n── 3 视觉层命中 ──'); +const hits = await evaluate('window.__cxs.styleHits()'); +check('返回按钮有样式命中(flex + 13px + pointer)', + hits.back.present && hits.back.display === 'flex' && hits.back.fontSize === '13px' && hits.back.cursor === 'pointer', + JSON.stringify(hits.back)); +check('分组标题有样式命中(12px 小字)', hits.group.present && hits.group.fontSize === '12px', JSON.stringify(hits.group)); +/* 打磨轮(web 端整体密度下调一号)把搜索框从 34px 压到 32px, + 形态判据(flex + 灰填充 + 圆角)不变,只更新高度期望值。 */ +check('搜索框有样式命中(flex + 32px 高)', + hits.search.present && hits.search.display === 'flex' && hits.search.height === '32px', JSON.stringify(hits.search)); +check('搜索输入框有样式命中(13px)', hits.searchInput.present && hits.searchInput.fontSize === '13px', JSON.stringify(hits.searchInput)); +/* 搜索框 = 全圆角胶囊(用户指名)。 + 注意:getComputedStyle 返回的是**声明值的解析结果**,浏览器不会在这里把 999px + 夹到半高(夹取只发生在绘制时)—— 所以期望值就是 999px,不是 16px。 + 与「方形井」的 10px 档必须分得开,否则胶囊退化成圆角矩形也看不出来。 */ +check('搜索框是全圆角胶囊(计算半径 999px,非 10px 的方形井档)', + hits.search.borderTopLeftRadius === '999px' && hits.search.borderTopLeftRadius !== '10px', + JSON.stringify({ radius: hits.search.borderTopLeftRadius, height: hits.search.height })); +check('胶囊两侧内衬加宽(14px,图标与文字不贴弧)', + hits.search.paddingLeft === '14px', 'paddingLeft=' + hits.search.paddingLeft); + + +/* ── 3b. 页头大标题(B2 注入 · 契约:.cx-sm-pagehead > .cx-sm-title)──────── */ +console.log('\n── 3b 页头大标题 ──'); +const ph0 = await evaluate('window.__cxs.pagehead()'); +check('页头存在且唯一(未重复注入)', ph0.present && ph0.headCount === 1 && ph0.titleCount === 1, + JSON.stringify(ph0)); +check('页头挂在内容列的直接子节点(header 行)上', ph0.parentIsDirectChildOfContent === true, JSON.stringify(ph0)); +check('页头在最左、与右上角操作座位同一行(Codex 的「大标题 + 页级操作」形态)', + ph0.firstInRow === true && ph0.parentHasActionSeat === true, + 'firstInRow=' + ph0.firstInRow + ' hasActionSeat=' + ph0.parentHasActionSeat); +check('标题文本 = 当前激活项的 label', ph0.text === snap0.activeLabel, + 'title=' + JSON.stringify(ph0.text) + ' active=' + JSON.stringify(snap0.activeLabel)); +check('标题真的有视觉(字号明显大于正文 13px)', + parseFloat(ph0.fontSize) >= 20, 'fontSize=' + ph0.fontSize + ' weight=' + ph0.fontWeight); +check('页头类名与视觉层契约一致(.cx-sm-pagehead / .cx-sm-title,视觉层才有命中)', + ph0.headClass === 'cx-sm-pagehead' && ph0.titleClass === 'cx-sm-title', + ph0.headClass + ' / ' + ph0.titleClass); + +/* ── 3c. 打磨轮新增形态:桌面标题栏让位 / 内容居中 / 灰白层次与合并卡 ── */ +console.log('\n── 3c 打磨轮形态(桌面标题栏 · 居中 · 层次)──'); + +/* 桌面端(Windows 无边框)宿主会在 打 data-windows-titlebar 并内联标题栏高度。 + 这里**模拟**那个状态:面板必须整体下移 40px,否则内容与标题栏/窗口按钮重叠。 */ +const winTitlebar = await evaluate(`(() => { + const root = document.documentElement; + const had = root.hasAttribute('data-windows-titlebar'); + const prev = root.style.getPropertyValue('--dsh-windows-titlebar-height'); + root.setAttribute('data-windows-titlebar', ''); + root.style.setProperty('--dsh-windows-titlebar-height', '40px'); + const panel = document.querySelector('[data-shortcut-modal="settings"]'); + const out = { paddingTop: getComputedStyle(panel).paddingTop, height: Math.round(panel.getBoundingClientRect().height) }; + if (!had) root.removeAttribute('data-windows-titlebar'); + if (prev) root.style.setProperty('--dsh-windows-titlebar-height', prev); + else root.style.removeProperty('--dsh-windows-titlebar-height'); + return out; +})()`); +check('模拟桌面标题栏时面板 padding-top = 40px(内容整体下移,不与标题栏碰撞)', + winTitlebar.paddingTop === '40px', JSON.stringify(winTitlebar)); +check('标题栏让位只加内边距、面板仍是整屏高(不缩高、不露底)', + winTitlebar.height === 960, JSON.stringify(winTitlebar)); + +/* 内容居中:Codex 的设置正文是一根窄柱,水平居中于侧栏之外的剩余空间。 */ +const centring = await evaluate(`(() => { + const panel = document.querySelector('[data-shortcut-modal="settings"]'); + const content = [...panel.children].filter((e) => e.tagName !== 'NAV').pop(); + const root = content === null ? null : content.querySelector('[data-slot="settings.section"] > *'); + if (root === null) return { present: false }; + const r = root.getBoundingClientRect(), c = content.getBoundingClientRect(); + return { present: true, rootWidth: Math.round(r.width), contentWidth: Math.round(c.width), + left: Math.round(r.x - c.x), right: Math.round(c.right - r.right), + maxWidth: getComputedStyle(root).maxWidth }; +})()`); +check('内容容器收窄到 760px 且水平居中(左右留白大致相等)', + centring.present === true && centring.rootWidth === 760 && Math.abs(centring.left - centring.right) <= 2, + JSON.stringify(centring)); +check('内容容器的 max-width 真的生效(不是打在 display:contents 包装上)', + centring.maxWidth === '760px', JSON.stringify(centring)); + +/* 灰背板 + 白内容面板的层次骨架,以及「连续行合并成一张卡」的圆角分布。 */ +const surface = await evaluate(`(() => { + const panel = document.querySelector('[data-shortcut-modal="settings"]'); + const content = [...panel.children].filter((e) => e.tagName !== 'NAV').pop(); + const rows = [...panel.querySelectorAll('[data-slot="settings.general.item"] > *')]; + const cs = (el) => getComputedStyle(el); + const filterOf = (el) => cs(el).backdropFilter || cs(el).webkitBackdropFilter || 'none'; + return { + panelBg: cs(panel).backgroundColor, panelFilter: filterOf(panel), + contentBg: cs(content).backgroundColor, contentRadius: cs(content).borderRadius, + contentBorder: cs(content).borderTopWidth + ' ' + cs(content).borderTopStyle, + contentMargin: cs(content).margin, + rowCount: rows.length, + firstRadius: rows.length > 0 ? cs(rows[0]).borderRadius : null, + lastRadius: rows.length > 0 ? cs(rows[rows.length - 1]).borderRadius : null, + middleRadius: rows.length > 2 ? cs(rows[1]).borderRadius : null, + rowBg: rows.length > 0 ? cs(rows[0]).backgroundColor : null, + rowBorderTopColor: rows.length > 0 ? cs(rows[0]).borderTopColor : null, + rowBorderLeftWidth: rows.length > 0 ? cs(rows[0]).borderLeftWidth : null, + rowBorderTopWidth: rows.length > 0 ? cs(rows[0]).borderTopWidth : null, + midBorderLeftWidth: rows.length > 2 ? cs(rows[1]).borderLeftWidth : null, + lastBorderLeftWidth: rows.length > 0 ? cs(rows[rows.length - 1]).borderLeftWidth : null, + }; +})()`); +/* 背板必须**不透明**(用户裁决 2026-09-27,真机实测教训): + 设置页是全页铺满的 overlay,底下压着主窗口内容 —— 用户装了会画壁纸的宠物插件, + 78% alpha + blur + saturate 会把壁纸色揉进设置页的灰,整页比 #f9f9f9 更深且偏色, + 而且随用户壁纸变化。铺满全窗的 overlay 用毛玻璃没有任何视觉受益,只赔确定性。 + 用户原话「卡片内是白色,边框(页面)带有 #f9f9f9 即可」—— 页面颜色必须钉死。 + 历史:本条曾是「毛玻璃生效:背板有 backdrop-filter,且底色是半透明」, + 随本轮改为不透明一并作废(毛玻璃是更早「居中弹窗」时代的诉求,全页化后不成立)。 + 断言仍令牌相对:alpha=1 且 RGB 对账 bg-sidebar 令牌,不写死 rgb(249,249,249)。 */ +check('背板不透明:无 backdrop-filter,且底色 alpha = 1(颜色钉死,不吃环境色)', + !/blur/.test(surface.panelFilter) + && /^rgb\(/.test(String(surface.panelBg).trim()), + 'filter=' + surface.panelFilter + ' / 背板=' + surface.panelBg); +/* 内容列现在是「平铺的白」,但仍然**不是**浮起面板:有轮廓就还是「面板」, + 有 gutter 就会在四周露背板、重现那条缝。所以平铺的形态断言原样保留。 */ +check('内容列是 flush 平铺:无圆角、无描边、无 gutter', + /^0px(\s|$)/.test(String(surface.contentRadius).trim()) + && /^0px none$/.test(String(surface.contentBorder).trim()) + && /^0px( 0px){0,3}$/.test(String(surface.contentMargin).trim()), + 'radius=' + surface.contentRadius + ' / border=' + surface.contentBorder + ' / margin=' + surface.contentMargin); +check('连续行合并成一张卡:首行上圆角 + 末行下圆角 + 中间行无圆角', + surface.rowCount > 2 + && /^12px 12px/.test(surface.firstRadius) + && /12px 12px$/.test(surface.lastRadius) + && surface.middleRadius === '0px', + JSON.stringify(surface)); +/* 背板 = 主界面侧栏(两屏并排同色): + 用户实测「设置页左栏 #ededed vs 主界面侧栏 #f9f9f9,差异明显」, + 本轮把 --cx-sm-surface-backdrop 由 bg-layer-3 换成 bg-sidebar。 + 这里直接对账:设置页背板计算色 === 主界面侧栏那一面(同一令牌、同一合成)。 + 断言写成「两边相等」而不是写死 rgb(249,249,249) —— 令牌相对,换皮肤不假失败; + 同时也与 layer-3 对比,确保没有退回旧令牌。 */ +const panelBgNow = await evaluate('getComputedStyle(document.querySelector(\'[data-shortcut-modal="settings"]\')).backgroundColor'); +/** 把计算色归一成不透明的 "rgb(r, g, b)",只比颜色本身、不比写法。 + 背板已改回**不透明**(见上文「背板不透明」那条),计算值本就是 "rgb(r, g, b)"; + 这个函数保留是为了让对账不依赖写法(color(srgb …) / rgba(…) / #hex 都认), + 与用户要的证据(左栏计算色 = 侧栏令牌实测值)口径一致。 + 注意:它**只看 RGB 三通道、不看 alpha**,所以「不透明」必须由专门那条断言把守。 */ +const rgbOnly = (s) => { + const t = String(s).trim(); + const cm = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/.exec(t); + if (cm !== null) return 'rgb(' + [cm[1], cm[2], cm[3]].map((v) => Math.round(parseFloat(v) * 255)).join(', ') + ')'; + const rg = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/.exec(t); + if (rg !== null) return 'rgb(' + [rg[1], rg[2], rg[3]].map((v) => Math.round(parseFloat(v))).join(', ') + ')'; + const hx = /^#([0-9a-f]{6})$/i.exec(t); + if (hx !== null) return 'rgb(' + [0, 2, 4].map((i) => parseInt(hx[1].slice(i, i + 2), 16)).join(', ') + ')'; + return t; +}; +const bodyToken = (name) => evaluate('getComputedStyle(document.body).getPropertyValue("' + name + '").trim()'); +const sidebarTok = await bodyToken("--dsw-alias-bg-sidebar"); +const layer3Tok = await bodyToken("--dsw-alias-bg-layer-3"); +/* 形态定稿(用户 2026-09-27 确认,Codex 参考图):**侧栏灰 + 内容区白平铺 + 白卡浅细边**。 + 内容列取 bg-base 平铺(浅 #ffffff;深 #111111 —— 上游 0.5.6 起深色窗口底与表面分层, + 彼时 #181818 与侧栏同面的时代已结束),保持 flush。 + 演进:此条曾是「白内容面板 + 不同背板」(两个面)→「整页同面、内容列不吃底色」 + (整页灰,用户 2026-09-27 否决)→ 现在这条。 + 断言令牌相对 —— 对账 --dsw-alias-bg-base,不写死 rgb(255,255,255)。 + ⚠️ 位置有约束:rgbOnly / bodyToken 在本段才声明,这几条断言必须待在它们之后(否则 TDZ)。 */ +const bgBaseTok = await bodyToken("--dsw-alias-bg-base"); +check('内容列 = bg-base 平铺(浅 #ffffff / 深 #111111)', + rgbOnly(surface.contentBg) === rgbOnly(bgBaseTok), + 'contentBg=' + rgbOnly(surface.contentBg) + ' / --dsw-alias-bg-base=' + bgBaseTok); +/* 浅色下内容列与背板**分色**(白 ≠ 侧栏那一档),这正是定稿形态要的层次来源。 */ +check('浅色内容列与背板分色(内容白 ≠ 侧栏色)', + rgbOnly(surface.contentBg) !== rgbOnly(panelBgNow), + 'contentBg=' + rgbOnly(surface.contentBg) + ' / 背板=' + rgbOnly(panelBgNow)); +/* 浅色卡边 = bg-sidebar 那一档(上游 0.6.4 起浅色侧栏 #f6f6f6):白卡浮在白底上,靠这条细边划出卡的边界 + (用户原话「边框带有 #f9f9f9 即可」,Codex 参考图那一版)。 + 对账 bg-sidebar 令牌而不是写死 rgb(249,249,249);左右边也必须在(卡的侧轮廓)。 */ +const sidebarTokLight = await bodyToken("--dsw-alias-bg-sidebar"); +/* 宽度不写死 0.5px:Chromium 把边框宽度吸附到设备像素,dpr>1 时 0.5px 的计算值会读成 1px。 + 改断「左右边存在、且与上下同宽」—— 语义等价且不吃 DPR。 */ +check('浅色卡边 = bg-sidebar 细边,且左右边齐全', + surface.rowBorderTopColor !== null + && rgbOnly(surface.rowBorderTopColor) === rgbOnly(sidebarTokLight) + && surface.rowBorderLeftWidth !== '0px' + && surface.rowBorderLeftWidth === surface.rowBorderTopWidth + && surface.lastBorderLeftWidth !== '0px', + '卡边=' + rgbOnly(surface.rowBorderTopColor) + ' / --dsw-alias-bg-sidebar=' + sidebarTokLight + + ' / 首行左=' + surface.rowBorderLeftWidth + ' 上=' + surface.rowBorderTopWidth + ' / 末行左=' + surface.lastBorderLeftWidth); +/* 中间行不给左右边,行间发丝线才是一条完整直线。 */ +check('卡内中间行不画左右边(发丝线不断口)', surface.midBorderLeftWidth === '0px', + '中间行左边宽=' + surface.midBorderLeftWidth); +check('背板与主界面侧栏同色(设置页左栏 = 主界面侧栏面)', + rgbOnly(panelBgNow) === rgbOnly(sidebarTok), + '左栏计算色=' + rgbOnly(panelBgNow) + ' / --dsw-alias-bg-sidebar=' + sidebarTok); +check('背板已不是 layer-3(没有退回旧令牌)', + rgbOnly(panelBgNow) !== rgbOnly(layer3Tok), + '左栏计算色=' + rgbOnly(panelBgNow) + ' / --dsw-alias-bg-layer-3=' + layer3Tok); +/* 形态定稿下层次由「侧栏灰 vs 内容白」的分色承担(见上文那条), + 这里只确认背板与内容列确实分处两色,且背板 = bg-sidebar —— 层次没塌。 */ + +/* ── 3d. 锚点迁移集成 + 卡着色令牌路径(PR #1 二审修复)────────────────── */ +console.log('\n── 3d 自有锚点盖印 · 卡着色令牌路径 ──'); + +/* (a) 面板在插件注入后带上自有锚点 data-cx-sm-panel。 + 本轮把面板锚点从「仅适配器的 data-dsh-surface="settings"」迁到 + 「宿主 data-shortcut-modal="settings" 优先、适配器兜底」,定位到面板后由 + src/settings-modal.js 的 findPanel() 盖上自有锚点;settings-modal.css 的全部形态规则 + 都改挂它 —— 它没盖上,整份视觉层就不命中任何元素(这正是本轮迁移的风险点)。 + 一条断言同时钉三件事:盖了、盖在真正的面板节点上(与宿主锚点是同一个节点)、没盖重。 */ +const stamped = await evaluate('window.__cxs.panelStamp()'); +check('面板被盖印自有锚点 data-cx-sm-panel(锚点迁移的集成证据)', + stamped.count === 1 && stamped.attr === '' && stamped.sameNode === true, + JSON.stringify(stamped)); + +/* (b)(c) §4.3 那条 class 后缀规则**已删除**(见 skins/codex-ink/settings-modal.css 的删除记录)。 + 模型 / Agent 预设两页的卡现在唯一的着色机制是宿主令牌 --dsw-alias-settings-card-fill + (宿主主题在 body 上声明为 bg-layer-2;本皮肤在 skin.css 里显式定义成 bg-layer-1)。 + 下面两条合起来证明:令牌路径活着,且选中态没有被它抹平。 + ⚠️ 必须先走回「模型」页:前面那些断言把现场切到了别的设置页,而这两页的卡是宿主自建结构, + 通用设置页上根本没有 _rowCard / _card。 */ +await clickItem('模型'); +const tokenProbe = await evaluate('window.__cxs.tokenValue("--dsw-alias-settings-card-fill")'); +const modelCards = await evaluate('window.__cxs.cardState("_rowCard")'); +check('模型页未选中卡的背景 = var(--dsw-alias-bg-layer-1) 的计算值(令牌路径活着)', + modelCards.pick !== null && modelCards.pick.bg === tokenProbe.layer1, + '卡=' + JSON.stringify(modelCards.pick && modelCards.pick.bg) + ' / layer-1=' + tokenProbe.layer1 + + ' / 卡数 可见/总=' + modelCards.visible + '/' + modelCards.total); + +/* 选中态仍可区分:切到「Agent 预设」——实测该页 4 张卡里有 1 张带状态类 + (class = _card + 一个 *_cardActive 状态类),是全设置面板里唯一能同时量到 + 「未选中 vs 选中」的页。 + 断言要求两者都存在且不同色:宿主若改实现把选中底色画丢,这一条会红。 */ +await clickItem('Agent 预设'); +const agentCards = await evaluate('window.__cxs.cardState("_card")'); +check('Agent 预设页:选中卡背景 ≠ 未选中卡背景(选中态未被令牌路径抹平)', + agentCards.pick !== null && agentCards.selected !== null + && agentCards.pick.bg !== agentCards.selected.bg, + '未选中=' + JSON.stringify(agentCards.pick && agentCards.pick.bg) + + ' / 选中=' + JSON.stringify(agentCards.selected && agentCards.selected.bg) + + ' / 卡数 可见/总=' + agentCards.visible + '/' + agentCards.total); +/* 两页的未选中卡必须落在同一档(同走 settings-card-fill → layer-1): + §4.3 删除后,这条是「模型页与 Agent 预设页白卡一致」的唯一保证。 */ +check('模型页与 Agent 预设页的未选中卡同为 layer-1 一档(两页白卡一致)', + modelCards.pick !== null && agentCards.pick !== null && modelCards.pick.bg === agentCards.pick.bg, + '模型=' + JSON.stringify(modelCards.pick && modelCards.pick.bg) + + ' / Agent=' + JSON.stringify(agentCards.pick && agentCards.pick.bg)); +await clickItem(snap0.activeLabel); + +/* ✕ 关闭按钮已隐藏:宿主那个按钮不该再可见。 + 只认计算 display —— 宿主若只是把它缩成 0 尺寸,那是另一种失败,不该被放过。 + 兜底路径不受影响:Escape 优先,settings-modal.js 的 button.click() 对 display:none 仍生效。 */ +const closeInfo = await evaluate('(() => { const btn = document.querySelector(\'[data-shortcut-modal="settings"] button:has([data-slot="settings.close"])\'); if (btn === null) return { present: false, display: null }; const cs = getComputedStyle(btn); return { present: true, display: cs.display, rectW: btn.getBoundingClientRect().width }; })()'); +check('右上角 ✕ 关闭按钮已隐藏(Codex 无此按钮,← 返回应用承担退出)', + closeInfo.present === true && closeInfo.display === 'none', + JSON.stringify(closeInfo)); + +/* 切项跟随:换一个设置项,标题文本必须跟着变。 */ +const otherLabel = snap0.hostItems.map((r) => r.text).find((t) => t !== snap0.activeLabel); +await clickItem(otherLabel); +const ph1 = await evaluate('window.__cxs.pagehead()'); +const activeAfterSwitch = (await evaluate('window.__cxs.snapshot()')).activeLabel; +check('切项后标题跟随更新', ph1.text === activeAfterSwitch && ph1.text === otherLabel, + 'title=' + JSON.stringify(ph1.text) + ' active=' + JSON.stringify(activeAfterSwitch) + ' clicked=' + JSON.stringify(otherLabel)); +check('切项后页头仍唯一(没有叠加出第二个)', ph1.headCount === 1, 'headCount=' + ph1.headCount); +await clickItem(snap0.activeLabel); + + +/* 行内文字层级:标题 14px/label-primary,说明 12px/label-caption。 + 这条专治「文字层级规则误伤标题」——上一版就这么坏过,69 项全绿也没拦住。 */ +const typeLight = await evaluate('window.__cxs.rowType()'); +check('行标题恢复 14px 且为 label-primary(未被文字层级规则染灰压小)', + typeLight.titleCount > 0 + && typeLight.titles.every((r) => r.fs === '14px' && r.color === typeLight.primary), + JSON.stringify({ primary: typeLight.primary, titles: typeLight.titles.slice(0, 3) })); +check('行内说明为 12px 且压到 label-caption 档(与标题明显分层)', + typeLight.descCount > 0 + && typeLight.descs.every((r) => r.fs === '12px' && r.color === typeLight.caption) + && typeLight.caption !== typeLight.primary, + JSON.stringify({ caption: typeLight.caption, descs: typeLight.descs.slice(0, 3) })); +check('标题与说明确实是两个不同层级(不是同色同号)', + typeLight.titles.every((tt) => typeLight.descs.every((dd) => dd.color !== tt.color && dd.fs !== tt.fs)), + JSON.stringify({ title0: typeLight.titles[0], desc0: typeLight.descs[0] })); +check('控件区裸数字未被文字层级规则误伤(仍为 label-primary,不被压成 caption 灰)', + typeLight.stray.every((r) => r.color === typeLight.primary), + JSON.stringify(typeLight.stray)); + +/* ── 4. 搜索过滤 ───────────────────────────────────────────────────────── */ +console.log('\n── 4 搜索过滤 ──'); +await setQuery('模型'); +const f1 = await evaluate('window.__cxs.snapshot()'); +checkEqual('过滤命中精确:只剩「模型」', f1.hostItems.filter((r) => !r.hidden).map((r) => r.text), ['模型']); +checkEqual('空分组标题被隐藏,只留命中的「编码」', headerFlowOf(f1), ['编码']); +check('过滤只改显隐,宿主节点一个不少', f1.hostItems.length === snap0.hostItems.length, + f1.hostItems.length + ' vs ' + snap0.hostItems.length); +check('被隐藏项确实不可见(computed display:none)', f1.leaked.length === 0, '复现项=' + JSON.stringify(f1.leaked)); +await screenshot('c1-04-search-filter.png'); + +await setQuery('zzzz-nothing-here'); +const f2 = await evaluate('window.__cxs.snapshot()'); +check('无结果提示出现、且排在视觉流最后', + f2.flow.filter((r) => r.part === 'empty' && !r.hidden).length === 1 + && f2.flow.filter((r) => !r.hidden).slice(-1)[0].part === 'empty', + JSON.stringify(f2.flow.filter((r) => !r.hidden).map((r) => [r.part, r.text]))); +check('无结果时所有宿主项都被隐藏', f2.hostItems.every((r) => r.hidden), + JSON.stringify(f2.hostItems.filter((r) => !r.hidden).map((r) => r.text))); +await screenshot('c1-05-search-empty.png'); + +await setQuery(''); +const f3 = await evaluate('window.__cxs.snapshot()'); +check('清空后全部恢复', f3.hostItems.every((r) => !r.hidden) && f3.leaked.length === 0, + '仍隐藏=' + JSON.stringify(f3.hostItems.filter((r) => r.hidden).map((r) => r.text))); +checkEqual('清空后视觉流回到映射表序列', flowOf(f3), EXPECT_FLOW); + +/* ── 5. 隐藏态耐久性(C1 回归:类名被宿主抹掉时不能复现) ──────────────── */ +console.log('\n── 5 隐藏态耐久性(真实交互路径回归)──'); +/* + 造出故障前提:过滤到某个可见项,此时**当前选中项**处于被隐藏状态; + 然后点那个可见项 —— 宿主会把选中态搬走,并对被隐藏项整条重写 className(抹掉我们的类名)。 + 修复前:该类名是唯一的隐藏钩子,于是被隐藏项当场 display:flex 复现、此后永久可见。 + 修复后:隐藏由 [data-cx-sm-hidden] 属性兜底,类名被抹也不影响。 +*/ +const activeLabel = snap0.activeLabel; +const targetLabel = snap0.hostItems.map((r) => r.text).find((t) => t !== activeLabel && t !== '已归档会话'); +check('找到用于构造场景的可见项', targetLabel !== undefined, 'target=' + targetLabel + ' active=' + activeLabel); +if (targetLabel !== undefined) { + await setQuery(targetLabel); + const beforeClick = await evaluate('window.__cxs.snapshot()'); + const victims = beforeClick.items.filter((r) => r.hasHiddenAttr).map((r) => r.text); + check('构造出故障前提:存在被隐藏项,且当前选中项就在其中', + victims.length > 0 && victims.indexOf(activeLabel) >= 0, + '选中项=' + activeLabel + ' 被隐藏=' + JSON.stringify(victims)); + await clickItem(targetLabel); + const afterClick = await evaluate('window.__cxs.snapshot()'); + check('选中态搬家后,被隐藏项不因类名被抹掉而复现', + afterClick.leaked.length === 0, '复现项=' + JSON.stringify(afterClick.leaked)); + check('被隐藏项仍是「属性 + 计算样式」一致(display:none)', + afterClick.items.filter((r) => r.hasHiddenAttr).every((r) => r.display === 'none'), + JSON.stringify(afterClick.items.filter((r) => r.hasHiddenAttr && r.display !== 'none').map((r) => r.text))); + await screenshot('c1-06-hidden-durability.png'); + await setQuery(''); +} + +/* ── 6. 切项 / 关闭重开:注入存活且不叠加 ──────────────────────────────── */ +console.log('\n── 6 切项 / 关闭重开 ──'); +/* 基准必须在**切项前一刻**现取:搜索测试合法地多出一个 .cx-sm-empty 提示节点, + 拿「刚打开设置时」的计数当基准会把那个合法增量误判成重复注入。 */ +const preSwitch = (await evaluate('window.__cxs.snapshot()')).injectedCount; +await clickItem('皮肤'); +const s1 = await evaluate('window.__cxs.snapshot()'); +check('切设置项后返回按钮与搜索框存活', s1.hasBack && s1.searchIndex === 1, + 'back=' + s1.backIndex + ' search=' + s1.searchIndex); +check('切设置项后 4 个分组标题仍在', s1.headers.filter((r) => !r.hidden).length === 4, + s1.headers.map((r) => r.text).join('/')); +check('切设置项不产生重复注入节点', s1.injectedCount === preSwitch, s1.injectedCount + ' vs ' + preSwitch); +/* 计数相等还不够 —— 数量对但同一件注入了两遍也会相等。逐件查唯一性。 */ +check('切设置项后每个注入件仍全局唯一(无重复节点)', + Object.values(s1.uniq).every((n) => n === 1), JSON.stringify(s1.uniq)); + +await evaluate('document.body.focus()'); +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(1800); +const closed = await evaluate('window.__cxs.panelCount()'); +check('Esc 关闭设置', closed === 0, 'panel count=' + closed); +const orphans = await evaluate('window.__cxs.orphanCount()'); +check('关闭后注入节点随面板一起消失(不留孤儿)', orphans === 0, 'orphans=' + orphans); + +await openSettings(); +await sleep(1500); +const r1 = await evaluate('window.__cxs.snapshot()'); +check('重开后完整重放(返回 + 搜索 + 4 标题)', + r1.hasBack && r1.searchIndex === 1 && r1.headers.length === 4, + 'back=' + r1.hasBack + ' search=' + r1.searchIndex + ' headers=' + r1.headers.length); +check('重放不叠加:每个注入件全局唯一', Object.values(r1.uniq).every((n) => n === 1), JSON.stringify(r1.uniq)); +check('重放后无残留空结果提示', + r1.items.filter((r) => r.part === 'empty' && !r.hidden).length === 0, + JSON.stringify(r1.items.filter((r) => r.part === 'empty').map((r) => [r.hidden, r.display]))); +checkEqual('重放后视觉流仍与映射表一致', flowOf(r1), EXPECT_FLOW); +const phReopen = await evaluate('window.__cxs.pagehead()'); +check('重开后页头完整重放且不重复(head/标题各 1 个)', + phReopen.present && phReopen.headCount === 1 && phReopen.titleCount === 1, JSON.stringify(phReopen)); +check('重开后标题回到当前激活项', phReopen.text === r1.activeLabel, + 'title=' + JSON.stringify(phReopen.text) + ' active=' + JSON.stringify(r1.activeLabel)); +await screenshot('c1-07-reopen-replay.png'); + +/* ── 7. 第三方项动态插入 → 「其他」组 ──────────────────────────────────── */ +console.log('\n── 7 未知项兜底 ──'); +const beforeUnknown = await evaluate('window.__cxs.snapshot()'); +const injected = await evaluate('window.__cxs.injectUnknown("第三方测试项")'); +check('能模拟第三方插件插入一个新设置项', injected === true, 'injected=' + injected); +await sleep(1500); +const u1 = await evaluate('window.__cxs.snapshot()'); +const unknown = u1.items.find((r) => r.text === '第三方测试项'); +check('未知项落「其他」组', unknown !== undefined && unknown.group === 'other', JSON.stringify(unknown)); +check('「其他」组标题存在', u1.headers.some((r) => r.part === 'group:other'), + u1.headers.map((r) => r.part).join('/')); +check('「其他」排在所有已知组之后(视觉流末尾)', + u1.flow.filter((r) => !r.hidden).slice(-1)[0].group === 'other', + JSON.stringify(u1.flow.filter((r) => !r.hidden).slice(-3).map((r) => [r.group, r.text]))); +check('原有宿主项一个没打乱', u1.hostItems.length === beforeUnknown.hostItems.length + 1, + u1.hostItems.length + ' vs ' + (beforeUnknown.hostItems.length + 1)); +await screenshot('c1-08-third-party.png'); + +await evaluate('window.__cxs.removeUnknown("第三方测试项")'); +await sleep(1500); +const u2 = await evaluate('window.__cxs.snapshot()'); +check('移除未知项后「其他」组标题消失(不留孤儿)', u2.headers.every((r) => r.part !== 'group:other'), + u2.headers.map((r) => r.part).join('/')); +check('移除后宿主项数回到原状', u2.hostItems.length === beforeUnknown.hostItems.length, + u2.hostItems.length + ' vs ' + beforeUnknown.hostItems.length); +check('宿主项在 DOM 里没被我们动过(仍是列表的直接子节点、仍是 button)', + u2.hostItems.every((r) => r.tag === 'BUTTON'), JSON.stringify(u2.hostItems.map((r) => r.tag))); + +/* ── 8. 暗色主题下结构断言同样成立 ─────────────────────────────────────── */ +console.log('\n── 8 暗色主题 ──'); +await evaluate('window.__cxs.setDark(true)'); +await sleep(1000); +const dark = await evaluate('window.__cxs.snapshot()'); +const darkHits = await evaluate('window.__cxs.styleHits()'); +checkEqual('暗色下 4 个分组标题仍在且顺序不变', headerFlowOf(dark), ['个人', '集成', '编码', '已归档']); +check('暗色下返回按钮与搜索框仍在', dark.hasBack && dark.searchIndex === 1, + 'back=' + dark.hasBack + ' search=' + dark.searchIndex); +checkEqual('暗色下视觉流与映射表一致', flowOf(dark), EXPECT_FLOW); +check('暗色下注入节点仍有样式命中(不是透明无字)', + darkHits.back.present && darkHits.group.present + && darkHits.back.color !== 'rgba(0, 0, 0, 0)' && darkHits.group.color !== 'rgba(0, 0, 0, 0)', + JSON.stringify({ back: darkHits.back.color, group: darkHits.group.color })); +check('暗色下被隐藏项依然不可见', dark.leaked.length === 0, JSON.stringify(dark.leaked)); +const typeDark = await evaluate('window.__cxs.rowType()'); +check('暗色下行标题仍是 label-primary/14px,说明仍是 caption/12px(不随主题塌陷)', + typeDark.titleCount > 0 && typeDark.descs.length > 0 + && typeDark.titles.every((r) => r.fs === '14px' && r.color === typeDark.primary) + && typeDark.descs.every((r) => r.fs === '12px' && r.color === typeDark.caption) + && typeDark.primary !== typeDark.caption, + JSON.stringify({ primary: typeDark.primary, caption: typeDark.caption, + title0: typeDark.titles[0], desc0: typeDark.descs[0] })); +const phDark = await evaluate('window.__cxs.pagehead()'); +check('暗色下页头仍在、文本仍跟随激活项、仍是唯一', + phDark.present && phDark.headCount === 1 && phDark.text === dark.activeLabel, + JSON.stringify(phDark)); +/* 暗色下同样要成立:背板跟主界面侧栏走,且与内容面板不同面(层次不能塌)。 */ +const panelBgDark = await evaluate('getComputedStyle(document.querySelector(\'[data-shortcut-modal="settings"]\')).backgroundColor'); +const sidebarDark = await evaluate('getComputedStyle(document.body).getPropertyValue("--dsw-alias-bg-sidebar").trim()'); +const contentBgDark = await evaluate('(() => { const p = document.querySelector(\'[data-shortcut-modal="settings"]\'); const c = [...p.children].filter((e) => e.tagName !== "NAV").pop(); return getComputedStyle(c).backgroundColor; })()'); +check('暗色下背板仍与主界面侧栏同色', + rgbOnly(panelBgDark) === rgbOnly(sidebarDark), + '左栏计算色=' + rgbOnly(panelBgDark) + ' / --dsw-alias-bg-sidebar=' + sidebarDark); +/* 暗色同样必须不透明:上面那条只比 RGB、不比 alpha,半透明也能骗过它, + 而「被壁纸染成偏色」在深色下同样是这个成因。 */ +const panelFilterDark = await evaluate('(() => { const p = document.querySelector(\'[data-shortcut-modal="settings"]\'); const cs = getComputedStyle(p); return cs.backdropFilter || cs.webkitBackdropFilter || "none"; })()'); +check('暗色下背板同样不透明:无 backdrop-filter,且底色 alpha = 1', + !/blur/.test(panelFilterDark) && /^rgb\(/.test(String(panelBgDark).trim()), + 'filter=' + panelFilterDark + ' / 背板=' + panelBgDark); +/* 深色卡边沿用 border-l1(白色 8%)—— 浅色那条侧栏色边画在深色卡上等于没有边。 + 这条同时守住「深色没有被浅色取值污染」。 */ +const rowBgDark = await evaluate('(() => { const r = document.querySelector(\'[data-shortcut-modal="settings"] [data-slot="settings.general.item"] > *\'); return r === null ? null : getComputedStyle(r).backgroundColor; })()'); +/* 暗色:内容列 = bg-base(令牌相对)。上游 0.5.6 把深色窗口底改成 #111111、表面/侧栏留 #181818 + —— 内容列(#111111)与侧栏(#181818)**分层**是上游的新现实,「深色通体一色」的设计注记 + 随之作废(皮肤跟踪令牌,不把旧的层级钉死)。这条只断内容列跟住 bg-base; + 分层事实写进证据文案,不断言 ≠(上游哪天再合并层级不该红我们)。 */ +const bgBaseDark = await evaluate('getComputedStyle(document.body).getPropertyValue("--dsw-alias-bg-base").trim()'); +check('暗色下内容列 = bg-base(令牌相对;与侧栏分层是上游 0.5.6 起的现实)', + rgbOnly(contentBgDark) === rgbOnly(bgBaseDark), + '内容列=' + rgbOnly(contentBgDark) + ' / bg-base=' + bgBaseDark + ' / 侧栏=' + rgbOnly(panelBgDark)); +check('暗色下分组卡仍是浮起的一层(卡色 ≠ 整页面色)', + rowBgDark !== null && rgbOnly(rowBgDark) !== rgbOnly(panelBgDark), + '卡=' + rgbOnly(rowBgDark) + ' / 面=' + rgbOnly(panelBgDark)); +const rowEdgeDark = await evaluate('(() => { const r = document.querySelector(\'[data-shortcut-modal="settings"] [data-slot="settings.general.item"] > *\'); return r === null ? null : getComputedStyle(r).borderTopColor; })()'); +const borderL1Dark = await evaluate('getComputedStyle(document.body).getPropertyValue("--dsw-alias-border-l1").trim()'); +check('暗色卡边仍是 border-l1 白色发丝(未被浅色侧栏色边污染)', + rowEdgeDark !== null && rgbOnly(rowEdgeDark) === rgbOnly(borderL1Dark) + && rgbOnly(rowEdgeDark) !== rgbOnly(sidebarDark), + '暗色卡边=' + rgbOnly(rowEdgeDark) + ' / border-l1=' + rgbOnly(borderL1Dark) + ' / 侧栏=' + rgbOnly(sidebarDark)); +const closeDark = await evaluate('(() => { const b = document.querySelector(\'[data-shortcut-modal="settings"] button:has([data-slot="settings.close"])\'); return b === null ? null : getComputedStyle(b).display; })()'); +check('暗色下 ✕ 仍隐藏', closeDark === 'none', 'display=' + closeDark); +await screenshot('c1-09-dark.png'); +await evaluate('window.__cxs.setDark(false)'); +await sleep(700); + +/* ── 9. 返回按钮点击关闭 ───────────────────────────────────────────────── */ +console.log('\n── 9 返回按钮行为 ──'); +const backBox = await evaluate('(() => { const el = document.querySelector("[data-cx-sm-part=\'back\']");' + + 'if (el === null) return null; const r = el.getBoundingClientRect();' + + 'return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()'); +check('能找到返回按钮', backBox !== null, JSON.stringify(backBox)); +if (backBox !== null) { + await click(backBox.x, backBox.y); + await sleep(1800); + const afterBack = await evaluate('window.__cxs.panelCount()'); + check('点返回按钮 → 设置模态关闭', afterBack === 0, 'panel count=' + afterBack); + const orphansAfter = await evaluate('window.__cxs.orphanCount()'); + check('关闭后不留注入孤儿', orphansAfter === 0, 'orphans=' + orphansAfter); +} + +/* 收尾:恢复干净现场,保证脚本幂等可复跑。 */ +await openSettings(); +await sleep(1200); +await setQuery(''); +await evaluate('window.__cxs.setDark(false)'); +await sleep(400); + +/* ── 10. console 零报错 ────────────────────────────────────────────────── */ +console.log('\n── 10 console ──'); +/* 白名单:逐条写明理由的宿主/环境既有噪音,基线里同样存在。 + 不是「把 error 都放行」—— 除这些之外任何 error / exception 一律算失败。 */ +const NOISE = [ + { re: /Failed to load resource.*(404|Not Found)/i, why: '宿主静态资源 404,基线即有' }, + { re: /favicon/i, why: 'favicon 缺失,与插件无关' }, + { re: /ERR_CONNECTION_REFUSED.*(sockjs|hot-update|__vite)/i, why: 'HMR / 开发服务器残留' }, +]; +const errs = pageLogs.filter((l) => l.startsWith('error') || l.startsWith('exception')); +const unexpected = errs.filter((l) => !NOISE.some((n) => n.re.test(l))); +check('console 零报错(已剔除 ' + NOISE.length + ' 类宿主既有噪音)', unexpected.length === 0, + unexpected.slice(0, 8).join('\n ')); +if (errs.length > unexpected.length) console.log(' (已白名单 ' + (errs.length - unexpected.length) + ' 条宿主噪音)'); +const codexWarns = pageLogs.filter((l) => l.indexOf('[codex-ui]') >= 0); +check('注入层没有打出 [codex-ui] 警告(结构自检全部通过)', codexWarns.length === 0, + codexWarns.slice(0, 5).join('\n ')); + +/* ── 汇总 ─────────────────────────────────────────────────────────────── */ +console.log('\n' + (failed === 0 ? 'PASS:全部通过' : 'FAIL:' + failed + ' 项未通过') + + '(共 ' + (passed + failed) + ' 项,通过 ' + passed + ')'); +console.log('截图目录:' + OUT_DIR); +ws.close(); child.kill(); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/live/settings-sweep.mjs b/scripts/live/settings-sweep.mjs new file mode 100644 index 0000000..cf36c5e --- /dev/null +++ b/scripts/live/settings-sweep.mjs @@ -0,0 +1,346 @@ +#!/usr/bin/env node +/** + * settings-pages-sweep.mjs — 逐个打开全部设置页,亮/暗各截一张,并做布局健康检查。 + * + * 用途:设置模态的「全页 Codex 化」改的是内容区(卡片行、页头、留白)。内容区行卡片化用了 + * 结构兜底选择器,**有可能在某些页误伤**(把分组容器 / 表单布局也卡片化,导致表单挤压、控件错位)。 + * 这个脚本就是用来逐页把这件事看清楚的:截图留证 + 自动量一批布局指标。 + * + * 用法: + * node scripts/live/settings-sweep.mjs --url "http://127.0.0.1:3199/?token=..." [--out docs/recon] [--prefix c1] [--min-pages 1] + * + * 产物:/-<序号>-<页名>-.png + 控制台一份逐页健康报告。 + * 退出码:只有「硬故障」(扫不到设置项 / 打不开页 / 页面空白 / 控件被裁剪到不可用)才非 0; + * 可疑但需要人眼判断的,只报告不失败。 + * ⚠️ **扫不到设置项本身就是硬故障**:宿主一定会注册设置项,0 项意味着侧栏没渲染出来 + * 或结构层整体失效。旧版对空列表只是「循环一轮都不跑」→ hardFail 恒为 0 → 退出码 0, + * 即「什么都没测」却报 PASS,是本脚本最容易骗过 CI 的假阳性路径(二审 M3 指出)。 + * 现在 labels.length === 0(或少于 --min-pages)直接非零退出。 + */ +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 { tmpdir } from 'node:os'; +import { chromePath } from '../lib/host.mjs'; +/* lib/host.mjs 不导出 tempDir:这里就地生成一个唯一临时 profile 目录。 */ +const tempDir = (name) => fs.mkdtempSync(join(tmpdir(), name + '-')); + +const HERE = dirname(fileURLToPath(import.meta.url)); +const WB = dirname(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 TOKEN_URL = arg('url', ''); +const OUT_DIR = arg('out', join(WB, 'docs', 'recon')); +const PREFIX = arg('prefix', 'c1'); +const CHROME = arg('chrome', '') || chromePath(); +const PORT = Number(arg('cdp-port', '9344')); +const DPR = Number(arg('dpr', '1.5')); +/* 期望扫到的设置项下限。宿主一定会注册设置项(通用设置/模型/内置插件…), + 本环境实测 13 项(见 docs/recon/c1-sweep-report.json);默认 1 已经是「有就行」的最低线, + 要钉住本环境的具体页数就显式传 --min-pages 13。 */ +const MIN_PAGES = Number(arg('min-pages', '1')); +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); } +fs.mkdirSync(OUT_DIR, { recursive: true }); + +const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + PORT, + '--user-data-dir=' + tempDir('dsh-cdp-sweep'), '--no-first-run', + '--no-default-browser-check', '--disable-gpu', '--window-size=1440,960', '--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 = {}, sid) => 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, ...(sid ? { sessionId: sid } : {}) })); +}); +const evaluate = async (expression) => { + const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId); + if (r.exceptionDetails !== undefined && r.exceptionDetails !== null) { + throw new Error('页面内求值抛错:' + String(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text).slice(0, 300)); + } + return r.result.value; +}; +const click = async (x, y) => { + 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: 1440, height: 960, 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); + +/** 页面内工具:把布局体检需要的一切都算好返回。自包含,只挂在 window 上。 */ +function sweepInstall() { + const panel = () => document.querySelector('[data-shortcut-modal="settings"]'); + const nav = () => { const p = panel(); return p === null ? null : Array.from(p.children).find((e) => e.tagName === 'NAV') || null; }; + const list = () => { + const n = nav(); + if (n === null) return null; + const hit = Array.from(n.children).filter((e) => e.getAttribute('data-codex-ui-injected') !== 'settings-modal').pop(); + return hit === undefined ? null : hit; + }; + const cells = () => { const l = list(); return l === null ? [] : Array.from(l.children).filter((e) => e.tagName === 'BUTTON'); }; + const content = () => { const p = panel(); return p === null ? null : Array.from(p.children).filter((e) => e.tagName !== 'NAV').pop() || null; }; + + /** 当前页名:取内容区里第一个非空文本块。 */ + const pageTitle = () => { + const c = content(); + if (c === null) return null; + const t = (c.innerText || '').split('\n').map((s) => s.trim()).find((s) => s !== ''); + return t === undefined ? null : t; + }; + const items = () => cells().map((el) => el.textContent.trim()); + + const openSettings = () => { const b = document.querySelector('button[aria-label="设置"]'); if (b === null) return false; b.click(); return true; }; + const panelCount = () => document.querySelectorAll('[data-shortcut-modal="settings"]').length; + const setDark = (on) => { if (on) document.body.setAttribute('data-ds-dark-theme', ''); else document.body.removeAttribute('data-ds-dark-theme'); }; + + const itemBox = (text) => { + const el = cells().find((c) => c.textContent.trim() === text); + 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) }; + }; + + /** + * 布局健康指标。目标是把「肉眼可疑」的页自动挑出来,而不是替代人眼看截图。 + * 关心的四类: + * contentEmpty —— 内容区几乎没文字(页没渲染出来 / 被压成 0 宽) + * textOverflow —— 有元素把文本溢出到祖先之外(挤压的典型信号) + * tinyControls —— 交互控件被压到过小(< 16px 的任一维) + * zeroWidthRows —— 页面根的子项里出现 0 宽/0 高(布局塌陷) + */ + const health = () => { + const c = content(); + if (c === null) return null; + const cr = c.getBoundingClientRect(); + const body = (c.innerText || '').trim(); + const overflowing = []; + const tiny = []; + const collapsed = []; + const benign = []; + /* 只看内容区,且只看有实际盒子的元素。 + display:contents 的包装元素(宿主的 data-slot 座位)本来就没有盒子 —— 跳过,否则每页都误报。 */ + for (const el of c.querySelectorAll('*')) { + if (getComputedStyle(el).display === 'contents') continue; + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + const tag = el.tagName; + /* 原生勾选框/单选框/滑杆本来就小,不是布局问题 —— 排除掉,否则误报会淹没真信号。 */ + const type = (el.getAttribute('type') || '').toLowerCase(); + const nativeSmall = tag === 'INPUT' && ['checkbox', 'radio', 'range', 'hidden', 'file', 'color'].includes(type); + if (!nativeSmall && (r.width < 16 || r.height < 16)) { + const interactive = tag === 'BUTTON' || tag === 'INPUT' || tag === 'SELECT' || el.getAttribute('role') === 'button'; + if (interactive) { + tiny.push({ tag: tag, cls: String(el.className).slice(0, 40), w: Math.round(r.width), h: Math.round(r.height), + text: (el.textContent || '').trim().slice(0, 20) }); + } + } + /* 溢出分两级,别混为一谈: + · escaped —— 元素盒子**越出内容区**(右边界超了 / 左边界缩了)。这是真信号: + 内容被推到可视区外,或被裁掉。 + · benign —— 元素内容比它自己的盒子宽,但盒子仍在内容区里、溢出的那点朝内侧空白摊开。 + **不影响可读性**。实测依据:皮肤页 ul.sectionList 内容需 931px、盒子 900px, + 但盒子右边界离内容区右边界还有 260px,溢出的 31px 只是画进了空白 —— 无裁剪、无滚动条。 + 只作信息记录,不计入可疑。 */ + if (r.right > cr.right + 2 || r.left < cr.left - 2) { + overflowing.push({ tag: tag, cls: String(el.className).slice(0, 40), + right: Math.round(r.right), contentRight: Math.round(cr.right), text: (el.textContent || '').trim().slice(0, 30) }); + } else if (el.scrollWidth > el.clientWidth + 8 && el.clientWidth > 0) { + const cs = getComputedStyle(el); + if (cs.overflowX === 'visible' && cs.textOverflow !== 'ellipsis' && cs.whiteSpace !== 'nowrap') { + benign.push({ tag: tag, cls: String(el.className).slice(0, 40), + scrollW: el.scrollWidth, clientW: el.clientWidth, text: (el.textContent || '').trim().slice(0, 30) }); + } + } + } + /* 页面根的直接子项有没有塌成 0 */ + const root = c.querySelector('[data-slot="settings.section"]'); + const rootKid = root === null ? null : root.firstElementChild; + if (rootKid !== null) { + for (const k of rootKid.children) { + const r = k.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) collapsed.push({ tag: k.tagName, cls: String(k.className).slice(0, 40) }); + } + } + return { + title: pageTitle(), + textLen: body.length, + contentW: Math.round(cr.width), + contentH: Math.round(cr.height), + text: body.slice(0, 160).replace(/\n/g, ' | '), + overflowing: overflowing.slice(0, 6), + benignOverflow: benign.slice(0, 6), + tiny: tiny.slice(0, 6), + collapsed: collapsed.slice(0, 6), + rowCount: rootKid === null ? 0 : rootKid.children.length, + }; + }; + + window.__sweep = { + openSettings: openSettings, panelCount: panelCount, setDark: setDark, items: items, + itemBox: itemBox, health: health, pageTitle: pageTitle, list: list, + }; +} +await evaluate('(' + sweepInstall.toString() + ')()'); + +const dismissOnboarding = async () => { + for (let round = 0; round < 8; round += 1) { + const box = await evaluate('(() => {' + + 'const re = /^(继续|稍后配置|跳过|知道了)$/;' + + 'const el = [...document.querySelectorAll("button")].find((e) =>' + + ' re.test((e.getAttribute("aria-label") || 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) }; })()'); + if (box === null) return; + await click(box.x, box.y); + await sleep(1200); + } +}; +const clickItem = async (label) => { + const box = await evaluate('window.__sweep.itemBox(' + JSON.stringify(label) + ')'); + if (box === null) return false; + await click(box.x, box.y); + await sleep(1400); + return true; +}; +const screenshot = async (file) => { + const s = await send('Page.captureScreenshot', { format: 'png' }, sessionId); + fs.writeFileSync(join(OUT_DIR, file), Buffer.from(s.data, 'base64')); +}; + +/** 文件名里的页名:去掉不能做文件名的字符。 */ +const slug = (name) => name.replace(/[\\/:*?"<>|\s]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 30); + +await dismissOnboarding(); +await sleep(800); +await evaluate('window.__sweep.openSettings()'); +await sleep(2500); + +const labels = await evaluate('window.__sweep.items()'); +console.log('侧栏共 ' + labels.length + ' 项:' + JSON.stringify(labels)); + +/* 空通过防护(先于任何截图/循环):一项都没有 = 侧栏没渲染 / 结构层失效。 + 此时下面的 for 循环一次都不进,报告为空、hardFail = 0、退出码 0 —— 假阳性。 + 少于 --min-pages 同理(页数变少意味着宿主改版或部分页面注册失败,同样不该静默放过)。 */ +if (labels.length === 0) { + console.error('FAIL 侧栏一个设置项都没扫到 —— 设置面板没渲染出来,或结构层整体失效。'); + console.error(' 这不是「没有异常」,是「什么都没测」:宿主一定会注册设置项,不会存在 0 项的合法场景。'); + console.error(' 排查:设置模态是否打开(button[aria-label="设置"])、' + + '面板锚点 [data-shortcut-modal="settings"] / [data-dsh-surface="settings"] 是否命中、' + + 'src/settings-modal.js 是否打出了 [codex-ui] 警告。'); + ws.close(); child.kill(); + process.exit(1); +} +if (labels.length < MIN_PAGES) { + console.error('FAIL 侧栏只有 ' + labels.length + ' 项,少于 --min-pages ' + MIN_PAGES + + '(宿主注册的设置项变少了,可能是改版或部分页面注册失败)'); + console.error(' 扫到的:' + JSON.stringify(labels)); + ws.close(); child.kill(); + process.exit(1); +} + +let hardFail = 0; +const report = []; +for (let i = 0; i < labels.length; i += 1) { + const label = labels[i]; + const okClick = await clickItem(label); + if (!okClick) { console.error('FAIL 点不到设置项:' + label); hardFail += 1; continue; } + await sleep(900); + const light = await evaluate('window.__sweep.health()'); + if (light === null) { console.error('FAIL 内容区没找到:' + label); hardFail += 1; continue; } + await screenshot(PREFIX + '-' + String(i).padStart(2, '0') + '-' + slug(label) + '-light.png'); + await evaluate('window.__sweep.setDark(true)'); + await sleep(800); + const dark = await evaluate('window.__sweep.health()'); + await screenshot(PREFIX + '-' + String(i).padStart(2, '0') + '-' + slug(label) + '-dark.png'); + await evaluate('window.__sweep.setDark(false)'); + await sleep(400); + + /* 硬故障判定:内容区没文字、或整页宽度塌掉。 */ + const empty = light.textLen < 12; + const narrow = light.contentW < 200; + if (empty || narrow) hardFail += 1; + const flag = empty ? ' ← 内容为空' : (narrow ? ' ← 内容区过窄' : ''); + report.push({ + i: i, label: label, light: light, dark: dark, + suspicious: light.overflowing.length > 0 || light.tiny.length > 0 || light.collapsed.length > 0, + empty: empty, narrow: narrow, + }); + console.log('[' + String(i).padStart(2, '0') + '] ' + label.padEnd(12) + + ' 内容 ' + light.contentW + '×' + light.contentH + ' / 正文 ' + light.textLen + ' 字 / 子项 ' + light.rowCount + + (light.overflowing.length > 0 ? ' / 溢出 ' + light.overflowing.length : '') + + (light.tiny.length > 0 ? ' / 小控件 ' + light.tiny.length : '') + + (light.collapsed.length > 0 ? ' / 塌陷 ' + light.collapsed.length : '') + + flag); +} + +await evaluate('window.__sweep.setDark(false)'); +await sleep(300); + +/* ── 汇总 ─────────────────────────────────────────────────────────────── */ +console.log('\n===== 逐页布局报告 ====='); +for (const r of report) { + const issues = []; + if (r.empty) issues.push('内容区没有文字(页可能没渲染)'); + if (r.narrow) issues.push('内容区宽度 < 200px(布局塌陷)'); + for (const o of r.light.overflowing) issues.push('文本溢出:<' + o.tag + ' class=' + o.cls + '> ' + o.scrollW + '>' + o.clientW + ' 「' + o.text + '」'); + for (const t of r.light.tiny) issues.push('控件过小:<' + t.tag + '> ' + t.w + '×' + t.h + ' 「' + t.text + '」'); + for (const c of r.light.collapsed) issues.push('子项塌陷:<' + c.tag + ' class=' + c.cls + '>'); + if (issues.length === 0) { console.log('[' + String(r.i).padStart(2, '0') + '] ' + r.label + ':未见异常'); continue; } + console.log('[' + String(r.i).padStart(2, '0') + '] ' + r.label + ':' + issues.length + ' 项可疑'); + for (const s of issues) console.log(' · ' + s); +} +const suspicious = report.filter((r) => r.suspicious); +const benignPages = report.filter((r) => r.light.benignOverflow.length > 0); +if (benignPages.length > 0) { + console.log('\n(以下为「盒子内溢出但未越界」,实测不影响可读性,仅记录:)'); + for (const r of benignPages) { + const b = r.light.benignOverflow[0]; + console.log(' · ' + r.label + ':<' + b.tag + ' class=' + b.cls + '> 内容 ' + b.scrollW + 'px / 盒子 ' + b.clientW + 'px'); + } +} +console.log('\n截图 ' + (report.length * 2) + ' 张 → ' + OUT_DIR); +console.log('硬故障 ' + hardFail + ' 项;需人眼复核 ' + suspicious.length + ' 页' + + (suspicious.length > 0 ? ':' + suspicious.map((r) => r.label).join('、') : '')); +fs.writeFileSync(join(OUT_DIR, PREFIX + '-sweep-report.json'), JSON.stringify(report, null, 1)); +console.log('明细 JSON:' + join(OUT_DIR, PREFIX + '-sweep-report.json')); +console.log('\n' + (hardFail === 0 ? 'PASS:全部页面打开且内容区正常' : 'FAIL:' + hardFail + ' 项硬故障')); +ws.close(); child.kill(); +process.exit(hardFail === 0 ? 0 : 1); diff --git a/scripts/specs/composer.mjs b/scripts/specs/composer.mjs index 80a7e73..8f6ab78 100644 --- a/scripts/specs/composer.mjs +++ b/scripts/specs/composer.mjs @@ -307,7 +307,30 @@ h4{margin:0 0 10px 24px;font:600 12px/18px ui-monospace,Consolas,monospace;color 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); + /* 期望值不硬编码 rgb:令牌在重锚后是 rgba 字面量,浏览器算出来的徽标色是它 + 在顶栏底色上的合成结果。临时元素走一遍 var() 让浏览器自己算,再与徽标比。 */ + const tertiaryExpected = await page.evaluate(() => { + const el = document.createElement('span'); + el.style.color = 'var(--dsw-alias-label-tertiary)'; + document.body.appendChild(el); + const c = getComputedStyle(el).color; + el.remove(); + return c; + }); + t.log('TERTIARY expected ' + tertiaryExpected); + /* 徽标圆角同样**不硬编码**:上游 v0.3.0 已把 .SVAs4q_label 的字面量 6px 换成 + var(--dsw-radius-xs)(宿主自己在 dsh-client-ui-theme 里声明该令牌为 4px),皮肤也定义同名令牌。 + 写死 6px 会同时冤枉宿主与皮肤 —— 改成「徽标圆角 == 该令牌在本页面的计算值」,临时元素法同配色那条。 */ + const badgeRadiusExpected = await page.evaluate(() => { + const el = document.createElement('span'); + el.style.borderRadius = 'var(--dsw-radius-xs)'; + document.body.appendChild(el); + const c = getComputedStyle(el).borderTopLeftRadius; + el.remove(); + return c; + }); + t.log('BADGE radius expected ' + badgeRadiusExpected); + t.check('徽标配色 = 本皮肤 --dsw-alias-label-tertiary', hdr.presetLabel.color === tertiaryExpected, hdr.presetLabel.color + ' vs ' + tertiaryExpected); 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); diff --git a/scripts/specs/sidebar-color.mjs b/scripts/specs/sidebar-color.mjs new file mode 100644 index 0000000..451ba28 --- /dev/null +++ b/scripts/specs/sidebar-color.mjs @@ -0,0 +1,102 @@ +/** + * sidebar-color — 侧栏底色的像素验收(侧栏 <-> 主区那一档),亮暗两套都量。 + * + * 判据不是本皮肤自己定的,来自 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],不重排版)。旧值那组是反例对照: + * 它必须复现出旧读数,否则说明夹具分辨不出差别,正面断言就没有意义。 + */ + +/* 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 hex = (c) => '#' + c.map((v) => v.toString(16).padStart(2, '0')).join(''); +const near = (a, b, tol) => Math.abs(a - b) <= tol; +const neutral = (c) => c[0] === c[1] && c[1] === c[2]; + +async function sidebarColor(t) { + const page = await t.page({ width: PAGE.w, height: PAGE.h, dpr: 2 }); + const html = '' + + '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)') + + '
' + + '