diff --git a/CHANGELOG.md b/CHANGELOG.md index 872bf1d..e0e5b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,108 @@ [简体中文](CHANGELOG.zh-CN.md) +## 0.6.0 - 2026-09-28 + +Face B of the model picker (the Codex reasoning power rail) lands, together with a full-repository review against the +replication spec and fixes for what it confirmed. + +### ⑳ Model picker face B: our own component takes over the model seat + +- **Seat takeover in the DOM, never parasitic on the host menu** (0.5.0's CSS re-layout was slow and bare, and was + reverted). Our trigger is appended to `[data-slot="conversation.input.model"]` and the popover to `document.body`; + while our trigger is in the seat, **one** direct-child `:has()` in `model-picker.css` sets the host's child to + `display:none` (it stays in the React tree). No marker attribute: React replacing the host child would drop it and the + host control would flash back; `:has()` only asks "are we seated", and removing our trigger restores the host at once. +- **Data and commits go through the host only**: `ctx.inject(['modelDirectories'], …)` waits for the service (the same + hook the host uses for its own seat; a missing service never blocks the skin), subscribes to `directoryFor(session).store` + and commits with `select({ provider, model, reasoningEffort })`. The session id comes from the seat's + `data-conversation-session` ancestor (the right-bar side chat has its own seat), falling back to `uiSession`. Seats the + host did not render (subagent sessions) are left alone. +- **Power rail verbatim from the Codex source**: track 24px / radius 12 / 10% foreground / `inset .5px` stroke; ticks + 4px with a 16px hit area, passed ticks 30% white; thumb a 28px white disc / `.5px` strong stroke / `0 0 2px` shadow; + filled part in the accent, ending at the thumb centre; motion `.3s cubic-bezier(.23,1,.32,1)`, thumb 0s on the first + frame and .3s after 16ms. Popover 254px wide (`spacing × 63.5`), enters in `.32s … 30ms` from `scale(.98)`; placed like + the host `place()` (right-aligned, 8px above, 12px viewport margin). +- **Interaction**: real drag (grab on press → free slide with **no commit** → one commit snapped to the nearest level on + release), `touch-action: none`; ←/→/Home/End; keyboard focus draws the 2px ring on the thumb; Escape closes and returns + focus (also when a mouse-open left focus on the trigger); choosing a model commits that model's default effort and + closes on success. Level count and names come from `reasoning.efforts`. +- **During the round trip** (the host marks the directory `selecting` for the whole selectModel round trip): status is + not part of the list signature, so the card is neither redrawn nor cleared; the rail and trigger show the pending level + optimistically with a spinner beside it (on a model change, at the end of that row). On failure the popover shows the + host's own message (with the session-in-use case spelled out) and the rail returns to the effective level. +- The trigger's effort label follows Codex `_ModelPickerTriggerEffortText`: all names stacked in one cell with a blurred + cross-fade, so the width never jumps. +- Copy is borrowed from the host `model` namespace first (word-for-word with the native menu, built-in model descriptions + localized too); our own table is only the fallback. +- New settings row "Codex model picker" (`modelPicker`, **on by default**). Off removes every node of ours and the host + menu (⑫ face A) returns at once; while the settings document has not arrived the component does not take over, so it + never flashes on and off. **`Config` therefore has 12 fields** (the replication spec lists 11 plus the theme preference). +- **Not built**: the three advanced states of the Codex rail (highlight, Fast-mode tick fly-out, the purple/blue gradient + beyond the maximum) — DSH has neither Fast mode nor that state; the purple Max label on the trigger is skipped too, + being outside the color whitelist. +- Note: 606faea once committed a 0.6.0 under the same name, but only the generated `client.js` — the component and style + sources, the build change, the checks and the probe never reached the repository — and it was reverted. This release is + a complete redo with sources, build and verification committed together. + +### Review fixes + +- **Two sources for the composer shadow**: `patches.css` ⑧ still carried `[data-composer-card] { box-shadow: + var(--dsw-elevation-panel) }`, a second source next to `composer.css` ⑭'s three Codex layers (specificity decided the + winner). The host only renders this card inside the conversation scroller; removed. +- **`--dsw-elevation-soft` is no longer invented**: 0.5.10's `0 8px 28px` at 10% ink (55% black in dark) had neither a + measurement nor a source anchor, yet it landed on the host `SegmentedControl` thumb — every segmented control in + settings wore it. The host's own value is back in charge. +- `--dsw-elevation-stroke` is now `0 0 0 .5px var(--dsw-elevation-stroke-color)` (the spec's wording; computed value unchanged). +- **Font stacks** are declared verbatim once in `skin.css` (checked word-for-word against the host 0.1.7-rc.2 + base_css_default), so the look no longer drifts with the host version. +- **Focus ring wired to the host token**: `--dsw-focus-ring-color: var(--dsw-codex-focus)`. The host's ten box-shadow ring + rules used to fall back to ink and are now Codex blue; the skin's own rings read the token too, which makes them honour + the host's "no ring for pointer input" contract (under `html[data-input-modality=pointer]` the host sets it to + transparent in place). +- New `--dsw-codex-border` (10% / 12%) and `--dsw-codex-border-strong` (15% / 20%) — Codex's `--color-border` and + `--color-border-strong`. +- **Wrong dark default on the settings card**: `SKIN_DEFAULTS.dark.surface` was still `#181818` although the dark window + background has been `#111111` since 0.5.6, so the background swatch showed a "follow skin" value that was not the skin's. + Fixed; `check-repo` now reconciles every field against skin.css and fails on drift. +- `.cx-error` read a token that does not exist (`--dsw-alias-state-error`), so "the save did not take effect" was plain + text colour; it now reads `error-primary`. +- **Font stack validation hardened**: `isFontStack` also rejects backslashes (`u\72l(` tokenizes as `url(`), comment + delimiters (a comment opened inside the value swallowed the whole dark override block), control characters and + unbalanced quotes. `check-repo` asserts the "dark block survives" outcome on the generated CSS. +- **The installer wrote invalid YAML on a fresh profile**: dsh rc.2 creates `cordis.patch.yml` as a flow-style `[]`, and + appending `- insert:` after it made `dsh web` die at boot with `failed to parse overlay`. An empty `[]` is now removed + first; a non-empty flow list is refused with a hint. +- **A local path shipped in the artifacts**: a clone path in a `composer.css` comment was copied verbatim into `theme.css` + and `client.js`, while the repository check only scanned JS and missed the JSON-escaped double backslash. The check now + covers stylesheets and docs in both spellings; local paths in `docs/` became placeholders. +- Stale comments: card radius 10px → 12px, "links are ink" → accent, 100ms → 150ms transitions, composer 14px → 16px, + ⑫ row radius 8 → 13, `install-plugin.mjs` → `src/build.mjs` in the layer headers, "five stylesheets" in `build.mjs`, + and the skin README's file table and "links are ink" rule. + +### Verification + +- **New** `scripts/power-rail-verify.mjs` (47 assertions, Chromium only): the real component and the real theme.css + against a fake directory written to the host contract, with `select()` slowed to 600ms on purpose — the real host + answers locally in under 60ms, too fast to observe pending behaviour in the live GUI. It found and fixed two defects: + built-in model descriptions rendered the dictionary key when no host dictionary was present, and Escape did nothing + while a mouse-open left focus on the trigger. +- **New** `scripts/pack-host-asar.mjs`: without a desktop shell, packs npm-installed host packages (the same set the shell + ships) into an `app.asar` the fixtures can read. +- `hero-verify.mjs`: "badge radius stays the official 6px" hard-coded rc.1's literal; rc.2 changed it to + `var(--dsw-radius-xs)` (the host gives 4px), so it always failed on rc.2. The expected value is now read from the host source. +- `live-gui-probe.mjs`: a fresh profile opens with two onboarding dialogs and the probe's clicks landed on their backdrop + — that is where the three "measured none" right-panel failures came from; the dialogs are dismissed first now. The model + seat is measured as face B (7 assertions: takeover, geometry, a keyboard change written into the host store and reverted); + with face B off it measures the face A pending window, with `--latency` (800ms by default) added to that round trip, + otherwise the window closes before the first sample. The probe puts the effort back afterwards. +- `settings-page-verify.mjs`: 9 rows; the dark background assertion still expected the pre-0.5.6 `#181818` and now expects + `#111111`; five new assertions for the switch (off, reload, no trigger of ours in the seat, host control visible, reset). +- Full run (npm `@deepseek-ai/dsh@0.1.7-rc.2` + an asar packed from it + Edge/Chromium 153; live GUI on a `dsh web` of the + same version): `check-repo` 23/23 · `audit` 36/36 · `elevation` 19/19 · `composer-shadow` 23/23 · `model-picker` 20 · + `power-rail` 47/47 · `rightbar` 42 · `hero` 25 · `sidebar-align` 6/6 · `sidebar-surface` 13/13 · live `live-gui-probe` + face B 14/14, face A 10/10 · `settings-page-verify` 29/29 · `theme-flash-probe` 9 windows, 0 intermediate frames (click → colour median 23ms). + ## 0.5.10 - 2026-09-28 A third Codex capture let the composer's ring be pinned down without depending on the device pixel ratio — and it diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 49474e8..4d7739f 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,80 @@ [English](CHANGELOG.md) +## 0.6.0 - 2026-09-28 + +模型选择器的 B 面(Codex 推理等级功率轨)落地;外加一轮对照复刻规格的全仓审查,修掉其中查实的问题。 + +### ⑳ 模型选择器 B 面:自建组件顶替模型位 + +- **做法是「DOM 顶替席位」,不寄生宿主菜单**(0.5.0 那条 CSS 重排的路卡顿、简陋,已 revert)。 + 自己的触发器追加进 `[data-slot="conversation.input.model"]`,弹层挂 `document.body`;席位里有我们的触发器时, + `model-picker.css` 用**一条**直接子代 `:has()` 把宿主那一格 `display:none`(仍在 React 树里)。不打标记属性 —— + React 换掉宿主子节点时标记会丢、宿主控件闪回;`:has()` 只看「我们在不在席」,摘掉触发器宿主立刻复原。 +- **数据与提交只走宿主**:`ctx.inject(['modelDirectories'], …)` 等服务(与宿主自己挂模型位同一个口子,服务缺席不阻塞皮肤), + `directoryFor(会话).store` 订阅,`select({ provider, model, reasoningEffort })` 提交。会话 id 取席位祖先的 + `data-conversation-session`(右栏侧边聊天有自己的席位),取不到再退到 `uiSession`。宿主没渲染的席位(子代理会话)不接管。 +- **功率轨按 Codex 源码逐字**:轨 24px / 圆角 12 / 前景 10% / `inset .5px` 描边;档位点 4px、热区 16px,走过的 30% 白; + 拇指 28px 白片 / `.5px` 强描边 / `0 0 2px` 微影;已选段强调色、止于拇指中线;主曲线 `.3s cubic-bezier(.23,1,.32,1)`, + 拇指首帧 0s、16ms 后抬到 .3s。弹层宽 254px(`spacing × 63.5`),入场 `.32s … 30ms` 自 `scale(.98)`; + 定位照宿主 `place()`(右沿对齐、上方 8px、视口留 12px)。 +- **交互**:真拖拽(按下抓取 → 自由滑动、**不提交** → 松手对齐最近档提交一次),`touch-action: none`;←/→/Home/End; + 键盘焦点把 2px 环画在拇指上;Escape 关闭并还焦点(鼠标打开后焦点在触发器上时也能关);换模型连带该模型的默认档提交,成功后关弹层。 + 档位数与名字全部来自 `reasoning.efforts`。 +- **往返期间**(宿主在整个 selectModel 往返里把目录标成 selecting):列表签名里没有 status,卡片不重画、不清空; + 轨与触发器按 pending 那一档乐观显示,档位名旁转圈(换模型时是那一行行尾转圈)。失败时弹层顶上给宿主同款提示 + (会话被占用单独一句),轨退回生效档。 +- 触发器档位文字照 Codex `_ModelPickerTriggerEffortText`:各档名叠在同一格、模糊交叉淡入,宽度不跳。 +- 文案先借宿主 `model` 命名空间(与原生菜单逐字一致,内置模型的说明也跟着本地化),宿主字典缺席才用自带表。 +- 设置卡新增一行「Codex 模型选择器」(`modelPicker`,**默认开**)。关掉即撤走全部自建节点,宿主原生菜单(⑫ A 面)立刻复原; + 设置文档还没到时不先接管,免得开了又撤闪一下。**Config 因此是 12 个字段**(复刻规格写的是 11 个 + 主题偏好)。 +- **没做**:Codex 功率轨的三个进阶态(高亮、Fast 圆点飞出、超出最大档的紫蓝渐变)—— DSH 没有 Fast 模式也没有那一态; + 触发器上 Max 档的紫色不做,它在彩色白名单之外。 +- 注:606faea 曾以同名 0.6.0 提交过一次,但只提交了生成物 `client.js`,组件与样式源文件、构建改动、体检与探针都没进仓库, + 随即 revert。本版是完整重做,源码、构建与验收一起入库。 + +### 审查修正 + +- **输入卡阴影双真源**:`patches.css` ⑧ 还写着 `[data-composer-card] { box-shadow: var(--dsw-elevation-panel) }`, + 与 `composer.css` ⑭ 的 Codex 三层阴影是同一元素的两个真源(谁生效只看权重)。宿主只在会话滚动区里渲染这张卡,已删。 +- **`--dsw-elevation-soft` 不再自造**:0.5.10 的 `0 8px 28px 10% 墨`(暗色 55% 黑)既无读数也无锚点,却落在宿主 + `SegmentedControl` 的选中片上(设置页每个分段控件都背着它)。交还宿主自己的值。 +- `--dsw-elevation-stroke` 改为 `0 0 0 .5px var(--dsw-elevation-stroke-color)`(规格原文;计算值不变)。 +- **字体栈**在 `skin.css` 逐字声明一次(与宿主 0.1.7-rc.2 的 base_css_default 逐字比对一致),外观不随宿主版本漂。 +- **焦点环接到宿主令牌上**:声明 `--dsw-focus-ring-color: var(--dsw-codex-focus)`。宿主 10 条用 box-shadow 画环的组件规则 + 此前落回墨色回落值,现在是 Codex 蓝;皮肤自己的环也改读它,于是继承宿主「指针操作不出环」的约定 + (`html[data-input-modality=pointer]` 时宿主把它就地置成 transparent)。 +- 新增 `--dsw-codex-border`(10% / 12%)与 `--dsw-codex-border-strong`(15% / 20%),即 Codex 的 `--color-border` / `--color-border-strong`。 +- **设置卡深色背景的默认值错了**:`SKIN_DEFAULTS.dark.surface` 停在 `#181818`,而 0.5.6 起深色窗口背景是 `#111111` + —— 背景色块显示的「跟随皮肤」值与实际不符。已改,`check-repo` 新增逐字段对账 skin.css,漂移即 FAIL。 +- `.cx-error` 读的是不存在的 `--dsw-alias-state-error`,「保存没生效」的提示一直是普通文字色;改读 `error-primary`。 +- **字体栈校验加固**:`isFontStack` 另挡反斜杠(`u\72l(` 在分词器眼里就是 `url(`)、注释起止(值里开一个注释会把深色覆盖整块吞掉)、 + 控制字符与不成对的引号。`check-repo` 对「吞掉深色块」直接断言产物。 +- **安装器在新 profile 上写坏 YAML**:dsh rc.2 新建 profile 的 `cordis.patch.yml` 是流式空列表 `[]`,安装器直接在后面接 + `- insert:`,`dsh web` 启动即抛 `failed to parse overlay`。现在空 `[]` 先摘掉,非空流式列表拒绝并提示。 +- **产物里带着本机路径**:`composer.css` 注释里的本地克隆路径被原样拼进 `theme.css` 与 `client.js` 发出去,而体检只扫 JS、 + 且认不出 JSON 转义后的双反斜杠。体检扩到样式与文档并认两种写法;`docs/` 里的本机路径换成占位符。 +- 过期注释:卡片圆角 10px → 12px、「链接 = 墨」→ 强调色、过渡 100ms → 150ms、输入区 14px → 16px、⑫ 行圆角 8 → 13、 + 各层头部的 `install-plugin.mjs` → `src/build.mjs`、`build.mjs` 的「五份样式」、皮肤 README 的文件表与「链接是墨色」。 + +### 验收 + +- **新增** `scripts/power-rail-verify.mjs`(47 项,只要 Chromium):真实组件 + 真实 theme.css + 按宿主契约写的假目录, + `select()` 往返故意放慢到 600ms —— 本机真宿主往返不到 60ms,pending 行为在真 GUI 上来不及量。它量出并修掉了两个问题: + 宿主字典缺席时内置模型说明画出了字典键名;鼠标打开后焦点在触发器上时 Escape 关不掉。 +- **新增** `scripts/pack-host-asar.mjs`:没有桌面壳时,把 npm 装的宿主包拼成夹具能读的 `app.asar`(与桌面壳同一批包)。 +- `hero-verify.mjs`:「徽标圆角保持官方 6px」写死的是 rc.1 的字面量;rc.2 改成了 `var(--dsw-radius-xs)`(宿主给 4px), + 在 rc.2 上必然 FAIL。期望值改为从宿主源码现取。 +- `live-gui-probe.mjs`:新 profile 一进来是两层引导弹层,探针的点击全落在遮罩上 —— 右栏三条断言「实测 none」就是这么来的, + 现在先关弹层。模型位按 B 面量 7 条(顶替、几何、键盘改档写进宿主 store 并改回);B 面关着时量 A 面 pending, + `--latency`(默认 800ms)给那一次往返加时延,否则窗口在第一次采样前就结束了。探针改完档位会改回去。 +- `settings-page-verify.mjs`:9 行;深色底色断言仍是 0.5.6 之前的 `#181818`,改为 `#111111`;新增开关 5 条 + (关掉后刷新、席位里没有自建触发器、宿主那一格可见,收工复位)。 +- 全套(npm `@deepseek-ai/dsh@0.1.7-rc.2` + 由它打包的 asar + Edge/Chromium 153;真 GUI 为同版本 `dsh web`): + `check-repo` 23/23 · `audit` 36/36 · `elevation` 19/19 · `composer-shadow` 23/23 · `model-picker` 20 · `power-rail` 47/47 · + `rightbar` 42 · `hero` 25 · `sidebar-align` 6/6 · `sidebar-surface` 13/13 · 真 GUI `live-gui-probe` B 面 14/14、A 面 10/10 · + `settings-page-verify` 29/29 · `theme-flash-probe` 9 段 0 异常帧(点击→变色中位 23ms)。 + ## 0.5.10 - 2026-09-28 第三张 Codex 截图让输入卡环的取值**不再依赖设备像素比**就能定下来 —— 也顺带查出 0.5.8 的 12% 偏重。 diff --git a/README.md b/README.md index b9f2551..e3316ab 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # codex-ui - **Codex appearance for DSH Web: window edges, sidebar divider, model menu, composer, theme colors** + **Codex appearance for DSH Web: window edges, sidebar divider, model picker and reasoning power rail, composer, theme colors** [简体中文](README.zh-CN.md) · [Changelog](CHANGELOG.md) · [MIT](LICENSE) @@ -26,14 +26,16 @@ 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`). +Developed against DSH `0.1.7-rc.1` (npm global install) and `0.1.7-rc.2` (Windows desktop shell `app.asar`); +the full 0.6.0 verification ran on the npm release `@deepseek-ai/dsh@0.1.7-rc.2` (a real `dsh web` instance plus an asar packed from it, see "Host paths"). The verification scripts read shipped CSS straight out of `app.asar`; a host upgrade that changes structure fails their assertions. ## Features | Id | Content | |---|---| -| ⑫ | Model picker: native trigger, opaque white menu (18px radius), 28px rows with 13px radius (concentric: 18 − 5px inset), permanent check column, pending spinner | +| ⑳ | **Model picker, face B** (on by default): a component of our own takes over the composer model seat — model list (grouped by provider, with descriptions) plus the Codex reasoning **power rail** (24px track / 4px ticks / 28px white thumb, real drag, ←/→/Home/End, levels from the data); during the round trip it shows the new level optimistically with a spinner and never clears the list; the trigger cross-fades the effort label with blur. Can be switched off in settings | +| ⑫ | Model picker, face A (the host menu when ⑳ is off): opaque white menu (18px radius), 28px rows with 13px radius (concentric: 18 − 5px inset), permanent check column, pending spinner | | ⑬ | Composer header turned blank, panel buttons keep the official icons | | ⑬c | Both header slots **released**: entries registered there render again (subagent descendant count / job roster / preset badge / open-in-app); the view tabs stay hidden. The entries are conditionally rendered, so the top bar is unchanged in the common case | | ⑭ | Composer card: radius (card 20/25, top strip = the card's own radius), shadow, geometry, tool row, hero layout | @@ -43,7 +45,7 @@ The verification scripts read shipped CSS straight out of `app.asar`; a host upg | ②c | Conversation window edge: 0.5px hairline plus a 24px ambient shadow | | ②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 / contrast | +| ⑱ | 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 | ## Screenshots @@ -55,7 +57,11 @@ Composer bottom row: no box by default, a chip on hover. ![Composer controls](assets/screenshots/composer-controls-hover.png) -Model menu while a reasoning effort is being written. +Model picker face B (captured on a real `dsh web` instance): model list plus reasoning power rail, light and dark. + +

Model picker · light Model picker · dark

+ +With face B switched off, the host menu (face A) while a reasoning effort is being written. ![Model menu pending](assets/screenshots/model-pending.png) @@ -71,8 +77,8 @@ node scripts/install-plugin.mjs --bundle --write # register through node scripts/build.mjs --check # report stale artifacts without writing ``` -The installer scopes the six stylesheets in `skins/codex-ink/` to `html[data-codex-ui]`, combines them with -`src/client.template.js` (which inlines `src/override.js` and `src/settings-card.js`) into `client.js`, copies the +The installer scopes the eight stylesheets in `skins/codex-ink/` to `html[data-codex-ui]`, combines them with +`src/client.template.js` (which inlines `src/override.js`, `src/model-picker.js` and `src/settings-card.js`) into `client.js`, copies the result to `profiles//vendor/codex-ui`, creates the `node_modules/codex-ui` junction, and keeps exactly one registration path. @@ -98,13 +104,15 @@ node scripts/install-skin.mjs --write # overwrite on drift | `src/client.template.js` | Browser half template (stylesheet, override layer, settings seat) | | `src/override.js` | Override-layer pure functions (no DOM; unit-tested by the repo checks) | | `src/settings-card.js` | The config card on the bundle page (inlined into `client.js` at build time) | +| `src/model-picker.js` | Model picker face B: seat takeover, popover, power rail (its pure functions are unit-tested by `check-repo`) | | `src/build.mjs` | Scoping and artifact generation; the only implementation | | `theme.css` `client.js` | Generated from `skins/codex-ink/` by `src/build.mjs` | -| `skins/codex-ink/` | Stylesheet sources (skin.css / patches.css / sidebar-align.css / sidebar-surface.css / window-shadow.css / composer.css / settings.css) | +| `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) | | `docs/` | Plans and decisions | | `scripts/build.mjs` | Regenerate the artifacts; `--check` compares without writing | | `scripts/check-repo.mjs` | Host-free repository checks; the CI entry point | | `scripts/host-paths.mjs` | Resolves `app.asar`, the global `@deepseek-ai` modules and Chromium | +| `scripts/pack-host-asar.mjs` | Without a desktop shell, packs npm-installed host packages into an `app.asar` the fixtures can read | | `scripts/install-plugin.mjs` `scripts/install-skin.mjs` | Installers | | `scripts/*-verify.mjs` `scripts/live-gui-probe.mjs` `scripts/settings-page-verify.mjs` | Fixture verification and live probing | | `scripts/make-verify-profile.mjs` | Builds a throwaway verification profile: plugin manager enabled, only this plugin, no existing profile touched | @@ -118,15 +126,16 @@ node scripts/install-skin.mjs --write # overwrite on drift |---|---|---| | `npm run check` | Syntax, JSON, manifest, artifact sync, encoding, docs pairing, machine-specific paths | none | | `node scripts/audit-codex-ink.mjs` | Skin structure, 36 WCAG pairs, color whitelist | none | -| `node scripts/model-picker-verify.mjs` | ⑫ and the pending indicator, 20 assertions | none | +| `node scripts/model-picker-verify.mjs` | ⑫ (face A, the host menu) and the pending indicator, 20 assertions | none | +| `node scripts/power-rail-verify.mjs` | ⑳ face B: seat takeover and hand-back, trigger and popover geometry, Codex power rail geometry verbatim, no commit while dragging / one snapped commit on release, no snap-back and no list reset during a slow (600ms) round trip with a spinner, the four keys, focus ring, Escape, model change carrying its default effort, failure notice, reduced motion, dark, the switch — 47 assertions | none (Chromium only) | | `node scripts/rightbar-verify.mjs` | Shadow layer, right panel, both dividers, 42 assertions | none | | `node scripts/sidebar-align-verify.mjs` | Sidebar column alignment, 6 assertions | none | | `node scripts/sidebar-surface-verify.mjs` | Sidebar scroll fade (the Codex mask ramp): mechanism plus pixels, four states side by side, 13 assertions | none | | `node scripts/hero-verify.mjs` | ⑬ ⑭ ⑰, the focus ring and the released header slots, 25 assertions | none | | `node scripts/composer-shadow-verify.mjs` | ⑱ Composer shadow aligned to Codex's `--elevation-composer`: per-layer geometry and alpha in light, the dark inset with zero outside shadow, the narrow-viewport 80→40px branch, plus rendered pixels (falloff radius, inside top edge) and one precondition self-check — 23 assertions | none | | `node scripts/elevation-verify.mjs` | ⑲ The `--dsw-elevation-*` tokens against Codex's source: per-layer geometry and alpha, layer 1 tracking the stroke, layers 2–3 identical across themes (Codex declares no dark variant), and a rendered menu panel — 19 assertions | none | -| `node scripts/live-gui-probe.mjs --url ` | Real GUI: 10 assertions on shadows, both dividers, the model menu pending window | a running `dsh web` | -| `node scripts/settings-page-verify.mjs --url ` | Real GUI: the card on the bundle page, its 8 rows, no override at defaults, switch and accent writes, survival across a reload — 22 assertions | a running `dsh web` with the plugin manager enabled | +| `node scripts/live-gui-probe.mjs --url ` | Real GUI: 7 assertions on shadows and both dividers, plus the model seat — 7 on face B when it is on (takeover, geometry, a keyboard change written into the host store and reverted), or 3 on the face A pending window when it is off (`--latency` adds 800ms to that round trip by default; locally it takes <60ms and the window cannot be sampled) | a running `dsh web` | +| `node scripts/settings-page-verify.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 — 29 assertions | a running `dsh web` with the plugin manager enabled | | `node scripts/theme-flash-probe.mjs --url ` | Per-frame sampling of the effective backdrop during theme and page switches (first opaque ancestor background); reports frames belonging to neither end of the transition (measured: 9 windows, ~720 frames, 0 anomalies) | same as above | `npm run check` needs no host. The fixture suites run locally: they need shipped CSS from `app.asar` plus a DOM @@ -141,7 +150,19 @@ The verification scripts read the host they run against. Each path is resolved i 2. `scripts/host.local.json`, a gitignored per-machine file, for example `{ "asar": "D:/.../resources/app.asar" }`; 3. a scan of the standard install locations, Playwright's browser cache and `npm root -g`. -No machine-specific path is committed. +No machine-specific path is committed (`npm run check` scans JS, stylesheets and docs, including the JSON-escaped form inside `client.js`). + +Without a desktop shell, pack the same packages from npm: + +```powershell +npm install @deepseek-ai/dsh@0.1.7-rc.2 --prefix +node scripts/pack-host-asar.mjs --from /node_modules --out /app.asar +$env:DSH_ASAR = "/app.asar"; $env:DSH_GLOBAL_MODULES = "/node_modules" +$env:DSH_CHROME = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" # system Edge when Playwright is not installed +``` + +The same npm package can also boot a real instance: `$env:DSH_HOME = "/home"; node /node_modules/@deepseek-ai/dsh/lib/bin.js web --port 3098 --no-open` +(the first run creates the web profile; then run `install-plugin.mjs --profile web --write`). ```powershell dsh --profile web --port 3099 --no-open # prints a token URL @@ -173,6 +194,7 @@ dark default, 60): | UI font | `fontUi` | empty | `--dsw-font-family` | | Code font | `fontCode` | empty | `--ds-font-family-code` | | Translucent sidebar | `translucentSidebar` | off | sidebar fill and row fills become translucent | +| Codex model picker | `modelPicker` | on | the ⑳ face B component takes over the model seat; off removes every node of ours and the host menu (⑫ face A) comes straight back | | Contrast | `contrastLight` / `contrastDark` | 45 / 60 | text tiers and the neutral alpha ladder | - **The theme row is not card-local view state**: it writes the host's `ui-theme` `preference`, the same setting as @@ -189,10 +211,12 @@ dark default, 60): `color-scheme` on `html`), then handed back idempotently when `theme/change` arrives with the same result. If the confirmation does not arrive within 2.5s, the preview rolls back to the truth. Measured click → colour change: **824ms → 22ms** (median), with the run sequence still two runs (light×n → dark×m) and no double publish. -- All 11 fields are `.volatile()`: the settings service only projects volatile fields, and that is exactly how the +- All 12 fields are `.volatile()`: the settings service only projects volatile fields, and that is exactly how the plugin manager knows the entry — no `Config`, no card. - **Empty means no override**: at the defaults the override layer emits an empty string and `data-codex-ui-theme` never - appears, so an untouched install looks byte-for-byte like 0.1.x (a repo check asserts this). + appears, so an untouched install's style layer is byte-for-byte 0.1.x (a repo check asserts this). The model picker is a + structural component and does not go through the override layer: it is on by default and governed by the single + `modelPicker` switch; switched off, the seat and the host menu are handed back completely. - Overrides live in one runtime `' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + /* 子代理会话:宿主在那里返回 null(席位是空的),组件不许出头。 */ + + '
' + + '' + + ''; + +fs.mkdirSync(join(tmpdir(), 'codex-ui-fixtures'), { recursive: true }); +const htmlPath = join(tmpdir(), 'codex-ui-fixtures', 'power-rail-verify.html'); +fs.writeFileSync(htmlPath, page); + +const PORT = 9357; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const PROFILE = tempDir('dsh-cdp-power-rail'); +fs.rmSync(PROFILE, { recursive: true, force: true }); +const child = spawn(chromePath(), ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + PROFILE, '--no-first-run', '--disable-gpu', '--hide-scrollbars', '--window-size=900,640', 'about:blank'], { stdio: 'ignore' }); +let info; for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } +if (!info) { child.kill(); throw new Error('headless chrome did not start'); } +const ws = new WebSocket(info.webSocketDebuggerUrl); +await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); +let seq = 0; const waiting = new Map(); +const pageErrors = []; +ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.method === 'Runtime.exceptionThrown') pageErrors.push(String(m.params.exceptionDetails.exception?.description ?? m.params.exceptionDetails.text).slice(0, 300)); + if (m.id && waiting.has(m.id)) { waiting.get(m.id)(m); waiting.delete(m.id); } +}; +const send = (method, params = {}, sid) => new Promise((res, rej) => { const id = ++seq; waiting.set(id, (m) => m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sid ? { sessionId: sid } : {}) })); }); +const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); +const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); +const S = (method, params) => send(method, params, sessionId); +const evalJs = async (expr) => { + const r = await S('Runtime.evaluate', { expression: expr, returnByValue: true }); + if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails).slice(0, 400)); + return r.result.value; +}; +const J = async (expr) => JSON.parse(await evalJs('JSON.stringify(' + expr + ')')); +const mouse = async (type, x, y, buttons = 0) => S('Input.dispatchMouseEvent', { type, x, y, button: type === 'mouseMoved' ? (buttons ? 'left' : 'none') : 'left', buttons, clickCount: 1 }); +const click = async (x, y) => { await mouse('mouseMoved', x, y); await mouse('mousePressed', x, y, 1); await mouse('mouseReleased', x, y, 0); }; +const key = async (k, code) => { + await S('Input.dispatchKeyEvent', { type: 'keyDown', key: k, code: k, windowsVirtualKeyCode: code }); + await S('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code: k, windowsVirtualKeyCode: code }); +}; +const center = async (sel) => J('(() => { const r = document.querySelector(' + JSON.stringify(sel) + ').getBoundingClientRect(); return [Math.round(r.left + r.width / 2), Math.round(r.top + r.height / 2)]; })()'); +const media = async (reduced) => S('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: reduced ? 'reduce' : 'no-preference' }] }); + +await S('Page.enable'); +await S('Runtime.enable'); +/* DPR 2:0.5px 的 border 在 DPR 1 下会被 Chromium 抬成 1 个设备像素,量不出发丝线(其余夹具同为 2)。 */ +await S('Emulation.setDeviceMetricsOverride', { width: 900, height: 640, deviceScaleFactor: 2, mobile: false }); +await media(false); +await S('Page.navigate', { url: 'file:///' + htmlPath.replaceAll('\\', '/') + '?v=' + Date.now() }); +await sleep(1200); + +const results = []; +const check = (name, ok, detail) => results.push([name, !!ok, detail]); +const near = (a, b, tol = 0.6) => Math.abs(a - b) <= tol; + +/** 一次取全弹层 / 轨的几何与状态。 */ +const STATE = `(() => { + const q = (s) => document.querySelector(s); + const box = (e) => { if (!e) return null; const b = e.getBoundingClientRect(); return { x: b.left, y: b.top, w: b.width, h: b.height, r: b.right, b: b.bottom }; }; + const cs = (e, p) => e ? getComputedStyle(e).getPropertyValue(p) : null; + const pop = q('.codex-mp-popover'); const root = q('.codex-mp-root'); const trig = q('.codex-mp-trigger'); + return { + open: pop !== null && !pop.hidden, + pop: box(pop), trig: box(trig), root: box(root), track: box(q('.codex-mp-track')), range: box(q('.codex-mp-range')), + thumb: box(q('.codex-mp-thumb-scale')), + ticks: [...document.querySelectorAll('.codex-mp-tick')].map((t) => ({ ...box(t), sel: t.dataset.selected, color: cs(t, 'background-color') })), + rows: document.querySelectorAll('.codex-mp-row').length, + checked: [...document.querySelectorAll('.codex-mp-row')].map((r) => r.getAttribute('aria-checked')), + now: root ? root.getAttribute('aria-valuenow') : null, + pos: root ? root.style.getPropertyValue('--codex-mp-pos') : null, + value: q('.codex-mp-effort-value') ? q('.codex-mp-effort-value').textContent : null, + spinner: !!q('.codex-mp-effort-value .codex-mp-spinner'), + trigEffort: trig ? [...trig.querySelectorAll('.codex-mp-effort-text')].filter((e) => e.dataset.active === 'true').map((e) => e.textContent).join() : null, + trigModel: trig ? trig.querySelector('.codex-mp-trigger-model').textContent : null, + expanded: trig ? trig.getAttribute('aria-expanded') : null, + error: q('.codex-mp-error') && !q('.codex-mp-error').hidden ? q('.codex-mp-error').textContent : null, + calls: window.__dir.calls.length, lastCall: window.__dir.calls.at(-1) ?? null, status: window.__dir.store.getSnapshot().status, + focus: document.activeElement ? document.activeElement.className : null, + kbd: root ? root.dataset.keyboardFocused ?? null : null, + thumbOutline: cs(q('.codex-mp-thumb-scale'), 'outline'), + }; +})()`; +const state = () => J(STATE); + +/* ── 1. 席位顶替 ───────────────────────────────────────────────────────── */ +const seat = await J(`(() => { + const slot = document.querySelector('[data-conversation-session="session-a"] [data-slot="conversation.input.model"]'); + const host = slot.querySelector('._7KE1Ra_root'); const ours = slot.querySelector('.codex-mp-trigger'); + const cs = ours ? getComputedStyle(ours) : null; + return { host: getComputedStyle(host).display, ours: ours !== null, h: ours ? ours.getBoundingClientRect().height : 0, + radius: cs ? cs.borderTopLeftRadius : null, bg: cs ? cs.backgroundColor : null, + sub: document.getElementById('sub-seat').children.length, text: ours ? ours.textContent : '' }; +})()`); +check('席位里有自建触发器', seat.ours, seat.text); +check('宿主那一格 display:none(仍在 DOM 里)', seat.host === 'none', seat.host); +check('宿主没渲染的席位(子代理会话)不接管', seat.sub === 0, seat.sub + ' 个子节点'); +check('触发器 28px 高、全圆角胶囊、默认透明', near(seat.h, 28) && seat.radius === '999px' && seat.bg === 'rgba(0, 0, 0, 0)', JSON.stringify(seat)); + +/* ── 2. 弹层与轨几何 ───────────────────────────────────────────────────── */ +const [tx, ty] = await center('.codex-mp-trigger'); +await click(tx, ty); +await sleep(80); +const anim = await J(`(() => { const cs = getComputedStyle(document.querySelector('.codex-mp-popover')); return { name: cs.animationName, dur: cs.animationDuration, delay: cs.animationDelay, ease: cs.animationTimingFunction, origin: cs.transformOrigin }; })()`); +await sleep(500); +let st = await state(); +const popStyle = await J(`(() => { const cs = getComputedStyle(document.querySelector('.codex-mp-popover')); return { radius: cs.borderTopLeftRadius, pad: cs.paddingTop, shadow: cs.boxShadow, bg: cs.backgroundColor }; })()`); +check('点触发器打开弹层(aria-expanded=true)', st.open && st.expanded === 'true', JSON.stringify({ open: st.open, expanded: st.expanded })); +check('弹层宽 254px(Codex spacing × 63.5)', near(st.pop.w, 254), st.pop.w); +check('弹层在触发器上方 8px、右沿对齐(宿主 place())', near(st.trig.y - st.pop.b, 8, 1) && near(st.trig.r, st.pop.r, 1), 'gap=' + (st.trig.y - st.pop.b) + ' right=' + (st.trig.r - st.pop.r)); +check('弹层 18px 圆角 + 内衬 5px + 菜单软影', popStyle.radius === '18px' && popStyle.pad === '5px' && /0px 8px 32px/.test(popStyle.shadow), JSON.stringify(popStyle)); +check('入场 .32s cubic-bezier(.23,1,.32,1) 30ms,从右下角长出', anim.name === 'codex-mp-enter' && anim.dur === '0.32s' && anim.delay === '0.03s' && /0\.23, 1, 0\.32, 1/.test(anim.ease), JSON.stringify(anim)); +check('列表三行、当前模型打勾', st.rows === 3 && st.checked[0] === 'true', JSON.stringify(st.checked)); +/* 宿主字典缺席(本夹具 locale 为 null)时,内置模型的说明要落回目录原文,不能把字典键名画出来。 */ +const desc = await evalJs('(document.querySelector(".codex-mp-row-desc") || {}).textContent || null'); +check('说明文字是目录原文、不是字典键名', typeof desc === 'string' && desc.startsWith('Fast, efficient') && !desc.startsWith('option.'), desc); +const railStyle = await J(`(() => { const t = getComputedStyle(document.querySelector('.codex-mp-track')); const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); + const c = getComputedStyle(document.querySelector('.codex-mp-container')); const rg = getComputedStyle(document.querySelector('.codex-mp-range')); + return { tRadius: t.borderTopLeftRadius, tBg: t.backgroundColor, tRing: t.boxShadow, thBg: th.backgroundColor, thBorder: th.borderTopWidth + ' ' + th.borderTopStyle, thShadow: th.boxShadow, + cH: c.height, cPad: c.paddingTop + ' ' + c.paddingLeft, rRadius: rg.borderTopLeftRadius + ' ' + rg.borderTopRightRadius, touch: getComputedStyle(document.querySelector('.codex-mp-root')).touchAction }; })()`); +check('轨 24px 高、12px 圆角', near(st.track.h, 24) && railStyle.tRadius === '12px', st.track.h + ' / ' + railStyle.tRadius); +check('轨底 = 前景 10%、0.5px 内描边', /0\.1\)$/.test(railStyle.tBg) && /inset/.test(railStyle.tRing) && /0\.5px/.test(railStyle.tRing), railStyle.tBg + ' | ' + railStyle.tRing); +check('容器 32px、上下 2 / 左右 6;根 28px、touch-action none', railStyle.cH === '32px' && railStyle.cPad === '2px 6px' && near(st.root.h, 28) && railStyle.touch === 'none', JSON.stringify(railStyle)); +/* 描边断言**声明值**:Chromium 153 把 0.5px 的 border 在计算值里取整成 1px(DPR 2 也一样), + Codex 源码同样写的是 border .5px、跑在同一个引擎上,渲染结果一致;计算值量不出「写的是不是 0.5」。 */ +const thumbDecl = await evalJs(`(() => { for (const sheet of document.styleSheets) for (const rule of sheet.cssRules) { + if (rule.selectorText && / \\.codex-mp-thumb$/.test(rule.selectorText)) return rule.style.getPropertyValue('border'); } + return null; })()`); +check('拇指 28px 白圆片、声明 0.5px 描边、0 0 2px 微影', near(st.thumb.w, 28) && railStyle.thBg === 'rgb(255, 255, 255)' && /^0\.5px solid/.test(thumbDecl ?? '') && /0px 0px 2px/.test(railStyle.thShadow), JSON.stringify({ w: st.thumb.w, bg: railStyle.thBg, decl: thumbDecl, computed: railStyle.thBorder })); +check('档位圆点 4px × 4 个', st.ticks.length === 4 && st.ticks.every((t) => near(t.w, 4) && near(t.h, 4)), st.ticks.length); +const W = st.root.w; +const expectX = (i) => st.root.x + 14 + (W - 28) * i / 3; +check('圆点等距落在拇指行程 [14, 宽 − 14] 上', st.ticks.every((t, i) => near(t.x + t.w / 2, expectX(i))), st.ticks.map((t) => Math.round((t.x + t.w / 2 - st.root.x) * 10) / 10).join(' / ')); +check('拇指中心 = 生效档(High = 第 3 档)', near(st.thumb.x + 14, expectX(2)) && st.now === '2', 'thumb=' + (st.thumb.x + 14 - st.root.x) + ' now=' + st.now); +check('强调色条止于拇指中线、只圆左侧两角', near(st.range.r, st.thumb.x + 14) && railStyle.rRadius === '12px 0px', 'range.r=' + st.range.r + ' thumb=' + (st.thumb.x + 14)); +check('走过的圆点 30% 白、没走到的另一色', st.ticks[0].sel === 'true' && st.ticks[3].sel === 'false' && st.ticks[0].color === 'rgba(255, 255, 255, 0.3)' && st.ticks[3].color !== st.ticks[0].color, st.ticks.map((t) => t.sel + ':' + t.color).join(' | ')); +if (SHOT) { + const s = await S('Page.captureScreenshot', { format: 'png', clip: { x: st.pop.x - 20, y: st.pop.y - 20, width: st.pop.w + 40, height: st.trig.b - st.pop.y + 40, scale: 1 } }); + fs.writeFileSync(join(EVID, 'power-rail-verify.png'), Buffer.from(s.data, 'base64')); +} + +/* ── 3. 拖动:中途不提交、松手对齐提交一次;pending 不回弹、不清空、有转圈 ───── */ +const thumbX = Math.round(st.thumb.x + 14), railY = Math.round(st.root.y + st.root.h / 2); +await mouse('mouseMoved', thumbX, railY); +await mouse('mousePressed', thumbX, railY, 1); +for (const x of [thumbX - 30, thumbX - 80, Math.round(st.root.x - 20)]) { await mouse('mouseMoved', x, railY, 1); await sleep(30); } +st = await state(); +check('拖动中不提交(select 调用 0 次)', st.calls === 0, 'calls=' + st.calls); +check('拖动中拇指跟随指针(越界夹到 0)', st.pos === '0', 'pos=' + st.pos); +await mouse('mouseReleased', Math.round(st.root.x - 20), railY, 0); +await sleep(120); +st = await state(); +check('松手提交一次、对齐到最左档 off', st.calls === 1 && st.lastCall && st.lastCall.reasoningEffort === 'off', JSON.stringify(st.lastCall)); +check('往返中(selecting)轨停在新档、不回弹', st.status === 'selecting' && st.now === '0' && st.pos === '0', 'status=' + st.status + ' now=' + st.now); +check('往返中列表不清空', st.rows === 3, 'rows=' + st.rows); +check('往返中档位名旁转圈、触发器已按新档显示', st.spinner && st.trigEffort === 'Off', 'spinner=' + st.spinner + ' trig=' + st.trigEffort); +await sleep(800); +st = await state(); +check('往返结束:转圈撤掉、弹层仍开着(改档不关)', !st.spinner && st.open && st.now === '0' && st.value === 'Off', JSON.stringify({ spinner: st.spinner, open: st.open, value: st.value })); + +/* ── 4. 键盘 ───────────────────────────────────────────────────────────── */ +await evalJs('document.querySelector(".codex-mp-root").focus()'); +await key('ArrowRight', 39); +await sleep(700); +st = await state(); +check('→ 进一档(low)', st.lastCall.reasoningEffort === 'low' && st.now === '1', JSON.stringify(st.lastCall)); +check('键盘焦点:拇指上 2px 焦点环(offset 0)', st.kbd === 'true' && /2px/.test(st.thumbOutline) && /solid/.test(st.thumbOutline), st.thumbOutline); +await key('End', 35); +await sleep(700); +st = await state(); +check('End 到最后一档(max)', st.lastCall.reasoningEffort === 'max' && st.now === '3', JSON.stringify(st.lastCall)); +const before = st.calls; +await key('End', 35); +await sleep(200); +st = await state(); +check('同一档不重复提交', st.calls === before, 'calls ' + before + ' → ' + st.calls); +await key('Home', 36); +await sleep(700); +st = await state(); +check('Home 回第一档(off)', st.lastCall.reasoningEffort === 'off' && st.now === '0', JSON.stringify(st.lastCall)); + +/* ── 5. 失败:宿主把会话占用报回来 ───────────────────────────────────────── */ +await evalJs('window.__dir.fail = { code: "session/writer-held", message: "held" }; "ok"'); +await key('ArrowRight', 39); +await sleep(800); +st = await state(); +check('提交失败:弹层顶上出现占用提示', st.error !== null && /(已被占用|already in use)/.test(st.error), st.error); +check('提交失败:轨退回生效档', st.now === '0', 'now=' + st.now); + +/* ── 6. Escape / 换模型 ───────────────────────────────────────────────── */ +await key('Escape', 27); +await sleep(150); +st = await state(); +check('Escape 关闭并把焦点还给触发器', !st.open && st.expanded === 'false' && st.focus === 'codex-mp-trigger', JSON.stringify({ open: st.open, focus: st.focus })); +await click(tx, ty); +await sleep(500); +const [rx, ry] = await J(`(() => { const r = document.querySelectorAll('.codex-mp-row')[1].getBoundingClientRect(); return [Math.round(r.left + r.width / 2), Math.round(r.top + r.height / 2)]; })()`); +await click(rx, ry); +await sleep(120); +const rowBusy = await J(`(() => { const r = document.querySelectorAll('.codex-mp-row')[1]; return { busy: r.getAttribute('aria-busy'), spin: !!r.querySelector('.codex-mp-spinner') }; })()`); +check('换模型往返中:那一行行尾转圈', rowBusy.busy === 'true' && rowBusy.spin, JSON.stringify(rowBusy)); +await sleep(800); +st = await state(); +check('换模型连带该模型的默认档提交(pro → max)', st.lastCall.model === 'deepseek-v4-pro' && st.lastCall.reasoningEffort === 'max', JSON.stringify(st.lastCall)); +check('换模型成功后关弹层、触发器换名', !st.open && st.trigModel === 'DeepSeek-V4-Pro' && st.trigEffort === 'Max', st.trigModel + ' · ' + st.trigEffort); + +/* ── 7. reduced-motion ─────────────────────────────────────────────────── */ +await media(true); +await click(tx, ty); +await sleep(300); +const rm = await J(`(() => { const p = document.querySelector('.codex-mp-popover'); return { attr: p.dataset.reducedMotion, anim: getComputedStyle(p).animationName, thumb: getComputedStyle(document.querySelector('.codex-mp-thumb-scale')).transitionDuration }; })()`); +check('reduced-motion:入场动画取消、拇指不过渡', rm.attr === 'true' && rm.anim === 'none' && /^0s|^1e-05s|0\.00001s/.test(rm.thumb), JSON.stringify(rm)); +/* 鼠标打开后焦点在触发器上:Escape 也得关(宿主的键盘处理挂在整格根上)。 */ +const focusBefore = await evalJs('document.activeElement ? document.activeElement.className : null'); +await key('Escape', 27); +await sleep(150); +st = await state(); +check('鼠标打开后(焦点在触发器)Escape 也能关', focusBefore === 'codex-mp-trigger' && !st.open, 'focus=' + focusBefore + ' open=' + st.open); +await media(false); + +/* ── 8. 深色 ───────────────────────────────────────────────────────────── */ +await evalJs('document.body.setAttribute("data-ds-dark-theme", ""); "ok"'); +await click(tx, ty); +await sleep(500); +const dark = await J(`(() => { const p = getComputedStyle(document.querySelector('.codex-mp-popover')); const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); + return { shadow: p.boxShadow, bg: p.backgroundColor, thumb: th.backgroundColor, border: th.borderTopColor, track: getComputedStyle(document.querySelector('.codex-mp-track')).boxShadow }; })()`); +st = await state(); +check('深色下弹层打开', st.open && near(st.pop.w, 254), 'open=' + st.open); +check('深色:菜单影 50% 黑 + 5% 亮环(暗色靠填充分层)', /rgba\(0, 0, 0, 0\.5\) 0px 8px 32px/.test(dark.shadow) && dark.bg === 'rgb(33, 33, 33)', JSON.stringify(dark)); +check('深色:拇指仍是白片、描边 20% 白、轨环 12% 白', dark.thumb === 'rgb(255, 255, 255)' && dark.border === 'rgba(255, 255, 255, 0.2)' && /rgba\(255, 255, 255, 0\.12\)/.test(dark.track), JSON.stringify(dark)); +if (SHOT) { + const p = await state(); + const s = await S('Page.captureScreenshot', { format: 'png', clip: { x: p.pop.x - 20, y: p.pop.y - 20, width: p.pop.w + 40, height: p.trig.b - p.pop.y + 40, scale: 1 } }); + fs.writeFileSync(join(EVID, 'power-rail-verify-dark.png'), Buffer.from(s.data, 'base64')); +} +await key('Escape', 27); +await evalJs('document.body.removeAttribute("data-ds-dark-theme"); "ok"'); + +/* ── 9. 开关 ───────────────────────────────────────────────────────────── */ +await evalJs('window.__picker.setEnabled(false); "ok"'); +await sleep(100); +const off = await J(`(() => ({ ours: document.querySelectorAll('.codex-mp-trigger').length, host: getComputedStyle(document.querySelector('._7KE1Ra_root')).display }))()`); +check('关掉:自建触发器撤走、宿主那一格复原', off.ours === 0 && off.host !== 'none', JSON.stringify(off)); +await evalJs('window.__picker.setEnabled(true); "ok"'); +await sleep(100); +const on = await J(`(() => ({ ours: document.querySelectorAll('.codex-mp-trigger').length, host: getComputedStyle(document.querySelector('._7KE1Ra_root')).display }))()`); +check('再打开:重新接管', on.ours === 1 && on.host === 'none', JSON.stringify(on)); +/* React 换掉宿主子节点:标记不会丢,因为根本没有标记 —— 只看我们在不在席。 */ +await evalJs(`(() => { const slot = document.querySelector('[data-conversation-session="session-a"] [data-slot="conversation.input.model"]'); + const fresh = document.createElement('div'); fresh.className = '_7KE1Ra_root'; fresh.innerHTML = ''; + slot.querySelector('._7KE1Ra_root').replaceWith(fresh); return 'ok'; })()`); +await sleep(50); +const swapped = await J(`(() => { const h = document.querySelector('._7KE1Ra_root'); return { host: getComputedStyle(h).display, ours: document.querySelectorAll('.codex-mp-trigger').length }; })()`); +check('宿主换掉自己的子节点后仍隐藏、不闪回', swapped.host === 'none' && swapped.ours === 1, JSON.stringify(swapped)); +check('页面没有未捕获异常', pageErrors.length === 0, pageErrors.join(' | ') || '0'); + +ws.close(); child.kill(); +let pass = 0; +for (const [n, ok, d] of results) { console.log((ok ? 'PASS ' : 'FAIL ') + n + (d !== undefined ? ' [' + String(d).slice(0, 160) + ']' : '')); if (ok) pass += 1; } +console.log('\n' + (pass === results.length ? 'ALL PASS' : 'FAILED') + ' (' + pass + '/' + results.length + ')'); +process.exit(pass === results.length ? 0 : 1); diff --git a/scripts/settings-page-verify.mjs b/scripts/settings-page-verify.mjs index 954cf0b..caeb4c6 100644 --- a/scripts/settings-page-verify.mjs +++ b/scripts/settings-page-verify.mjs @@ -212,7 +212,7 @@ const shape = JSON.parse(await evaluate('(() => {' + ' link: getComputedStyle(document.documentElement).getPropertyValue("--dsw-alias-link").trim(),' + '}); })()', sessionId)); check('组合包页上有配置卡', shape.form === 1, JSON.stringify(shape)); -check('八行:主题/强调色/背景/前景/UI 字体/代码字体/半透明侧边栏/对比度', shape.rows === 8 && shape.labels.length === 8, JSON.stringify(shape.labels)); +check('九行:主题/强调色/背景/前景/UI 字体/代码字体/半透明侧边栏/Codex 模型选择器/对比度', shape.rows === 9 && shape.labels.length === 9, JSON.stringify(shape.labels)); check('默认值下不产生覆盖(无 data-codex-ui-theme)', shape.overrideAttr === false, 'attr=' + shape.overrideAttr); /* 读 body 上的生效值:深色的链接默认是 #0169cc,与亮色不同,读 html 会永远看到亮色那一份。 */ const bodyLink = await evaluate('getComputedStyle(document.body).getPropertyValue("--dsw-alias-link").trim()', sessionId); @@ -250,6 +250,14 @@ if (hexBox !== null) { await shot(OUT.replace(/\.png$/, '-accent.png')); } +/* 关掉「Codex 模型选择器」:刷新后在首页量宿主那一格是否复原(组合包页上没有输入区可量)。 */ +const pickerSwitch = () => evaluate('(() => { const el = document.querySelector("[aria-label=\\"Codex 模型选择器\\"]"); return el === null ? null : (el.getAttribute("aria-checked") ?? String(el.checked)); })()', sessionId); +check('模型选择器开关默认开着', (await pickerSwitch()) === 'true', 'checked=' + (await pickerSwitch())); +const pickerOff = await clickByLabel('Codex 模型选择器'); +check('找到模型选择器开关', pickerOff !== null, JSON.stringify(pickerOff)); +await sleep(1500); +check('开关写入后显示为关', (await pickerSwitch()) === 'false', 'checked=' + (await pickerSwitch())); + /* ── 主题那一行:写的是宿主主题偏好,整个应用一起变 ───────────────────── */ /** 读分段控件当前选中的那一段文本。 */ const selectedSegment = () => evaluate('(() => {' @@ -287,9 +295,10 @@ if (toDark !== null) { }; console.log('THEME 切到深色 ' + JSON.stringify(dark)); check('切深色后整个应用进了深色(body[data-ds-dark-theme])', dark.dark === true, JSON.stringify(dark)); - check('深色底色生效 #181818', String(dark.base).toLowerCase() === '#181818', 'base=' + dark.base); + /* 深色窗口背景自 0.5.6 起是 #111111(#181818 是表面/侧栏那一层)。 */ + check('深色底色生效 #111111', String(dark.base).toLowerCase() === '#111111', 'base=' + dark.base); check('分段显示深色', dark.segment === '深色', 'segment=' + dark.segment); - check('下面三行改为编辑深色那一套(背景色块 = #181818)', String(dark.swatch).toLowerCase() === '#181818', 'swatch=' + dark.swatch); + check('下面三行改为编辑深色那一套(背景色块 = #111111)', String(dark.swatch).toLowerCase() === '#111111', 'swatch=' + dark.swatch); await shot(OUT.replace(/\.png$/, '-dark.png')); } @@ -308,6 +317,10 @@ check('刷新后皮肤仍生效', afterReload.skin === true, JSON.stringify(afte check('刷新后覆盖仍在(强调色 #ff0000)', afterReload.link.toLowerCase() === '#ff0000', 'link=' + afterReload.link); check('刷新后侧栏半透明仍在', afterReload.sidebar.replace(/\s/g, '') === 'rgba(255,255,255,0.72)', 'sidebar=' + afterReload.sidebar); check('刷新后主题仍是深色(写的是宿主偏好,不是页面状态)', await evaluate('document.body.hasAttribute("data-ds-dark-theme")', sessionId) === true, JSON.stringify(afterReload)); +const seatOff = JSON.parse(await evaluate('(() => { const slot = document.querySelector("[data-slot=\\"conversation.input.model\\"]");' + + 'return JSON.stringify({ slot: slot !== null, ours: document.querySelectorAll(".codex-mp-trigger").length,' + + ' host: slot === null ? null : [...slot.children].filter((c) => getComputedStyle(c).display !== "none").length }); })()', sessionId)); +check('选择器关着:席位里没有自建触发器、宿主那一格可见', seatOff.slot === true && seatOff.ours === 0 && seatOff.host >= 1, JSON.stringify(seatOff)); /* 收工把主题还给亮色:刷新后已经在首页,先走回组合包页,别留下深色现场。 */ const onCard = async () => (await evaluate('document.querySelectorAll(".cx-form").length', sessionId)) > 0; @@ -320,6 +333,12 @@ if (!(await onCard())) { } const toLight = await clickByText('^亮色$', sessionId); if (toLight !== null) { await click(toLight.x, toLight.y, sessionId); await sleep(1500); } +/* 模型选择器开关还原:清掉用户层那一格(点这一行的「重置」),回到默认开。 */ +const pickerReset = await evaluate('(() => { const row = [...document.querySelectorAll(".cx-row")].find((r) => (r.querySelector(".cx-row__label") || {}).textContent?.includes("Codex 模型选择器"));' + + 'const b = row === undefined ? null : [...row.querySelectorAll("button")].find((x) => x.textContent.trim() === "重置");' + + 'if (!b) return null; const r = b.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()', sessionId); +if (pickerReset !== null) { await click(pickerReset.x, pickerReset.y, sessionId); await sleep(1200); } +check('收工复位:模型选择器回到默认开', (await pickerSwitch()) === 'true', 'checked=' + (await pickerSwitch())); const restored = { segment: await selectedSegment(), base: await bodyVar('--dsw-alias-bg-base') }; console.log('THEME 复位 ' + JSON.stringify(restored)); check('收工复位回亮色', String(restored.base).toLowerCase() === '#ffffff' && restored.segment === '亮色', JSON.stringify(restored)); diff --git a/skins/codex-ink/README.md b/skins/codex-ink/README.md index 4de340a..fbd9f46 100644 --- a/skins/codex-ink/README.md +++ b/skins/codex-ink/README.md @@ -1,6 +1,6 @@ # codex-ink -Codex / ChatGPT direction. These stylesheets are the sources for the codex-ui plugin: `src/build.mjs` reads the five +Codex / ChatGPT direction. These stylesheets are the sources for the codex-ui plugin: `src/build.mjs` reads the eight CSS files here, scopes their selectors to `html[data-codex-ui]`, and writes `theme.css` and `client.js`; `scripts/install-plugin.mjs` installs them. The same directory satisfies the Skin v2 manifest format and can be picked up by a skin loader on its own. @@ -11,22 +11,24 @@ up by a skin loader on its own. |---|---|---| | `skin.json` | Manifest | id, accent, light and dark previews | | `skin.css` | L1 tokens and L2 typography | `--dsw-alias-*` remapping; spacing, radius, type scale, motion and elevation token layers | -| `patches.css` | L3 components | Focus ring, links, card contract, mono pills, tag tone normalization, reduced motion, ⑫ model picker, ⑬ composer header and card, ⑯ right panel guide entries, ⑰ composer control hover | +| `patches.css` | L3 components | Focus ring, links, card contract, mono pills, tag tone normalization, reduced motion, ⑫ host model menu (face A), ⑬ composer header and card, ⑯ right panel guide entries, ⑰ composer control hover | +| `model-picker.css` | L3 model picker component | Face B: the trigger, popover and reasoning power rail built by `src/model-picker.js` (Codex `_Track` / `_Tick` / `_Thumb` geometry verbatim); paints only `.codex-mp-*` plus one seat-hiding rule, never the host menu. Verified by `scripts/power-rail-verify.mjs` | | `sidebar-align.css` | L3 sidebar alignment | New session and plugin rows land on the same two columns as the workspace list (icon column 20px, text column 42px); selectors cover both the rc.1 flat DOM and the rc.2 nested DOM | | `sidebar-surface.css` | L3 sidebar surface | Sidebar scroll fade: the host's 24px overlay steps aside for Codex's 40px four-stop mask ramp, applied to the scroller with the rightmost 12px left unmasked for the scrollbar. Verified by `scripts/sidebar-surface-verify.mjs` | | `window-shadow.css` | L3 window edges | Conversation window 0.5px hairline plus a 24px ambient shadow; right panel keeps a hairline on its left edge only and bleeds upward; right divider handle hover gradient | | `composer.css` | L3 composer | Card geometry and surface, 44px editor area, 28px bottom control row, suggestion menu, hero layout. This layer is allowed to use `[class*=…]` suffix anchors | +| `settings.css` | L3 settings | Layout of the configuration card on the plugin manager bundle page (`.cx-*`) | | `preview/` | Assets | Light and dark previews | ## Design rules -1. Chrome stays ink: buttons, links and selection are ink; the light theme primary button is `#1A1C1F` on white, dark inverts. The focus ring is the one exception — it uses Codex `--color-border-focus` (`#339CFF`; dark at 70%). +1. Chrome stays ink: buttons and selection are ink; the light theme primary button is white on ink, dark inverts. The only exception is the accent: links (light `#339CFF` / dark `#0169CC`), the focus ring (Codex `--color-border-focus`, `#339CFF`, dark at 70%) and the filled part of the power rail. 2. Greys carry hierarchy. Light `#FFFFFF → #F1F1EF → #E5E5E5`; dark `#111111 → #181818 → #212121 → #282828` (window background `#111111`, sidebar/surface `#181818`; the composer card sits on the surface and the 0.5px hairline separates them). 3. Light sidebar `#EEF4F9`, active row `#E2E9ED`, hover `#E8EEF3`. 4. Metadata (token counts, model names, timestamps, badges, paths, shortcuts) uses `--ds-font-family-code`, 11px and `.04em`/`.08em` tracking; `:lang(zh)` exempts Chinese from tracking and uppercase. 5. Radius and spacing come from `--dsw-radius-*` and `--dsw-space-*`. Cards use a 0.5px stroke instead of a shadow. 6. Motion runs at 150 / 200 / 300ms with `cubic-bezier(.4, 0, .2, 1)`, taken from the Codex app `--transition-duration-*` and `--default-transition-timing-function`; `prefers-reduced-motion` jumps to the end state. -7. Color whitelist: the three state colors, diff red and green, and badge fills (state color at 8% to 16% opacity). The six task-board tag tones collapse onto the state colors plus ink and grey. +7. Color whitelist: the three state colors, diff red and green, badge fills (state color at 8% to 16% opacity), and the accent itself (links, focus ring, power rail). The six task-board tag tones collapse onto the state colors plus ink and grey. ## Token contract for third-party plugins diff --git a/skins/codex-ink/README.zh-CN.md b/skins/codex-ink/README.zh-CN.md index 08a7203..93b7616 100644 --- a/skins/codex-ink/README.zh-CN.md +++ b/skins/codex-ink/README.zh-CN.md @@ -1,6 +1,6 @@ # codex-ink · 墨白终端 -代码方向为 Codex / ChatGPT。这是 codex-ui 插件的样式正本:`src/build.mjs` 读本目录的五份 CSS,把选择器作用域化到 +代码方向为 Codex / ChatGPT。这是 codex-ui 插件的样式正本:`src/build.mjs` 读本目录的八份 CSS,把选择器作用域化到 `html[data-codex-ui]`,写出 `theme.css` 与 `client.js`;`scripts/install-plugin.mjs` 负责安装。同一份目录也满足 Skin v2 清单格式,可由皮肤加载器单独收录。 @@ -10,22 +10,24 @@ Skin v2 清单格式,可由皮肤加载器单独收录。 |---|---|---| | `skin.json` | 清单 | id、accent、明暗预览图 | | `skin.css` | L1 令牌 + L2 排版 | `--dsw-alias-*` 重映射;spacing / radius / 字阶 / motion / elevation 令牌层 | -| `patches.css` | L3 组件 | 焦点环、链接、卡片契约、mono pill 徽标、tag tone 归一、reduced-motion、⑫ 模型选择器、⑬ 输入区顶栏与卡片、⑯ 右栏展开选择组件、⑰ composer 控件悬停 | +| `patches.css` | L3 组件 | 焦点环、链接、卡片契约、mono pill 徽标、tag tone 归一、reduced-motion、⑫ 宿主模型菜单(A 面)、⑬ 输入区顶栏与卡片、⑯ 右栏展开选择组件、⑰ composer 控件悬停 | +| `model-picker.css` | L3 模型选择器组件 | B 面:`src/model-picker.js` 自建的触发器、弹层与推理等级功率轨(Codex `_Track` / `_Tick` / `_Thumb` 逐字几何);只画 `.codex-mp-*` 与席位的一条隐藏规则,不碰宿主菜单。验收 `scripts/power-rail-verify.mjs` | | `sidebar-align.css` | L3 侧栏对齐 | 新会话行与插件行落到工作区列表行的两条竖线(图标列 20px、文字列 42px);选择器同时覆盖 rc.1 扁平 DOM 与 rc.2 嵌套 DOM | | `sidebar-surface.css` | L3 侧栏面 | 侧栏滚动渐隐:把宿主那条 24px 覆盖层让位,改用 Codex 的 40px 四段 mask 斜坡(挂在滚动容器上,右侧 12px 让开滚动条槽)。验收 `scripts/sidebar-surface-verify.mjs` | | `window-shadow.css` | L3 窗口边缘 | 会话窗口 0.5px 发丝线加 24px 环境影;右栏面板左沿只留 0.5px 发丝线、影只往上泄;右分界线拖拽柄悬停渐变 | | `composer.css` | L3 输入区 | 卡片几何与表面、编辑区 44px、底部控件行 28px、候选菜单、hero 布局。本层允许使用 `[class*=…]` 后缀锚点 | +| `settings.css` | L3 设置页 | 插件管理 → 组合包页那张配置卡的版式(`.cx-*`) | | `preview/` | 资源 | 明暗预览图 | ## 设计规则 -1. chrome 以墨色为主:按钮、链接、选中态都是墨色;浅色主题主按钮为白底 `#1A1C1F` 字,深色反转。唯一例外是焦点环,取 Codex `--color-border-focus`(`#339CFF`,深色 70%)。 +1. chrome 以墨色为主:按钮与选中态都是墨色;浅色主题主按钮为墨底白字,深色反转。例外只有强调色:链接(浅 `#339CFF` / 深 `#0169CC`)、焦点环(Codex `--color-border-focus`,`#339CFF`,深色 70%)与功率轨的已选段。 2. 灰阶承担层级。浅色 `#FFFFFF → #F1F1EF → #E5E5E5`;深色 `#111111 → #181818 → #212121 → #282828`(窗口背景 `#111111`、侧栏/表面 `#181818`;输入卡坐在表面上,分隔靠 0.5px 发丝线)。 3. 浅色侧栏 `#EEF4F9`,激活行 `#E2E9ED`,悬停 `#E8EEF3`。 4. 元信息(token 数、模型名、时间戳、徽标、路径、快捷键)用 `--ds-font-family-code`、11px、`.04em`/`.08em` 字距;`:lang(zh)` 下中文免字距与大写。 5. 圆角与间距取自 `--dsw-radius-*` 与 `--dsw-space-*`。卡片用 0.5px 描边代替投影。 6. 动效为 150 / 200 / 300ms,缓动 `cubic-bezier(.4, 0, .2, 1)`(取自 Codex 应用的 `--transition-duration-*` 与 `--default-transition-timing-function`);`prefers-reduced-motion` 下直接跳到终态。 -7. 彩色白名单:state 三色、diff 红绿,以及徽标底色(state 色 8% 到 16% 透明度)。任务板 6 档 tag tone 归到 state 三色加墨色与灰色。 +7. 彩色白名单:state 三色、diff 红绿、徽标底色(state 色 8% 到 16% 透明度),以及强调色本身(链接、焦点环、功率轨)。任务板 6 档 tag tone 归到 state 三色加墨色与灰色。 ## 第三方插件的令牌契约 diff --git a/skins/codex-ink/composer.css b/skins/codex-ink/composer.css index 6050440..555625f 100644 --- a/skins/codex-ink/composer.css +++ b/skins/codex-ink/composer.css @@ -2,10 +2,10 @@ codex-ink(墨白终端)· L3 输入区完整层 ---------------------------------------------------------------------------- 源文件:skins/codex-ink/composer.css - 由 scripts/install-plugin.mjs 与其余各层一起拼接,并作用域化到 html[data-codex-ui]。 + 由 src/build.mjs 与其余各层一起拼接,并作用域化到 html[data-codex-ui]。 本文件写「裸选择器」——不要自己再加 html[...] 前缀,那会被 scopeCss 直通。 - 来源(本地克隆:D:\A-part-of-new-software\PROJIECT\dsh-codex-ui): + 来源(上游仓库 MichengAI/dsh-codex-ui 的源码;行号是移植时那一版的): · src/client/CodexSidebar.tsx L119-L137 → ⑭·1 ⑭·2 ⑭·3 · src/client/composer-tool-menus.ts L6-L30 → ⑭·4(仅官方锚点部分) · src/client/new-conversation-style.ts L3-L14 → ⑭·5 @@ -173,7 +173,7 @@ body[data-ds-dark-theme] [data-conversation-scroll] { } [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div :is(button, select):focus-visible { - outline: 2px solid var(--dsw-codex-focus); + outline: 2px solid var(--dsw-focus-ring-color); outline-offset: 2px; } diff --git a/skins/codex-ink/model-picker.css b/skins/codex-ink/model-picker.css new file mode 100644 index 0000000..59c6e72 --- /dev/null +++ b/skins/codex-ink/model-picker.css @@ -0,0 +1,471 @@ +/* ============================================================================ + codex-ink(墨白终端)· L3 模型选择器组件(B 面:功率轨) + ---------------------------------------------------------------------------- + 源文件:skins/codex-ink/model-picker.css + 消费者:src/model-picker.js(自建 DOM,不是宿主的菜单)。 + 由 src/build.mjs 与其余各层一起拼接,并作用域化到 html[data-codex-ui]; + 本文件写裸选择器,注释里不写花括号(scopeCss 按花括号配对)。 + + 纪律(0.5.0 卡顿的教训,check-repo 有一条断言守着): + · 本层只画自建节点(.codex-mp-*),外加宿主席位的**一条**隐藏规则; + · 一条选择器都不挂宿主的菜单 portal(body 直属的 role=menu), + 也不改宿主菜单的 display / height / padding。 + + 取值来源: + Codex = app.asar 模型选择器实现块 _Root_xwb5v_19 / _Track_xwb5v_212 / + _Tick_xwb5v_40 / _Thumb_xwb5v_12 的逐字值(复刻提示词第六节); + 宿主 = dsh-client-ui-model-selection 0.1.7-rc.2 的 ModelSelect.module.css + (触发器几何与 z-index 照抄,好让我们的触发器在工具条里与原生那一格同位同高); + 本皮肤 = 已有令牌(⑫ 菜单面板契约、⑰ 胶囊、同心圆角)。 + ============================================================================ */ + + +/* ── ① 席位顶替 ───────────────────────────────────────────────────────── + 我们的触发器在席时,宿主那一格隐藏(仍在 React 树里,生命周期不动)。 + 直接子代 :has() 的失效范围只有席位自己的几个子节点,不是 0.5.0 那种挂在 + portal 菜单上的长链。触发器一摘(设置里关掉、或组件卸载),宿主那一格立刻复原。 */ +[data-slot="conversation.input.model"]:has(> .codex-mp-trigger) > :not(.codex-mp-trigger) { + display: none; +} + + +/* ── ② 触发器:模型名 + 档位名 + chevron ───────────────────────────────── + 几何照宿主 _7KE1Ra_trigger(高 28、内衬 0 4 0 8、gap 4、13px/20px、 + max-width min(360px, 45cqw)),外观走 ⑰ 的 Codex 胶囊:全圆角、默认透明无描边、 + 悬停或展开才填 --dsw-codex-hover-fill。焦点环由 ⑫·1 的席位规则统一画(1px 细线)。 */ +.codex-mp-trigger { + box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + min-width: 0; + max-width: min(360px, 45cqw); + padding: 0 4px 0 8px; + border: 0; + border-radius: var(--dsw-radius-pill); + background: transparent; + color: var(--dsw-alias-label-secondary); + font-family: inherit; + font-size: 13px; + font-weight: 400; + line-height: 20px; + cursor: pointer; + transition: background-color var(--dsw-motion-fast) var(--dsw-ease); +} + +.codex-mp-trigger:hover, +.codex-mp-trigger[aria-expanded="true"] { + background: var(--dsw-codex-hover-fill); +} + +.codex-mp-trigger-model { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 档位文字叠层(Codex _ModelPickerTriggerEffortText 逐字):各档名叠在同一格, + 格宽取最宽的名字;当前那层清晰,其余 opacity 0 + blur(--blur-xs = 4px)。 + 改档因此是模糊交叉淡入,不是换文本,触发器宽度也不跳。 */ +.codex-mp-effort-layers { + display: grid; + grid-template-columns: max-content; + flex-shrink: 1000; + width: max-content; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-caption); +} + +.codex-mp-effort-layers[hidden] { + display: none; +} + +.codex-mp-effort-text { + grid-area: 1 / 1; + justify-self: center; + opacity: 0; + filter: blur(4px); + white-space: nowrap; + will-change: opacity, filter; + transition: + opacity var(--dsw-motion-slow) var(--dsw-ease), + filter var(--dsw-motion-slow) var(--dsw-ease); +} + +.codex-mp-effort-text[data-active="true"] { + opacity: 1; + filter: none; +} + +/* chevron:宿主 _7KE1Ra_chevron 的 .12s 翻转 */ +.codex-mp-trigger-chevron { + display: inline-flex; + flex: none; + color: var(--dsw-alias-label-caption); + transition: transform 120ms var(--dsw-ease); +} + +.codex-mp-trigger[aria-expanded="true"] .codex-mp-trigger-chevron { + transform: rotate(180deg); +} + + +/* ── ③ 弹层 ───────────────────────────────────────────────────────────── + 宽:Codex calc(var(--spacing) * 63.5),--spacing = 4px ⇒ 254px。 + 入场:Codex .32s cubic-bezier(.23, 1, .32, 1) 30ms both,opacity 0 / scale(.98) → 1; + 弹层在触发器上方、右沿对齐,所以从右下角长出来。 + 面板:⑫·2 的菜单契约 —— 不透明 layer-1 + 18px 圆角 + 内衬 5px + 0 8px 32px 软影 + 1px 环。 + z-index / max-height 照宿主 _7KE1Ra_menu。 */ +.codex-mp-popover { + position: fixed; + z-index: 1100; + box-sizing: border-box; + display: flex; + flex-direction: column; + width: calc(4px * 63.5); + max-width: calc(100vw - 24px); + max-height: min(360px, 100vh - 96px); + padding: 5px; + border-radius: var(--dsw-radius-menu); + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + box-shadow: 0 8px 32px rgba(13, 13, 13, 0.13), 0 0 0 1px rgba(13, 13, 13, 0.04); + font-size: 13px; + line-height: 20px; + transform-origin: bottom right; + animation: codex-mp-enter 0.32s cubic-bezier(0.23, 1, 0.32, 1) 30ms both; +} + +body[data-ds-dark-theme] .codex-mp-popover { + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(236, 236, 236, 0.05); +} + +.codex-mp-popover[hidden] { + display: none; +} + +.codex-mp-popover[data-reduced-motion="true"] { + animation: none; +} + +@keyframes codex-mp-enter { + from { + opacity: 0; + transform: scale(0.98); + } + + to { + opacity: 1; + transform: none; + } +} + +/* 提交失败那一条(宿主 _7KE1Ra_error 的底色、字色、11px/16px;圆角按同心取 18 − 5) */ +.codex-mp-error { + flex: none; + margin-bottom: 3px; + padding: 6px 9px; + border-radius: calc(var(--dsw-radius-menu) - 5px); + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); + font-size: 11px; + line-height: 16px; +} + +.codex-mp-error[hidden] { + display: none; +} + + +/* ── ④ 模型列表:行几何同 ⑫·3(28px / 4px 9px / gap 8 / 圆角 18 − 5 = 13) ── */ +.codex-mp-list { + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; +} + +.codex-mp-status { + padding: 8px 9px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.codex-mp-retry { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; +} + +/* 分组标题:宿主 _7KE1Ra_groupTitle 的 11px/16px/500 辅墨;横向内衬对齐行文字的 9px */ +.codex-mp-group { + padding: 4px 9px 2px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + font-weight: 500; + line-height: 16px; +} + +.codex-mp-group:not(:first-child) { + margin-top: 3px; +} + +.codex-mp-row { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 28px; + padding: 4px 9px; + border: 0; + border-radius: calc(var(--dsw-radius-menu) - 5px); + background: none; + color: inherit; + font-family: inherit; + font-size: 13px; + line-height: 20px; + text-align: left; + cursor: pointer; +} + +.codex-mp-row:hover, +.codex-mp-row[aria-checked="true"] { + background: var(--dsw-alias-interactive-bg-active); +} + +/* 键盘焦点:菜单内改 1px 内描线(全局 2px 环不贴行盒) */ +.codex-mp-row:focus-visible { + outline: 1px solid var(--dsw-focus-ring-color); + outline-offset: -1px; + background: var(--dsw-alias-interactive-bg-active); +} + +.codex-mp-row[aria-busy="true"] { + cursor: progress; +} + +.codex-mp-row-copy { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.codex-mp-row-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +/* 说明:宿主 _7KE1Ra_status 的 12px/18px 辅墨,单行截断(全文在 title 里) */ +.codex-mp-row-desc { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.codex-mp-row-check { + display: grid; + flex: 0 0 14px; + place-items: center; + color: var(--dsw-alias-label-primary); +} + +/* pending 转圈:与 ⑫ 同一副(12px / 1.5px 环 / 顶边前景色 / Codex --animate-spin 1s linear) */ +.codex-mp-spinner { + box-sizing: border-box; + display: inline-block; + flex: none; + width: 12px; + height: 12px; + border: 1.5px solid var(--dsw-alias-border-l3); + border-top-color: var(--dsw-alias-label-primary); + border-radius: 50%; + animation: codex-ui-spin 1s linear infinite; +} + + +/* ── ⑤ 推理等级:标题行 + 功率轨 ───────────────────────────────────────── + 与列表之间一条 0.5px l1 发丝线(发丝即层级);标题行与菜单行同高同衬。 */ +.codex-mp-effort { + flex: none; + margin-top: 5px; + padding-top: 5px; + border-top: 0.5px solid var(--dsw-alias-border-l1); +} + +.codex-mp-effort[hidden] { + display: none; +} + +.codex-mp-effort-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; + padding: 4px 9px; + color: var(--dsw-alias-label-secondary); +} + +.codex-mp-effort-value { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--dsw-alias-label-tertiary); +} + +/* Codex _Container:高 32 / 上下 2 / 左右 6 / 外边 2,纵向居中 */ +.codex-mp-container { + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: center; + height: 32px; + margin-inline: 2px; + padding-block: 2px; + padding-inline: 6px; +} + +/* Codex _Root:高 28、整宽、居中;touch-action: none 必须写,否则移动端手势被滚动抢走。 + 动效令牌逐字:主曲线 .3s cubic-bezier(.23, 1, .32, 1),档位变换 .12s, + 拇指跟随时长首帧 0s(脚本 16ms 后打 data-armed 抬到 .3s,打开时不从 0 滑过来)。 */ +.codex-mp-root { + --codex-mp-motion-duration: 0.3s; + --codex-mp-motion-easing: cubic-bezier(0.23, 1, 0.32, 1); + --codex-mp-tick-transform-duration: 0.12s; + --codex-mp-thumb-motion-duration: 0s; + + position: relative; + display: flex; + align-items: center; + width: 100%; + height: 28px; + outline: none; + touch-action: none; + cursor: pointer; +} + +.codex-mp-root[data-armed="true"] { + --codex-mp-thumb-motion-duration: var(--codex-mp-motion-duration); +} + +.codex-mp-root[data-dragging="true"], +.codex-mp-root[data-dragging="true"] .codex-mp-tick { + cursor: grabbing; +} + +.codex-mp-root[data-disabled="true"] { + opacity: 0.6; +} + +.codex-mp-popover[data-reduced-motion="true"] .codex-mp-root { + --codex-mp-motion-duration: 0s; + --codex-mp-tick-transform-duration: 0s; + --codex-mp-thumb-motion-duration: 0s; +} + +/* Codex _Track:24px 高、12px 圆角、前景 10% 底、0.5px 内描边(--color-border) */ +.codex-mp-track { + position: relative; + flex-grow: 1; + height: 24px; + overflow: hidden; + border-radius: 12px; + background: color-mix(in srgb, var(--dsw-alias-label-primary) 10%, transparent); + box-shadow: inset 0 0 0 0.5px var(--dsw-codex-border); +} + +/* Codex _Range:强调色,只圆左侧两角,止于拇指中线。 + 位置公式与拇指、圆点同一条:calc(14px + (100% − 28px) × pos)。 */ +.codex-mp-range { + position: absolute; + top: 0; + inset-inline-start: 0; + width: calc(14px + (100% - 28px) * var(--codex-mp-pos, 0)); + height: 100%; + border-radius: 12px 0 0 12px; + background: var(--dsw-alias-link); + transition: width var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing); +} + +/* Codex _Tick:4px 圆点,热区 16px(::before 外扩 6px);走过的 30% 白,没走到的 description 50% */ +.codex-mp-tick { + position: absolute; + top: 50%; + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + color: color-mix(in srgb, var(--dsw-alias-label-tertiary) 50%, transparent); + transform: translate(-50%, -50%); + transition: + transform var(--codex-mp-tick-transform-duration) var(--codex-mp-motion-easing), + filter var(--codex-mp-tick-transform-duration) var(--codex-mp-motion-easing); +} + +.codex-mp-tick::before { + content: ""; + position: absolute; + inset: -6px; +} + +.codex-mp-tick:hover { + filter: brightness(0.85); + transform: translate(-50%, -50%) scale(2); +} + +.codex-mp-tick[data-selected="true"] { + color: #ffffff4d; +} + +/* Codex _ThumbScale + _Thumb:28px 定位盒 + 白色圆片(0.5px 强描边 + 0 0 2px 10% 微影)。 + 白片亮暗同色,Codex 原样如此。 */ +.codex-mp-thumb-scale { + position: absolute; + top: 50%; + left: calc(14px + (100% - 28px) * var(--codex-mp-pos, 0)); + width: 28px; + height: 28px; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + will-change: left; + transition: left var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing); +} + +.codex-mp-thumb { + position: absolute; + inset: 0; + box-sizing: border-box; + display: block; + border: 0.5px solid var(--dsw-codex-border-strong); + border-radius: 50%; + background: #fff; + box-shadow: 0 0 2px #0000001a; +} + +/* 键盘焦点:Codex outline 2px + offset 0,画在拇指上(不是全局那条 2px + 2px) */ +.codex-mp-root[data-keyboard-focused="true"] .codex-mp-thumb-scale { + outline: 2px solid var(--dsw-focus-ring-color); + outline-offset: 0; +} + +/* 没有生效档(跟随提供方默认):拇指与色条不画,只剩轨与圆点 */ +.codex-mp-root[data-unset] .codex-mp-thumb-scale, +.codex-mp-root[data-unset] .codex-mp-range { + visibility: hidden; +} diff --git a/skins/codex-ink/patches.css b/skins/codex-ink/patches.css index 652b7e2..1ae94e8 100644 --- a/skins/codex-ink/patches.css +++ b/skins/codex-ink/patches.css @@ -2,25 +2,28 @@ codex-ink(墨白终端)· L3 组件层契约 ---------------------------------------------------------------------------- 治理目标(对应方案 §7): - ① 卡片统一契约:纯描边 + 10px 圆角 + 近零阴影 + 16px padding; + ① 卡片统一契约:纯描边 + 12px 圆角(--dsw-radius-m)+ 近零阴影 + 16px padding; ② 徽标/chip 统一 mono pill; ③ 彩色白名单归一:插件自带调色板收敛到 state 三色 + 墨灰; ④ hover = 背景升一档,禁自创投影; ⑤ 焦点环、cursor、动效时长三条 a11y/交互底线。 - 锚点纪律:只使用 skin-center 语义属性契约 - (data-dsh-surface / data-dsh-part / data-dsh-plugin / data-slot), - 不使用 CSS-Modules 哈希类名([class*=…]),以免触发加载器的 - "reliance on CSS-Modules hash class names" 警告。 + 锚点纪律:优先使用 skin-center 语义属性契约 + (data-dsh-surface / data-dsh-part / data-dsh-plugin / data-slot)。 + 宿主没有 data-* 的位置(⑰ 加号与两个触发器)才退到 CSS-Modules 类名的 + **后缀**写法(方括号 class 属性后缀匹配),只依赖语义后缀、不依赖随构建变化的哈希前缀; + 哈希全名一律不用。计数由 src/build.mjs 每次构建自报。 ========================================================================== */ -/* ── ① 焦点环:2px Codex 强调蓝 + 2px offset(-—color-border-focus,亮 #339cff / 暗 70%) ─ */ +/* ── ① 焦点环:2px Codex 强调蓝 + 2px offset(--color-border-focus,亮 #339cff / 暗 70%) + 颜色读宿主的 --dsw-focus-ring-color(skin.css 已把它接到 --dsw-codex-focus):宿主在指针 + 操作时就地把它置成 transparent,读令牌才能继承这条「鼠标点击不出环」的约定。 ── */ :focus-visible { - outline: 2px solid var(--dsw-codex-focus); + outline: 2px solid var(--dsw-focus-ring-color); outline-offset: 2px; } -/* ── ② 链接 = 墨 + hover 下划线,不着色 ─────────────────────────────────── */ +/* ── ② 链接 = 强调色(--dsw-alias-link)+ hover 下划线 ──────────────────── */ a { color: var(--dsw-alias-link); text-decoration: none; @@ -33,7 +36,7 @@ a:focus-visible { text-decoration: underline; } -/* ── ③ 可点击元素:cursor + 100ms 干脆过渡 ──────────────────────────────── */ +/* ── ③ 可点击元素:cursor + 150ms 干脆过渡(--dsw-motion-fast) ─────────── */ button, a, summary, @@ -82,7 +85,7 @@ button[aria-disabled="true"], padding: var(--dsw-space-4); } -/* 弹层 / 模态:大圆角 + 一点点软影 */ +/* 弹层 / 模态:大圆角(阴影交给宿主的 elevation 令牌,本层不另画) */ [role="dialog"], [data-dsh-surface="overlay"] [data-dsh-part="panel"] { border-radius: var(--dsw-radius-l); @@ -150,18 +153,16 @@ button[aria-disabled="true"], border-radius: var(--dsw-radius-s); } -/* ── ⑧ 输入区:工具不是主角,14px 圆角 + 纯描边 ────────────────────────── - 卡片([data-composer-card])的圆角只在 ⑭ 层(composer.css)声明一处:20px, - corner-shape 可用时 25px 超椭圆。旧版这里还写着 var(--dsw-radius-l)=16px, - 与 ⑭ 层构成同一元素的第二真源,谁生效只看选择器权重 —— 已删。 */ +/* ── ⑧ 输入区:工具不是主角,16px 圆角(--dsw-radius-l)+ 纯描边 ───────────── + 卡片([data-composer-card])的**圆角与阴影**只在 ⑭ 层(composer.css)声明一处: + 圆角 20px(corner-shape 可用时 25px 超椭圆),阴影是 Codex --elevation-composer 三层。 + 旧版这里还各写过一次 —— 圆角 var(--dsw-radius-l)=16px、阴影 var(--dsw-elevation-panel) —— + 与 ⑭ 层构成同一元素的第二真源,谁生效只看选择器权重。两条都已删:宿主只在会话滚动区里 + 渲染这张卡(真 GUI 实测 1 张、且在 [data-conversation-scroll] 内),⑭ 层全覆盖。 */ [data-dsh-surface="composer"] [data-dsh-part="composer-input"] { border-radius: var(--dsw-radius-l); } -[data-composer-card] { - box-shadow: var(--dsw-elevation-panel); -} - [data-dsh-part="composer-input"] { font-family: var(--dsw-font-family); line-height: var(--dsw-leading-base); @@ -210,9 +211,12 @@ button[aria-disabled="true"], · 触发器 = 宿主原生形态(模型名 + 强度 + chevron),本层不重绘, 只把焦点环归一成一条细线。旧版藏 chevron、改 mono 字体、压 24px 高, 实测与仓库截图 conversation-light.png 的触发器不符,全部撤掉。 - · 菜单面板 = 仓库 composer-tool-menus 的皮肤契约:不透明白 + 大圆角 + - 软影 0 8px 32px + 1px 描边环;行 28px、内衬 4px 9px、圆角 8px、 + · 菜单面板 = 仓库 composer-tool-menus 的皮肤契约:不透明白 + 18px 圆角 + + 软影 0 8px 32px + 1px 描边环;行 28px、内衬 4px 9px、圆角 13px(同心:18 − 内衬 5)、 hover 淡填充。旧版 TUI 编号行、页脚提示行、隐藏勾选列一并废弃。 + · 设置卡的「Codex 模型选择器」开着时,这一席位由 model-picker.css 的自建组件顶替, + 本段只在开关关掉(或组件没挂上)时作用于宿主原生菜单 —— 即 A 面退化路径 + (B 面组件见 docs/plan-model-picker.zh-CN.md)。 · 选中行 = 淡填充 + 勾选图标;pending 行 = 行尾 StateDot 转圈。 旧版把行尾整列 display:none,点强度后转圈被藏、全列表又被全局 disabled 规则压到 45% 透明度 —— 点了像没反应,这就是「选择推理 @@ -225,7 +229,7 @@ button[aria-disabled="true"], /* ── ⑫·1 触发器:只归一焦点环(官方 box-shadow 环 + 全局 2px outline → 1px 细线) ── */ [data-slot="conversation.input.model"] button:focus-visible { - outline: 1px solid var(--dsw-codex-focus); + outline: 1px solid var(--dsw-focus-ring-color); outline-offset: 1px; box-shadow: none; } @@ -278,7 +282,7 @@ body > div[role="menu"][aria-busy] button[role="menuitemradio"][aria-checked="tr /* 键盘焦点:全局 2px 环在菜单内不贴行盒,改 1px 内描线 */ body > div[role="menu"][aria-busy] button[role="menuitem"]:focus-visible, body > div[role="menu"][aria-busy] button[role="menuitemradio"]:focus-visible { - outline: 1px solid var(--dsw-codex-focus); + outline: 1px solid var(--dsw-focus-ring-color); outline-offset: -1px; background: var(--dsw-alias-interactive-bg-active); } @@ -377,7 +381,7 @@ body[data-ds-dark-theme] [data-codex-filebar] { titleRow 的官方布局是 titleCluster(flex:1)+ utilities + corner, 故去掉旧版的 justify-content:flex-end —— 标题要留在左边。 - ⚠ 本注释块里**不能出现花括号**(左右都不行):scripts/install-plugin.mjs 的 + ⚠ 本注释块里**不能出现花括号**(左右都不行):src/build.mjs 的 scopeCss 是按「下一个左花括号」做括号配对的,注释里混进一个花括号就会把 后面的规则整段错位 —— 实测表现为紧随其后的规则被重复加 html[data-codex-ui] 前缀而失效、整份 theme.css 花括号数不平衡。所以注释里只写行式记法。 */ @@ -391,7 +395,7 @@ body[data-ds-dark-theme] [data-codex-filebar] { 配色不动:条目只用 --dsw-* 语义令牌(--dsw-alias-label-tertiary、--dsw-alias-interactive-bg-hover、 --dsw-alias-border-l4 …),本皮肤已重锚这些令牌,因此跟着现在的配色走,不需要另配一套色。 出口恒为 div[data-slot=槽名][style=display:contents],不需要也不应该改出口本身。 - ⚠ 本部注释里不能出现花括号(install-plugin.mjs 的 scopeCss 按花括号配对)。 */ + ⚠ 本部注释里不能出现花括号(src/build.mjs 的 scopeCss 按花括号配对)。 */ [data-slot="conversation.session.header"] > [data-conversation-tabs] { display: none; } diff --git a/skins/codex-ink/settings.css b/skins/codex-ink/settings.css index 2fc6538..f1fb24e 100644 --- a/skins/codex-ink/settings.css +++ b/skins/codex-ink/settings.css @@ -92,7 +92,7 @@ } .cx-hex:focus-visible { - outline: 2px solid var(--dsw-codex-focus); + outline: 2px solid var(--dsw-focus-ring-color); outline-offset: 1px; } @@ -108,7 +108,7 @@ } .cx-text:focus-visible { - outline: 2px solid var(--dsw-codex-focus); + outline: 2px solid var(--dsw-focus-ring-color); outline-offset: 1px; } @@ -134,7 +134,9 @@ .cx-error { margin: 8px 0 0; - color: var(--dsw-alias-state-error, var(--dsw-alias-label-primary)); + /* 旧值读的是 --dsw-alias-state-error —— 这个令牌宿主与本皮肤都没有,于是永远落到回退的 + 主文本色,「保存没生效」的提示看起来跟普通说明一样。改读真实存在的 error-primary。 */ + color: var(--dsw-alias-state-error-primary); font-size: 11px; line-height: 16px; } diff --git a/skins/codex-ink/sidebar-align.css b/skins/codex-ink/sidebar-align.css index 7ad0423..e9d6e1b 100644 --- a/skins/codex-ink/sidebar-align.css +++ b/skins/codex-ink/sidebar-align.css @@ -2,7 +2,7 @@ codex-ink(墨白终端)· L3 侧栏对齐层 ---------------------------------------------------------------------------- 源文件:skins/codex-ink/sidebar-align.css - 由 scripts/install-plugin.mjs 与 skin.css / patches.css 一起拼接,并作用域化到 + 由 src/build.mjs 与 skin.css / patches.css 一起拼接,并作用域化到 html[data-codex-ui]。因此本文件写「裸选择器」——不要自己再加 html[...] 前缀, 那会被 scopeCss 判为已作用域而直通,永不命中。 @@ -101,7 +101,7 @@ div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.ba } div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button:focus-visible { - outline: 2px solid var(--dsw-codex-focus); + outline: 2px solid var(--dsw-focus-ring-color); outline-offset: -2px; } diff --git a/skins/codex-ink/skin.css b/skins/codex-ink/skin.css index 55f18fa..abaf335 100644 --- a/skins/codex-ink/skin.css +++ b/skins/codex-ink/skin.css @@ -87,6 +87,20 @@ body { --dsw-codex-hover-fill: #f2f2f3; /* 焦点环:Codex --color-border-focus = --blue-300 = #339cff(亮色直接取色) */ --dsw-codex-focus: #339cff; + /* 宿主的焦点环令牌接到 Codex 焦点色上。宿主 ui-theme 的 :focus-visible 与 10 条用 + box-shadow 画环的组件规则都读 var(--dsw-focus-ring-color, 墨色回落),而宿主从不在根上 + 声明它 —— 不接的话那 10 处聚焦时是墨色内环。宿主在指针操作时会把它就地置成 transparent + (html[data-input-modality=pointer] … :focus-visible),本皮肤的环也读这个令牌, + 所以同一条「鼠标点击不出环」的宿主约定对皮肤一样生效。 + var() 在声明它的元素上解析:body 上读到的是 body 自己的 --dsw-codex-focus, + 深色那一档(70%)因此不需要再写一遍。 */ + --dsw-focus-ring-color: var(--dsw-codex-focus); + /* Codex 的两条中性描边(app.asar 令牌,CHANGELOG 0.5.9 / 0.5.10 取证): + --color-border = --alpha-10(亮)/ --alpha-12(暗) + --color-border-strong = 15%(亮)/ 20%(暗) + 输入卡环与功率轨描边用前者,功率轨拇指用后者。 */ + --dsw-codex-border: rgba(13, 13, 13, 0.1); + --dsw-codex-border-strong: rgba(13, 13, 13, 0.15); --dsw-alias-interactive-bg-active: rgba(13, 13, 13, 0.08); --dsw-alias-interactive-bg-hover-solid: #f1f1ef; --dsw-alias-interactive-bg-hover-accent: rgba(13, 13, 13, 0.06); @@ -249,6 +263,8 @@ body[data-ds-dark-theme] { --dsw-codex-hover-fill: rgba(255, 255, 255, 0.08); /* 焦点环:Codex 深色 --color-border-focus = --blue-300 70%,即 #339cffb3。 */ --dsw-codex-focus: rgba(51, 156, 255, 0.7); + --dsw-codex-border: rgba(255, 255, 255, 0.12); + --dsw-codex-border-strong: rgba(255, 255, 255, 0.2); --dsw-alias-interactive-bg-active: rgba(255, 255, 255, 0.1); --dsw-alias-interactive-bg-hover-solid: #282828; --dsw-alias-interactive-bg-hover-accent: rgba(255, 255, 255, 0.08); @@ -391,6 +407,14 @@ body { --dsw-leading-xl: 1.35; --dsw-leading-2xl: 1.25; + /* 字体栈(原样照抄宿主 dsh-client-ui-theme 的 base_css_default,0.1.7-rc.2 逐字比对一致)。 + 本皮肤自己声明一次,外观就不随宿主版本漂:UI 栈系统字体优先 + 中文回退链, + 代码栈等宽优先 + 中文回退,两条都保留中文回退。设置页 fontUi / fontCode 覆盖的就是这两个令牌。 */ + --dsw-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif; + --ds-font-family-code: "SF Mono", "JetBrains Mono", "Fira Code", Consolas, + "Liberation Mono", Menlo, Courier, "PingFang SC", "Microsoft YaHei"; + /* 元信息 mono 规约:等宽 + 大写 + 加宽字距(Codex TUI 血统) */ --dsw-font-meta: var(--ds-font-family-code); --dsw-meta-size: 11px; @@ -432,7 +456,10 @@ body { body, body * { --dsw-elevation-stroke-color: rgba(13, 13, 13, 0.08); - --dsw-elevation-stroke: 0 0 0 0.5px rgba(13, 13, 13, 0.08); + /* 描边几何引用颜色令牌,而不是把同一个 rgba 再抄一遍:颜色只有上一行这一个真源, + 亮暗两套各改一处即可(Codex 原文 --elevation-stroke 同样是「几何 + 颜色令牌」)。 + 同一元素上声明、同一元素上解析,所以 body * 上逐元素都取到本元素的颜色。 */ + --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color); --dsw-elevation-panel: var(--dsw-elevation-stroke); /* Codex --elevation-prominent 逐字(app.asar @67930856): var(--elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d @@ -440,21 +467,22 @@ body * { 这个令牌是**宿主菜单与面板**的主阴影(.QsffPG_menu / ._7KE1Ra_menu / .JObwrW_panel 等 14 处消费),不是边角料。 */ --dsw-elevation-prominent: var(--dsw-elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d; - /* --dsw-elevation-soft 在 Codex 里**没有同名令牌**(Codex 只有 stroke / stroke-subtle / - prominent / sidebar / composer / composer-dark)。这是本皮肤自造的一档,保留待验。 */ - --dsw-elevation-soft: var(--dsw-elevation-stroke), 0 8px 28px rgba(13, 13, 13, 0.1); + /* --dsw-elevation-soft **不覆盖**,交还宿主自己的值(stroke + 0 4px 16px #00000008 + 0 0 24px #00000008)。 + Codex 没有同名令牌(只有 stroke / stroke-subtle / prominent / sidebar / composer / composer-dark), + 0.5.10 及以前这里写的 0 8px 28px 10% 墨(暗色 55% 黑)既无截图读数也无源码锚点, + 却落在宿主 SegmentedControl 的选中片上 —— 设置页每个分段控件都背着一团自造的大影。 + 宿主值里的第一层仍是 var(--dsw-elevation-stroke),所以描边照样跟本皮肤走。 */ } body[data-ds-dark-theme], body[data-ds-dark-theme] * { --dsw-elevation-stroke-color: rgba(255, 255, 255, 0.09); - --dsw-elevation-stroke: 0 0 0 0.5px rgba(255, 255, 255, 0.09); + --dsw-elevation-stroke: 0 0 0 0.5px var(--dsw-elevation-stroke-color); --dsw-elevation-panel: var(--dsw-elevation-stroke); /* 这里**不再覆盖** --dsw-elevation-prominent**:Codex 只在 :root 声明一次、没有暗色变体, 所以暗色沿用同一份字面值。后果是暗色菜单不再有 50% 黑的大外影,只剩发丝线 + 填充 —— 与 Codex 暗色的整体做法一致(0.5.7 已证输入卡暗色无外影,0.5.8 已证暗色靠填充分层)。 原先的 0 4px 16px rgba(0,0,0,.5) 无任何取证。 */ - --dsw-elevation-soft: var(--dsw-elevation-stroke), 0 8px 28px rgba(0, 0, 0, 0.55); } /* ── 排版层:标题字重 600 封顶 ───────────────────────────────────────────── */ diff --git a/skins/codex-ink/window-shadow.css b/skins/codex-ink/window-shadow.css index f9ae4e8..6fa8428 100644 --- a/skins/codex-ink/window-shadow.css +++ b/skins/codex-ink/window-shadow.css @@ -1,13 +1,13 @@ /* ============================================================================ codex-ink · L3 窗口边缘阴影层 ---------------------------------------------------------------------------- - 源文件:skins/codex-ink/window-shadow.css。install-plugin.mjs 把本文件的选择器 + 源文件:skins/codex-ink/window-shadow.css。src/build.mjs 把本文件的选择器 作用域化到 html[data-codex-ui] 后拼进 client.js 的内嵌样式表,所以这里写裸选择器: 自己再加 html[...] 前缀会被 scopeCss 判为已作用域而直通,永远不命中。 注释里禁止出现花括号 —— scopeCss 按「下一个左花括号」做括号配对。 目标(三条,不多做): - 1. 会话窗口(AppFrame 中列):1px 发丝线 + 24px 全向环境影(左沿糊到侧栏、上沿糊进标题栏条); + 1. 会话窗口(AppFrame 中列):0.5px 发丝线 + 24px 全向环境影(左沿糊到侧栏、上沿糊进标题栏条); 2. 右栏面板:同样的顶沿,但**左边框只留发丝线、不带影**; 3. 右栏分界线拖拽柄悬停时的中段渐变聚焦。 diff --git a/src/build.mjs b/src/build.mjs index 591f4da..45f5e35 100644 --- a/src/build.mjs +++ b/src/build.mjs @@ -4,10 +4,11 @@ * 只有这一处实现作用域化与拼装。安装脚本(scripts/install-plugin.mjs)与 CI 校验 * (scripts/check-repo.mjs)都调用它,产物一致性与安装结果由同一条代码路径决定。 * - * 三样东西拼进 client.js: - * 1. skins/codex-ink 的五份样式(作用域化后内嵌); + * 四样东西拼进 client.js: + * 1. skins/codex-ink 的样式(SKIN_PARTS 那八份,作用域化后内嵌); * 2. src/override.js —— 设置页的覆盖层纯函数(构建期去掉 export 关键字,包成 IIFE); - * 3. src/settings-card.js —— 组合包页那张配置卡(直接拼进 factory 体内)。 + * 3. src/model-picker.js —— 模型选择器 B 面组件(同上,包成 IIFE); + * 4. src/settings-card.js —— 组合包页那张配置卡(直接拼进 factory 体内)。 * * 作用域化规则: * :root → html[data-codex-ui] @@ -33,12 +34,16 @@ export const CSS_PLACEHOLDER = '/*__CODEX_UI_CSS__*/ null'; export const OVERRIDE_PLACEHOLDER = '/*__CODEX_UI_OVERRIDE__*/ null'; /** 设置卡片占位符。 */ export const SETTINGS_PLACEHOLDER = '/*__CODEX_UI_SETTINGS__*/ null'; +/** 模型选择器模块占位符。 */ +export const MODEL_PICKER_PLACEHOLDER = '/*__CODEX_UI_MODEL_PICKER__*/ null'; /** 模板文件。 */ export const TEMPLATE = join(HERE, 'client.template.js'); /** 覆盖层纯函数模块(同时被夹具直接 import 做单测)。 */ export const OVERRIDE_FILE = join(HERE, 'override.js'); /** 设置卡片片段。 */ export const SETTINGS_FILE = join(HERE, 'settings-card.js'); +/** 模型选择器组件(同时被 check-repo 直接 import 单测纯函数)。 */ +export const MODEL_PICKER_FILE = join(HERE, 'model-picker.js'); /** * 拼进 theme.css 的源文件,顺序即层叠顺序。 * [文件名, 该层的说明] —— 说明写进分隔注释。 @@ -46,6 +51,7 @@ export const SETTINGS_FILE = join(HERE, 'settings-card.js'); export const SKIN_PARTS = [ ['skin.css', 'L1/L2 令牌与排版层'], ['patches.css', 'L3 组件层'], + ['model-picker.css', 'L3 模型选择器组件'], ['sidebar-align.css', 'L3 侧栏对齐层'], ['sidebar-surface.css', 'L3 侧栏面层'], ['window-shadow.css', 'L3 窗口边缘阴影层'], @@ -117,12 +123,13 @@ export const hashAnchors = (text) => (text.match(/\[class[$*^]?=/g) ?? []).lengt * 只认 `export const` / `export function` 两种形式;出现别的 export 形式就抛 —— * 静默漏掉一个导出会变成运行时的 undefined,比构建失败难查得多。 * @param source - 模块源码。 + * @param name - 报错时用的文件名。 * @returns 去掉 export 的源码。 */ -export function stripExports(source) { +export function stripExports(source, name = 'override.js') { const rest = source.replace(/^export (const|function) /gm, '$1 '); const leftovers = rest.match(/^\s*export\b.*$/gm); - if (leftovers !== null) throw new Error('override.js 里有不支持的导出形式:' + leftovers.join(' / ')); + if (leftovers !== null) throw new Error(name + ' 里有不支持的导出形式:' + leftovers.join(' / ')); return rest; } @@ -133,6 +140,24 @@ export const OVERRIDE_EXPORTS = [ 'isHex', 'mixHex', 'withAlpha', 'contrastEffect', 'themeOverrideCss', 'isFontStack', 'sel', ]; +/** 模型选择器模块暴露给客户端的名字(模板只用 installModelPicker,其余留给验收探针)。 */ +export const MODEL_PICKER_EXPORTS = [ + 'MODEL_SLOT', 'TRIGGER_CLASS', 'POPOVER_CLASS', 'THUMB_SIZE', 'MOTION_ARM_MS', 'POPOVER_GAP', 'POPOVER_MARGIN', + 'snapIndex', 'indexRatio', 'offsetRatio', 'ratioOffset', 'sortGroups', 'sessionIdOf', 'viewOf', 'listSignature', + 'installModelPicker', +]; + +/** + * 把一个 ESM 模块包成 IIFE 表达式(占位符落在 `const x = …` 的右值上,所以只给表达式)。 + * @param file - 模块路径。 + * @param label - 报错时用的文件名。 + * @param names - 要暴露的名字。 + * @returns 表达式源码。 + */ +const iife = (file, label, names) => '(() => {\n' + + stripExports(fs.readFileSync(file, 'utf8'), label) + + '\nreturn { ' + names.join(', ') + ' };\n})()'; + /** * 生成产物,不落盘。 * @returns {{themeCss: string, clientJs: string, sources: Record, scopedBytes: number, hashAnchors: Record}} @@ -148,18 +173,18 @@ export function build() { }); const themeCss = scopeCss(blocks.join('\n\n')); const tplSrc = fs.readFileSync(TEMPLATE, 'utf8'); - for (const [name, placeholder] of [['样式', CSS_PLACEHOLDER], ['覆盖层', OVERRIDE_PLACEHOLDER], ['设置卡片', SETTINGS_PLACEHOLDER]]) { + for (const [name, placeholder] of [['样式', CSS_PLACEHOLDER], ['覆盖层', OVERRIDE_PLACEHOLDER], ['模型选择器', MODEL_PICKER_PLACEHOLDER], ['设置卡片', SETTINGS_PLACEHOLDER]]) { if (!tplSrc.includes(placeholder)) throw new Error('模板缺少' + name + '占位符 ' + placeholder + ':' + TEMPLATE); } - /* 占位符本身就落在 `const __override = …` 的右值位置,所以这里只给一个表达式。 */ - const overrideModule = '(() => {\n' - + stripExports(fs.readFileSync(OVERRIDE_FILE, 'utf8')) - + '\nreturn { ' + OVERRIDE_EXPORTS.join(', ') + ' };\n})()'; + /* 占位符本身就落在 `const __override = …` / `const __modelPicker = …` 的右值位置。 */ + const overrideModule = iife(OVERRIDE_FILE, 'override.js', OVERRIDE_EXPORTS); + const pickerModule = iife(MODEL_PICKER_FILE, 'model-picker.js', MODEL_PICKER_EXPORTS); const settingsCard = fs.readFileSync(SETTINGS_FILE, 'utf8'); /* 用函数式替换:CSS 里的 $& / $' 等序列不会被当成替换模式展开。 */ const clientJs = tplSrc .replace(CSS_PLACEHOLDER, () => JSON.stringify(themeCss)) .replace(OVERRIDE_PLACEHOLDER, () => overrideModule) + .replace(MODEL_PICKER_PLACEHOLDER, () => pickerModule) .replace(SETTINGS_PLACEHOLDER, () => settingsCard); return { themeCss, clientJs, sources, scopedBytes: themeCss.length, hashAnchors: anchors }; } diff --git a/src/client.template.js b/src/client.template.js index 838c14f..471b2e3 100644 --- a/src/client.template.js +++ b/src/client.template.js @@ -1,11 +1,12 @@ /** * codex-ui — Browser half(唯一样式源是 skins/codex-ink,经 src/build.mjs 生成;本文件是模板,勿手改)。 * - * 职责三件: - * 1. 把 /skins/codex-ink 的整套 Codex 化样式(L1/L2 令牌 + L3 组件层 + 设置页层) + * 职责四件: + * 1. 把 skins/codex-ink 的整套 Codex 化样式(L1/L2 令牌 + L3 组件层 + 设置页层) * 以作用域 html[data-codex-ui] 注入到文档,并在卸载时完整收回; * 2. 把设置页的取值渲染成一层覆盖(多一个属性 ⇒ 特异性压过皮肤,源样式一个字不动); - * 3. 在官方插件管理的组合包页(座位 plugins.bundle.config)注册那张配置卡。 + * 3. 在官方插件管理的组合包页(座位 plugins.bundle.config)注册那张配置卡; + * 4. 用自建组件顶替 composer 的模型位(Codex 模型列表 + 推理等级功率轨),设置里可关。 * 不依赖 skin-center / dsh-web-all:样式文本随本文件一起下发,无外部请求。 */ window.__ModuleLoader__.load({ @@ -35,6 +36,8 @@ window.__ModuleLoader__.load({ const CSS = /*__CODEX_UI_CSS__*/ null; /** 生成期注入的覆盖层模块(源:src/override.js)。 */ const __override = /*__CODEX_UI_OVERRIDE__*/ null; + /** 生成期注入的模型选择器组件(源:src/model-picker.js)。 */ + const __modelPicker = /*__CODEX_UI_MODEL_PICKER__*/ null; /* 生成期注入的设置卡片(源:src/settings-card.js)。 */ /*__CODEX_UI_SETTINGS__*/ null @@ -87,17 +90,74 @@ window.__ModuleLoader__.load({ console.warn('[codex-ui] ctx.effect 不可用:样式已注入,但不会随 fiber 卸载回收。'); } /* 设置页那一半:任何一步失败都只降级,不能连皮肤一起拖下水。 */ + let formScope = null; try { - installSettings(ctx, root); + formScope = installSettings(ctx, root); } catch (error) { console.warn('[codex-ui] 设置页挂载失败,皮肤照常:', error); } + /* 模型选择器同理:挂不上就把席位留给宿主原生菜单(A 面样式照常生效)。 */ + try { + installModelPicker(ctx, formScope); + } catch (error) { + console.warn('[codex-ui] 模型选择器挂载失败,宿主原生菜单照常:', error); + } + } + + /** + * 模型选择器 B 面(src/model-picker.js)。 + * + * 等宿主的 modelDirectories 服务就绪再装:ctx.inject(deps, fn) 是宿主自己挂 composer 模型位的 + * 同一个口子(dsh-client-ui-model-selection 也是这么等的)。服务不在时不阻塞本插件激活 —— + * 不能把它写进上面的 inject 列表,那样缺一个服务整个皮肤都停在 pending;服务撤走时 fn 的作用域 + * 连同我们的节点一起回收。 + * 设置卡的「Codex 模型选择器」(modelPicker,默认开)关掉 → setEnabled(false):自建节点全撤, + * 宿主那一格经 model-picker.css ① 的 :has() 立刻复原。 + * @param ctx - 客户端上下文。 + * @param formScope - 本插件的设置表单;没有设置服务时为 null(此时按默认开)。 + */ + function installModelPicker(ctx, formScope) { + if (typeof ctx.inject !== 'function') { + console.warn('[codex-ui] ctx.inject 不可用:模型选择器不挂,宿主原生菜单照常。'); + return; + } + /** 设置文档里的开关;文档还没到时返回 null(保持现状,不先接管再撤回)。 */ + const wanted = () => { + if (formScope === null) return true; + const snapshot = formScope.getSnapshot(); + if (snapshot !== undefined && snapshot !== null && snapshot.value === undefined) return null; + const value = snapshot === undefined || snapshot === null || snapshot.value === null ? {} : snapshot.value; + return value.modelPicker !== false; + }; + ctx.inject(['modelDirectories'], (scope) => { + const picker = __modelPicker.installModelPicker({ + models: scope.modelDirectories, + /* 席位祖先上没有 data-conversation-session 时退到主视图会话(uiSession 投影)。 */ + sessionFallback: () => { + const ui = ctx.reflect.get('uiSession'); + const current = ui === undefined || ui === null ? null : ui.current; + return current === undefined || current === null || current.value === undefined || current.value === null ? null : current.value.key; + }, + locale: ctx.reflect.get('locale'), + enabled: wanted() === true, + }); + const sync = () => { + const next = wanted(); + if (next !== null) picker.setEnabled(next); + }; + const off = formScope !== null && typeof formScope.subscribe === 'function' ? formScope.subscribe(sync) : null; + scope.effect(() => () => { + if (typeof off === 'function') off(); + picker.dispose(); + }, 'codex-ui: model picker'); + }); } /** * 挂上覆盖层与组合包页的配置卡。 * @param ctx - 客户端上下文。 * @param root - 。 + * @returns 本插件的设置表单(模型选择器的开关也读它);没有设置服务时 null。 */ function installSettings(ctx, root) { const forms = ctx.configForms === undefined || ctx.configForms === null ? null : ctx.configForms; @@ -105,7 +165,7 @@ window.__ModuleLoader__.load({ if (scope === null || scope === undefined) { /* 没有设置服务:不注册座位、不加覆盖层,皮肤照常。 */ console.warn('[codex-ui] 没有 configForms 服务:设置页不可用,皮肤照常。'); - return; + return null; } const tag = document.createElement('style'); tag.dataset.plugin = PLUGIN_ID; @@ -210,6 +270,7 @@ window.__ModuleLoader__.load({ /* 点下去立刻按目标主题显示,文档往返在背后跑。 */ previewTheme, }); + return scope; } return { apply, inject, PLUGIN_ID }; diff --git a/src/model-picker.js b/src/model-picker.js new file mode 100644 index 0000000..c40681d --- /dev/null +++ b/src/model-picker.js @@ -0,0 +1,828 @@ +/** + * model-picker.js — Codex 模型选择器 B 面(浏览器半,构建期拼进 client.js)。 + * + * 为什么是**自己的 DOM**:宿主菜单是竖列 radio,横向功率轨必须自建。0.5.0 用纯 CSS 把宿主 + * 菜单重排成轨,~25 条 :has() 挂在一个在 hover / focus / aria-busy 里反复重渲染的 portal 上 —— + * 切换卡顿、界面简陋,已 revert。这里走「DOM 顶替席位」: + * 1. 不注册 slot,也不动宿主的 React 树; + * 2. 自己的触发器追加进 [data-slot="conversation.input.model"],弹层挂 document.body; + * 3. 席位里有我们的触发器时,样式表用**一条**直接子代 :has() 把宿主那一格 display:none + * (model-picker.css ①)。不打标记属性:React 换掉宿主子节点时标记会丢、宿主控件闪回; + * :has() 只看「我们在不在席」,摘掉触发器宿主立刻复原。 + * 数据与提交全部走宿主唯一真源 ctx.modelDirectories,本模块不缓存模型列表。 + * + * 驱动契约(读 @deepseek-ai/dsh-client-ui-model-selection 0.1.7-rc.2 源码得到): + * models.directoryFor(sessionId) → ModelDirectory(会话作用域未物化时**会抛**) + * dir.store.getSnapshot() → { current, retainedEffort, groups, status, pending, error } + * status : 'loading' | 'ready' | 'selecting' | 'error' + * groups : [{ id, name, models: [{ id, name, description?, reasoning?: { defaultEffort?, efforts: [{ id, name }] } }] }] + * current : { provider, model, reasoningEffort? } | null + * pending : 正在往返的那次 selection | null + * dir.store.subscribe(fn) → 退订函数 + * dir.load() → 刷新目录(宿主在每次打开菜单时调一次) + * dir.select({ provider, model, reasoningEffort? }) → Promise<{ ok } | { ok:false, error:{ code, message } }> + * 会话 id:席位祖先上的 data-conversation-session(宿主 ConversationRoot 打的), + * 取不到再退到 uiSession.current.value.key(主视图那一个会话)。 + * + * 卡顿的解码:宿主把目录在**整个 selectModel 往返**(实测 ~1.1s)里标成 selecting。 + * 列表的签名里只放「长什么样」的东西,selecting 不在里面 —— 改档位时卡片不重画、不清空; + * 往返期间轨与触发器按 pending 那一档乐观显示,并在档位名旁转圈(宿主菜单那里一个都不画)。 + */ + +/** 席位名。 */ +export const MODEL_SLOT = 'conversation.input.model'; +/** 自建节点的类名根(样式表只画这些类,不碰宿主任何节点)。 */ +export const TRIGGER_CLASS = 'codex-mp-trigger'; +export const POPOVER_CLASS = 'codex-mp-popover'; +/** Codex _ThumbScale 28px:拇指中心的行程是 [14, 宽 − 14]。 */ +export const THUMB_SIZE = 28; +/** Codex --model-picker-power-slider-thumb-input-motion-duration:首帧 0s,16ms 后抬到 .3s。 */ +export const MOTION_ARM_MS = 16; +/** 弹层定位(宿主 ModelSelect 的 place():右沿对齐触发器、上方留 8px、视口留 12px)。 */ +export const POPOVER_GAP = 8; +export const POPOVER_MARGIN = 12; +/** 宿主给内置模型的说明做了本地化,按同一张表取(宿主 BUILTIN_DESCRIPTION_KEYS)。 */ +const BUILTIN_DESCRIPTION_KEYS = { + 'deepseek-account/deepseek-v4-flash': 'option.deepseekV4Flash.description', + 'deepseek-account/deepseek-v4-pro': 'option.deepseekV4Pro.description', + 'deepseek-official/deepseek-v4-flash': 'option.deepseekV4Flash.description', + 'deepseek-official/deepseek-v4-pro': 'option.deepseekV4Pro.description', +}; + +/** + * 文案。优先借宿主 model 命名空间(措辞与原生菜单逐字一致,也跟着宿主的语言走); + * 宿主没有这把钥匙时才落到这里。键名与宿主相同,en 的两条说明也用来判断「是不是内置原文」。 + */ +const COPY = { + zh: { + 'provider.account': 'DeepSeek Account', + 'trigger.fallback': '请选择模型', + 'trigger.loading': '正在加载模型…', + 'trigger.aria': '选择模型,当前 {model}', + 'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}', + 'menu.aria': '模型与推理等级', + 'menu.model': '模型', + 'menu.effort': '推理等级', + 'effort.providerDefault': 'Default', + 'error.action': '模型操作失败:{message}', + 'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。', + 'action.reload': '重新加载', + 'empty.models': '没有可用的模型。', + }, + en: { + 'provider.account': 'DeepSeek Account', + 'option.deepseekV4Flash.description': 'Fast, efficient, and economical; suited to focused, routine, or parallel tasks.', + 'option.deepseekV4Pro.description': 'Stronger agentic coding, knowledge, and difficult reasoning; suited to complex or quality-critical tasks at higher cost.', + 'trigger.fallback': 'Select model', + 'trigger.loading': 'Loading models…', + 'trigger.aria': 'Select model, current {model}', + 'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}', + 'menu.aria': 'Model and reasoning effort', + 'menu.model': 'Model', + 'menu.effort': 'Effort', + 'effort.providerDefault': 'Default', + 'error.action': 'Model operation failed: {message}', + 'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance (such as dsh web or the desktop app). Quit other running DSH instances and try again.', + 'action.reload': 'Reload', + 'empty.models': 'No models available.', + }, +}; + +/** + * 松手对齐:连续比例 → 最近档位下标。 + * @param ratio - 0..1(超界夹住,非数当 0)。 + * @param count - 档位数。 + * @returns 0..count-1。 + */ +export function snapIndex(ratio, count) { + if (!Number.isFinite(count) || count <= 1) return 0; + const clamped = Math.min(1, Math.max(0, Number.isFinite(ratio) ? ratio : 0)); + return Math.round(clamped * (count - 1)); +} + +/** + * 档位 → 连续比例(拇指、色条与圆点共用的那个 --codex-mp-pos)。单档时居中。 + * @param index - 档位下标。 + * @param count - 档位数。 + * @returns 0..1。 + */ +export function indexRatio(index, count) { + if (!Number.isFinite(count) || count <= 1) return 0.5; + return Math.min(1, Math.max(0, index / (count - 1))); +} + +/** + * 指针位置 → 连续比例:拇指中心只能走 [thumb/2, width − thumb/2](Codex _Thumb 的行程)。 + * @param clientX - 指针横坐标。 + * @param left - 轨左沿。 + * @param width - 轨宽。 + * @param thumb - 拇指直径。 + * @returns 0..1。 + */ +export function offsetRatio(clientX, left, width, thumb = THUMB_SIZE) { + if (!Number.isFinite(width) || width <= thumb) return 0; + return Math.min(1, Math.max(0, (clientX - left - thumb / 2) / (width - thumb))); +} + +/** + * 与 CSS 同一条公式的像素值(夹具与验收用):calc(14px + (100% − 28px) × ratio)。 + * @param ratio - 0..1。 + * @param width - 轨宽。 + * @param thumb - 拇指直径。 + * @returns 相对轨左沿的 px。 + */ +export function ratioOffset(ratio, width, thumb = THUMB_SIZE) { + return thumb / 2 + (width - thumb) * ratio; +} + +/** + * 分组排序:与宿主菜单同序(deepseek-account → deepseek-official → 其余保持原序)。 + * @param groups - 目录里的分组。 + * @returns 新数组。 + */ +export function sortGroups(groups) { + const rank = (group) => (group.id === 'deepseek-account' ? 0 : group.id === 'deepseek-official' ? 1 : 2); + return (Array.isArray(groups) ? groups : []).map((group, i) => [group, i]) + .sort((a, b) => rank(a[0]) - rank(b[0]) || a[1] - b[1]) + .map(([group]) => group); +} + +/** + * 席位所属的会话 id。 + * @param slot - 席位元素。 + * @param fallback - 取不到时的回退(返回主视图会话 id 的函数)。 + * @returns 会话 id 或 null。 + */ +export function sessionIdOf(slot, fallback) { + const owner = slot !== null && typeof slot.closest === 'function' ? slot.closest('[data-conversation-session]') : null; + const fromDom = owner === null ? null : owner.getAttribute('data-conversation-session'); + if (typeof fromDom === 'string' && fromDom !== '') return fromDom; + try { + const key = typeof fallback === 'function' ? fallback() : null; + return typeof key === 'string' && key !== '' ? key : null; + } catch { + return null; + } +} + +/** + * 目录快照 → 这一席位要显示的一切(纯函数,宿主 ModelSelect 的派生逻辑逐条对齐)。 + * pending 若是同一模型上的改档,档位按 pending 乐观显示 —— 往返 ~1.1s 里轨不回弹。 + * @param snap - dir.store.getSnapshot()。 + * @returns 视图模型。 + */ +export function viewOf(snap) { + const empty = { groups: [], current: null, choice: null, efforts: [], index: -1, effective: undefined, hasReasoning: false, pending: null, pendingEffort: false, status: 'loading', error: null, retainedEffort: undefined }; + if (snap === undefined || snap === null) return empty; + const groups = sortGroups(snap.groups); + const current = snap.current === undefined ? null : snap.current; + let choice = null; + if (current !== null) { + for (const group of groups) { + const model = (Array.isArray(group.models) ? group.models : []).find((m) => m.id === current.model); + if (group.id === current.provider && model !== undefined) { choice = { group, model }; break; } + } + } + const reasoning = choice === null || choice.model.reasoning === undefined || choice.model.reasoning === null ? null : choice.model.reasoning; + const efforts = reasoning === null || !Array.isArray(reasoning.efforts) ? [] : reasoning.efforts; + const pending = snap.pending === undefined ? null : snap.pending; + const pendingEffort = pending !== null && current !== null && pending.provider === current.provider && pending.model === current.model + && pending.reasoningEffort !== current.reasoningEffort; + const saved = pendingEffort ? pending.reasoningEffort : (current === null ? undefined : current.reasoningEffort); + const effective = saved !== undefined && saved !== null ? saved : (reasoning === null ? undefined : reasoning.defaultEffort); + return { + groups, + current, + choice, + efforts, + index: efforts.findIndex((level) => level.id === effective), + effective, + hasReasoning: reasoning !== null, + pending, + pendingEffort, + status: typeof snap.status === 'string' ? snap.status : 'loading', + error: typeof snap.error === 'string' && snap.error !== '' ? snap.error : null, + retainedEffort: snap.retainedEffort, + }; +} + +/** + * 列表签名:只含「列表长什么样」—— 不含 status(selecting 期间不重画), + * 含 pending 的那一行(行尾转圈要画出来)。 + * @param view - viewOf 的结果。 + * @returns 字符串。 + */ +export function listSignature(view) { + const parts = [view.current === null ? '' : view.current.provider + '/' + view.current.model]; + parts.push(view.pending === null ? '' : view.pending.provider + '/' + view.pending.model); + parts.push(view.groups.length === 0 ? view.status + ':' + (view.error ?? '') : ''); + for (const group of view.groups) { + parts.push(group.id + '=' + group.name + ':' + (Array.isArray(group.models) ? group.models.map((m) => m.id + '|' + m.name + '|' + (m.description ?? '')).join(',') : '')); + } + return parts.join(';'); +} + +/** + * 安装模型选择器。 + * @param env - { models, sessionFallback, locale, enabled?, document?, window? }。 + * enabled: false 表示先不接管,等 setEnabled(true)(设置文档还没到时用,免得先接管再撤回闪一下)。 + * @returns 句柄:setEnabled / refresh / isActive / dispose。 + */ +export function installModelPicker(env) { + const doc = env.document ?? globalThis.document; + const win = env.window ?? globalThis.window ?? globalThis; + const models = env.models; + const slotSelector = '[data-slot="' + MODEL_SLOT + '"]'; + /** slot 元素 → 席位状态。 */ + const seats = new Map(); + let enabled = env.enabled !== false; + let disposed = false; + /** 当前打开弹层的那一个席位。 */ + let openSeat = null; + let popover = null; + let listBox = null; + let errorBox = null; + let effortBox = null; + let effortValue = null; + let rail = null; + let railSignature = ''; + let lastListSignature = ''; + let armTimer = 0; + let drag = null; + /** 最近一次提交失败的文案(弹层顶上那条)。 */ + let failure = null; + + /* ── 文案 ────────────────────────────────────────────────────────────── */ + const hostT = (() => { + try { return env.locale !== undefined && env.locale !== null && typeof env.locale.bind === 'function' ? env.locale.bind('model') : null; } catch { return null; } + })(); + const lang = () => { + let active = null; + try { active = env.locale ? env.locale.getSnapshot().active : null; } catch { active = null; } + const tag = typeof active === 'string' ? active : (typeof navigator === 'undefined' ? '' : navigator.language); + return typeof tag === 'string' && tag.toLowerCase().startsWith('en') ? 'en' : 'zh'; + }; + const fill = (template, params) => (params === undefined ? template : template.replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m))); + /** 先问宿主(返回键名本身即没有),再落本表。 */ + const t = (key, params) => { + if (hostT !== null) { + try { + const hosted = hostT(key, params); + if (typeof hosted === 'string' && hosted !== key) return hosted; + } catch { /* 宿主字典缺席就用本表 */ } + } + const own = COPY[lang()][key] ?? COPY.zh[key] ?? key; + return fill(own, params); + }; + const groupName = (group) => (group.id === 'deepseek-account' ? t('provider.account') : (group.name || group.id)); + /* 内置模型的说明只有宿主字典里有中文;宿主字典缺席时 t() 会把键名原样还回来 —— 那就用目录自带的原文。 */ + const descriptionOf = (group, model) => { + const key = BUILTIN_DESCRIPTION_KEYS[group.id + '/' + model.id]; + if (key === undefined || model.description !== COPY.en[key]) return model.description; + const localized = t(key); + return localized === key ? model.description : localized; + }; + + /* ── DOM 小工具 ─────────────────────────────────────────────────────── */ + const el = (tag, className, text) => { + const node = doc.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + const CHEVRON = ''; + const CHECK = ''; + const reducedMotion = () => typeof win.matchMedia === 'function' && win.matchMedia('(prefers-reduced-motion: reduce)').matches; + + /* ── 席位 ────────────────────────────────────────────────────────────── */ + function createSeat(slot) { + const button = el('button', TRIGGER_CLASS); + button.type = 'button'; + button.setAttribute('aria-haspopup', 'dialog'); + button.setAttribute('aria-expanded', 'false'); + const model = el('span', 'codex-mp-trigger-model'); + const layers = el('span', 'codex-mp-effort-layers'); + layers.setAttribute('aria-hidden', 'true'); + const chevron = el('span', 'codex-mp-trigger-chevron'); + chevron.innerHTML = CHEVRON; + button.append(model, layers, chevron); + const seat = { slot, button, model, layers, layerKey: '', sessionId: null, dir: null, off: null }; + button.addEventListener('click', (event) => { + event.stopPropagation(); + if (openSeat === seat) close(false); + else open(seat, event.detail === 0); + }); + button.addEventListener('keydown', (event) => { + /* 鼠标打开后焦点留在触发器上(宿主也是),Escape 必须在这里也能关 —— 宿主的键盘处理挂在整格根上。 */ + if (event.key === 'Escape' && openSeat === seat) { + event.preventDefault(); + close(true); + return; + } + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault(); + if (openSeat !== seat) open(seat, true); + } + }); + return seat; + } + + /** 换绑会话:退订旧目录,解析新目录并订阅。会话作用域未物化时宿主会抛 —— 吞掉,下一趟再试。 */ + function bind(seat, sessionId) { + if (seat.off !== null) { try { seat.off(); } catch { /* 目录已随会话作用域销毁 */ } seat.off = null; } + seat.sessionId = sessionId; + seat.dir = null; + if (sessionId === null) return; + try { + const dir = models.directoryFor(sessionId); + if (dir === undefined || dir === null || dir.store === undefined) return; + seat.dir = dir; + seat.off = dir.store.subscribe(() => onStore(seat)); + } catch { + seat.dir = null; + } + } + + const snapshotOf = (seat) => { + if (seat.dir === null) return null; + try { return seat.dir.store.getSnapshot(); } catch { return null; } + }; + + /** 可以接管:宿主确实渲染了这一格(子代理会话里宿主返回 null,我们也不出头),且目录已有可显示的内容。 */ + const canSeat = (seat, view) => { + const hostChild = [...seat.slot.children].some((child) => child !== seat.button); + return hostChild && seat.dir !== null && (view.current !== null || view.groups.length > 0); + }; + + function syncSeat(slot) { + let seat = seats.get(slot); + if (seat === undefined) { seat = createSeat(slot); seats.set(slot, seat); } + const id = sessionIdOf(slot, env.sessionFallback); + if (id !== seat.sessionId || seat.dir === null) bind(seat, id); + const view = viewOf(snapshotOf(seat)); + if (!canSeat(seat, view)) { + if (seat.button.parentElement !== null) seat.button.remove(); + if (openSeat === seat) close(false); + return; + } + paintTrigger(seat, view); + if (seat.button.parentElement !== slot) slot.appendChild(seat.button); + } + + function dropSeat(seat) { + if (openSeat === seat) close(false); + if (seat.off !== null) { try { seat.off(); } catch { /* ignore */ } } + seat.button.remove(); + seats.delete(seat.slot); + } + + /** 全量对账:新席位接上、失联席位撤掉。 */ + function scan() { + if (disposed || !enabled) return; + const live = new Set(doc.querySelectorAll(slotSelector)); + for (const seat of [...seats.values()]) if (!live.has(seat.slot) || !seat.slot.isConnected) dropSeat(seat); + for (const slot of live) syncSeat(slot); + } + + function onStore(seat) { + if (disposed || !enabled) return; + syncSeat(seat.slot); + if (openSeat === seat) renderPopover(); + } + + /* ── 触发器 ──────────────────────────────────────────────────────────── */ + function effortLabelOf(view) { + if (!view.hasReasoning) return view.retainedEffort; + if (view.effective === undefined) return t('effort.providerDefault'); + const level = view.efforts.find((e) => e.id === view.effective); + return level === undefined ? view.effective : level.name; + } + + function paintTrigger(seat, view) { + const waiting = view.current === null && view.status === 'loading'; + const modelLabel = waiting ? t('trigger.loading') + : view.choice !== null ? view.choice.model.name + : view.current === null ? t('trigger.fallback') : view.current.provider + '/' + view.current.model; + const effortLabel = effortLabelOf(view); + if (seat.model.textContent !== modelLabel) seat.model.textContent = modelLabel; + /* 档位文字叠层(Codex _ModelPickerTriggerEffortText):所有档名叠在同一格里, + 当前那层 data-active —— 改档是模糊交叉淡入,不是换文本;格宽 = 最宽的那个名字,不跳宽。 */ + const names = view.hasReasoning ? [...(view.effective === undefined ? [t('effort.providerDefault')] : []), ...view.efforts.map((e) => e.name)] : []; + if (effortLabel !== undefined && !names.includes(effortLabel)) names.push(effortLabel); + const key = names.join('\u0000'); + if (key !== seat.layerKey) { + seat.layerKey = key; + seat.layers.textContent = ''; + for (const name of names) seat.layers.appendChild(el('span', 'codex-mp-effort-text', name)); + } + /* 对账在任何元素增删时都会跑(流式输出期间很频繁):属性只在变了时才写,重复对账不触发样式失效。 */ + for (const layer of seat.layers.children) { + const active = layer.textContent === effortLabel ? 'true' : 'false'; + if (layer.getAttribute('data-active') !== active) layer.setAttribute('data-active', active); + } + if (seat.layers.hidden !== (names.length === 0)) seat.layers.hidden = names.length === 0; + const title = effortLabel === undefined ? modelLabel : modelLabel + ' · ' + effortLabel; + if (seat.button.title !== title) seat.button.title = title; + const aria = waiting ? t('trigger.loading') : view.current === null ? t('trigger.fallback') + : effortLabel === undefined ? t('trigger.aria', { model: modelLabel }) : t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel }); + if (seat.button.getAttribute('aria-label') !== aria) seat.button.setAttribute('aria-label', aria); + if (seat.button.hasAttribute('data-pending') !== (view.pending !== null)) seat.button.toggleAttribute('data-pending', view.pending !== null); + } + + /* ── 弹层 ────────────────────────────────────────────────────────────── */ + function ensurePopover() { + if (popover !== null) return; + popover = el('div', POPOVER_CLASS); + popover.setAttribute('role', 'dialog'); + popover.hidden = true; + errorBox = el('div', 'codex-mp-error'); + errorBox.setAttribute('role', 'alert'); + errorBox.hidden = true; + listBox = el('div', 'codex-mp-list'); + listBox.setAttribute('role', 'radiogroup'); + effortBox = el('div', 'codex-mp-effort'); + const head = el('div', 'codex-mp-effort-head'); + head.appendChild(el('span', 'codex-mp-effort-label')); + effortValue = el('span', 'codex-mp-effort-value'); + head.appendChild(effortValue); + const container = el('div', 'codex-mp-container'); + rail = el('div', 'codex-mp-root'); + rail.setAttribute('role', 'slider'); + rail.tabIndex = 0; + const track = el('div', 'codex-mp-track'); + track.appendChild(el('div', 'codex-mp-range')); + const thumbScale = el('span', 'codex-mp-thumb-scale'); + thumbScale.appendChild(el('span', 'codex-mp-thumb')); + rail.append(track, thumbScale); + container.appendChild(rail); + effortBox.append(head, container); + popover.append(errorBox, listBox, effortBox); + popover.addEventListener('keydown', onPopoverKey); + popover.addEventListener('focusout', onFocusOut); + wireRail(); + doc.body.appendChild(popover); + } + + function open(seat, viaKeyboard) { + if (!enabled || disposed) return; + if (openSeat !== null && openSeat !== seat) close(false); + ensurePopover(); + openSeat = seat; + failure = null; + lastListSignature = ''; + railSignature = ''; + seat.button.setAttribute('aria-expanded', 'true'); + popover.setAttribute('aria-label', t('menu.aria')); + listBox.setAttribute('aria-label', t('menu.model')); + popover.setAttribute('data-reduced-motion', reducedMotion() ? 'true' : 'false'); + /* 首帧不动画(拇指从 0 滑到当前档很难看):Codex 的 thumb-input-motion-duration 首帧 0s、16ms 后抬到 .3s。 */ + rail.removeAttribute('data-armed'); + popover.hidden = false; + renderPopover(); + if (armTimer !== 0) win.clearTimeout(armTimer); + armTimer = win.setTimeout(() => { armTimer = 0; if (rail !== null) rail.setAttribute('data-armed', 'true'); }, MOTION_ARM_MS); + /* 宿主在打开菜单时刷新一次目录(reload()),这里同样做一次;结果经 store 订阅回来。 */ + if (seat.dir !== null && typeof seat.dir.load === 'function') { + try { const p = seat.dir.load(); if (p && typeof p.catch === 'function') p.catch(() => {}); } catch { /* 子代理会话会抛:不刷新即可 */ } + } + if (viaKeyboard) { + const checked = listBox.querySelector('[aria-checked="true"]') ?? listBox.querySelector('.codex-mp-row'); + if (checked !== null) checked.focus(); + } + } + + function close(restoreFocus) { + if (openSeat === null) return; + const seat = openSeat; + openSeat = null; + drag = null; + if (popover !== null) popover.hidden = true; + seat.button.setAttribute('aria-expanded', 'false'); + if (restoreFocus && seat.button.isConnected) seat.button.focus(); + } + + /** 宿主 place() 的同一套:右沿对齐触发器、上方 8px、夹在视口 12px 内。 */ + function place() { + if (openSeat === null || popover === null || popover.hidden) return; + const rect = openSeat.button.getBoundingClientRect(); + const w = popover.offsetWidth; + const h = popover.offsetHeight; + let x = rect.right - w; + let y = rect.top - POPOVER_GAP - h; + if (w > 0) x = Math.min(Math.max(x, POPOVER_MARGIN), win.innerWidth - w - POPOVER_MARGIN); + if (h > 0) y = Math.min(Math.max(y, POPOVER_MARGIN), win.innerHeight - h - POPOVER_MARGIN); + popover.style.left = Math.round(x) + 'px'; + popover.style.top = Math.round(y) + 'px'; + } + + function renderPopover() { + if (openSeat === null || popover === null) return; + const view = viewOf(snapshotOf(openSeat)); + errorBox.hidden = failure === null; + errorBox.textContent = failure ?? ''; + const signature = listSignature(view); + if (signature !== lastListSignature) { + lastListSignature = signature; + renderList(view); + } + renderEffort(view); + place(); + } + + function renderList(view) { + listBox.textContent = ''; + if (view.groups.length === 0) { + const status = el('div', 'codex-mp-status'); + if (view.status === 'error') { + status.textContent = t('error.action', { message: view.error ?? '' }) + ' '; + const retry = el('button', 'codex-mp-retry', t('action.reload')); + retry.type = 'button'; + retry.addEventListener('click', () => { if (openSeat !== null && openSeat.dir !== null) openSeat.dir.load().catch(() => {}); }); + status.appendChild(retry); + } else { + status.textContent = view.status === 'loading' ? t('trigger.loading') : t('empty.models'); + } + listBox.appendChild(status); + return; + } + const titled = view.groups.length > 1; + for (const group of view.groups) { + const list = Array.isArray(group.models) ? group.models : []; + if (list.length === 0) continue; + if (titled) listBox.appendChild(el('div', 'codex-mp-group', groupName(group))); + for (const model of list) listBox.appendChild(buildRow(view, group, model)); + } + } + + function buildRow(view, group, model) { + const selected = view.current !== null && view.current.provider === group.id && view.current.model === model.id; + const pending = view.pending !== null && view.pending.provider === group.id && view.pending.model === model.id && !view.pendingEffort; + const row = el('button', 'codex-mp-row'); + row.type = 'button'; + row.setAttribute('role', 'radio'); + row.setAttribute('aria-checked', selected ? 'true' : 'false'); + if (pending) row.setAttribute('aria-busy', 'true'); + const copy = el('span', 'codex-mp-row-copy'); + copy.appendChild(el('span', 'codex-mp-row-name', model.name || model.id)); + const description = descriptionOf(group, model); + if (typeof description === 'string' && description !== '') { + const desc = el('span', 'codex-mp-row-desc', description); + desc.title = description; + copy.appendChild(desc); + } + const check = el('span', 'codex-mp-row-check'); + /* pending 行尾换成转圈(宿主此时一个都不画,面板冻着像卡住);勾选留给已生效的那一行。 */ + if (pending) check.appendChild(el('span', 'codex-mp-spinner')); + else if (selected) check.innerHTML = CHECK; + row.append(copy, check); + row.addEventListener('click', (event) => { + event.stopPropagation(); + choose(group, model); + }); + return row; + } + + /* ── 功率轨 ──────────────────────────────────────────────────────────── */ + function renderEffort(view) { + const show = view.hasReasoning && view.efforts.length > 0; + effortBox.hidden = !show; + if (!show) return; + effortBox.querySelector('.codex-mp-effort-label').textContent = t('menu.effort'); + effortValue.textContent = ''; + if (view.pendingEffort) effortValue.appendChild(el('span', 'codex-mp-spinner')); + effortValue.appendChild(el('span', '', effortLabelOf(view) ?? '')); + rail.setAttribute('aria-label', t('menu.effort')); + const signature = view.choice.group.id + '/' + view.choice.model.id + ':' + view.efforts.map((e) => e.id).join(','); + if (signature !== railSignature) { + railSignature = signature; + for (const tick of rail.querySelectorAll('.codex-mp-tick')) tick.remove(); + const thumbScale = rail.querySelector('.codex-mp-thumb-scale'); + view.efforts.forEach((level, i) => { + const tick = el('span', 'codex-mp-tick'); + tick.setAttribute('data-index', String(i)); + tick.title = level.name; + /* 档位等距落在拇指行程 [14, 宽 − 14] 上 —— 与拇指、色条同一条 calc,零布局读。 */ + tick.style.left = 'calc(' + THUMB_SIZE / 2 + 'px + (100% - ' + THUMB_SIZE + 'px) * ' + indexRatio(i, view.efforts.length) + ')'; + rail.insertBefore(tick, thumbScale); + }); + rail.setAttribute('aria-valuemin', '0'); + rail.setAttribute('aria-valuemax', String(view.efforts.length - 1)); + rail.setAttribute('data-count', String(view.efforts.length)); + } + rail.toggleAttribute('data-unset', view.index < 0); + if (view.index >= 0) { + rail.setAttribute('aria-valuenow', String(view.index)); + rail.setAttribute('aria-valuetext', view.efforts[view.index].name); + } else { + rail.removeAttribute('aria-valuenow'); + rail.setAttribute('aria-valuetext', effortLabelOf(view) ?? ''); + } + /* 拖动中不让 store 的回调把拇指拽回去。 */ + if (drag === null) paintRail(view.index < 0 ? null : indexRatio(view.index, view.efforts.length)); + } + + /** + * 画到连续位置:拇指、色条、「已走过」的圆点都由同一个 --codex-mp-pos 驱动。 + * @param ratio - 0..1;null 表示没有生效档(跟随提供方默认),此时拇指与色条藏起、圆点全按未选画。 + */ + function paintRail(ratio) { + rail.style.setProperty('--codex-mp-pos', String(ratio === null ? 0 : Number(ratio.toFixed(4)))); + const ticks = rail.querySelectorAll('.codex-mp-tick'); + const count = ticks.length; + ticks.forEach((tick, i) => tick.setAttribute('data-selected', ratio !== null && indexRatio(i, count) <= ratio + 1e-6 ? 'true' : 'false')); + } + + function currentEfforts() { + if (openSeat === null) return null; + const view = viewOf(snapshotOf(openSeat)); + return view.choice === null || view.efforts.length === 0 ? null : view; + } + + function wireRail() { + rail.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + const view = currentEfforts(); + if (view === null) return; + event.preventDefault(); + rail.focus({ preventScroll: true }); + rail.setAttribute('data-keyboard-focused', 'false'); + const rect = rail.getBoundingClientRect(); + drag = { pointerId: event.pointerId, left: rect.left, width: rect.width, count: view.efforts.length, ratio: offsetRatio(event.clientX, rect.left, rect.width) }; + try { rail.setPointerCapture(event.pointerId); } catch { /* 合成事件没有真指针 */ } + rail.setAttribute('data-dragging', 'true'); + paintRail(drag.ratio); + }); + rail.addEventListener('pointermove', (event) => { + if (drag === null || event.pointerId !== drag.pointerId) return; + drag.ratio = offsetRatio(event.clientX, drag.left, drag.width); + paintRail(drag.ratio); + }); + const release = (event, commitIt) => { + if (drag === null || event.pointerId !== drag.pointerId) return; + const { count, ratio } = drag; + drag = null; + rail.removeAttribute('data-dragging'); + try { if (rail.hasPointerCapture(event.pointerId)) rail.releasePointerCapture(event.pointerId); } catch { /* ignore */ } + const view = currentEfforts(); + if (view === null) return; + const index = commitIt ? snapIndex(ratio, count) : view.index; + paintRail(index < 0 ? null : indexRatio(index, count)); + if (commitIt) chooseEffort(view, index); + }; + rail.addEventListener('pointerup', (event) => release(event, true)); + rail.addEventListener('pointercancel', (event) => release(event, false)); + /* 捕获意外丢失(节点被换掉、系统手势)按取消处理:退回生效档,不提交半截拖动。 */ + rail.addEventListener('lostpointercapture', (event) => release(event, false)); + rail.addEventListener('keydown', (event) => { + const view = currentEfforts(); + if (view === null) return; + const last = view.efforts.length - 1; + const now = view.index < 0 ? 0 : view.index; + let next = null; + if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') next = Math.max(0, now - 1); + else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') next = Math.min(last, now + 1); + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = last; + if (next === null) return; + event.preventDefault(); + event.stopPropagation(); + rail.setAttribute('data-keyboard-focused', 'true'); + paintRail(indexRatio(next, view.efforts.length)); + chooseEffort(view, next); + }); + rail.addEventListener('focus', () => { + let visible = false; + try { visible = rail.matches(':focus-visible'); } catch { visible = false; } + rail.setAttribute('data-keyboard-focused', visible ? 'true' : 'false'); + }); + rail.addEventListener('blur', () => rail.setAttribute('data-keyboard-focused', 'false')); + } + + /* ── 提交 ────────────────────────────────────────────────────────────── */ + function submit(seat, selection, closeOnOk) { + if (seat.dir === null) return; + failure = null; + let pending; + try { pending = seat.dir.select(selection); } catch (error) { failure = t('error.action', { message: String(error && error.message ? error.message : error) }); renderPopover(); return; } + Promise.resolve(pending).then((result) => { + if (result === undefined || result === null) return; + if (result.ok) { + if (closeOnOk && openSeat === seat) close(true); + return; + } + const error = result.error ?? {}; + failure = error.code === 'session/writer-held' ? t('error.sessionInUse') : t('error.action', { message: (error.code ?? '') + ': ' + (error.message ?? '') }); + if (openSeat === seat) renderPopover(); + }, (error) => { + failure = t('error.action', { message: String(error && error.message ? error.message : error) }); + if (openSeat === seat) renderPopover(); + }); + } + + /** 选模型:同一个就只关掉(宿主 choose());否则连带该模型的默认档位一起提交,成功后关。 */ + function choose(group, model) { + const seat = openSeat; + if (seat === null) return; + const view = viewOf(snapshotOf(seat)); + if (view.pending !== null) return; + if (view.current !== null && view.current.provider === group.id && view.current.model === model.id) { close(true); return; } + const effort = model.reasoning === undefined || model.reasoning === null ? undefined : model.reasoning.defaultEffort; + submit(seat, { provider: group.id, model: model.id, ...(effort === undefined ? {} : { reasoningEffort: effort }) }, true); + } + + /** 改档:同一档不提交;弹层保持打开(轨上可以接着调)。 */ + function chooseEffort(view, index) { + const seat = openSeat; + if (seat === null || view.current === null) return; + const level = view.efforts[index]; + if (level === undefined || level.id === view.effective) return; + submit(seat, { provider: view.current.provider, model: view.current.model, reasoningEffort: level.id }, false); + } + + /* ── 键盘与关闭 ─────────────────────────────────────────────────────── */ + function onPopoverKey(event) { + if (event.key === 'Escape') { + event.preventDefault(); + close(true); + return; + } + if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && event.target instanceof win.HTMLElement && event.target.classList.contains('codex-mp-row')) { + event.preventDefault(); + const rows = [...listBox.querySelectorAll('.codex-mp-row')]; + const at = rows.indexOf(event.target); + const next = rows[(at + (event.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length]; + if (next !== undefined) next.focus(); + } + } + + function onFocusOut(event) { + const to = event.relatedTarget; + if (openSeat === null || !(to instanceof win.Node)) return; + if (popover.contains(to) || openSeat.button.contains(to)) return; + close(false); + } + + const onPointerDown = (event) => { + if (openSeat === null) return; + if (popover !== null && popover.contains(event.target)) return; + if (openSeat.button.contains(event.target)) return; + close(false); + }; + const onViewport = () => place(); + + /* ── 生命周期 ───────────────────────────────────────────────────────── */ + /* 只看元素的增删(childList)与会话标记本身:流式输出改的是文本节点,不会打进来; + 回调同步对账,React 换出新席位的那一帧就接上,宿主控件不会先闪一下。 */ + let observer = null; + if (typeof win.MutationObserver === 'function') { + /* 自己弹层里的重画(列表、档位)不必对账:跳过目标落在弹层内的记录。 */ + const ours = (node) => popover !== null && (node === popover || popover.contains(node)); + observer = new win.MutationObserver((records) => { + for (const record of records) { + if (ours(record.target)) continue; + if (record.type === 'attributes') { scan(); return; } + const moved = [...record.addedNodes, ...record.removedNodes]; + if (moved.some((node) => node.nodeType === 1 && !ours(node))) { scan(); return; } + } + }); + } + const start = () => { + if (observer !== null) observer.observe(doc.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-conversation-session'] }); + doc.addEventListener('pointerdown', onPointerDown, true); + win.addEventListener('resize', onViewport); + win.addEventListener('scroll', onViewport, true); + scan(); + }; + const stop = () => { + close(false); + if (observer !== null) observer.disconnect(); + doc.removeEventListener('pointerdown', onPointerDown, true); + win.removeEventListener('resize', onViewport); + win.removeEventListener('scroll', onViewport, true); + for (const seat of [...seats.values()]) dropSeat(seat); + }; + if (enabled) start(); + + return { + /** 开关。关掉即撤走所有自建节点与订阅,宿主那一格经 :has() 立刻复原。 */ + setEnabled(next) { + const value = next !== false; + if (value === enabled || disposed) return; + enabled = value; + if (enabled) start(); + else stop(); + }, + /** 手动对账(验收用)。 */ + refresh() { scan(); }, + /** 是否至少接管着一个席位(验收用)。 */ + isActive() { return enabled && [...seats.values()].some((seat) => seat.button.isConnected); }, + dispose() { + if (disposed) return; + stop(); + disposed = true; + if (armTimer !== 0) win.clearTimeout(armTimer); + if (popover !== null) popover.remove(); + popover = null; + }, + }; +} diff --git a/src/override.js b/src/override.js index ecb4693..378816b 100644 --- a/src/override.js +++ b/src/override.js @@ -28,10 +28,14 @@ export const SETTINGS_ENTRY_ID = 'codex-ui'; /** Codex 默认对比度:亮 45 / 暗 60。 */ export const DEFAULT_CONTRAST = { light: 45, dark: 60 }; -/** 皮肤默认值(空串即回落到这里)。 */ +/** + * 皮肤默认值(空串即回落到这里)。surface = 设置页「背景」那一行 = --dsw-alias-bg-base。 + * 深色 surface 自 0.5.6 起是窗口背景 #111111(#181818 是表面/侧栏那一层);这里曾停在 #181818, + * 设置卡的背景色块因此显示错的默认值。check-repo 现在逐字段对账 skin.css,漂移即 FAIL。 + */ export const SKIN_DEFAULTS = { light: { accent: '#339cff', focus: '#339cff', surface: '#ffffff', ink: '#1a1c1f', sidebar: '#eef4f9' }, - dark: { accent: '#0169cc', focus: '#339cff', surface: '#181818', ink: '#ffffff', sidebar: '#181818' }, + dark: { accent: '#0169cc', focus: '#339cff', surface: '#111111', ink: '#ffffff', sidebar: '#181818' }, }; /** @@ -227,7 +231,12 @@ export function themeOverrideCss(values = {}) { } /** - * 一组合法的字体栈(不许出现会闭合声明的字符)。 + * 一组合法的字体栈(不许出现会闭合声明、或能改变后文切分的字符)。 + * + * 值是原样拼进覆盖层 `