diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a191043..25fcae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,6 @@ jobs: with: node-version: ${{ matrix.node }} + # 含产物与源码同源(等价于 build.mjs --check);夹具与真 GUI 验收要宿主与浏览器,留在本机跑。 - name: Repository checks - run: node scripts/check-repo.mjs - - - name: Build is reproducible - run: node scripts/build.mjs --check + run: node scripts/check.mjs diff --git a/.gitignore b/.gitignore index 1f15eda..488a516 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ node_modules -scripts/.preview-build/ -*.bak-pre-codex-ui-* .DS_Store Thumbs.db scripts/host.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e5b63..f4b2e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,352 @@ [简体中文](CHANGELOG.zh-CN.md) +## 0.6.4 - 2026-09-28 + +Both sidebars were rebased. Codex has **no sidebar color token**; the left panel is painted with a translucent +scrim, so its rendered color depends on what sits behind the window. Two consequences: the old light `#EEF4F9` +was a reading taken over a blue backdrop, and the old dark `#181818` was the *surface* colour, not the sidebar — +which made the dark sidebar 7 levels **lighter** than the window background, i.e. the hierarchy ran backwards. + +### Where the difference actually comes from + +`app-shared-fa3f1d5d5942.css` (Codex desktop 26.924.2738.0), electron window, left panel whose appearance is +not `content-surface`: + +```css +.app-shell-left-panel:not([data-app-shell-left-panel-appearance=content-surface]) { + background: color-mix(in srgb, var(--color-surface-tertiary) 70%, transparent); +} +``` + +Light `--color-surface-tertiary` = `--gray-75` = `#F3F3F3`. Over a white base: +`0.7 x 243 + 0.3 x 255 = 246.6 -> #F6F6F6`. + +`[data-app-shell-page-surface=true]` additionally gets `--color-surface-secondary` (`#F9F9F9`) at 85%, so the +sidebar is always the darker of the two — that is the entire hierarchy; there is no extra token. + +### Measured (one capture per theme, sampled away from text) + +| Surface | Codex light | codex-ink before | now | Codex dark | codex-ink before | now | +|---|---|---|---|---|---|---| +| sidebar base | `#f6f6f6` (246) | `#eef4f9` (238,244,249) | **`#f6f6f6`** | `#0f0f0f` (15) | `#181818` (24) | **`#0f0f0f`** | +| sidebar selected row | `#e9eaea` (233) | `#e2e9ed` | **`#e9e9e9`** | `#1f1f1f` (31) | `#282828` (40) | **`#1f1f1f`** | +| content base | `#ffffff` | `#ffffff` | `#ffffff` | `#111111` (17) | `#111111` | `#111111` | +| sidebar minus content | -9 | -17 | **-9** | -2 | **+7** | **-2** | + +Light: the old value was off by R -8 / G -2 / B +3 — a blue cast — so the step read as a hue shift rather than +a grey step. Dark: the old value was the surface colour, so the sidebar sat 7 levels **above** the background — +the direction was inverted. Both themes now put the sidebar below the content (255 -> 246, 17 -> 15). + +### What is not the source + +The screenshot that prompted this is Codex's own **Settings -> Appearance** panel: every visible label matches +Codex's zh-CN locale keys (`settings.general.appearance.chromeTheme.accent / surface / ink / uiFontFamily / +codeFontFamily / translucentSidebar / contrast`, plus `import` / `export`). But the code block it shows is not +live Codex text. `themePreview`, `ThemeConfig` and `sidebar-elevated` have **zero occurrences** across all +19,310 files of the Codex app bundle (raw byte search, no extension or size filter) and zero across 57,865 +files of this workspace. Codex ships `--color-surface-elevated` / `--color-surface-elevated-secondary`, never +`sidebar-elevated`; the only `ThemeConfig` hits in the bundle are mermaid's `quadrantDiagram` / `xychartDiagram` +builders. The colour mechanism is the CSS scrim quoted above, not that snippet. + +### Changed + +- `skins/codex-ink/skin.css`, light: `--dsw-alias-bg-sidebar` and `--dsw-specific-sidebar-fill` `#eef4f9` -> + `#f6f6f6`; `--dsw-specific-sidebar-nav-item-active` `#e2e9ed` -> `#e9e9e9`; + `--dsw-specific-sidebar-nav-item-hover` `#e8eef3` -> `#f0f0f0`. +- `skins/codex-ink/skin.css`, dark: the same four tokens `#181818` -> `#0f0f0f`, `#282828` -> `#1f1f1f`, + `#212121` -> `#171717`. The dark **surface** tokens are untouched — `--dsw-composer-surface` stays `#181818` + so the composer card still measures 35, matching Codex. +- `src/override.js`: `SKIN_DEFAULTS.light.sidebar` follows — the settings card shows this as the + "follow the skin" default and `check-repo.mjs` reconciles the two. +- `scripts/model-picker-verify.mjs`: fixture backdrop follows. +- New fixture `scripts/sidebar-color-verify.mjs` (16 assertions): the six surfaces rendered side by side at DPR 2 + on one page, captured once per theme by toggling `body[data-ds-dark-theme]`, new values against old values at + the same sampling points, plus a negative control per theme proving the rig can tell the old reading from the + new one. + +### Boundaries + +- The skin writes the **composited** value `#f6f6f6`, not the literal + `color-mix(in srgb, #F3F3F3 70%, transparent)`. DSH paints the sidebar fill on more than one layer (the + titlebar strip and the sidebar both take `--dsw-specific-sidebar-fill`), so a literal 70% scrim would + compound into a different, layer-count-dependent result. The composite is what Codex shows on a white base, + which is the case this skin guarantees. +- The dark **surface** chain is not part of this change: `#181818` / `#212121` / `#282828` / `#303030` were + not re-measured against Codex's dark capture beyond the composer card (35, matches). +- The dark sidebar `#0f0f0f` is a *measured* value, not a derived one. Codex's dark scrim resolves through + `--color-surface-tertiary`, whose dark value has more than one declaration in the bundle; over a black window + base the arithmetic gives 13 with one candidate and 15 with the other, and the capture says 15. + +## 0.6.3 - 2026-09-28 + +The composer shadow and the window-frame shadow are now **fitted to measured pixels** instead of copied from +Codex's source tokens — the two disagree: the `0 4px 80px 8px` far field of `--elevation-composer` is not +present in the reference capture, and the token's `1px` ring rasterises to 2 device pixels at DPR 2 where the +capture shows 1. + +### Measurements (same sampling line on both sides, DPR 2, device pixels) + +Composer (outward from the card edge): + +| | before | after | Codex capture | +|---|---|---|---| +| ring device pixels | 2 | **1** | 1 | +| ring minimum | 226 (Δ29) | **222 (Δ33)** | 222 (Δ33) | +| bottom-edge Δ | 8,8,7,7,6,6,5,5,5,4,… | 13,12,11,11,10,9,8,8,7,6,6,5,4,4,3,3,2,2,2,1,1,1,1 | 13,12,11,10,9,8,7,6,5,4,4,4,2,2,2,2,2,1,1,1,1,1,1 | +| distance to zero | 56 (= 28 CSS px) | **24 (= 12 CSS px)** | 24 | +| total absolute error vs Codex | 147 | **55** | — | + +Window frame (from the hairline into the sidebar): + +| | before | after | Codex capture | +|---|---|---|---| +| sidebar-side Δ | 6,6,5,5,5,5,5,4,4,4,4,4,3,3,3,3,3,3,2,2,2,2,2,2 | 8,7,7,6,6,5,5,4,4,4,3,3,2,2,2,2,1,1,1,1,0,0,0,0 | 8,8,6,6,5,5,4,3,3,3,2,2,2,2,2,1,1,1,1,1,1,1,1,1 | +| distance to zero | 32 (= 16 CSS px) | **21 (= 10.5 CSS px)** | 26 (= 13 CSS px) | +| total absolute error vs Codex | 47 | **22** | — | + +### What changed + +- `skins/codex-ink/composer.css`: `--dcu-composer-shadow` goes from + `0 0 0 1px rgba(13,13,13,.1), 0 2px 8px 0 #0000000a, 0 4px 80px 8px #00000006` to + `0 0 0 0.5px rgba(13,13,13,.1), 0 2px 12px 0 rgba(0,0,0,.09)`; the narrow-viewport + (`max-width: 639px`) 80→40px override is deleted — there is no far field left to shrink. +- `skins/codex-ink/window-shadow.css`: the centre column's ambient shadow tightens from + `0 0 24px rgba(13,13,13,.05)` to `0 0 13px rgba(13,13,13,.07)`; the hairline is untouched + (the measured gap is within ±4 levels, i.e. sampling noise). +- `scripts/composer-shadow-verify.mjs`: four assertions were re-derived to the new criteria + (three layers → two, ring 1px → 0.5px, near field 8px@4% → 12px@9%, falloff "≥35px" → + "inside Codex's measured band of 3~13px"), 20 → 21 assertions. +- `scripts/rightbar-verify.mjs`: two assertions still pinned the pre-fit centre-column halo + (`0 0 24px @5%`) and had been failing since the fit; they now pin the measured + `0 0 13px @7%` in both the desktop shell and the web form. + +### Boundaries + +- **The dark window-frame shadow is unchanged** (`0 0 24px rgba(0,0,0,.5)`): there is no dark reference + capture on hand, and changing it without a measurement would be eyeballing. Codex's `--shadow-card` has no + dark override and stays at 5.1% black — that item is still unmeasured. +- The fitting fixture is headless Chrome plus the host's real CSS Modules plus this skin, not the live app; + it does reproduce the ring seen in the user's on-screen capture (fixture 226 before, live 226 / 223). +- Only the skin side changed (`skins/codex-ink/*.css`); no DSH platform shadow or stroke definition was touched. + +## 0.6.2 - 2026-09-28 + +Fixes the **release timing** of 0.6.1's local pending: switching back to the tier already in force dropped the +optimistic value immediately, so the earlier in-flight commit then dragged the display back — the reported +"during loading every drag rebounds, and you must wait it out". + +### Symptom (reproduced frame by frame) + +After high → max there is a round-trip window. Switching back to high inside it: + +``` +3:Max 3:Max 3:Max 3:Max 3:Max 3:Max 3:Max <- switched back to Max inside the window, correct +1:Low 1:Low <- yanked back (the earlier commit landed) +3:Max 3:Max 3:Max 3:Max ... <- only when the second commit lands +``` + +### Root cause + +- The host's `current` is the **durable next-request projection** + (`read:dsh-client-ui-model-selection/lib/types/client/directory.d.ts:14`) and therefore **trails** the commit: + between sending a selection and its landing, `current` still names the old tier. +- 0.6.1's `settlePending` released the pending as soon as `current` equalled it. **Switching back to the tier + already in force makes those two equal by construction** (pending = old tier = the current `current`), so the + pending was dropped in the very same frame it was set and the optimistic cover vanished. +- The earlier commit then landed, `current` became it, and the display was dragged along — the rebound. It only + returned to the target tier once the second commit landed too. +- So this is neither the host's fault nor "slow loading" as such: the host **accepts the commits in order** (the + second one really is sent and does land). What was being pulled back was the **display**. + +### Fix + +- A pending may only be released **after the commit's RPC has settled** (`seat.pendingSettled`): on settle, mark + it, then check whether the host has caught up; if it has not, keep the pending and wait rather than dropping it + on the spot. +- Safety valve: if the host never echoes (a dropped response, or a later commit superseding it), `PENDING_ECHO_MS + = 12s` force-clears it so the UI cannot be pinned to a tier that will never land. Failure paths (a throw or + `ok:false`) still clear immediately — a failure is a failure, and an optimistic value must not cover it. + +### Criteria + +- `power-rail-verify` 61 → **62 assertions**: a new "switching back to the original tier inside the round-trip + window keeps the target tier all the way (no rebound)" samples 22 frames across the fixture's 600ms round trip + and fails if any frame leaves the target. Pre-fix it measured `3:Max x7 -> 1:Low x2 -> 3:Max x13`; post-fix all + 22 frames are `3:Max`. +## 0.6.1 - 2026-09-28 + +Fixes a **criterion mismatch** shipped in 0.6.0: the model picker's optimistic display was built on a field the real host +does not provide. + +### Symptom + +Changing the reasoning effort made the rail **snap back to the old tier** on release, and the bottom trigger stayed on +the old tier for the whole round trip (~1.1s measured) — i.e. "the slider moved, the thinking label did not". + +### Root cause + +- The installed `@deepseek-ai/dsh-client-ui-model-selection` is **0.1.7-rc.1**; its `ModelDirectoryState` carries + exactly six fields: `{ current, routable, groups, failures, status, error }` (`lib/types/client/directory.d.ts:13-32`). + **There is no `pending` and no `retainedEffort`** — neither word occurs once in that package's `client.js`. +- 0.6.0's `viewOf()` read `snap.pending` to derive the optimistic tier; with the field absent `pendingEffort` was + always false, so `effective` always equalled the durable `current.reasoningEffort`. +- The store's first notification (`status → selecting`) therefore repainted the rail from the **stale** current. + Frame by frame: the rail was back on the old tier at `t=234ms` and only reached the new tier at `t=252ms`; + `data-pending` and `.codex-mp-spinner` never appeared at all. +- The fixture built its snapshot from the **documented rc.2 contract and supplied `pending` itself**, so those three + criteria proved nothing on the real host. + +### Fix (one source file; appearance and interaction shape unchanged) + +- `viewOf(snap, localPending)` takes an optional second argument: **the host's `pending` wins when present** (so a + future host that adds it is picked up automatically); only when it is missing does the seat's own record apply. +- Seat-level `seat.pending`: `submit()` records the target selection and repaints **immediately**, so the rail, the + bottom trigger and the popover's effort label sit on the target tier from that frame on. +- Three release points, all idempotent, each guarded so a late response cannot clear a newer submission: the host's + `current` catches up (`settlePending`, run on every store notification and every scan), the submission resolves ok, + or it fails / throws. + +### Criteria corrected (this matters more than the fix) + +- `scripts/power-rail-verify.mjs`'s fake directory **drops `pending`**, matching the installed host field for field. + Run against the pre-fix source that fixture scores **44/47**, failing exactly "rail stays on the new tier during the + round trip" / "trigger already shows the new tier during the round trip" / "model row spinner during a model change"; + post-fix it is **47/47**. The fixture can now tell "genuinely optimistic" from "simply never measured". +- `scripts/check-repo.mjs`'s power-rail view case gains six assertions: the seat's own record is used when the host + provides no `pending`; `null` / omitted arguments change nothing; a pending on another model does not hijack this + model's tier; the host's `pending` wins once it exists. +- `src/model-picker.js`'s driver contract now states the measured shape (the six fields of 0.1.7-rc.1) instead of + copying fields that do not exist. + +### ㉑ The reasoning slider takes dsh-claude-style's form (form only) + +**This block only, form only**: the control stays in the popover, keeps the same seat and the same interaction +(real drag / snap on release / four keys). Only the track's appearance follows the reference; every other region +and control of the codex skin is untouched. + +- **The form baseline is dsh-claude-style's .dsh-claude-effort-* rules** (its own comments name Claude Desktop's + Effort slider as the reference). Every measurement is read from its source, not eyeballed: groove **26px tall, + 8px radius** (a rounded rectangle, not a pill); fill = label ink at **26%**, square on the right, ending at the + knob's centre; ticks 4px in the same ink as the fill (passed ones are swallowed by the fill, so there is no + selected/unselected pair any more); knob **16×30, 5px radius**, white with a 0 1px 3px shadow (a rounded + rectangle, not a bead); and a new **Faster / Smarter** row under the groove (11px caption colour — it names the + axis, not a value). +- **The top rung swaps in a violet dot matrix** (apex): fill and ticks step aside for a **5-row block grid** + (1 device-pixel gaps, 0.5 device-pixel margins, solved in whole device pixels — a fractional pitch rasterizes as + alternating gaps); each block's phase, cycle and tone are **hash-scattered** (nothing ordered, so it reads as a + field of particles rather than one sweeping bar); the left end dissolves back into the bare track by smoothstep + while the right end stays solid; the knob takes a violet tint and a 10px glow, and the level's name goes violet. + The violet comes in two sets, one per theme (light #8b7ad0 / dark #9d8ce0). +- **The blue sweep ported last round is gone with the form** — it came from plugin-effort-slider, not from the + reference; the top rung is now the matrix alone, and the build carries no effect the reference does not have. +- **Still triggered by the continuous ratio**: the matrix lights up at the far right *before* release, not only once + the committed level changes; a single-tier model has no "highest tier". +- The level's name swaps the way the baseline does: the incoming one rises out of a blur while the outgoing ghost + blurs away upward, re-armed on every real change. +- Both off switches as before: the system prefers-reduced-motion and the script's data-reduced-motion. + +### Criteria + +- power-rail-verify 53 → **61 assertions**: groove 26/8, hit area 30, fill 26% with 8px 0 0 8px, knob 16×30/5px/ + white/shadow, the Faster-Smarter row, tick travel [8, width−8], knob centre = the level in force, fill ending at + the knob's centre, ticks sharing the fill's ink; seven for the apex (grid lit with 5×N cells, fill and ticks + yielding, scattered phases with 8 tone buckets, plume fading 0→1, flash cycle inside the 1.45s scatter band, + violet knob with glow, violet level name); three for dark (groove still 26/8, apex violet swapped to the dark set, + knob a violet tint rather than white). +- check-repo's unit assertion moved from the sweep to the matrix: two keyframes, three mountable classes, + **no --codex-mp-pos in the block's declaration** (the hard condition for reusability), 8 tone buckets, both + theme sets of the apex violet, and both off switches. +- Two fixture precision issues fixed along the way: keyframe selectors did not accept decimal percentages + (17.24%), and --codex-mp-apex-* was first defined on the rail where the level's name in the head could not + read it (moved to the card). + + +- `power-rail-verify` 47 → **53 assertions**: the shimmer trio (animation-name / 1.8s / 250% gradient), + the thumb breathing on the same cycle, the accent genuinely coming from `--dsw-alias-link` (found inside the + gradient string), no glow at the far left, glowing before release at the far right, and both animations stopped + under reduced motion. +- `check-repo` gains a structural assertion: both keyframes and both mountable classes exist, **the unit's declaration + block never mentions `--codex-mp-pos`** (the hard condition for reusability), the accent is a single source, and both + off switches are present. +- Also fixed a fixture precision issue in `check-repo`: keyframe selectors were filtered only as single `0%`, so a + comma list like `0%, 100%` was misread as a CSS selector. +## 0.6.1 - 2026-09-28 + +An architecture cleanup: no feature changes, much faster while a reply streams in, and the engineering scripts reduced +to three entry points. Before and after the refactor, every element's computed style was compared across 14 UI states on +a real `dsh web`: zero differences apart from the one intended change under "Fixes". + +### Performance + +- **No more `:has()` in ancestor positions**: 9 rules in sidebar alignment, 2 in the sidebar fade, 1 for the model + picker seat; the composer hero rule became a fixed-depth child `:has()`. An ancestor-position `:has()` makes Chromium + re-match the whole subtree under every affected ancestor each time the conversation inserts a node carrying `data-slot`. + Simulated streaming (200 frames, each 30 spans plus one `data-slot` node), median of 5 runs: + + | Mode | Style recalc | Wall time | + |---|---|---| + | web | 4264 → 267 ms | 6.3 → 2.3 s | + | Windows desktop shell (title bar) | 9518 → 442 ms | 11.7 → 2.5 s | + + In the desktop-shell mode the skin's extra recalc over a bare host is now within noise. Every new anchor keeps the + old selector's specificity, so the cascade against host rules is unchanged. +- **Model picker seat**: the host control used to be hidden by `seat:has(> .codex-mp-trigger) > :not(trigger)`; now the + **seat itself** gets `data-codex-ui-seated` when our trigger goes in, and loses it when the trigger is removed. The mark + sits on the slot outlet, not on the host's child, so React replacing that child does not drop it (0.6.0's reason for + avoiding a mark no longer applies; the fixture "still hidden after the host replaces its own child" passes). +- **Narrower observer**: the model picker's MutationObserver rescans everything only on attribute changes and on seats + being added or removed; otherwise it re-syncs just the touched seats and the ones not settled yet. Script time while + streaming: 47.5 → 10.1 ms. + +### Fixes + +- **The dark `html` background never applied**: `skin.css`'s `:root:has(body[data-ds-dark-theme])` was scoped to + `html[data-codex-ui] :root:has(…)` (a descendant selector that never matches), so in dark mode `html` stayed light + `#fff`, normally hidden behind `body`. The scoper now maps compound selectors starting with `:root` to the root itself. + This is the release's only visible difference: in dark mode the `html` background goes `#fff` → `#111111` (the + intended design; see the last item under "Settings page" in the README). It is its own commit (249e6d1) and can be + reverted on its own. + +### Structure + +- The browser half is split into ES modules under `src/client/`: `index.js` (`inject` and `apply`), `stylesheet.js`, + `settings.js`, `theme-preview.js`, `settings-card.js`, `override.js`, `model-picker/{index,component,view}.js`, + `constants.js`, `host.js`. The string-splicing template `src/client.template.js` and `src/build.mjs` are gone. A new + feature is one module exporting `installXxx(ctx)` plus one line in `apply`. +- `scripts/build.mjs` is the only build: a zero-dependency bundler turns the modules into IIFEs in dependency order + (relative imports become destructuring, host packages go through the loader's `require`), with the stylesheet + inlined as the virtual module `codex-ui:theme.css`; unknown import or export forms fail the build. The CSS scoper + understands comments and strings, and comments are stripped from the output. +- Unreachable code removed: the settings card's summary branch, the no-primitives fallback and theme fallback table, + and the matching `.cx-row--stack` and fallback styles in `settings.css`. +- Deduplicated styles: repeated shadows and fills became 4 tokens (`--dsw-codex-ambient`, `--dsw-codex-menu-shadow`, + `--dsw-codex-suggest-shadow`, `--dsw-codex-suggest-fill`), dark declarations identical to light ones were dropped, + comments were slimmed; the eight stylesheets went from 2491 to about 1820 lines. + +### Engineering + +- Scripts went from 25 files / 4886 lines to 15 / 3356, with three entry points and shared code in `scripts/lib/` + (host / cdp / checks): + - `scripts/check.mjs`: the former `check-repo.mjs` plus `audit-codex-ink.mjs`, 60 checks, the CI entry point; new are a + DSH client plugin contract check on `client.js` (executed once in isolation: loader id = package name, `inject` is + exactly the three required services, the inlined stylesheet equals `theme.css`) and a `peerDependencies` check. + - `scripts/verify.mjs` + `scripts/specs/`: the eight former `*-verify.mjs` fixtures with the same assertion names and + counts, 195 in total; `npm run verify`. + - `scripts/live/`: `gui.mjs`, `settings.mjs` (absorbing `theme-flash-probe.mjs`: no intermediate frame while switching + theme) and the new `parity.mjs` (per-element computed-style comparison before and after a change). +- **Install goes through the standard `dsh plugin add link:`**: removed `install-plugin.mjs`, `make-verify-profile.mjs`, + `pack-host-asar.mjs` (an npm-installed `node_modules` works as a fixture host directly), `make-preview.mjs` and + `scripts/fixtures/`; `package.json` drops `install:*`, adds `verify`, and declares `@deepseek-ai/schemastery` in + `peerDependencies` (`index.js` imports it; the host provides it). See "Install" in the README for **migrating**. +- Fixture fixes: the old hero / composer-shadow fixtures truncated the host's conversation root styles at the first + `content:""` (2672 of 6717 characters read); with the full CSS the scroller clips the shadow, so the fixture now puts + the padding inside the scroller as the real app does, with unchanged readings (edge 224, 42px falloff). The face A + menu fixture and the right-bar fixture, already broken on 0.6.0, work again. The settings check resets every override + when it finishes, so later comparisons are not polluted. +- Removed `docs/plan-settings-page.zh-CN.md` (an executed plan whose conclusions are in the changelog) and the + verification screenshots the READMEs no longer reference. + ## 0.6.0 - 2026-09-28 Face B of the model picker (the Codex reasoning power rail) lands, together with a full-repository review against the diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 4d7739f..8002fbd 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,295 @@ [English](CHANGELOG.md) +## 0.6.4 - 2026-09-28 + +亮暗两套侧栏都换了基准。Codex **没有「侧栏色」令牌**;那一面是半透明遮罩,渲染出来的颜色取决于窗口背后是什么。 +两个后果:亮色旧值 `#EEF4F9` 是某次蓝底下的读数;暗色旧值 `#181818` 是**表面**那一层的色,不是侧栏 —— +于是暗色侧栏比窗口背景还亮 7 级,层级方向反了。 + +### 色差到底从哪来 + +`app-shared-fa3f1d5d5942.css`(Codex desktop 26.924.2738.0),electron 窗口、左侧板外观非 +`content-surface` 时: + +```css +.app-shell-left-panel:not([data-app-shell-left-panel-appearance=content-surface]) { + background: color-mix(in srgb, var(--color-surface-tertiary) 70%, transparent); +} +``` + +亮色 `--color-surface-tertiary` = `--gray-75` = `#F3F3F3`。叠在白色底上: +`0.7 × 243 + 0.3 × 255 = 246.6 → #F6F6F6`。 + +`[data-app-shell-page-surface=true]` 另外叠 `--color-surface-secondary`(`#F9F9F9`)的 85%,所以侧栏永远 +比主区深一档 —— 层级就是这么来的,没有第二个令牌。 + +### 实测(每套主题一张截图,避开文字取样) + +| 面 | Codex 亮 | codex-ink 旧 | 新 | Codex 暗 | codex-ink 旧 | 新 | +|---|---|---|---|---|---|---| +| 侧栏底 | `#f6f6f6`(246) | `#eef4f9`(238,244,249) | **`#f6f6f6`** | `#0f0f0f`(15) | `#181818`(24) | **`#0f0f0f`** | +| 侧栏选中行 | `#e9eaea`(233) | `#e2e9ed` | **`#e9e9e9`** | `#1f1f1f`(31) | `#282828`(40) | **`#1f1f1f`** | +| 主区底 | `#ffffff` | `#ffffff` | `#ffffff` | `#111111`(17) | `#111111` | `#111111` | +| 侧栏 − 主区 | −9 | −17 | **−9** | −2 | **+7** | **−2** | + +亮色:旧值偏差 R −8 / G −2 / B +3 —— 是一层蓝调,那一档读起来是色相变化而不是灰阶变化。 +暗色:旧值就是表面色,侧栏反而比背景亮 7 级,方向是反的。 +现在两套里侧栏都比主区低一档(255 → 246、17 → 15)。 + +### 不是源头 + +引出这一轮的那张截图,是 Codex 自己的**设置 → 外观**面板:每个可见标签都能在 Codex 的 zh-CN 语言包 +里找到对应键(`settings.general.appearance.chromeTheme.accent / surface / ink / uiFontFamily / +codeFontFamily / translucentSidebar / contrast`,外加 `import` / `export`)。但它上面那段代码块不是 +Codex 的活字符串:`themePreview`、`ThemeConfig`、`sidebar-elevated` 在 Codex 应用包的全部 19,310 个文件里 +**零命中**(原始字节搜索,不限扩展名、不限体积),在本仓库的 57,865 个文件里也零命中。Codex 只有 +`--color-surface-elevated` 与 `--color-surface-elevated-secondary`,没有 `sidebar-elevated`;包里唯一命中 +`ThemeConfig` 的是 mermaid 的 `quadrantDiagram` / `xychartDiagram` 图表构建器。取色机制是上面那条 CSS +遮罩,不是那段代码。 + +### 改动 + +- `skins/codex-ink/skin.css`(亮):`--dsw-alias-bg-sidebar` 与 `--dsw-specific-sidebar-fill` `#eef4f9` → + `#f6f6f6`;`--dsw-specific-sidebar-nav-item-active` `#e2e9ed` → `#e9e9e9`; + `--dsw-specific-sidebar-nav-item-hover` `#e8eef3` → `#f0f0f0`。 +- `skins/codex-ink/skin.css`(暗):同样四个令牌 `#181818` → `#0f0f0f`、`#282828` → `#1f1f1f`、 + `#212121` → `#171717`。暗色**表面**令牌不动 —— `--dsw-composer-surface` 仍是 `#181818`, + 输入卡实测仍为 35,与 Codex 同值。 +- `src/override.js`:`SKIN_DEFAULTS.light.sidebar` 跟着改 —— 设置卡把它作为「跟随皮肤」的默认值显示, + `check-repo.mjs` 会对账这两处。 +- `scripts/model-picker-verify.mjs`:夹具底色跟着改。 +- 新增夹具 `scripts/sidebar-color-verify.mjs`(16 项):六个面在同一页上以 DPR 2 并排渲染,靠切 + `body[data-ds-dark-theme]` 亮暗各截一次,新值与旧值在同一采样点对读,外加每套主题一组反例对照 —— + 证明夹具分辨得出旧读数与新读数。 + +### 边界 + +- 皮肤写的是**合成后的值** `#f6f6f6`,不是字面的 `color-mix(in srgb, #F3F3F3 70%, transparent)`。 + DSH 把侧栏填充画在不止一层上(标题栏条与侧栏都吃 `--dsw-specific-sidebar-fill`),字面 70% 遮罩会 + 叠加成另一个结果、且结果随层数变。合成值就是 Codex 在白底上显示的值,而白底正是本皮肤保证的底。 +- 暗色**表面**那条链不在本次范围:`#181818` / `#212121` / `#282828` / `#303030` 除输入卡(实测 35,同值)外 + 没有对着 Codex 的暗色截图重新量过。 +- 暗色侧栏 `#0f0f0f` 是**实测值**,不是推导值。Codex 的暗色遮罩要经 `--color-surface-tertiary`, + 而那个令牌在包里不止一处声明;叠在黑色窗口底上,一个候选算得 13、另一个算得 15,实测是 15。 + +## 0.6.3 - 2026-09-28 + +输入卡阴影与窗口边框阴影改为**按实测像素拟合**,不再照抄 Codex 的源码 token —— 两者对不上: +源码 `--elevation-composer` 的 `0 4px 80px 8px` 远场在参考图里量不到,环的 `1px` 在 DPR 2 下 +渲成 2 个设备像素,而参考图里只有 1 个。 + +### 实测(两边同一采样线,DPR 2,单位 = 设备像素) + +输入卡(从卡边往外): + +| | 旧值 | 新值 | Codex 参考图 | +|---|---|---|---| +| 环占几个设备像素 | 2 | **1** | 1 | +| 环最深 | 226(Δ29) | **222(Δ33)** | 222(Δ33) | +| 卡下沿 Δ | 8,8,7,7,6,6,5,5,5,4,… | 13,12,11,11,10,9,8,8,7,6,6,5,4,4,3,3,2,2,2,1,1,1,1 | 13,12,11,10,9,8,7,6,5,4,4,4,2,2,2,2,2,1,1,1,1,1,1 | +| 归零距离 | 56(= 28 CSS px) | **24(= 12 CSS px)** | 24 | +| 与 Codex 的绝对差之和 | 147 | **55** | — | + +窗口边框(从发丝线往侧栏): + +| | 旧值 | 新值 | Codex 参考图 | +|---|---|---|---| +| 侧栏侧 Δ | 6,6,5,5,5,5,5,4,4,4,4,4,3,3,3,3,3,3,2,2,2,2,2,2 | 8,7,7,6,6,5,5,4,4,4,3,3,2,2,2,2,1,1,1,1,0,0,0,0 | 8,8,6,6,5,5,4,3,3,3,2,2,2,2,2,1,1,1,1,1,1,1,1,1 | +| 归零距离 | 32(= 16 CSS px) | **21(= 10.5 CSS px)** | 26(= 13 CSS px) | +| 与 Codex 的绝对差之和 | 47 | **22** | — | + +### 改了什么 + +- `skins/codex-ink/composer.css`:`--dcu-composer-shadow` 由 + `0 0 0 1px rgba(13,13,13,.1), 0 2px 8px 0 #0000000a, 0 4px 80px 8px #00000006` + 改为 `0 0 0 0.5px rgba(13,13,13,.1), 0 2px 12px 0 rgba(0,0,0,.09)`; + 窄屏段(`max-width: 639px`)的 80→40px 覆盖一并删掉 —— 没有远场可收。 +- `skins/codex-ink/window-shadow.css`:中列环境影由 `0 0 24px rgba(13,13,13,.05)` + 收到 `0 0 13px rgba(13,13,13,.07)`;发丝线不动(实测差在 ±4 级,属采样噪声)。 +- `scripts/composer-shadow-verify.mjs`:4 条旧判据按新口径重写(三层→两层、环 1px→0.5px、 + 近场 8px@4%→12px@9%、衰减半径「≥35px」→「落进 Codex 实测带 3~13px」),20 → 21 项。 +- `scripts/rightbar-verify.mjs`:两条断言还钉着拟合前的中列环境影(`0 0 24px @5%`), + 从那次拟合起就一直红着;现改为钉实测值 `0 0 13px @7%`,桌面壳与 web 形态各一条。 + +### 边界 + +- **暗色窗口边框影未动**(仍是 `0 0 24px rgba(0,0,0,.5)`):手上没有暗色的参考截图, + 量不到就改等于目测调参。Codex 的 `--shadow-card` 在暗色下没有覆盖、仍是 5.1% 黑 —— 这一条待实测。 +- 拟合用的夹具是 headless Chrome + 宿主真实 CSS Modules + 本皮肤,不是活着的应用; + 但它复现了用户同屏截图里的环(改前夹具 226 / 实机 226、223)。 +- 只动了皮肤侧(`skins/codex-ink/*.css`);DSH 平台侧的基础阴影/描边一行未改。 + +## 0.6.2 - 2026-09-28 + +修 0.6.1 那个本地 pending 的**释放时机**:切换回「当前那一档」时,乐观值会被立刻撤掉, +于是先前那次提交一落地就把显示拽回旧档 —— 现象就是「加载期间怎么滑都回弹,只能等加载完」。 + +### 症状(逐帧复现) + +high → max 之后有一段往返窗口。在窗口内切回 high: + +``` +3:Max 3:Max 3:Max 3:Max 3:Max 3:Max 3:Max ← 窗口内切回 Max,显示正确 +1:Low 1:Low ← 被拽回(前一次提交落地) +3:Max 3:Max 3:Max 3:Max ... ← 第二次提交落地才回来 +``` + +### 根因 + +- 宿主的 `current` 是 **durable next-request projection**(`read:dsh-client-ui-model-selection/lib/types/client/directory.d.ts:14`), + 它**落后于**提交 —— 提交发出去之后、落地之前,`current` 仍是旧档。 +- 0.6.1 的 `settlePending` 判据是「`current` 等于 pending 就撤」。**切回当前那一档时这两个值天然相等** + (pending = 旧档 = 当前 current),于是 pending 在设置的同一帧就被撤掉,乐观保护消失。 +- 之后先前那次提交落地,`current` 变成它,显示被拽过去 —— 回弹。等第二次提交也落地才回到目标档。 +- 所以这不是宿主的锅,也不是「加载慢」本身:宿主的提交是**按序接受**的(第二次确实发出并最终生效), + 被拽回去的只有**显示**。 + +### 修法 + +- pending 只在**本次提交的 RPC 已经落地之后**才允许撤(`seat.pendingSettled`): + 落地时先置位、再看宿主有没有追平;没追平就留着 pending 等它,而不是当场撤。 +- 兜底:宿主迟迟不回显(丢包 / 被更晚的提交取代)挂 `PENDING_ECHO_MS = 12s` 强制撤, + 免得界面卡在一个永不落地的档上。失败路径(异常 / `ok:false`)仍然立即撤 —— 错就是错, + 不能让乐观值盖住失败提示。 + +### 判据 + +- `power-rail-verify` 61 → **62 项**:新增「往返窗口内切回原档:全程停在目标档(不回弹)」, + 在夹具的 600ms 往返里逐帧取样 22 次,任何一帧离开目标档就 FAIL。 + 修前实测 `3:Max ×7 → 1:Low ×2 → 3:Max ×13`,修后 22 帧全在 `3:Max`。 +## 0.6.1 - 2026-09-28 + +修 0.6.0 的一个**判据错位**:模型选择器的乐观显示建在一个真实宿主并不提供的字段上。 + +### 症状 + +改推理档位时,松手后轨会**回弹到旧档**,整段往返(实测 ~1.1s)里底部触发器都停在旧档 —— 也就是 +「滑块已切、底部思考还没跟上」。 + +### 根因 + +- 安装中的 `@deepseek-ai/dsh-client-ui-model-selection` 是 **0.1.7-rc.1**,它的 `ModelDirectoryState` + 只有六个字段:`{ current, routable, groups, failures, status, error }`(`lib/types/client/directory.d.ts:13-32`)。 + **既没有 `pending` 也没有 `retainedEffort`** —— 这两个词在该包 `client.js` 里各出现 0 次。 +- 0.6.0 的 `viewOf()` 读 `snap.pending` 决定乐观档位;字段缺席 ⇒ `pendingEffort` 恒为 false ⇒ + `effective` 永远等于 durable 的 `current.reasoningEffort`。 +- 于是 store 的第一次通知(`status → selecting`)会用**旧** current 重画轨。逐帧实测: + `t=234ms` 轨回到旧档、`t=252ms` 才到新档;`data-pending` 与 `.codex-mp-spinner` 全程未出现。 +- 夹具当时按**文档里的 rc.2 契约**造快照,**自己喂了 `pending`**,所以这三条判据在真机上等于没测。 + +### 修法(只动一个源文件,外观与交互形态不变) + +- `viewOf(snap, localPending)` 多一个可选入参:**宿主给了 `pending` 就用宿主的**(将来宿主补上即自动接管), + 没给才用席位自己记的那一笔。 +- 席位级 `seat.pending`:`submit()` 提交前记下目标 selection 并**立刻重画**,轨 / 底部触发器 / 弹层档位名 + 从这一帧起就是目标档。 +- 撤除点三个,全都幂等,且只在「还是本次那一笔」时才撤(晚到的旧响应不会清掉更新的提交): + 宿主 `current` 追平(`settlePending`,随 store 通知与每轮 scan 跑)、提交成功返回、提交失败或抛异常。 + +### 判据修正(这条比修法本身重要) + +- `scripts/power-rail-verify.mjs` 的假目录**去掉 `pending`**,快照形状与安装中的宿主逐字一致。 + 拿修前的源码跑这套夹具:**44/47**,恰好挂在「往返中轨停在新档不回弹」「往返中触发器已按新档显示」 + 「换模型往返中行尾转圈」三条上;修后 **47/47**。夹具从此能区分「真乐观」与「只是没测到」。 +- `scripts/check-repo.mjs` 的功率轨视图用例补 6 条:无宿主 pending 时采用席位自己那一笔、 + `null` / 省略入参不改变行为、别的模型上的 pending 不污染本模型档位、宿主补上 pending 时优先用宿主的。 +- `src/model-picker.js` 顶部驱动契约改为实测口径(0.1.7-rc.1 的六字段),不再照抄不存在的字段。 + +### ㉑ 推理强度滑杆换成 dsh-claude-style 的形态(只换形态) + +**只补这一块、只换形态**:控件仍在弹层里、仍是同一个席位与同一条交互(真拖拽 / 松手对齐 / 键盘四键), +只把轨的外观换成参考图的形态;codex 皮肤其余区域与控件一个没动。 + +- **形态基准 = dsh-claude-style 的 .dsh-claude-effort-* 规则**(它自己的注释写明对标 Claude Desktop 的 + Effort slider)。几何逐条从它的源码取,不是目测:槽 **26px 厚 / 8px 圆角**(圆角矩形,不是胶囊); + 填充 = 主文本墨 **26%**、右端方角、止于旋钮中心;刻度 4px、与填充同墨(走过去的被填充吞掉, + 不再分选中/未选中两色);旋钮 **16×30 / 5px 圆角**白片 + 0 1px 3px 软影(不是圆珠); + 槽下方多一行**更快 / 更强**(11px caption 色,命名的是方向不是取值)。 +- **顶档换成紫色点阵**(apex):填充与刻度隐去,换上 **5 行方块网格**(1 设备像素缝、0.5 设备像素外边距, + 在整设备像素上解网格 —— 小数 pitch 会被栅格化成交替的缝);每格的相位/周期/色调由坐标 **hash 散开** + (不成序,读起来是一片粒子而不是一道扫过的光带);左端按 smoothstep 溶回裸槽、右端实心; + 旋钮染紫 + 10px 光晕,档位名转紫。顶档紫两套主题各一份(亮 #8b7ad0 / 暗 #9d8ce0)。 +- **上一轮那个蓝色扫光已随形态一并删掉** —— 它是按 plugin-effort-slider 移植的,不是参考图的东西; + 顶档现在只有点阵,成品里没有参考图以外的动效。 +- **触发判据仍是连续比例**:拉到最右端**松手前**点阵就亮,不等生效档变了才亮;单档模型没有「最高档」。 +- 档位名交换按基准的做法:进来的从下方模糊上浮、出去的(ghost 压在原位)向上飘走,每次换档重新起跑。 +- 两条关动效的口子照旧:系统 prefers-reduced-motion 与脚本的 data-reduced-motion。 + +### 判据 + +- power-rail-verify 53 → **61 项**:槽 26/8、热区 30、填充 26% + 8px 0 0 8px、旋钮 16×30/5px/白片/软影、 + 更快更强那一行、圆点行程 [8, 宽−8]、旋钮中心 = 生效档、填充止于旋钮中线、圆点与填充同墨; + 顶档七条(点阵亮起 5×N 格、填充与刻度让位、相位散开 + 8 档色调、羽化 0→1、闪动周期落在 1.45s 散相带、 + 旋钮染紫 + 光晕、档位名转紫);深色三条(槽仍是 26/8、顶档紫换暗色那套、旋钮是紫片不是白片)。 +- check-repo 的单元判据从「流光」改成「点阵」:两个 keyframes、三个可挂载类、 + **方块声明块里不出现 --codex-mp-pos**(可复用的硬条件)、8 档色调、顶档紫亮暗各一份、两条关闭口子。 +- 顺带修了两个夹具精度问题:关键帧选择器的过滤不认小数百分比(17.24%), + 以及 --codex-mp-apex-* 一开始定义在轨上、而档位名在 head 里读不到它(已改到卡片上)。 + + +- `power-rail-verify` 47 → **53 项**:流光三件套(animation-name / 1.8s / 250% 渐变底)、拇指呼吸同周期、 + 强调色确实来自 `--dsw-alias-link`(在渐变串里找得到)、最左端不亮、最右端松手前已亮、reduced-motion 下都停。 +- `check-repo` 新增一条结构性判据:两个 keyframes 与可挂载类都在、**单元声明块里不出现 `--codex-mp-pos`** + (可复用的硬条件)、强调色是单一来源、两条关闭口子都在。 +- 顺带修了 `check-repo` 的一个夹具精度问题:keyframes 关键帧选择器的过滤只认单个 `0%`, + 逗号并列的 `0%, 100%` 会被当成 CSS 选择器误报。 +## 0.6.1 - 2026-09-28 + +架构整理:功能不变,流式输出时明显更快,工程脚本收拢成三个入口。重构前后在真 `dsh web` 上把 14 个界面状态逐元素比对计算样式: +除「修正」一节那一处有意的变化外,零差异。 + +### 性能 + +- **去掉落在祖先位置的 `:has()`**:侧栏对齐 9 条、侧栏渐隐 2 条、模型选择器席位 1 条;输入区 hero 那条改成固定深度的子代 `:has()`。 + 祖先位置的 `:has()` 让会话区每插入一个带 `data-slot` 的节点,Chromium 都要把受影响祖先下的整片子树重配一遍样式。 + 模拟流式输出(200 帧,每帧 30 个 span 加 1 个 `data-slot` 节点),5 次取中位: + + | 形态 | 样式重算 | 整段耗时 | + |---|---|---| + | web | 4264 → 267 ms | 6.3 → 2.3 s | + | Windows 桌面壳(标题栏形态) | 9518 → 442 ms | 11.7 → 2.5 s | + + 桌面壳形态下皮肤相对裸宿主的额外重算已在噪声以内。新锚点的特异性与旧写法逐条相同,与宿主规则的先后关系不变。 +- **模型选择器席位**:原先靠 `席位:has(> .codex-mp-trigger) > :not(触发器)` 隐藏宿主那一格;现在触发器挂进席位时给**席位本身** + 打 `data-codex-ui-seated`,撤走时一起摘掉。标记在席位出口上、不在宿主子节点上,React 换掉宿主子节点不影响它 + (0.6.0 不打标记的顾虑因此不成立;夹具「宿主换掉自己的子节点后仍隐藏」照常通过)。 +- **观察器收窄**:模型选择器的 MutationObserver 只在属性变化、席位增删时全量扫描,其余只重同步被触及的席位与尚未落定的席位。 + 流式时脚本耗时 47.5 → 10.1 ms。 + +### 修正 + +- **深色下 `html` 底色从未生效**:`skin.css` 的 `:root:has(body[data-ds-dark-theme])` 被作用域化成了 + `html[data-codex-ui] :root:has(…)`(后代选择器,永远匹配不到),深色时 `html` 一直是浅色的 `#fff`,只是平时被 `body` 盖住。 + 作用域器现在把以 `:root` 开头的复合选择器映射到根本身。这是本版唯一的可见差异:深色状态下 `html` 的底色 `#fff` → `#111111` + (本来就是这么设计的,见 README「设置页」最后一条)。它单独一个提交(249e6d1),要回退可以单独 revert。 + +### 结构 + +- 浏览器半拆成 `src/client/` 下的 ES 模块:`index.js`(`inject` 与 `apply`)、`stylesheet.js`、`settings.js`、`theme-preview.js`、 + `settings-card.js`、`override.js`、`model-picker/{index,component,view}.js`、`constants.js`、`host.js`。 + 旧的字符串拼接模板 `src/client.template.js` 与 `src/build.mjs` 删除。加功能 = 一个模块导出 `installXxx(ctx)` + `apply` 里一行。 +- `scripts/build.mjs` 是唯一的构建:零依赖打包器把模块按依赖顺序打成 IIFE(相对导入 → 解构,宿主包 → loader 的 `require`), + 样式作为虚拟模块 `codex-ui:theme.css` 内联;不认识的 import / export 写法直接报错。CSS 作用域器认得注释与字符串,产物去掉注释。 +- 删掉走不到的代码:设置卡的摘要分支、无 primitives 时的降级与主题回退表,`settings.css` 里对应的 `.cx-row--stack` 与回退样式。 +- 样式去重:重复的阴影与底色收成 4 个令牌(`--dsw-codex-ambient`、`--dsw-codex-menu-shadow`、`--dsw-codex-suggest-shadow`、 + `--dsw-codex-suggest-fill`),去掉深色里与浅色同值的重复声明,注释瘦身;八份 CSS 2491 → 约 1820 行。 + +### 工程 + +- 脚本从 25 个文件 / 4886 行收成 15 个 / 3356 行,三个入口,公共部分在 `scripts/lib/`(host / cdp / checks): + - `scripts/check.mjs`:原 `check-repo.mjs` + `audit-codex-ink.mjs`,60 项,CI 入口;新增 `client.js` 的 DSH 插件契约检查 + (隔离执行一遍:loader id = 包名、`inject` 恰为三个必需服务、内嵌样式与 `theme.css` 一致)与 `peerDependencies` 检查。 + - `scripts/verify.mjs` + `scripts/specs/`:原 8 个 `*-verify.mjs` 夹具,断言名与项数不变,共 195 项;`npm run verify`。 + - `scripts/live/`:`gui.mjs`、`settings.mjs`(并入原 `theme-flash-probe.mjs`:主题切换逐帧无中间帧),新增 `parity.mjs`(改动前后逐元素计算样式对账)。 +- **安装改走 DSH 标准的 `dsh plugin add link:`**:删掉 `install-plugin.mjs`、`make-verify-profile.mjs`、`pack-host-asar.mjs` + (npm 装出来的 `node_modules` 直接就能当夹具宿主)、`make-preview.mjs` 与 `scripts/fixtures/`;`package.json` 去掉 `install:*`、 + 加 `verify`,并在 `peerDependencies` 声明 `@deepseek-ai/schemastery`(`index.js` 导入它,由宿主提供)。**迁移**见 README「安装」。 +- 夹具修正:旧 hero / composer-shadow 夹具在第一个 `content:""` 处截断了宿主会话根样式(6717 字符只读到 2672);按全文读后阴影会被 + 滚动容器裁掉,夹具改成像真应用一样把留白放在滚动容器里,读数不变(边缘 224、衰减 42px)。0.6.0 上已跑不通的 A 面菜单夹具与 + 右栏夹具修好。settings 验收结束时重置全部覆盖,不再污染之后的比对。 +- 删掉 `docs/plan-settings-page.zh-CN.md`(已执行的旧计划,结论已在更新日志里)与 README 不再引用的验收截图。 + ## 0.6.0 - 2026-09-28 模型选择器的 B 面(Codex 推理等级功率轨)落地;外加一轮对照复刻规格的全仓审查,修掉其中查实的问题。 diff --git a/README.md b/README.md index e3316ab..f7b8f01 100644 --- a/README.md +++ b/README.md @@ -27,14 +27,15 @@ images are in the sections below and in `assets/reference/`. ## Host compatibility Developed against DSH `0.1.7-rc.1` (npm global install) and `0.1.7-rc.2` (Windows desktop shell `app.asar`); -the full 0.6.0 verification ran on the npm release `@deepseek-ai/dsh@0.1.7-rc.2` (a real `dsh web` instance plus an asar packed from it, see "Host paths"). -The verification scripts read shipped CSS straight out of `app.asar`; a host upgrade that changes structure fails their assertions. +the full 0.6.1 verification ran on the npm release `@deepseek-ai/dsh@0.1.7-rc.2` (fixtures against both its +`node_modules` and an `app.asar` packed from it, the live checks on a `dsh web` booted from it; see "Host and browser"). +Fixtures read the shipped host CSS and render code directly, so a structural host change fails their assertions. ## Features | Id | Content | |---|---| -| ⑳ | **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 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 optimistic value prefers the host's `pending` and falls back to the seat's own record — 0.1.7-rc.1 has no such field); **the slider's form follows dsh-claude-style** (a 26px groove with an 8px radius, a 26% ink fill, a 16×30 knob, and a Faster/Smarter row under the groove), and **the top rung swaps in a violet dot matrix** (5 rows of hash-scattered blocks, #8b7ad0 light / #9d8ce0 dark, stopped by both `prefers-reduced-motion` and the script's `data-reduced-motion`); 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 | @@ -67,27 +68,33 @@ With face B switched off, the host menu (face A) while a reasoning effort is bei ## Install +A standard DSH plugin install; none of this repository's scripts are involved: + ```powershell -npm run install:web # node scripts/install-plugin.mjs --write -npm run install:desktop # desktop shell; restart the app afterwards -npm run build # regenerate theme.css and client.js from skins/codex-ink +dsh plugin --profile web add link: # adds a link dependency and puts the package into dsh.profile.bundles +dsh web +dsh plugin --profile web remove codex-ui # uninstall: removes the dependency and the bundles entry +``` + +To keep your everyday profile untouched, try it in a new profile first: -node scripts/install-plugin.mjs # dry run (web profile by default) -node scripts/install-plugin.mjs --bundle --write # register through dsh.profile.bundles and drop the redundant insert -node scripts/build.mjs --check # report stale artifacts without writing +```powershell +dsh codex --from-default-profile web --dump-config # creates the codex profile from the web template +dsh plugin --profile codex add link: +dsh codex --port 3099 --no-open ``` -The installer scopes the eight stylesheets in `skins/codex-ink/` to `html[data-codex-ui]`, combines them with -`src/client.template.js` (which inlines `src/override.js`, `src/model-picker.js` and `src/settings-card.js`) into `client.js`, copies the -result to `profiles//vendor/codex-ui`, creates the `node_modules/codex-ui` junction, and keeps exactly one -registration path. +The desktop app owns its profile and the CLI refuses to write it: open "Plugins" → "Add plugin" in the app and enter +the absolute repo path (the dialog accepts a package name, a Git URL, a tarball or a local absolute path). +`@deepseek-ai/schemastery` is declared in `peerDependencies` and provided by the host. -Never use both registration paths at once — two rows share the id and the market check disables the plugin: +`link:` points at the repository itself: after changing a stylesheet or a component, run `npm run build` and reload the +page. A change to the `Config` in `index.js` needs a `dsh web` restart (the host half loads at boot). -| Path | How | Used by | -|---|---|---| -| bundle | the package name goes into the profile `package.json` `dsh.profile.bundles`; the package's own `cordis.patch.yml` inserts the entry | the desktop shell profile and the web profile (`--bundle`) | -| insert | a hand-written `- insert:` in the profile `cordis.patch.yml` | throwaway verification profiles that skip pnpm install | +**Migrating from 0.6.0 or earlier**: `install-plugin.mjs` is gone. It installed a copy at +`profiles//vendor/codex-ui` plus a `node_modules/codex-ui` junction, registered either through the package name in +`dsh.profile.bundles` or a hand-written `- insert:` in the profile `cordis.patch.yml`. Remove those before the standard +install — two registrations of the same id fail the market check and the plugin is disabled. The same stylesheets can be picked up by a skin loader: @@ -96,81 +103,97 @@ node scripts/install-skin.mjs # per-file SHA256 check against $DSH_HOME node scripts/install-skin.mjs --write # overwrite on drift ``` +## Development + +```powershell +npm run build # regenerate theme.css and client.js from skins/codex-ink and src/client +node scripts/build.mjs --check # report stale artifacts without writing +``` + +The build has no dependencies and one implementation, `scripts/build.mjs`: the eight stylesheets are stripped of +comments and scoped to `html[data-codex-ui]` into `theme.css`; the ES modules in `src/client/` are bundled in dependency +order into one classic script, `client.js` (the DSH `__ModuleLoader__.load` shape, stylesheet inlined, host packages such +as `react` resolved through the loader's `require`). It accepts two import forms (`import { a, b as c } from '…'`, +`import * as ns from '…'`) and three export forms (`export const | function | class`); anything else fails the build +instead of bundling wrongly. + +Adding a feature: a module in `src/client/` exporting `installXxx(ctx)` plus one line in `apply` in +`src/client/index.js`; new styles go into a new file in `skins/codex-ink/`, registered in `SKIN_PARTS` in +`scripts/build.mjs`. Required host services go into `inject` (one missing service keeps the plugin inactive); optional +ones go through a `ctx.inject([...], cb)` child scope, as the model picker does. + ## Layout | Path | Content | |---|---| -| `index.js` `cordis.patch.yml` `package.json` | Host half and manifest | -| `src/client.template.js` | Browser half template (stylesheet, override layer, settings seat) | -| `src/override.js` | Override-layer pure functions (no DOM; unit-tested by the repo checks) | -| `src/settings-card.js` | The config card on the bundle page (inlined into `client.js` at build time) | -| `src/model-picker.js` | Model picker face B: seat takeover, popover, power rail (its pure functions are unit-tested by `check-repo`) | -| `src/build.mjs` | Scoping and artifact generation; the only implementation | -| `theme.css` `client.js` | Generated from `skins/codex-ink/` by `src/build.mjs` | -| `skins/codex-ink/` | Stylesheet sources (skin.css / patches.css / model-picker.css / sidebar-align.css / sidebar-surface.css / window-shadow.css / composer.css / settings.css) | +| `index.js` `cordis.patch.yml` `package.json` | Host half (the `Config` schema) and manifest | +| `src/client/index.js` | Browser half entry: `inject` and `apply`, wiring up the modules below in order | +| `src/client/stylesheet.js` | Injects the skin (`style[data-plugin]`, which the host removes on unload and hot reload) | +| `src/client/settings.js` `theme-preview.js` `settings-card.js` | Settings: the override `' - + '' - + '
' - + '
' - + '
' - + '
' - + '
'; - -fs.mkdirSync(FIX, { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const htmlPath = join(FIX, 'composer-shadow-verify.html'); -fs.writeFileSync(htmlPath, page); - -const PORT = 9351; -const DSF = 2; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const PROFILE = tempDir('dsh-cdp-shadow-verify'); -fs.rmSync(PROFILE, { recursive: true, force: true }); -const child = spawn(chromePath(), ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + PROFILE, '--no-first-run', '--disable-gpu', '--hide-scrollbars', '--window-size=1100,700', 'about:blank'], { stdio: 'ignore' }); -let info; for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } -if (!info) { child.kill(); throw new Error('headless chrome did not start'); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const pending = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }; -const send = (method, params = {}, sid) => new Promise((res, rej) => { const id = ++seq; pending.set(id, (m) => m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sid ? { sessionId: sid } : {}) })); }); -const evalJs = async (expr) => { - const res = (await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }, sessionId)).result; - if (res.value === undefined) throw new Error('eval returned undefined: ' + JSON.stringify(res)); - return res.value; -}; - -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: DSF, mobile: false }, sessionId); -await send('Page.navigate', { url: 'file:///' + htmlPath.replaceAll('\\', '/') + '?v=' + Date.now() }, sessionId); -await sleep(1500); - -const PROBE = fs.readFileSync(join(HERE, 'fixtures', 'composer-shadow.probe.js'), 'utf8'); -const probe = async () => JSON.parse(await evalJs(PROBE)); - -/** 把 rgba 计算值拆成层:inset 标记、四个长度、alpha。 */ -const parseLayers = (s) => s.split(/,(?![^(]*\))/).map((x) => x.trim()).filter(Boolean).map((x) => { - const inset = /(^|\s)inset(\s|$)/.test(x); - const nums = [...x.matchAll(/(-?\d+(?:\.\d+)?)px/g)].map((m) => parseFloat(m[1])); - const col = x.match(/rgba?\([^)]*\)/); - let alpha = null; - if (col) { - const parts = col[0].replace(/^rgba?\(|\)$/g, '').split(',').map((v) => v.trim()); - alpha = parts.length === 4 ? parseFloat(parts[3]) : 1; - } - return { inset, nums, alpha, raw: x }; -}); -const near = (a, b, tol = 0.006) => Math.abs(a - b) <= tol; -/** 把计算色换算成 0..255 通道值。color-mix 会算成 color(srgb r g b)(0..1 浮点), - 字面量则是 rgb(r, g, b) —— 两种记法都要认,否则断言会被记法差异误判。 */ -const chan = (c) => { - const m = String(c).match(/color\(srgb\s+([\d.]+)/); - if (m) return parseFloat(m[1]) * 255; - const r = String(c).match(/rgba?\(\s*([\d.]+)/); - return r ? parseFloat(r[1]) : NaN; -}; - -/* ── 像素测量:截图回灌进页面,用 canvas 取竖直扫描线 ─────────────────── */ -const scanAbove = async (rect, cssPx) => { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - return JSON.parse(await evalJs('(async () => {' - + 'const img = new Image(); img.src = "data:image/png;base64,' + shot.data + '";' - + 'await img.decode();' - + 'const c = document.createElement("canvas"); c.width = img.width; c.height = img.height;' - + 'const g = c.getContext("2d"); g.drawImage(img, 0, 0);' - + 'const D = ' + DSF + ';' - + 'const x = Math.round((' + rect.x + ' + ' + rect.w + ' / 2) * D);' - + 'const yTop = Math.round(' + rect.y + ' * D);' - + 'const out = [];' - + 'for (let d = 0; d < ' + cssPx + ' * D; d += 1) {' - + ' const y = yTop - d; if (y < 0) break;' - + ' const p = g.getImageData(x, y, 1, 1).data;' - + ' out.push([d / D, (p[0] + p[1] + p[2]) / 3]);' - + '}' - + 'const page = g.getImageData(x, 4, 1, 1).data;' - + 'const body = g.getImageData(x, yTop + Math.round(20 * D), 1, 1).data;' - + 'return JSON.stringify({ base: (page[0] + page[1] + page[2]) / 3, body: (body[0] + body[1] + body[2]) / 3, prof: out });' - + '})()')); -}; - -const results = []; -const check = (name, pass, detail) => { results.push([name, pass, detail]); }; - -/* ── ① 亮色 ─────────────────────────────────────────────────────────── */ -const light = await probe(); -const L = parseLayers(light.shadow); -console.log('LIGHT var ' + light.varShadow); -console.log('LIGHT calc ' + light.shadow); -console.log('LIGHT base=' + light.canvasBase + ' surface=' + light.surface); -check('亮色画布 = #ffffff', light.canvasBase.toLowerCase() === '#ffffff', light.canvasBase); -/* 亮色卡与页面**同色**:Codex 亮色靠阴影分层,不靠填充(三份实测同向,见 composer.css 头注)。 - 0.5.7 及以前亮色也叠 5% 墨 = #f4f4f4,比 Codex 暗 11 级。 */ -check('亮色输入卡 = #ffffff(与页面同色,不叠罩)', light.bg === 'rgb(255, 255, 255)', light.bg); -check('亮色输入卡 ≠ #f4f4f4(旧值,暗 11 级)', light.bg !== 'rgb(244, 244, 244)', light.bg); -check('亮色阴影为三层', L.length === 3, L.length + ' 层'); -check('亮色三层皆非 inset', L.every((l) => !l.inset), L.map((l) => l.inset).join(',')); -if (L.length === 3) { - /* 环取 12% 而非源码字面的 3.9%:实测边缘要 220~234,4% 只能渲到 241。见 composer.css 头注。 */ - /* 环取 10%(Codex --color-border = --alpha-10),不是源码路径A 的 3.9%、也不是 0.5.8 的 12%: - 同图内比较与标定渲染两法都指向 ~10.4%。见 composer.css 头注。 */ - check('层1 环 = 0 0 0 1px @10%(Codex --color-border,双法标定)', !L[0].inset && L[0].nums.join(' ') === '0 0 0 1' && near(L[0].alpha, 0.1, 0.015), L[0].raw); - check('层2 近场 = 0 2px 8px 0 @4%', L[1].nums.join(' ') === '0 2 8 0' && near(L[1].alpha, 0.039), L[1].raw); - check('层3 远场 = 0 4px 80px 8px @2.4%(旧皮肤缺这一层)', L[2].nums.join(' ') === '0 4 80 8' && near(L[2].alpha, 0.024, 0.004), L[2].raw); -} -/* 渲染边缘:Codex 参考图与用户截图都落在 220~234,本皮肤必须也落进去。 - 旧值 4% 渲出 241(偏亮),这正是 0.5.7 引入、0.5.8 修回的偏差。 */ -const lightEdge = await (async () => { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - return JSON.parse(await evalJs('(async () => {' - + 'const img = new Image(); img.src = "data:image/png;base64,' + shot.data + '"; await img.decode();' - + 'const c = document.createElement("canvas"); c.width = img.width; c.height = img.height;' - + 'const g = c.getContext("2d"); g.drawImage(img, 0, 0); const D = ' + DSF + ';' - + 'const y = Math.round((' + light.rect.y + ' + ' + light.rect.h + ' / 2) * D);' - + 'const xL = Math.round(' + light.rect.x + ' * D);' - + 'let best = 255; for (let d = 1; d <= 6; d += 1) {' - + ' const p = g.getImageData(xL - d, y, 1, 1).data; const v = (p[0] + p[1] + p[2]) / 3;' - + ' if (v < best) best = v; }' - + 'return JSON.stringify({ edge: Math.round(best) }); })()')); -})(); -console.log('LIGHT 渲染边缘(卡左 6px 内最暗)= ' + lightEdge.edge + ' Codex 实测 220~234'); -check('亮色卡边缘落在 Codex 实测带内(210~234)', lightEdge.edge >= 210 && lightEdge.edge <= 234, String(lightEdge.edge)); - -const lightScan = await scanAbove(light.rect, 60); -const lightDev = lightScan.prof.map(([d, v]) => [d, lightScan.base - v]); -const lightPeak1 = lightDev.find(([d]) => d === 1)?.[1] ?? 0; -const lightExtent = lightDev.filter(([, v]) => v >= 1).map(([d]) => d).pop() ?? 0; -console.log('LIGHT 1px处偏离 ' + lightPeak1.toFixed(1) + ' 衰减半径 ' + lightExtent + 'px'); -check('亮色衰减半径 ≥ 35px(远场真的画出来了;旧皮肤仅 9px)', lightExtent >= 35, lightExtent + 'px'); -/* 这条原先写「1px 处 ≤ 22」,是拿 DSF=1 + 4% 环标定的口径,量到的其实是**环本身**; - Codex 实测的环像素是 220(偏离 35),所以旧口径把「环」和「环外一格」比成了同一件事。 - 现在环的判据交给上面的显式边缘带断言(210~234),这里只留信息打印。 */ -console.log('LIGHT 1px处(即环像素)偏离 ' + lightPeak1.toFixed(1) + '(信息项,不作断言)'); - -/* ── ② 暗色 ─────────────────────────────────────────────────────────── */ -await evalJs('document.body.setAttribute("data-ds-dark-theme",""); "ok"'); -await sleep(400); -const dark = await probe(); -const D = parseLayers(dark.shadow); -console.log('DARK var ' + dark.varShadow); -console.log('DARK calc ' + dark.shadow); -console.log('DARK base=' + dark.canvasBase); -check('暗色画布 = #111111(Codex 主题面板的「背景」)', dark.canvasBase.toLowerCase() === '#111111', dark.canvasBase); -check('暗色表面 = #181818(卡片所坐的面,非窗口背景)', dark.surface.toLowerCase() === '#181818', dark.surface); -check('暗色属性确实生效(前提自检)', dark.dark === true, String(dark.dark)); -check('暗色阴影为单层', D.length === 1, D.length + ' 层'); -/* 暗色相反:靠**填充**分层 —— 5% 白叠 surface #181818 = 35.6 → 36(Codex 实测 35)。 */ -/* 5% 白叠 #181818 = 35.55(Codex 参考裁图实测 35)—— color-mix 会算成 color(srgb 0.1394…) */ -check('暗色输入卡 ≈ 35.55(5% 白叠 #181818;Codex 实测 35)', Math.abs(chan(dark.bg) - 35.55) < 1.0, dark.bg + ' → ' + chan(dark.bg).toFixed(2)); -check('暗色输入卡 ≠ 亮色值(两套机制相反)', dark.bg !== light.bg, dark.bg); -if (D.length === 1) { - check('暗色为 inset 内嵌高光(Codex --elevation-composer-dark)', D[0].inset === true, D[0].raw); - check('暗色几何 = 0 0 1px 0 @20%(#fff3)', D[0].nums.join(' ') === '0 0 1 0' && near(D[0].alpha, 0.2), D[0].raw); -} -const darkScan = await scanAbove(dark.rect, 60); -const darkDev = darkScan.prof.map(([d, v]) => [d, darkScan.base - v]); -const darkOuter = darkDev.filter(([d]) => d >= 2 && d <= 40).reduce((m, [, v]) => Math.max(m, Math.abs(v)), 0); -console.log('DARK 卡外 2..40px 最大偏离 ' + darkOuter.toFixed(1) + ' 卡内顶边 ' + darkScan.body.toFixed(0) + ' vs 卡体基线'); -check('暗色卡外零投影(2..40px 偏离 ≤ 1)', darkOuter <= 1, darkOuter.toFixed(1)); -/* 卡内顶边应比卡体更亮 —— inset 高光的签名。取 +0px 与 +6px 对比。 */ -const darkInside = await (async () => { - const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); - return JSON.parse(await evalJs('(async () => {' - + 'const img = new Image(); img.src = "data:image/png;base64,' + shot.data + '"; await img.decode();' - + 'const c = document.createElement("canvas"); c.width = img.width; c.height = img.height;' - + 'const g = c.getContext("2d"); g.drawImage(img, 0, 0); const D = ' + DSF + ';' - + 'const x = Math.round((' + dark.rect.x + ' + ' + dark.rect.w + ' / 2) * D);' - + 'const yTop = Math.round(' + dark.rect.y + ' * D);' - + 'const px = (dy) => { const p = g.getImageData(x, yTop + dy, 1, 1).data; return (p[0] + p[1] + p[2]) / 3; };' - + 'return JSON.stringify({ edge: px(0), inner: px(Math.round(6 * D)) }); })()')); -})(); -console.log('DARK 卡内顶边 ' + darkInside.edge.toFixed(1) + ' 卡内 +6px ' + darkInside.inner.toFixed(1)); -check('暗色卡内顶边被点亮(比卡体亮 ≥ 2;旧皮肤 36/36 全平)', darkInside.edge - darkInside.inner >= 2, (darkInside.edge - darkInside.inner).toFixed(1)); - -/* ── ③ 窄屏:远场 80px → 40px(Codex 的 width<40rem 分支) ────────────── - 必须先摘掉深色属性:暗色分支的 inset 声明特异性更高,会盖掉窄屏覆盖值, - 不摘就会测到暗色阴影,让这条断言永远「通过」在错误的前提上。 */ -await evalJs('document.body.removeAttribute("data-ds-dark-theme"); "ok"'); -await send('Emulation.setDeviceMetricsOverride', { width: 600, height: 700, deviceScaleFactor: DSF, mobile: false }, sessionId); -await sleep(500); -check('窄屏段确实回到亮色(前提自检)', (await probe()).dark === false, 'body 无 data-ds-dark-theme'); -const narrow = await probe(); -const N = parseLayers(narrow.shadow); -const nFar = N.find((l) => l.nums.length === 4 && l.nums[2] === 40); -console.log('NARROW ' + narrow.shadow); -check('窄屏(600px)远场收到 40px', nFar !== undefined, narrow.shadow); -check('窄屏仍是三层', N.length === 3, N.length + ' 层'); - -/* 证据图:把亮/暗两态并排出图 */ -await send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 700, deviceScaleFactor: DSF, mobile: false }, sessionId); -await sleep(400); -const evidShot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); -fs.writeFileSync(join(EVID, 'composer-shadow-verify.png'), Buffer.from(evidShot.data, 'base64')); - -ws.close(); child.kill(); - -let pass = 0; -for (const [name, ok, detail] of results) { - console.log((ok ? 'PASS ' : 'FAIL ') + name + (detail !== undefined ? ' [' + detail + ']' : '')); - if (ok) pass += 1; -} -console.log('\n' + (pass === results.length ? 'ALL PASS' : 'FAILED') + ' (' + pass + '/' + results.length + ')'); -process.exit(pass === results.length ? 0 : 1); diff --git a/scripts/elevation-verify.mjs b/scripts/elevation-verify.mjs deleted file mode 100644 index 0c49e66..0000000 --- a/scripts/elevation-verify.mjs +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env node -/** - * elevation-verify.mjs — ⑲ --dsw-elevation-* 令牌与 Codex 源码对账。 - * - * 判据来源:Codex 桌面端 26.924.2738.0 的 resources/app.asar @67930525 起逐字: - * --elevation-stroke: 0 0 0 .5px var(--color-border-strong) - * --elevation-stroke-subtle: 0 0 0 .5px rgb(from var(--color-border-strong) r g b / .04) (暗色 .06) - * --elevation-prominent: var(--elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d - * --elevation-sidebar: var(--elevation-stroke), 0 3px 7.5px #00000008, 0 0 16px #00000005 - * - * 本夹具量四件事: - * ① 四个令牌在亮/暗两套下都解析得出来; - * ② --dsw-elevation-prominent 与 Codex 逐字一致(几何 + 两层 alpha); - * ③ 亮暗的 prominent **必须相同** —— Codex 只在 :root 声明一次、没有暗色变体; - * ④ --dsw-elevation-panel 仍等于 stroke(本皮肤的分层选择,防误改)。 - * 用法:node scripts/elevation-verify.mjs - */ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromePath, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const WB = dirname(HERE); -const EVID = join(WB, 'assets', 'screenshots'); -const theme = fs.readFileSync(join(WB, 'theme.css'), 'utf8'); - -/* 舞台上放一个"菜单面板",直接吃 --dsw-elevation-prominent;亮暗各一个。 */ -const page = '' - + '' - + '' - + '
'; - -fs.mkdirSync(join(tmpdir(), 'codex-ui-fixtures'), { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const htmlPath = join(tmpdir(), 'codex-ui-fixtures', 'elevation-verify.html'); -fs.writeFileSync(htmlPath, page); - -const PORT = 9355; -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const PROFILE = tempDir('dsh-cdp-elev'); -fs.rmSync(PROFILE, { recursive: true, force: true }); -const child = spawn(chromePath(), ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + PROFILE, '--no-first-run', '--disable-gpu', '--hide-scrollbars', '--window-size=460,520', 'about:blank'], { stdio: 'ignore' }); -let info; for (let i = 0; i < 60 && !info; i++) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch {} if (!info) await sleep(250); } -if (!info) { child.kill(); throw new Error('headless chrome did not start'); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const pending = new Map(); -ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } }; -const send = (method, params = {}, sid) => new Promise((res, rej) => { const id = ++seq; pending.set(id, (m) => m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result)); ws.send(JSON.stringify({ id, method, params, ...(sid ? { sessionId: sid } : {}) })); }); -const evalJs = async (expr) => (await send('Runtime.evaluate', { expression: expr, returnByValue: true }, sessionId)).result.value; - -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 460, height: 520, deviceScaleFactor: 2, mobile: false }, sessionId); -await send('Page.navigate', { url: 'file:///' + htmlPath.replaceAll('\\', '/') + '?v=' + Date.now() }, sessionId); -await sleep(1400); - -const TOKENS = '(() => { const cs = getComputedStyle(document.body);' - + 'const g = (n) => cs.getPropertyValue(n).trim();' - + 'const menu = document.getElementById("m");' - + 'return JSON.stringify({ stroke: g("--dsw-elevation-stroke"), panel: g("--dsw-elevation-panel"),' - + ' prominent: g("--dsw-elevation-prominent"), soft: g("--dsw-elevation-soft"),' - + ' menuShadow: getComputedStyle(menu).boxShadow,' - + ' menuFill: getComputedStyle(menu).backgroundColor }); })()'; - -const light = JSON.parse(await evalJs(TOKENS)); -await evalJs('document.body.setAttribute("data-ds-dark-theme",""); "ok"'); -await sleep(400); -const dark = JSON.parse(await evalJs(TOKENS)); - -console.log('LIGHT stroke ' + light.stroke); -console.log('LIGHT prominent ' + light.prominent); -console.log('LIGHT menu calc ' + light.menuShadow); -console.log('DARK stroke ' + dark.stroke); -console.log('DARK prominent ' + dark.prominent); -console.log('DARK menu calc ' + dark.menuShadow); - -/* Codex 逐字:var(--elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d - 计算值里 var 会被替换掉,两层 alpha 归一为 0.039 / 0.051(#0a=3.92%、#0d=5.10%)。 */ -const layers = (s) => s.split(/,(?![^(]*\))/).map((x) => x.trim()).filter(Boolean); -const hasGeom = (s, a, b, c, d) => { - const m = s.match(/(-?[\d.]+)px\s+(-?[\d.]+)px\s+(-?[\d.]+)px\s+(-?[\d.]+)px/); - return !!m && [+m[1], +m[2], +m[3], +m[4]].every((v, i) => Math.abs(v - [a, b, c, d][i]) < 0.01); -}; -const alphaNear = (s, want, tol = 0.006) => { - const m = s.match(/rgba?\([^)]*?,\s*([\d.]+)\s*\)/); - return !!m && Math.abs(parseFloat(m[1]) - want) <= tol; -}; - -const results = []; -const check = (n, ok, d) => results.push([n, ok, d]); - -/* 几何与 alpha 一律读**计算值**(menuShadow):自定义属性的字面值里 0 spread 会被省略、 - 颜色是 hex 不是 rgba,直接断言字面值会因记法差异误判。 */ -for (const [tag, t] of [['亮', light], ['暗', dark]]) { - const L = layers(t.prominent); - const C = layers(t.menuShadow); - check(tag + '色 --dsw-elevation-prominent 为三层', L.length === 3, L.length + ' 层'); - check(tag + '色 层2 几何 = 0 3px 7.5px 0(Codex 逐字)', hasGeom(C[1] ?? '', 0, 3, 7.5, 0), (C[1] ?? '').slice(0, 60)); - check(tag + '色 层2 alpha = 3.92%(#0000000a)', alphaNear(C[1] ?? '', 0.039), (C[1] ?? '').slice(0, 60)); - check(tag + '色 层3 几何 = 0 0 20px 0(Codex 逐字)', hasGeom(C[2] ?? '', 0, 0, 20, 0), (C[2] ?? '').slice(0, 60)); - check(tag + '色 层3 alpha = 5.10%(#0000000d)', alphaNear(C[2] ?? '', 0.051), (C[2] ?? '').slice(0, 60)); - check(tag + '色 层1 = --dsw-elevation-stroke', L[0] === t.stroke, (L[0] ?? '').slice(0, 60)); - check(tag + '色 --dsw-elevation-panel 仍等于 stroke', t.panel === t.stroke, t.panel); - check(tag + '色 菜单面板确实吃到 prominent', t.menuShadow !== 'none' && t.menuShadow.length > 20, t.menuShadow.slice(0, 70)); -} -/* Codex 只在 :root 声明 --elevation-prominent 一次、没有暗色覆盖 —— 所以**第 2、3 层**必须 - 亮暗逐字相同。(第 1 层是 var(--elevation-stroke),它本来就随主题反相,不参与这条比较。) */ -const lit = layers(light.prominent), dk = layers(dark.prominent); -check('亮暗 层2 同值(Codex 无暗色变体)', lit[1] === dk[1], lit[1] + ' | ' + dk[1]); -check('亮暗 层3 同值(Codex 无暗色变体)', lit[2] === dk[2], lit[2] + ' | ' + dk[2]); -check('亮暗 stroke 不同值(描边随主题反相)', light.stroke !== dark.stroke, light.stroke + ' | ' + dark.stroke); - -/* 证据图:暗色态 */ -const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); -fs.writeFileSync(join(EVID, 'elevation-verify.png'), Buffer.from(shot.data, 'base64')); -ws.close(); child.kill(); - -let pass = 0; -for (const [n, ok, d] of results) { console.log((ok ? 'PASS ' : 'FAIL ') + n + (d !== undefined ? ' [' + d + ']' : '')); if (ok) pass += 1; } -console.log('\n' + (pass === results.length ? 'ALL PASS' : 'FAILED') + ' (' + pass + '/' + results.length + ')'); -process.exit(pass === results.length ? 0 : 1); diff --git a/scripts/fixtures/composer-shadow.probe.js b/scripts/fixtures/composer-shadow.probe.js deleted file mode 100644 index dfb5511..0000000 --- a/scripts/fixtures/composer-shadow.probe.js +++ /dev/null @@ -1,21 +0,0 @@ -(() => { - /* 深色令牌声明在 body[data-ds-dark-theme] 上(不是 :root), - 所以取令牌一律以 body 为先、html 兜底 —— 只读 documentElement 会在深色下读到亮色值。 */ - const b = getComputedStyle(document.body); - const h = getComputedStyle(document.documentElement); - const tok = (n) => b.getPropertyValue(n).trim() || h.getPropertyValue(n).trim(); - const card = document.querySelector('[data-composer-card]'); - const scroll = document.querySelector('[data-conversation-scroll]'); - const cs = getComputedStyle(card); - const r = card.getBoundingClientRect(); - return JSON.stringify({ - shadow: cs.boxShadow, - bg: cs.backgroundColor, - radius: cs.borderTopLeftRadius, - varShadow: getComputedStyle(scroll).getPropertyValue('--dcu-composer-shadow').trim(), - canvasBase: tok('--dsw-alias-bg-base'), - surface: tok('--dsw-composer-surface'), - dark: document.body.hasAttribute('data-ds-dark-theme'), - rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }, - }); -})() \ No newline at end of file diff --git a/scripts/fixtures/hero-verify.probe.js b/scripts/fixtures/hero-verify.probe.js deleted file mode 100644 index 873c495..0000000 --- a/scripts/fixtures/hero-verify.probe.js +++ /dev/null @@ -1,32 +0,0 @@ -(() => { - const H = '[data-slot="conversation.session.header"]'; - const q = (s) => document.querySelector(s); - const vis = (el) => el ? el.getClientRects().length > 0 : null; - const cs = (el, p) => el ? getComputedStyle(el).getPropertyValue(p) : null; - const row = q('[data-phase=hero] [class$=_heroWorkspaceRow]'); - const card = q('[data-composer-card]'); - /* ⑬·3c 放开后,槽里的条目按**官方真实 class** 建模:量它的取色是否跟着本皮肤令牌走。 */ - const label = q('[data-slot="conversation.session.header.actions"] span'); - return JSON.stringify({ - header: { - title: vis(q(H + ' [class$=_crumbs]')), - presetBadge: vis(q('[data-slot="conversation.session.header.actions"] span')), - utilityItem: vis(q('[data-slot="conversation.session.header.utilities"] span')), - tabs: vis(q(H + ' [data-conversation-tabs]')), - corner: vis(q('[data-conversation-header-corner]')), - }, - presetLabel: { - vis: vis(label), - color: cs(label, 'color'), - bg: cs(label, 'background-color'), - radius: cs(label, 'border-top-left-radius'), - h: label ? Math.round(label.getBoundingClientRect().height) : null, - font: cs(label, 'font-size'), - }, - card: { radius: cs(card, 'border-top-left-radius'), shadow: cs(card, 'box-shadow'), border: cs(card, 'border-top-width'), token: cs(document.documentElement, '--dsw-radius-card').trim(), h: card ? Math.round(card.getBoundingClientRect().height) : null, w: card ? Math.round(card.getBoundingClientRect().width) : null, bg: cs(card, 'background-color'), padTop: cs(card, 'padding-top') }, - heroRow: { present: row !== null, bg: cs(row, 'background-color'), radius: cs(row, 'border-top-left-radius'), height: row ? Math.round(row.getBoundingClientRect().height) : null }, - /* ⑭·2 输入区纵向留白:Codex 实测(同一字号标定)编辑区 ≈79、底衬 ≈12,本层按此对齐。 */ - editor: (() => { const e = q('[data-lexical-editor=true]'); return e ? { minH: cs(e, 'min-height'), h: Math.round(e.getBoundingClientRect().height) } : null; })(), - footPad: (() => { const s = q('[data-input-scroll]'); const r = s && s.nextElementSibling; return r ? cs(r, 'padding-bottom') : null; })(), - }); -})() \ No newline at end of file diff --git a/scripts/fixtures/preview.html b/scripts/fixtures/preview.html deleted file mode 100644 index 526c9f7..0000000 --- a/scripts/fixtures/preview.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - -codex-ink preview - - - - - -
-
-

墨白终端 · 令牌重映射

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

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

-

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

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

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

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

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

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

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

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

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

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

' + label + '

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

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

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

原生 shipped(无 codex-ui)

' + stage() + '
' - + '

+ codex-ui theme.css

' + stage() + '
' - + '
'; - -fs.mkdirSync(FIX, { recursive: true }); -fs.mkdirSync(EVID, { recursive: true }); -const htmlPath = join(FIX, 'sidebar-align-verify.html'); -fs.writeFileSync(htmlPath, page); - -/* 左联要「无 codex-ui」:插件样式表是最后一条 ' + + '' + + block('new-sidebar', AT.sidebar[0], AT.sidebar[1], '') + + block('new-content', AT.content[0], AT.content[1], 'background:var(--dsw-alias-bg-base)') + + block('new-active', AT.active[0], AT.active[1], 'background:var(--dsw-specific-sidebar-nav-item-active)') + + '
' + + block('old-sidebar', OLD_AT.sidebar[0], OLD_AT.sidebar[1], '') + + block('old-content', OLD_AT.content[0], OLD_AT.content[1], 'background:var(--dsw-alias-bg-base)') + + block('old-active', OLD_AT.active[0], OLD_AT.active[1], 'background:var(--dsw-specific-sidebar-nav-item-active)') + + '
' + + ' +`, 900); + /* 子代理会话(session-sub):宿主在那里返回 null(席位是空的),组件不许出头。 */ + + const near = (a, b, tol = 0.6) => Math.abs(a - b) <= tol; + const state = () => page.evaluate(railState); + const style = (sel, ...props) => page.evaluate(computed, sel, props); + + /* 1. 席位顶替 */ + const seat = await page.evaluate(() => { + const slot = document.querySelector('[data-conversation-session="session-a"] [data-slot="conversation.input.model"]'); + const ours = slot.querySelector('.codex-mp-trigger'); + const cs = ours ? getComputedStyle(ours) : null; + return { host: getComputedStyle(slot.querySelector('._7KE1Ra_root')).display, ours: ours !== null, h: ours ? ours.getBoundingClientRect().height : 0, + radius: cs?.borderTopLeftRadius ?? null, bg: cs?.backgroundColor ?? null, sub: document.getElementById('sub-seat').children.length, text: ours?.textContent ?? '' }; + }); + t.check('席位里有自建触发器', seat.ours, seat.text); + t.check('宿主那一格 display:none(仍在 DOM 里)', seat.host === 'none', seat.host); + t.check('宿主没渲染的席位(子代理会话)不接管', seat.sub === 0, seat.sub + ' 个子节点'); + t.check('触发器 28px 高、全圆角胶囊、默认透明', near(seat.h, 28) && seat.radius === '999px' && seat.bg === 'rgba(0, 0, 0, 0)', JSON.stringify(seat)); + + /* 2. 弹层与轨几何 */ + const [tx, ty] = await page.center('.codex-mp-trigger'); + await page.click(tx, ty); + await t.sleep(80); + const anim = await style('.codex-mp-popover', 'animation-name', 'animation-duration', 'animation-delay', 'animation-timing-function'); + await t.sleep(500); + let st = await state(); + const pop = await style('.codex-mp-popover', 'border-top-left-radius', 'padding-top', 'box-shadow'); + t.check('点触发器打开弹层(aria-expanded=true)', st.open && st.expanded === 'true', JSON.stringify({ open: st.open, expanded: st.expanded })); + t.check('弹层宽 254px(Codex spacing × 63.5)', near(st.pop.w, 254), st.pop.w); + t.check('弹层在触发器上方 8px、右沿对齐(宿主 place())', near(st.trig.y - st.pop.b, 8, 1) && near(st.trig.r, st.pop.r, 1), 'gap=' + (st.trig.y - st.pop.b) + ' right=' + (st.trig.r - st.pop.r)); + t.check('弹层 18px 圆角 + 内衬 5px + 菜单软影', pop['border-top-left-radius'] === '18px' && pop['padding-top'] === '5px' && /0px 8px 32px/.test(pop['box-shadow']), JSON.stringify(pop)); + t.check('入场 .32s cubic-bezier(.23,1,.32,1) 30ms,从右下角长出', anim['animation-name'] === 'codex-mp-enter' && anim['animation-duration'] === '0.32s' && anim['animation-delay'] === '0.03s' && /0\.23, 1, 0\.32, 1/.test(anim['animation-timing-function']), JSON.stringify(anim)); + t.check('列表三行、当前模型打勾', st.rows === 3 && st.checked[0] === 'true', JSON.stringify(st.checked)); + /* 宿主字典缺席(本夹具 locale 为 null)时,内置模型的说明要落回目录原文,不能把字典键名画出来。 */ + const desc = await page.evaluate(() => document.querySelector('.codex-mp-row-desc')?.textContent ?? null); + t.check('说明文字是目录原文、不是字典键名', typeof desc === 'string' && desc.startsWith('Fast, efficient') && !desc.startsWith('option.'), desc); + /* 形态基准 = dsh-claude-style 的 .dsh-claude-effort-*(它自己注明对标 Claude Desktop 的 Effort slider): + 槽是 26px 厚、8px 圆角的矩形(不是胶囊),旋钮是 16×30 的圆角矩形(不是圆珠),槽下方一行两端刻度。 */ + const rail = await page.evaluate(() => { + const groove = getComputedStyle(document.querySelector('.codex-mp-track'), '::before'); + const thumbEl = getComputedStyle(document.querySelector('.codex-mp-thumb')); + const c = getComputedStyle(document.querySelector('.codex-mp-container')); + const rg = getComputedStyle(document.querySelector('.codex-mp-range')); + const ends = document.querySelector('.codex-mp-ends'); + return { grooveH: groove.height, grooveR: groove.borderTopLeftRadius, grooveBg: groove.backgroundColor, + hitH: getComputedStyle(document.querySelector('.codex-mp-track')).height, + thBg: thumbEl.backgroundColor, thR: thumbEl.borderTopLeftRadius, thShadow: thumbEl.boxShadow, + cH: c.height, cPad: c.paddingTop + ' ' + c.paddingLeft, + rRadius: rg.borderTopLeftRadius + ' ' + rg.borderTopRightRadius, rBg: rg.backgroundColor, + touch: getComputedStyle(document.querySelector('.codex-mp-root')).touchAction, + ends: [...document.querySelectorAll('.codex-mp-ends span')].map((e) => e.textContent), + endsSize: ends ? getComputedStyle(ends).fontSize : null }; + }); + t.check('槽 26px 厚、8px 圆角(圆角矩形,不是胶囊)', rail.grooveH === '26px' && rail.grooveR === '8px', rail.grooveH + ' / ' + rail.grooveR); + t.check('槽底走主题变量(--dsw-alias-interactive-bg-hover)', rail.grooveBg !== 'rgba(0, 0, 0, 0)', rail.grooveBg); + t.check('热区 = 旋钮高 30px;根 30px、touch-action none', rail.hitH === '30px' && near(st.root.h, 30) && rail.touch === 'none', rail.hitH + ' / ' + st.root.h + ' / ' + rail.touch); + t.check('填充 = 主文本墨 26%、只圆左侧两角', /0\.26\)$/.test(rail.rBg) && rail.rRadius === '8px 0px', rail.rBg + ' | ' + rail.rRadius); + t.check('旋钮 16×30 圆角矩形、白片、软影(不是圆珠)', near(st.thumb.w, 16) && near(st.thumb.h, 30) && rail.thR === '5px' && rail.thBg === 'rgb(255, 255, 255)' && /0px 1px 3px/.test(rail.thShadow), JSON.stringify({ w: st.thumb.w, h: st.thumb.h, r: rail.thR, bg: rail.thBg })); + t.check('两端刻度名 更快 / 更强、11px caption 色', rail.ends.join('|') === '更快|更强' && rail.endsSize === '11px', rail.ends.join('|') + ' ' + rail.endsSize); + t.check('档位圆点 4px × 4 个', st.ticks.length === 4 && st.ticks.every((k) => near(k.w, 4) && near(k.h, 4)), st.ticks.length); + const expectX = (i) => st.root.x + 8 + (st.root.w - 16) * i / 3; + t.check('圆点等距落在旋钮行程 [8, 宽 − 8] 上', st.ticks.every((k, i) => near(k.x + k.w / 2, expectX(i))), st.ticks.map((k) => Math.round((k.x + k.w / 2 - st.root.x) * 10) / 10).join(' / ')); + t.check('旋钮中心 = 生效档(High = 第 3 档)', near(st.thumb.x + 8, expectX(2)) && st.now === '2', 'thumb=' + (st.thumb.x + 8 - st.root.x) + ' now=' + st.now); + t.check('填充止于旋钮中线', near(st.range.r, st.thumb.x + 8), 'range.r=' + st.range.r + ' thumb=' + (st.thumb.x + 8)); + t.check('圆点与填充同墨(走过去的被填充吞掉,不再分两色)', st.ticks[0].color === st.ticks[3].color && /0\.26\)$/.test(st.ticks[0].color), st.ticks.map((k) => k.color).join(' | ')); + await t.shot(page, 'power-rail-verify.png', { x: st.pop.x - 20, y: st.pop.y - 20, width: st.pop.w + 40, height: st.trig.b - st.pop.y + 40, scale: 1 }); + + /* 3. 拖动:中途不提交、松手对齐提交一次;pending 不回弹、不清空、有转圈 */ + const thumbX = Math.round(st.thumb.x + 8); + const railY = Math.round(st.root.y + st.root.h / 2); + const outside = Math.round(st.root.x - 20); + await page.move(thumbX, railY); + await page.mouse('mousePressed', thumbX, railY, 1); + for (const x of [thumbX - 30, thumbX - 80, outside]) { await page.move(x, railY, 1); await t.sleep(30); } + st = await state(); + t.check('拖动中不提交(select 调用 0 次)', st.calls === 0, 'calls=' + st.calls); + t.check('拖动中拇指跟随指针(越界夹到 0)', st.pos === '0', 'pos=' + st.pos); + await page.mouse('mouseReleased', outside, railY, 0); + await t.sleep(120); + st = await state(); + t.check('松手提交一次、对齐到最左档 off', st.calls === 1 && st.lastCall?.reasoningEffort === 'off', JSON.stringify(st.lastCall)); + t.check('往返中(selecting)轨停在新档、不回弹', st.status === 'selecting' && st.now === '0' && st.pos === '0', 'status=' + st.status + ' now=' + st.now); + t.check('往返中列表不清空', st.rows === 3, 'rows=' + st.rows); + t.check('往返中档位名旁转圈、触发器已按新档显示', st.spinner && st.trigEffort === 'Off', 'spinner=' + st.spinner + ' trig=' + st.trigEffort); + await t.sleep(800); + st = await state(); + t.check('往返结束:转圈撤掉、弹层仍开着(改档不关)', !st.spinner && st.open && st.now === '0' && st.value === 'Off', JSON.stringify({ spinner: st.spinner, open: st.open, value: st.value })); + + /* 4. 键盘 */ + await page.evaluate(() => document.querySelector('.codex-mp-root').focus()); + await page.key('ArrowRight', 39); + await t.sleep(700); + st = await state(); + t.check('→ 进一档(low)', st.lastCall.reasoningEffort === 'low' && st.now === '1', JSON.stringify(st.lastCall)); + t.check('键盘焦点:拇指上 2px 焦点环(offset 0)', st.kbd === 'true' && /2px/.test(st.thumbOutline) && /solid/.test(st.thumbOutline), st.thumbOutline); + await page.key('End', 35); + await t.sleep(700); + st = await state(); + t.check('End 到最后一档(max)', st.lastCall.reasoningEffort === 'max' && st.now === '3', JSON.stringify(st.lastCall)); + const calls = st.calls; + await page.key('End', 35); + await t.sleep(200); + st = await state(); + t.check('同一档不重复提交', st.calls === calls, 'calls ' + calls + ' → ' + st.calls); + await page.key('Home', 36); + await t.sleep(700); + st = await state(); + t.check('Home 回第一档(off)', st.lastCall.reasoningEffort === 'off' && st.now === '0', JSON.stringify(st.lastCall)); + + /* 5. 失败:宿主把会话占用报回来 */ + await page.evaluate(() => { window.__dir.fail = { code: 'session/writer-held', message: 'held' }; }); + await page.key('ArrowRight', 39); + await t.sleep(800); + st = await state(); + t.check('提交失败:弹层顶上出现占用提示', st.error !== null && /(已被占用|already in use)/.test(st.error), st.error); + t.check('提交失败:轨退回生效档', st.now === '0', 'now=' + st.now); + + /* 6. Escape / 换模型 */ + await page.key('Escape', 27); + await t.sleep(150); + st = await state(); + t.check('Escape 关闭并把焦点还给触发器', !st.open && st.expanded === 'false' && st.focus === 'codex-mp-trigger', JSON.stringify({ open: st.open, focus: st.focus })); + await page.click(tx, ty); + await t.sleep(500); + const [rx, ry] = await page.evaluate(() => { + const r = document.querySelectorAll('.codex-mp-row')[1].getBoundingClientRect(); + return [Math.round(r.left + r.width / 2), Math.round(r.top + r.height / 2)]; + }); + await page.click(rx, ry); + await t.sleep(120); + const rowBusy = await page.evaluate(() => { + const r = document.querySelectorAll('.codex-mp-row')[1]; + return { busy: r.getAttribute('aria-busy'), spin: r.querySelector('.codex-mp-spinner') !== null }; + }); + t.check('换模型往返中:那一行行尾转圈', rowBusy.busy === 'true' && rowBusy.spin, JSON.stringify(rowBusy)); + await t.sleep(800); + st = await state(); + t.check('换模型连带该模型的默认档提交(pro → max)', st.lastCall.model === 'deepseek-v4-pro' && st.lastCall.reasoningEffort === 'max', JSON.stringify(st.lastCall)); + t.check('换模型成功后关弹层、触发器换名', !st.open && st.trigModel === 'DeepSeek-V4-Pro' && st.trigEffort === 'Max', st.trigModel + ' · ' + st.trigEffort); + + /* 6.5 顶档点阵(形态基准里的 apex)──────────────────────────────────── + 形态基准 dsh-claude-style 的 .dsh-claude-effort-matrix:5 行方块、坐标 hash 散开相位, + 左端溶回裸槽、右端实心;拉到最右端即触发,不等松手。 */ + await page.click(tx, ty); + await t.sleep(400); + const mx = await page.evaluate(() => { + const cs = (s, prop) => { const e = document.querySelector(s); return e ? getComputedStyle(e).getPropertyValue(prop) : null; }; + const root = document.querySelector('.codex-mp-root'); + const sqs = [...document.querySelectorAll('.codex-mp-matrix-sq')]; + const opac = sqs.map((e) => Number(e.style.opacity)); + return { tier: root.dataset.tier ?? null, boxTier: document.querySelector('.codex-mp-effort').dataset.tier ?? null, + display: cs('.codex-mp-matrix', 'display'), cells: document.querySelectorAll('.codex-mp-matrix-cell').length, + tones: new Set(sqs.map((e) => e.dataset.tone)).size, delays: new Set(sqs.map((e) => e.style.animationDelay)).size, + fadeL: Math.min(...opac), fadeR: Math.max(...opac), + flash: cs('.codex-mp-matrix-sq', 'animation-name'), flashDur: cs('.codex-mp-matrix-sq', 'animation-duration'), + cellIn: cs('.codex-mp-matrix-cell', 'animation-name'), + fillOpacity: cs('.codex-mp-range', 'opacity'), tickOpacity: cs('.codex-mp-tick', 'opacity'), + thumbBg: cs('.codex-mp-thumb', 'background-color'), thumbShadow: cs('.codex-mp-thumb', 'box-shadow'), + valueColor: cs('.codex-mp-value-text', 'color') }; + }); + t.check('顶档:点阵亮起(display:grid、5 行 × N 格)', mx.tier === 'max' && mx.boxTier === 'max' && mx.display === 'grid' && mx.cells > 40 && mx.cells % 5 === 0, JSON.stringify({ tier: mx.tier, display: mx.display, cells: mx.cells })); + t.check('顶档:填充与刻度让位给点阵', mx.fillOpacity === '0' && mx.tickOpacity === '0', mx.fillOpacity + ' / ' + mx.tickOpacity); + t.check('顶档:每格相位 hash 散开、色调 8 档', mx.delays > 20 && mx.tones === 8, 'delays=' + mx.delays + ' tones=' + mx.tones); + t.check('顶档:左端溶回裸槽、右端实心(羽化)', mx.fadeL < 0.2 && mx.fadeR === 1, mx.fadeL + ' → ' + mx.fadeR); + /* 每块的周期由 hash 散开(1.45s × [0.92, 1.08]),所以量的是散相带而不是一个定值。 */ + t.check('顶档:方块闪动 + 入场(周期在 1.45s 的散相带里)', mx.flash === 'codex-mp-flash' && mx.cellIn === 'codex-mp-cell-in' && Number.parseFloat(mx.flashDur) >= 1.33 && Number.parseFloat(mx.flashDur) <= 1.57, JSON.stringify({ flash: mx.flash, dur: mx.flashDur, in: mx.cellIn })); + t.check('顶档:旋钮染紫 + 光晕', /10px/.test(mx.thumbShadow) && mx.thumbBg !== 'rgb(255, 255, 255)', mx.thumbBg + ' | ' + mx.thumbShadow); + t.check('顶档:档位名转紫(--codex-mp-apex-ink #5f51b5)', mx.valueColor === 'rgb(95, 81, 181)', mx.valueColor); + const rb = await page.evaluate(() => { const b = document.querySelector('.codex-mp-root').getBoundingClientRect(); return { left: b.left + 8, right: b.right - 8, cy: b.top + b.height / 2 }; }); + await page.mouse('mousePressed', rb.left, rb.cy, 1); + await t.sleep(60); + const atLeft = await page.evaluate(() => { const r = document.querySelector('.codex-mp-root'); return { tier: r.dataset.tier ?? null, display: getComputedStyle(document.querySelector('.codex-mp-matrix')).display }; }); + await page.move(rb.right, rb.cy, 1); + await t.sleep(60); + const atRight = await page.evaluate(() => { const r = document.querySelector('.codex-mp-root'); return { tier: r.dataset.tier ?? null, drag: r.dataset.dragging ?? null, display: getComputedStyle(document.querySelector('.codex-mp-matrix')).display }; }); + await page.mouse('mouseReleased', rb.right, rb.cy, 0); + await t.sleep(700); + t.check('拖到最左端:点阵收起(判据是连续比例,不是生效档)', atLeft.tier === null && atLeft.display === 'none', JSON.stringify(atLeft)); + t.check('拖到最右端:松手前点阵就已亮', atRight.tier === 'max' && atRight.drag === 'true' && atRight.display === 'grid', JSON.stringify(atRight)); + /* 收尾把弹层关掉:下一节假定弹层是关着的。 */ + await page.key('Escape', 27); + await t.sleep(200); + + /* 6.6 往返窗口内切回原档:全程不回弹 ──────────────────────────────────── + 用户报的现象:high→max 之后有一段加载期,这期间怎么滑都回弹,必须等加载完。 + 这里把窗口拉长到夹具的 600ms,先在窗口内切走、再切回来,逐帧看生效档有没有被拽回。 */ + await page.click(tx, ty); + await t.sleep(400); + const rb2 = await page.evaluate(() => { const b = document.querySelector('.codex-mp-root').getBoundingClientRect(); return { left: b.left + 8, right: b.right - 8, cy: b.top + b.height / 2 }; }); + const midX = rb2.left + (rb2.right - rb2.left) / 3; + /* 第一次:切到 low(往返 600ms 开始) */ + await page.mouse('mousePressed', midX, rb2.cy, 1); + await page.mouse('mouseReleased', midX, rb2.cy, 0); + await t.sleep(80); + /* 窗口内:切回 max */ + await page.mouse('mousePressed', rb2.right, rb2.cy, 1); + await page.move(rb2.right, rb2.cy, 1); + await page.mouse('mouseReleased', rb2.right, rb2.cy, 0); + const trace = []; + for (let i = 0; i < 22; i += 1) { + await t.sleep(50); + trace.push(await page.evaluate(() => { const r = document.querySelector('.codex-mp-root'); const v = document.querySelector('.codex-mp-value-text'); return (r.getAttribute('aria-valuenow') ?? '-') + ':' + (v ? v.textContent : '?'); })); + } + const strayed = trace.filter((s) => !s.startsWith('3:')); + t.check('往返窗口内切回原档:全程停在目标档(不回弹)', strayed.length === 0, trace.join(' ')); + await page.key('Escape', 27); + await t.sleep(200); + + /* 7. reduced-motion */ + await page.media({ 'prefers-reduced-motion': 'reduce' }); + await page.click(tx, ty); + await t.sleep(300); + const rm = await page.evaluate(() => { + const p = document.querySelector('.codex-mp-popover'); + const root = document.querySelector('.codex-mp-root'); + return { attr: p.dataset.reducedMotion, anim: getComputedStyle(p).animationName, thumb: getComputedStyle(document.querySelector('.codex-mp-thumb-scale')).transitionDuration, + tier: root.dataset.tier ?? null, matrix: getComputedStyle(document.querySelector('.codex-mp-matrix-sq')).animationName, cell: getComputedStyle(document.querySelector('.codex-mp-matrix-cell')).animationName }; + }); + t.check('reduced-motion:入场动画取消、拇指不过渡', rm.attr === 'true' && rm.anim === 'none' && /^0s|^1e-05s|0\.00001s/.test(rm.thumb), JSON.stringify(rm)); + t.check('reduced-motion:顶档点阵与入场都停(两条口子都接上了)', rm.tier === 'max' && rm.matrix === 'none' && rm.cell === 'none', JSON.stringify({ tier: rm.tier, matrix: rm.matrix, cell: rm.cell })); + /* 鼠标打开后焦点在触发器上:Escape 也得关(宿主的键盘处理挂在整格根上)。 */ + const focusBefore = await page.evaluate(() => document.activeElement?.className ?? null); + await page.key('Escape', 27); + await t.sleep(150); + st = await state(); + t.check('鼠标打开后(焦点在触发器)Escape 也能关', focusBefore === 'codex-mp-trigger' && !st.open, 'focus=' + focusBefore + ' open=' + st.open); + await page.media({ 'prefers-reduced-motion': 'no-preference' }); + + /* 8. 深色 */ + await page.evaluate(() => document.body.setAttribute('data-ds-dark-theme', '')); + await page.click(tx, ty); + await t.sleep(500); + const dark = await page.evaluate(() => { + const p = getComputedStyle(document.querySelector('.codex-mp-popover')); + const th = getComputedStyle(document.querySelector('.codex-mp-thumb')); + const root = document.querySelector('.codex-mp-root'); + const groove = getComputedStyle(document.querySelector('.codex-mp-track'), '::before'); + const sq = document.querySelector('.codex-mp-matrix-sq'); + return { shadow: p.boxShadow, bg: p.backgroundColor, thumb: th.backgroundColor, thumbShadow: th.boxShadow, + grooveBg: groove.backgroundColor, grooveH: groove.height, apex: getComputedStyle(root).getPropertyValue('--codex-mp-apex').trim(), + sqBg: sq ? getComputedStyle(sq).backgroundColor : null }; + }); + st = await state(); + t.check('深色下弹层打开', st.open && near(st.pop.w, 254), 'open=' + st.open); + t.check('深色:菜单影 50% 黑 + 5% 亮环(暗色靠填充分层)', /rgba\(0, 0, 0, 0\.5\) 0px 8px 32px/.test(dark.shadow) && dark.bg === 'rgb(33, 33, 33)', JSON.stringify(dark)); + t.check('深色:槽仍是 26px/8px 圆角矩形、底色走主题变量', dark.grooveH === '26px' && dark.grooveBg !== 'rgba(0, 0, 0, 0)', dark.grooveH + ' / ' + dark.grooveBg); + t.check('深色:顶档紫换成暗色那一套 #9d8ce0,方块吃到它', dark.apex === '#9d8ce0' && /157, 140, 224|0\.6/.test(dark.sqBg ?? ''), dark.apex + ' | ' + dark.sqBg); + t.check('深色:顶档旋钮是紫色染片 + 光晕(不是白片)', dark.thumb !== 'rgb(255, 255, 255)' && /10px/.test(dark.thumbShadow), dark.thumb + ' | ' + dark.thumbShadow); + await t.shot(page, 'power-rail-verify-dark.png', { x: st.pop.x - 20, y: st.pop.y - 20, width: st.pop.w + 40, height: st.trig.b - st.pop.y + 40, scale: 1 }); + await page.key('Escape', 27); + await page.evaluate(() => document.body.removeAttribute('data-ds-dark-theme')); + + /* 9. 开关 */ + const seatNow = () => page.evaluate(() => ({ ours: document.querySelectorAll('.codex-mp-trigger').length, host: getComputedStyle(document.querySelector('._7KE1Ra_root')).display })); + await page.evaluate(() => window.__picker.setEnabled(false)); + await t.sleep(100); + const off = await seatNow(); + t.check('关掉:自建触发器撤走、宿主那一格复原', off.ours === 0 && off.host !== 'none', JSON.stringify(off)); + await page.evaluate(() => window.__picker.setEnabled(true)); + await t.sleep(100); + const on = await seatNow(); + t.check('再打开:重新接管', on.ours === 1 && on.host === 'none', JSON.stringify(on)); + /* React 换掉宿主子节点:标记打在席位出口上,不在宿主子节点上,换掉子节点不会丢。 */ + await page.evaluate(() => { + const slot = document.querySelector('[data-conversation-session="session-a"] [data-slot="conversation.input.model"]'); + const fresh = document.createElement('div'); + fresh.className = '_7KE1Ra_root'; + fresh.innerHTML = ''; + slot.querySelector('._7KE1Ra_root').replaceWith(fresh); + }); + await t.sleep(50); + const swapped = await seatNow(); + t.check('宿主换掉自己的子节点后仍隐藏、不闪回', swapped.host === 'none' && swapped.ours === 1, JSON.stringify(swapped)); + t.check('页面没有未捕获异常', page.errors.length === 0, page.errors.join(' | ') || '0'); +} + +export default { 'host-menu': hostMenu, 'power-rail': powerRail }; diff --git a/scripts/specs/rightbar.mjs b/scripts/specs/rightbar.mjs new file mode 100644 index 0000000..6c81ecb --- /dev/null +++ b/scripts/specs/rightbar.mjs @@ -0,0 +1,218 @@ +/** + * 右栏(文件 / 浏览器 / 终端)三件套: + * ① 面板顶沿边框阴影(与中列同源); + * ② 左边框淡细(压掉宿主 dockkit 的 1px l4,边界只剩中列环)+ 分界线拖拽柄悬停:2px 纵向渐变; + * ③ 空栏 guide「展开选择组件」:平行(无胶囊)、图标 20、无描述、快捷键灰底 pill。 + * 宿主样式取 ui-theme / layout / sidebar-right / sidebar-terminal / primitives,dockkit 在前端主样式表里。 + * before 页 = 皮肤剥掉 ⑯ 段与 window-shadow 的右栏段,另把中列 / 面板阴影压成 none,只作对照。 + */ +import { cssAfter, cssFor, frontendCss, mapFor, themeLayers } from '../lib/host.mjs'; + +function probeRightbar() { + const cs = (el, p) => (el ? getComputedStyle(el, p) : null); + const q = (s) => document.querySelector(s); + const panel = q('[data-sidebar-right-panel]'); + const handle = q('[data-side="rightbar"]'); + const handleLeft = q('[data-side="sidebar"]'); + const entry = q('[data-sidebar-right-guide-entry]'); + const desc = entry.querySelectorAll('span:nth-child(2) > span'); + const keys = entry.lastElementChild; + const toggle = q('[data-sidebar-right-toggle]'); + const pane = q('[data-dockkit-host="dock"] [data-dockkit-pane]'); + const side = cs(q('[data-slot=sidebar]').parentElement); + const term = (() => { + const row = q('[data-sidebar-right-guide-entry="terminal"]'); + if (!row) return { missing: true }; + const icon = row.querySelector(':scope > span:nth-child(2)'); + const text = row.querySelector(':scope > span:nth-child(3)'); + const title = text?.querySelector(':scope > span:nth-child(1) > span:first-child'); + const chev = row.querySelector('button[aria-haspopup="menu"]'); + const r = row.getBoundingClientRect(); + const tr = title?.getBoundingClientRect(); + const files = q('[data-sidebar-right-guide-entry="workspace"]'); + return { + border: cs(row).borderTopWidth, bg: cs(row).backgroundColor, + iconBox: cs(icon).width, iconSvg: icon.querySelector('svg') ? cs(icon.querySelector('svg')).width : 'none', + desc: text?.querySelector(':scope > span:nth-child(2)') ? cs(text.querySelector(':scope > span:nth-child(2)')).display : 'absent', + chev: chev ? cs(chev).display + '/' + Math.round(chev.getBoundingClientRect().width) : 'missing', + titleCenterDelta: tr ? Math.round((tr.top + tr.height / 2 - (r.top + r.height / 2)) * 10) / 10 : 'n/a', + rowH: Math.round(r.height * 10) / 10, filesRowH: files ? Math.round(files.getBoundingClientRect().height * 10) / 10 : -1, + keysH: row.querySelector(':scope > span:has(kbd)') ? cs(row.querySelector(':scope > span:has(kbd)')).height : 'missing', + }; + })(); + const line = (el) => ({ opacity: cs(el, '::before').opacity, width: cs(el, '::before').width, grad: /linear-gradient/.test(cs(el, '::before').backgroundImage), bg: cs(el, '::before').backgroundImage, content: cs(el, '::before').content }); + return { + panelShadow: cs(panel).boxShadow !== 'none', + panelBox: cs(panel).boxShadow, + panelTop: Math.round(panel.getBoundingClientRect().top), + panelH: Math.round(panel.getBoundingClientRect().height), + centerBox: cs(q('[data-slot=main]').parentElement).boxShadow, + paneBorder: cs(pane).borderLeftWidth + ' ' + cs(pane).borderLeftColor, + handleBefore: line(handle), + handleLeftBefore: line(handleLeft), + hero: cs(q('[data-sidebar-right-guide] > span[aria-hidden]')).display, + after: cs(q('[data-sidebar-right-guide]'), '::after').display, + entry: { border: cs(entry).borderTopWidth, bg: cs(entry).backgroundColor, minH: cs(entry).minHeight, radius: cs(entry).borderTopLeftRadius, width: cs(entry).width }, + iconBox: cs(entry.querySelector('span:first-child')).width, + desc: desc.length > 1 ? cs(desc[1]).display : 'absent', + keys: { h: cs(keys).height, radius: cs(keys).borderTopLeftRadius, bg: cs(keys).backgroundColor }, + toggle: { bg: cs(toggle).backgroundColor, radius: cs(toggle).borderTopLeftRadius }, + sidebarBorder: side.borderRightColor + '|' + side.borderRightWidth + '|' + side.borderRightStyle, + term, + }; +} + +/** 宿主样式:令牌层 + AppFrame + SidebarRight + GuideBody + ShortcutKeys + dockkit + Button + TerminalGuide,外加类名映射。 */ +function hostParts(host) { + const layout = host.file('@deepseek-ai/dsh-client-ui-layout/lib/client.js'); + const right = host.file('@deepseek-ai/dsh-client-ui-sidebar-right/lib/client.js'); + const terminal = host.file('@deepseek-ai/dsh-client-ui-sidebar-terminal/lib/client.js'); + const prim = (f) => host.file('@deepseek-ai/dsh-client-ui-primitives/lib/' + f); + /* dockkit 的类名形如 _tabCell_<哈希>_<行号>,哈希随构建变:先从 tabCell 认出哈希,再按它收规则。 */ + const front = frontendCss(host); + const hash = /\._tabCell_(\w+?)_\d+/.exec(front)?.[1]; + if (hash === undefined) throw new Error('前端样式表里找不到 dockkit 的 _tabCell_ 类名'); + const dockCss = [...front.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => m[1].includes('_' + hash + '_')).map((m) => m[0]).join('\n'); + const dock = (key) => { + const m = new RegExp('\\._' + key + '_([\\w-]+)').exec(dockCss); + if (m === null) throw new Error('dockkit 里没有类名 ' + key); + return '_' + key + '_' + m[1]; + }; + const css = [ + themeLayers(host), + cssFor(layout, '@deepseek-ai/dsh-client-ui-layout/AppFrame.module.css'), + cssFor(right, '@deepseek-ai/dsh-client-ui-sidebar-right/SidebarRight.module.css'), + cssFor(right, '@deepseek-ai/dsh-client-ui-sidebar-right/GuideBody.module.css'), + prim('ShortcutKeys.module.css').replace(/^\s*}\s*/, ''), + dockCss, + prim('Button.module.css').replace(/^[\s\S]*?(?=\/\*|\.)/, ''), + cssAfter(terminal, 'TerminalGuide.module.css.mjs'), + ].map((text) => '').join(''); + return { + css, + F: mapFor(layout, 'AppFrame_module_css_default'), + P: mapFor(right, 'SidebarRight_module_css_default'), + G: mapFor(right, 'GuideBody_module_css_default'), + T: mapFor(terminal, 'TerminalGuide_module_css_default'), + D: Object.fromEntries(['tabCell', 'tabHost', 'tabHostHeader', 'tabHostBody', 'stripTabs', 'stripFill', 'stripChrome', 'pane'].map((k) => [k, dock(k)])), + }; +} + +async function rightbar(t) { + const { css, F, P, G, T, D } = hostParts(t.host); + const svg = (d, size = 16) => ``; + const entry = (icon, title, desc, combo, kind) => `
`; + /* TerminalGuide(terminal 插件注册的自定义卡):button.main 绝对定位点击层在前,子序 button→icon→text(titleRow+description)→keys。 */ + const termEntry = `
+ + + + +Ctrl+\`
`; + const stage = `
+
+
+
+
+
+
+
+
+
开始
+
+
+
+ +${entry('M1.5 4.5h4l1.2 1.5h7.8v7.5h-13z', '工作区文件', '浏览会话工作区的文件', 'Ctrl+P', 'workspace')} +${entry('M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13M1.5 8h13M8 1.5c2 1.8 3 4 3 6.5s-1 4.7-3 6.5c-2-1.8-3-4-3-6.5s1-4.7 3-6.5', '浏览器', '浏览网页', 'Ctrl+T', 'browser')} +${termEntry} +
+
+
+
`; + const html = (theme, titlebar = true) => `${css} + +${stage}`; + /* before 页:剥掉 ⑯ 段与 window-shadow 的右栏段,再把中列的发丝线 + 环境影压成 none —— 本组修复项在对照上必须有真实差异。 */ + const before = t.theme({ 'patches.css': [['⑯ 右栏展开引导(', '⑰ composer 底部控件(']], 'window-shadow.css': [['右栏面板:左沿只留 l1 发丝线', null]] }) + /* 中列那支阴影 0.6.3 起是 0 0 13px @7%(亮)/ 0 0 24px @50%(暗):两个值都要剥掉, + 否则 before 页还带着影,本组的负对照就是空的。 */ + .replace(/box-shadow:\s*0 0 0 0\.5px var\(--dsw-alias-border-l2\),\s*0 0 (13px rgba\(13, 13, 13, 0\.07\)|24px rgba\(0, 0, 0, 0\.5\));/g, 'box-shadow: none;'); + + const open = async (theme, titlebar) => { + const page = await t.page({ width: 1000, height: 780, dpr: 2 }); + await page.setContent(html(theme, titlebar), 600); + return page; + }; + const pb = await open(before); + const pa = await open(t.theme()); + const was = await pb.evaluate(probeRightbar); + const idle = await pa.evaluate(probeRightbar); + /* 悬停左分界线柄(8px 骑跨 300 分界)→ 读 ::before;再悬停右分界线柄(骑跨 526,取柄内偏右一点避开中列右沿)。 */ + await pa.move(300, 390); + await t.sleep(500); + const hoverLeft = await pa.evaluate(probeRightbar); + await pa.move(528, 390); + await t.sleep(500); + const hovered = await pa.evaluate(probeRightbar); + /* web 页最后开:headless 单窗口只有前台页签处理 :hover,先开会抢走 after 页的前台态。 */ + const pw = await open(t.theme(), false); + const web = await pw.evaluate(probeRightbar); + const after = idle; + t.log('BEFORE ' + JSON.stringify(was)); + t.log('IDLE ' + JSON.stringify(idle)); + t.log('HOVER-L ' + JSON.stringify(hoverLeft.handleLeftBefore) + ' HOVER-R ' + JSON.stringify(hovered.handleBefore)); + + t.check('面板有边框阴影', after.panelShadow === true, after.panelBox); + t.check('面板左沿 0.5px l1 发丝线', /rgba\(13, 13, 13, 0\.07\) 0px 0px 0px 0\.5px/.test(after.panelBox), after.panelBox); + t.check('面板左沿不糊影(影只往上泄)', /rgba\(13, 13, 13, 0\.05\) 0px -12px 24px -12px/.test(after.panelBox), after.panelBox); + t.check('面板顶沿贴标题栏条下沿', after.panelTop === 36, after.panelTop); + t.check('面板撑满列高(塌高即回归)', after.panelH === 720, after.panelH); + t.check('中列 0.5px l2 发丝线', /rgba\(13, 13, 13, 0\.12\) 0px 0px 0px 0\.5px/.test(after.centerBox), after.centerBox); + /* 0.6.3 起环境影按 Codex 实测拟合(SSE 47 -> 22),判据随之改写: + 旧值 0 0 24px @5% 是照抄源码 token 的那一支,实测偏散偏浅。 */ + t.check('中列全向环境影 13px @7%(0.6.3 实测拟合;旧值 24px @5%)', after.centerBox.includes('rgba(13, 13, 13, 0.07) 0px 0px 13px'), after.centerBox); + t.check('栏内 pane 的 1px l4 深边框被压掉', after.paneBorder === '0px rgb(0, 0, 0)' && was.paneBorder === '1px rgba(13, 13, 13, 0.24)', after.paneBorder + ' / 对照 ' + was.paneBorder); + t.check('右栏悬停线默认隐藏', idle.handleBefore.opacity === '0', idle.handleBefore.opacity); + t.check('右栏悬停线出现', hovered.handleBefore.opacity === '1', hovered.handleBefore.opacity); + t.check('右栏悬停线宽 2px', hovered.handleBefore.width === '2px', hovered.handleBefore.width); + t.check('右栏悬停线纵向渐变', hovered.handleBefore.grad === true); + t.check('左分界线无悬停线(静默)', idle.handleLeftBefore.content === 'none' && idle.handleLeftBefore.bg === 'none', idle.handleLeftBefore.content); + t.check('左分界线无悬停线(悬停仍是静默)', hoverLeft.handleLeftBefore.content === 'none' && hoverLeft.handleLeftBefore.bg === 'none', hoverLeft.handleLeftBefore.content); + t.check('罗盘 hero 隐藏', after.hero === 'none', after.hero); + t.check('底部占位移除', after.after === 'none', after.after); + t.check('条目无描边', after.entry.border === '0px', after.entry.border); + t.check('条目无底色', /rgba\(0, 0, 0, 0\)|transparent/.test(after.entry.bg), after.entry.bg); + t.check('条目行高 52', after.entry.minH === '52px', after.entry.minH); + t.check('条目圆角 12', after.entry.radius === '12px', after.entry.radius); + t.check('条目宽 380', after.entry.width === '380px', after.entry.width); + t.check('图标 20', after.iconBox === '20px', after.iconBox); + t.check('描述行隐藏', after.desc === 'none', after.desc); + t.check('快捷键 pill 高 24', after.keys.h === '24px', after.keys.h); + t.check('快捷键 pill 圆角 12', after.keys.radius === '12px', after.keys.radius); + t.check('快捷键 pill 灰底', after.keys.bg === 'rgb(241, 241, 239)', after.keys.bg); + t.check('收起按钮灰底', after.toggle.bg === 'rgb(241, 241, 239)', after.toggle.bg); + t.check('收起按钮圆角 12', after.toggle.radius === '12px', after.toggle.radius); + t.check('终端行无描边', after.term.border === '0px', after.term.border); + t.check('终端行无底色', /rgba\(0, 0, 0, 0\)|transparent/.test(after.term.bg), after.term.bg); + t.check('终端行小字隐藏', after.term.desc === 'none', after.term.desc); + t.check('终端图标 20', after.term.iconBox === '20px', after.term.iconBox); + t.check('终端图标 svg 20', after.term.iconSvg === '20px', after.term.iconSvg); + t.check('终端标题垂直居中', Math.abs(after.term.titleCenterDelta) <= 1, after.term.titleCenterDelta); + t.check('终端行高与标准行一致', after.term.rowH === after.term.filesRowH, after.term.rowH + ' / ' + after.term.filesRowH); + t.check('选 Shell chevron 保留', /^flex\/20|^block\/20|inline-flex\/20/.test(after.term.chev), after.term.chev); + t.check('终端快捷键 pill 高 24', after.term.keysH === '24px', after.term.keysH); + t.check('web 中列全向环境影 13px @7%(与桌面壳同值)', web.centerBox.includes('rgba(13, 13, 13, 0.07) 0px 0px 13px'), web.centerBox); + t.check('web 中列 0.5px l2 发丝线', /rgba\(13, 13, 13, 0\.12\) 0px 0px 0px 0\.5px/.test(web.centerBox), web.centerBox); + t.check('web 侧栏边界 l1 细线', /rgba\(13, 13, 13, 0\.07\)\|(0\.5|1)px\|solid/.test(web.sidebarBorder), web.sidebarBorder); + t.check('web 面板上沿影(负 spread)', /rgba\(13, 13, 13, 0\.05\) 0px -12px 24px -12px/.test(web.panelBox), web.panelBox); + t.check('web 面板撑满列高', web.panelH === 720, web.panelH); + + await t.shot(pb, 'rightbar-before.png'); + await t.shot(pa, 'rightbar-after.png'); + await t.shot(pw, 'rightbar-web.png'); +} + +export default { rightbar }; diff --git a/scripts/specs/sidebar.mjs b/scripts/specs/sidebar.mjs new file mode 100644 index 0000000..e7103c6 --- /dev/null +++ b/scripts/specs/sidebar.mjs @@ -0,0 +1,219 @@ +/** + * 侧栏两件事,宿主样式都取 dsh-client-ui-sidebar 的 SidebarRoot 与 workspace 的 WorkspaceBrowser / Rows。 + * align 「新会话 / 插件」行 ↔「工作区」下方行的图标列 / 文字列对齐(getBoundingClientRect)。 + * surface 滚动渐隐从宿主的 24px 覆盖层换成 Codex 的 40px mask 斜坡:① 机制(mask-image / 覆盖层 display); + * ② 观感:把底部横带截下来逐像素解码,还原遮罩 alpha 曲线。同一份 DOM 上切 html[data-codex-ui] + * 得到原生 / 皮肤两态,每态再分不透明 / 半透明侧栏底两列。 + */ +import { cssFor, mapFor, themeLayers } from '../lib/host.mjs'; + +function sidebarHost(host) { + const sidebar = host.file('@deepseek-ai/dsh-client-ui-sidebar/lib/client.js'); + const workspace = host.file('@deepseek-ai/dsh-client-ui-workspace/lib/client.js'); + const sidebarCss = cssFor(sidebar, '@deepseek-ai/dsh-client-ui-sidebar/SidebarRoot.module.css'); + return { + sidebarCss, + css: '', + S: mapFor(sidebar, 'SidebarRoot_module_css_default'), + W: mapFor(workspace, 'WorkspaceBrowser_module_css_default'), + R: mapFor(workspace, 'Rows_module_css_default'), + }; +} + +const svg = (size) => ``; + +/* ── 列对齐 ────────────────────────────────────────────────────────────── */ + +/** 左列关掉插件样式表读一遍(原生),右列开着读一遍;位置相对侧栏根 div 左边缘。 */ +async function probeAlign(cls) { + const plugin = document.getElementById('codex-ui-theme'); + const q = (c) => '.' + CSS.escape(c); + const rect = (root, sel) => { + const el = root.querySelector(sel); + if (el === null) return null; + const r = el.getBoundingClientRect(); + const base = root.getBoundingClientRect(); + return { l: +(r.left - base.left).toFixed(2), r: +(r.right - base.left).toFixed(2) }; + }; + const read = (root) => ({ + newSessionIcon: rect(root, q(cls.S.newSession) + ' svg'), + newSessionText: rect(root, q(cls.S.newSessionLabel)), + panelIcon: rect(root, q(cls.S.panelGlyph)), + panelText: rect(root, q(cls.S.panelTitle)), + headerLabel: rect(root, q(cls.W.sectionLabel)), + projectIcon: rect(root, q(cls.R.projectRow) + ' ' + q(cls.R.folder)), + projectText: rect(root, q(cls.R.projectRow) + ' ' + q(cls.R.title)), + sessionText: rect(root, q(cls.R.sessionRow) + ' ' + q(cls.R.title)), + }); + const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + plugin.disabled = true; + await frame(); + const before = read(document.querySelector('.plain')); + plugin.disabled = false; + await frame(); + return { before, after: read(document.querySelectorAll('.col')[1]) }; +} + +async function align(t) { + const { css, sidebarCss, S, W, R } = sidebarHost(t.host); + /* rc.2 的新会话按钮多一层 newSessionLabelMask(button > mask > content > svg + label),rc.1 是扁平的。 */ + const masked = /newSessionLabelMask/.test(sidebarCss); + t.log('New Session DOM:' + (masked ? 'rc.2 嵌套' : 'rc.1 扁平')); + const NEWCHAT = ''; + const FOLDER = ''; + const label = NEWCHAT + `新会话`; + const group = (name, withSession) => `
+
${FOLDER}${svg(16)}${name}
+${withSession ? `
/ui-ux-pro-max dsh 模型选择器10分钟
` : ''} +
`; + /* 侧栏外壳 + 工作区浏览器,结构照 shipped 渲染代码,类名取自真实 CSS-Modules 映射;外面是 sidebar 席位出口(display:contents)。 */ + const stage = `
+
${svg(24)}deepseek
+ + +
+
工作区
+
${group('PROJIECT', true)}${group('未分组', false)}
+
+
+
`; + const page = await t.page({ width: 1300, height: 900, dpr: 2 }); + /* 左联要「无 codex-ui」:插件样式表加载后 disabled 掉即回到原生。 */ + await page.setContent(`${css} + +

原生 shipped(无 codex-ui)

${stage}

+ codex-ui theme.css

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

${id}

+
+
deepseek
+ +
+
工作区
+
+${Array.from({ length: 20 }, (_, i) => `
row ${i}
`).join('')} +
+
+
`; + /* 夹具专用:行间距归零 + 滚动容器 padding-bottom 归零 + 滚动条常驻,让底部横带的像素读数只反映遮罩 alpha。 + 遮罩挂在滚动容器上、覆盖层挂在它的父盒上,两者几何都不受这几条影响。 */ + const fixtureCss = `.${W.groupSection} > * + * { margin-top: 0 } .${W.list} { padding-bottom: 0; overflow-y: scroll } +body{margin:0;background:#ffffff;font-family:"Segoe UI","Microsoft YaHei",sans-serif}.wrap{display:flex;gap:20px;padding:16px;align-items:flex-start} +.col > h4{margin:0 0 6px;font:600 12px/16px ui-monospace,Consolas,monospace;color:#8a8a8a}`; + /* html 上先不挂 data-codex-ui:皮肤全部规则都挂在这个属性下,摘掉即原生。 */ + const page = await t.page({ width: 900, height: 760, dpr: 1 }); + await page.setContent(`${css} +
${column('A', false)}${column('At', true)}
`, 900); + + /** 切皮肤开关,读探针,再截滚动视口底部 60px 的横带并解码。 */ + const measure = async (skinOn, bottom) => { + await page.evaluate((on) => document.documentElement.toggleAttribute('data-codex-ui', on), skinOn); + await page.frame(); + const styles = await page.evaluate(probeSurface); + return { styles, img: bottom === null ? null : await page.pixels({ x: 0, y: Math.round(bottom - 60), width: 900, height: 60, scale: 1 }) }; + }; + const first = await measure(false, null); + const BOTTOM = first.styles.A.list.y + first.styles.A.list.h; + const native = await measure(false, BOTTOM); + const skinned = await measure(true, BOTTOM); + + /** 横带里一列(内容区 x+60..x+120)逐行平均亮度(Rec.601),y = 距滚动视口底边的像素数。 */ + const band = (shot, column) => { + const { x } = shot.styles[column].list; + const rows = []; + for (let i = 0; i < 60; i += 1) { + let sum = 0; + for (let px = Math.round(x + 60); px < Math.round(x + 120); px += 1) { + const [r, g, b] = shot.img.rgb(px, i); + sum += 0.299 * r + 0.587 * g + 0.114 * b; + } + rows.push({ y: 59 - i, lum: sum / (Math.round(x + 120) - Math.round(x + 60)) }); + } + /* 由亮度反推遮罩 alpha:alpha = (bg − lum) / (bg − ink),bg / ink 用同一条曲线自标定。 */ + const bg = Math.max(...rows.map((r) => r.lum)); + const span = Math.max(1, bg - Math.min(...rows.map((r) => r.lum))); + return { lum: (y) => rows.find((r) => r.y === y).lum, alpha: (y) => (bg - rows.find((r) => r.y === y).lum) / span }; + }; + const A = band(native, 'A'); + const At = band(native, 'At'); + const B = band(skinned, 'A'); + const Bt = band(skinned, 'At'); + t.log('遮罩 alpha(y = 距视口底边) A(原生) B(codex-ui) | At(原生·半透明) Bt(codex-ui·半透明)'); + for (let y = 56; y >= 0; y -= 4) t.log(String(y).padStart(3) + ' ' + [A, B, At, Bt].map((c) => c.alpha(y).toFixed(3)).join(' ')); + + const maxOver = (ys, fn) => Math.max(...ys.map(fn)); + const headDiff = maxOver([4, 8, 12, 16, 20, 24], (y) => Math.abs(A.alpha(y) - B.alpha(y))); + const tailDiff = maxOver([28, 32, 36, 40, 44], (y) => Math.abs(A.alpha(y) - B.alpha(y))); + const bottomOpaque = Math.abs(A.lum(0) - B.lum(0)); + const bottomTranslucent = Bt.lum(0) - At.lum(0); + const fillInvariance = maxOver([0, 2, 4, 8, 16, 24, 40], (y) => Math.abs(B.lum(y) - Bt.lum(y))); + const on = skinned.styles.A; + const off = native.styles.A; + + t.check('原生侧栏:滚动容器没有 mask', off.maskImage === 'none', off.maskImage); + t.check('codex-ui:滚动容器有 2 层 mask', (on.maskImage.match(/linear-gradient\(/g) ?? []).length === 2, on.maskImage); + t.check('两列几何一致(宽度/高度/列表底边)', Math.abs(native.styles.At.list.h - off.list.h) < 0.5 && Math.abs(native.styles.At.list.y - off.list.y) < 0.5, + 'At h=' + native.styles.At.list.h.toFixed(1) + ' A h=' + off.list.h.toFixed(1)); + t.check('codex-ui:第 2 层 mask 宽 = 12px(让开滚动条槽)', /(^|[\s,])12px\s+100%(,|$)/.test(on.maskSize.replace(/\s+/g, ' ')), on.maskSize); + t.check('codex-ui:第 1 层 mask 宽 = 100% − 12px', on.maskSize.replace(/\s+/g, ' ').startsWith('calc(100% - 12px) 100%'), on.maskSize); + t.check('原生侧栏:宿主覆盖层在渲染', off.fadeDisplay !== 'none', off.fadeDisplay); + t.check('codex-ui:宿主覆盖层已让位', on.fadeDisplay === 'none', on.fadeDisplay); + /* e0 / 85 / 2e / 00 是 Codex 原文的十六进制 alpha;浏览器把它算成 0.88 / 0.52 / 0.18 / 0。 */ + t.check('codex-ui:渐变里有 Codex 的四段 alpha 停点(e0/85/2e/00 → .88/.52/.18/0)', + ['0.88)', '0.52)', '0.18)', '0) 100%'].every((stop) => on.maskImage.includes('(0, 0, 0, ' + stop)), on.maskImage); + /* 如实记录:不透明底下宿主那 24px 覆盖层与 Codex 前 24px 斜坡形状重合,本层不是为了外观而做的; + 差别在半透明底(设置卡的半透明侧栏)—— 覆盖层挡不住内容,mask 与底色无关。 */ + t.check('不透明底下:前 24px 与宿主覆盖层逐点差 ≤ 0.13(如实记录:外观几乎等价)', headDiff <= 0.13, headDiff.toFixed(3)); + t.check('不透明底下:24–40px 尾巴确有差别(这是本层唯一的形状差)', tailDiff > 0.02, tailDiff.toFixed(3)); + t.check('不透明底:两种机制的底边结果一致(|A−B| ≤ 15)', bottomOpaque <= 15, bottomOpaque.toFixed(1)); + t.check('半透明底:宿主覆盖层挡不住内容(At 底边比 Bt 暗 ≥ 40)', bottomTranslucent >= 40, bottomTranslucent.toFixed(1)); + t.check('codex-ui 的 mask 对侧栏底色 alpha 免疫(|B−Bt| ≤ 15)', fillInvariance <= 15, fillInvariance.toFixed(1)); + + await page.evaluate(() => document.documentElement.setAttribute('data-codex-ui', '')); + await page.frame(); + await t.shot(page, 'sidebar-surface-verify.png', { x: 0, y: Math.round(BOTTOM - 120), width: 560, height: 130, scale: 2 }); +} + +export default { align, surface }; diff --git a/scripts/theme-flash-probe.mjs b/scripts/theme-flash-probe.mjs deleted file mode 100644 index c9299b2..0000000 --- a/scripts/theme-flash-probe.mjs +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env node -/** - * theme-flash-probe.mjs — 抓主题切换时那一帧「闪」。 - * - * 为什么要有它:主题切换的闪屏是 1–3 帧的事(~16–50ms),肉眼看到、截图拍不到、 - * 断言也抓不住。这里在页面里挂一个 requestAnimationFrame 采样器,按帧记录 - * 画布相关的计算色与主题标记,再在 Node 侧找出「既不是起点色、也不是终点色」的中间帧。 - * - * node scripts/theme-flash-probe.mjs --url "http://127.0.0.1:3098/?token=..." [--frames 180] - */ -import { spawn } from 'node:child_process'; -import http from 'node:http'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { chromePath, tempDir } from './host-paths.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const arg = (name, fallback) => { - const i = process.argv.indexOf('--' + name); - return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1] : fallback; -}; -const TOKEN_URL = arg('url', ''); -const CHROME = arg('chrome', '') || chromePath(); -const PORT = Number(arg('cdp-port', '9342')); -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -if (!TOKEN_URL) { console.error('缺少 --url'); process.exit(2); } - -const origin = new URL(TOKEN_URL).origin; -const grab = (url) => new Promise((resolve, reject) => { - const u = new URL(url); - http.get({ hostname: u.hostname, port: u.port, path: u.pathname + u.search }, (res) => { resolve((res.headers['set-cookie'] || [''])[0]); res.resume(); }).on('error', reject); -}); -const cookie = (await grab(TOKEN_URL)).split(';')[0]; -const child = spawn(CHROME, ['--headless=new', '--remote-debugging-port=' + PORT, '--user-data-dir=' + tempDir('dsh-cdp-flash'), - '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--window-size=1280,900', '--hide-scrollbars', 'about:blank'], { stdio: 'ignore' }); -let info; -for (let i = 0; i < 60 && !info; i += 1) { try { const r = await fetch('http://127.0.0.1:' + PORT + '/json/version'); if (r.ok) info = await r.json(); } catch { /* 等 */ } if (!info) await sleep(250); } -if (!info) { console.error('Chromium 没起来'); process.exit(2); } -const ws = new WebSocket(info.webSocketDebuggerUrl); -await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); -let seq = 0; const waiting = new Map(); -/** 主题发布序列:宿主每次 theme/change 都会被本插件打一行日志(临时埋点),这里全部收集起来。 */ -const themeLog = []; -ws.onmessage = (ev) => { - const m = JSON.parse(ev.data); - if (m.method === 'Runtime.consoleAPICalled') { - const text = m.params.args.map((a) => String(a.value ?? a.description ?? '')).join(' '); - if (text.includes('[codex-ui][probe]')) themeLog.push(text.replace('[codex-ui][probe] ', '')); - } - const fn = waiting.get(m.id); - if (fn) { waiting.delete(m.id); fn(m); } -}; -const send = (method, params = {}, sessionId) => new Promise((res, rej) => { - const id = ++seq; - waiting.set(id, (m) => (m.error ? rej(new Error(method + ': ' + JSON.stringify(m.error))) : res(m.result))); - ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); -}); -const evaluate = async (expression, sessionId) => (await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId)).result.value; -const click = async (x, y, sessionId) => { - await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, sessionId); - await send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, sessionId); - await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, sessionId); -}; -const clickByText = async (pattern, sessionId) => evaluate('(() => {' - + 'const re = new RegExp(' + JSON.stringify(pattern) + ');' - + 'const els = [...document.querySelectorAll("button, a, [role=tab], [role=menuitem], [role=button]")];' - + 'const el = els.find((e) => (re.test(e.getAttribute("aria-label") || "") || re.test(e.textContent.trim())) && e.offsetParent !== null);' - + 'if (!el) return null; const r = el.getBoundingClientRect();' - + 'return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), text: (el.getAttribute("aria-label") || el.textContent.trim()).slice(0, 30) }; })()', sessionId); - -const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); -const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); -await send('Page.enable', {}, sessionId); -await send('Runtime.enable', {}, sessionId); -await send('Network.enable', {}, sessionId); -await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }, sessionId); -const eq = cookie.indexOf('='); -await send('Network.setCookie', { name: cookie.slice(0, eq), value: cookie.slice(eq + 1), domain: new URL(origin).hostname, path: '/', httpOnly: true }, sessionId); -await send('Page.navigate', { url: origin + '/' }, sessionId); -await sleep(11000); -for (let i = 0; i < 4; i += 1) { const d = await clickByText('^(继续|稍后配置|跳过|知道了)$', sessionId); if (d === null) break; await click(d.x, d.y, sessionId); await sleep(1200); } -const entry = await clickByText('^插件$', sessionId); -if (entry !== null) { await click(entry.x, entry.y, sessionId); await sleep(2000); } -const open = await clickByText('^查看 codex-ui$', sessionId); -if (open === null) { console.error('找不到 codex-ui 入口'); process.exit(1); } -await click(open.x, open.y, sessionId); -await sleep(3000); - -/** - * 采样器:每帧记录「**实际画在屏幕上的那一层**」。 - * 只看元素的 backgroundColor 不够 —— 大多数元素是透明的,真正被看到的颜色要沿祖先往上 - * 找第一个不透明的底色;主题切换的闪,正是这种「有效底色」在某一帧翻错。 - */ -const SAMPLER = `(() => { - /** 沿祖先找第一个不透明底色;返回 { color, from } 便于定位是哪一层。 */ - const backdrop = (x, y) => { - let el = document.elementFromPoint(x, y); - while (el !== null && el !== document.documentElement.parentElement) { - const cs = getComputedStyle(el); - const bg = cs.backgroundColor; - const m = /rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)(?:,\\s*([\\d.]+))?/.exec(bg); - if (m !== null && (m[4] === undefined || Number(m[4]) > 0.99)) { - return m[1] + ',' + m[2] + ',' + m[3] + '@' + (el.getAttribute('data-slot') || el.className.toString().slice(0, 20) || el.tagName); - } - el = el.parentElement; - } - return 'none'; - }; - const pts = [[640, 450], [1400, 450], [140, 700]]; - window.__flash = []; - const loop = () => { - const frame = { - t: Math.round(performance.now()), - dark: document.body.hasAttribute('data-ds-dark-theme'), - scheme: getComputedStyle(document.documentElement).colorScheme, - override: document.documentElement.hasAttribute('data-codex-ui-theme'), - preview: document.documentElement.getAttribute('data-codex-ui-preview') || '', - skin: document.documentElement.hasAttribute('data-codex-ui'), - body: getComputedStyle(document.body).backgroundColor, - html: getComputedStyle(document.documentElement).backgroundColor, - card: document.querySelectorAll('.cx-form').length, - p0: backdrop(pts[0][0], pts[0][1]), - p1: backdrop(pts[1][0], pts[1][1]), - p2: backdrop(pts[2][0], pts[2][1]), - }; - window.__flash.push(frame); - if (window.__flash.length < 20000) window.__flashRaf = requestAnimationFrame(loop); - }; - loop(); - return true; -})()`; -await evaluate(SAMPLER, sessionId); - -/** 一次操作:清空采样 → 点 → 等落定 → 回收这一段(连同点击时刻一起带走)。 */ -const capture = async (act, settle = 1300) => { - await evaluate('window.__flash = []', sessionId); - const box = await act(); - if (box === null) return null; - const t0 = await evaluate('performance.now()', sessionId); - await click(box.x, box.y, sessionId); - await sleep(settle); - const frames = await evaluate('(() => { const f = window.__flash; window.__flash = []; return f; })()', sessionId); - return { frames, t0 }; -}; -/** 点击 → 屏幕上真的变色之间的毫秒数(以第一帧 dark 标记翻转计)。 */ -const latencyOf = ({ frames, t0 }) => { - if (frames === undefined || frames.length === 0) return null; - const before = frames[0].dark; - const flip = frames.find((f) => f.dark !== before); - return flip === undefined ? null : Math.round(flip.t - t0); -}; -const tap = (label) => async () => clickByText('^' + label + '$', sessionId); -const spot = (f) => f.p0 + ' | ' + f.p1 + ' | ' + f.p2; -/** - * 异常帧 = 有效底色既不是切换前的值、也不是切换后的稳定值。 - * 这正是肉眼看到的「闪」:某一帧画出来的东西不属于任何一端。 - */ -/** 把逐帧的深浅状态压成连续段,例:L×12 → D×68 —— 段数多于 2 就是「回打」(黑→白→黑)。 */ -const runsOf = (frames) => { - const runs = []; - for (const f of frames) { - const key = f.dark ? 'D' : 'L'; - if (runs.length > 0 && runs[runs.length - 1][0] === key) runs[runs.length - 1][1] += 1; - else runs.push([key, 1]); - } - return runs; -}; -const analyse = (name, frames) => { - if (frames === null || frames.length === 0) { console.log(name + ':没采到帧'); return 0; } - const runs = runsOf(frames); - console.log(' ' + name + ' 深浅序列 ' + runs.map(([k, n]) => k + '×' + n).join(' → ') + (runs.length > 2 ? ' ← 回打!' : '')); - const head = frames[0]; - const last = frames[frames.length - 1]; - const bad = frames.filter((f, i) => i > 0 && i < frames.length - 1 && spot(f) !== spot(head) && spot(f) !== spot(last)); - console.log(name + ':帧 ' + String(frames.length).padStart(3) + ' 异常 ' + bad.length + (bad.length === 0 ? '' : ' ' + spot(head) + ' → ' + spot(last))); - for (const f of bad.slice(0, 5)) console.log(' FLASH t=' + f.t + ' dark=' + f.dark + ' scheme=' + f.scheme + ' ' + spot(f)); - return bad.length; -}; - -/* 起始态固定亮色,然后来回切 4 趟:偶发闪屏要靠重复切换才抓得到。 */ -const seg = await evaluate('(() => { const t = document.querySelector("[role=tab][aria-selected=true]"); return t === null ? null : t.textContent.trim(); })()', sessionId); -if (seg !== '亮色') { const b = await clickByText('^亮色$', sessionId); if (b !== null) { await click(b.x, b.y, sessionId); await sleep(1500); } } - -let anomalies = 0; -let captured = 0; -/* 来回切:偶发闪屏要靠重复切换才抓得到;--switches N 可只跑前 N 趟。 */ -const all = [['深色', tap('深色')], ['亮色', tap('亮色')], ['深色', tap('深色')], ['亮色', tap('亮色')], ['深色', tap('深色')], ['亮色', tap('亮色')]]; -const want = Number(arg('switches', '6')); -const plan = all.slice(0, Number.isInteger(want) && want > 0 ? Math.min(want, all.length) : all.length); -const latencies = []; -for (const [label, act] of plan) { - themeLog.push('--- 点 ' + label + ' ---'); - const shot2 = await capture(act); - if (shot2 === null) { console.log('切换 ' + label + ':找不到分段'); break; } - captured += 1; - const ms = latencyOf(shot2); - if (ms !== null) latencies.push(ms); - anomalies += analyse('主题 → ' + label, shot2.frames); - console.log(' ↑ 点击 → 变色 ' + (ms === null ? '未观测到' : ms + 'ms')); -} -if (latencies.length > 0) { - console.log('\n变色延迟:样本 ' + latencies.join(' / ') + ' ms,中位 ' + latencies.slice().sort((a, b) => a - b)[Math.floor(latencies.length / 2)] + 'ms'); -} - -/* 页面切换也采一遍:设置页 ↔ 会话页来回走。 */ -const pageShot = await capture(async () => clickByText('^新会话$', sessionId), 1600); -if (pageShot !== null) anomalies += analyse('切到会话页', pageShot.frames); -const backShot = await capture(async () => clickByText('^插件$', sessionId), 1800); -if (backShot !== null) anomalies += analyse('切回插件页', backShot.frames); -const reopenShot = await capture(async () => clickByText('^查看 codex-ui$', sessionId), 2000); -if (reopenShot !== null) anomalies += analyse('打开组合包页', reopenShot.frames); - -const total = await evaluate('(() => { cancelAnimationFrame(window.__flashRaf); return window.__flash.length; })()', sessionId); -console.log('\n主题发布序列(宿主 theme/change 逐次):'); -for (const line of themeLog) console.log(' ' + line); -console.log('\n采样段 ' + (captured + 3) + ' 段,异常帧合计 ' + anomalies + ',探测器仍在跑的帧 ' + total); -console.log(anomalies === 0 ? 'RESULT 未捕捉到中间态帧' : 'RESULT 捕捉到 ' + anomalies + ' 个中间态帧'); -ws.close(); child.kill(); diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000..75258ad --- /dev/null +++ b/scripts/verify.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * verify.mjs — 夹具验收:宿主真实 shipped 样式 + 按宿主渲染代码复刻的 DOM + 本插件样式 / 组件, + * 在无头 Chromium 里读计算样式、几何与像素,逐条断言。 + * + * node scripts/verify.mjs [spec…] [--host ] [--shots <目录>] [--verbose] + * + * spec 即 scripts/specs/<名>.mjs,不写就全跑。每个 spec 默认导出 { 小节名: async (t) => … }, + * 每个小节独占一个浏览器进程(悬停态只在前台页签里算,小节之间不能共用)。 + * 宿主来源与浏览器见 lib/host.mjs;截图默认写进系统临时目录,--shots 指定别处(例如 assets/screenshots)。 + * + * 为什么是夹具而不是截真 GUI:launch token 只在宿主进程内存里,桌面壳的 profile 又被 Electron 独占。 + * 真 GUI 的验收在 scripts/live/。 + */ +import fs from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { SKIN_DIR, SKIN_PARTS, scopeCss, stripComments } from './build.mjs'; +import { launch, sleep } from './lib/cdp.mjs'; +import { checklist, summarize } from './lib/checks.mjs'; +import { openHost } from './lib/host.mjs'; + +const SPEC_DIR = join(dirname(fileURLToPath(import.meta.url)), 'specs'); + +/** + * 夹具用的皮肤样式:与 build.mjs 同一条管线(去注释 → 作用域化)。before 对照页要「没有某一段的皮肤」, + * cuts 按**源文件**里的注释标记剪(产物已去注释):{ 'patches.css': [['⑫ …', '⑬ …']] }, + * 从 from 所在注释的起点剪到 to 所在注释的起点;to 为 null 剪到文件尾。 + */ +function themeCss(cuts = {}) { + return SKIN_PARTS.map((file) => { + let src = fs.readFileSync(join(SKIN_DIR, file), 'utf8'); + for (const [from, to] of cuts[file] ?? []) { + const at = src.indexOf(from); + const until = to === null ? src.length : src.indexOf(to, at); + if (at < 0 || until < 0) throw new Error(file + ' 里找不到标记:' + (at < 0 ? from : to)); + if (src.indexOf(from, at + 1) >= 0) throw new Error(file + ' 里标记不唯一:' + from); + src = src.slice(0, src.lastIndexOf('/*', at)) + src.slice(to === null ? until : src.lastIndexOf('/*', until)); + } + return scopeCss(stripComments(src)); + }).join('\n'); +} + +const opts = { host: undefined, shots: join(tmpdir(), 'codex-ui-shots'), verbose: false }; +const named = []; +for (let i = 2; i < process.argv.length; i += 1) { + const a = process.argv[i]; + if (a === '--verbose') opts.verbose = true; + else if (a === '--host' || a === '--shots') opts[a.slice(2)] = process.argv[++i]; + else named.push(a); +} +const shots = resolve(opts.shots); +const available = fs.readdirSync(SPEC_DIR).filter((f) => f.endsWith('.mjs')).map((f) => basename(f, '.mjs')).sort(); +const wanted = named.length === 0 ? available : named; +const unknown = wanted.filter((name) => !available.includes(name)); +if (unknown.length > 0) { + console.error('没有这个 spec:' + unknown.join(' ') + '。可选:' + available.join(' ')); + process.exit(2); +} +fs.mkdirSync(shots, { recursive: true }); + +let host = null; +const all = []; +const table = []; +for (const spec of wanted) { + const sections = (await import(pathToFileURL(join(SPEC_DIR, spec + '.mjs')).href)).default; + for (const [section, run] of Object.entries(sections)) { + console.log('\n━━ ' + spec + ' / ' + section); + const { results, check } = checklist(); + let browser = null; + const t = { + check, + sleep, + theme: themeCss, + log: (...args) => { if (opts.verbose) console.log(' ', ...args); }, + /** 宿主只在用到时才打开(elevation / power-rail 不需要宿主)。 */ + get host() { + if (host === null) { + host = openHost(opts.host); + console.log('宿主 ' + host.label); + } + return host; + }, + async page(viewport) { + browser ??= await launch(); + return browser.newPage(viewport); + }, + async shot(page, file, clip) { + const path = join(shots, file); + await page.screenshot({ path, clip }); + t.log('截图 ' + path); + }, + }; + try { + await run(t); + } catch (e) { + check(section + ' 运行出错', false, e.stack ?? e.message); + } finally { + await browser?.close(); + } + all.push(...results); + table.push([spec, section, results.filter((r) => r.ok).length, results.length]); + } +} + +console.log('\n━━ 汇总(截图在 ' + shots + ')'); +for (const [spec, section, pass, total] of table) { + console.log(' ' + (pass === total ? 'PASS' : 'FAIL') + ' ' + (spec + ' / ' + section).padEnd(32) + pass + '/' + total); +} +summarize(all); diff --git a/skins/codex-ink/README.md b/skins/codex-ink/README.md index fbd9f46..1a95e11 100644 --- a/skins/codex-ink/README.md +++ b/skins/codex-ink/README.md @@ -1,8 +1,8 @@ # codex-ink -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 +Codex / ChatGPT direction. These stylesheets are the sources for the codex-ui plugin: `scripts/build.mjs` reads the eight +CSS files here, scopes their selectors to `html[data-codex-ui]`, and writes `theme.css`, which is inlined into `client.js`; +see the repository README for installing (`dsh plugin add link:`). The same directory satisfies the Skin v2 manifest format and can be picked up by a skin loader on its own. ## Files @@ -12,9 +12,9 @@ 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, ⑫ 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` | +| `model-picker.css` | L3 model picker component | Face B: the trigger, popover and reasoning power rail built by `src/client/model-picker/` (Codex `_Track` / `_Tick` / `_Thumb` geometry verbatim); paints only `.codex-mp-*` plus one seat-hiding rule, never the host menu. Verified by `scripts/verify.mjs model-picker` | | `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` | +| `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/verify.mjs sidebar` | | `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-*`) | @@ -23,8 +23,8 @@ up by a skin loader on its own. ## Design rules 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`. +2. Greys carry hierarchy. Light `#FFFFFF → #F1F1EF → #E5E5E5`; dark `#111111 → #181818 → #212121 → #282828` (window background `#111111`, **surface** `#181818`; the composer card sits on the surface and the 0.5px hairline separates them). The sidebar is a separate chain: `#F6F6F6` light / `#0F0F0F` dark, one step below the content in both. +3. Light sidebar `#F6F6F6` = Codex's `--color-surface-tertiary` (`#F3F3F3`) at 70% over white (Codex has no sidebar token; the panel is a translucent scrim), active row `#E9E9E9`, hover `#F0F0F0`. 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. @@ -41,17 +41,17 @@ up by a skin loader on its own. ## Verification ```bash -node scripts/audit-codex-ink.mjs +node scripts/check.mjs ``` -The script checks the `skin.json` structure, measures 36 WCAG contrast pairs, and audits the color whitelist in `patches.css`. +The checks that concern this directory: the `skin.json` structure, 36 measured WCAG contrast pairs, and the color whitelist in `patches.css`. Current result: 36/36 pass, 19 AAA pairs, zero colors outside the whitelist. ## Install ```powershell -node scripts/install-plugin.mjs --write # plugin path, the primary route -node scripts/install-skin.mjs --write # skin loader path: sync to $DSH_HOME/skins/codex-ink +dsh plugin --profile web add link: # plugin path, the primary route +node scripts/install-skin.mjs --write # skin loader path: sync to $DSH_HOME/skins/codex-ink ``` ## Not covered diff --git a/skins/codex-ink/README.zh-CN.md b/skins/codex-ink/README.zh-CN.md index 93b7616..f2c44e0 100644 --- a/skins/codex-ink/README.zh-CN.md +++ b/skins/codex-ink/README.zh-CN.md @@ -1,7 +1,7 @@ # codex-ink · 墨白终端 -代码方向为 Codex / ChatGPT。这是 codex-ui 插件的样式正本:`src/build.mjs` 读本目录的八份 CSS,把选择器作用域化到 -`html[data-codex-ui]`,写出 `theme.css` 与 `client.js`;`scripts/install-plugin.mjs` 负责安装。同一份目录也满足 +代码方向为 Codex / ChatGPT。这是 codex-ui 插件的样式正本:`scripts/build.mjs` 读本目录的八份 CSS,把选择器作用域化到 +`html[data-codex-ui]`,写出 `theme.css` 并内联进 `client.js`;安装见仓库 README(`dsh plugin add link:`)。同一份目录也满足 Skin v2 清单格式,可由皮肤加载器单独收录。 ## 文件 @@ -11,9 +11,9 @@ 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、⑫ 宿主模型菜单(A 面)、⑬ 输入区顶栏与卡片、⑯ 右栏展开选择组件、⑰ composer 控件悬停 | -| `model-picker.css` | L3 模型选择器组件 | B 面:`src/model-picker.js` 自建的触发器、弹层与推理等级功率轨(Codex `_Track` / `_Tick` / `_Thumb` 逐字几何);只画 `.codex-mp-*` 与席位的一条隐藏规则,不碰宿主菜单。验收 `scripts/power-rail-verify.mjs` | +| `model-picker.css` | L3 模型选择器组件 | B 面:`src/client/model-picker/` 自建的触发器、弹层与推理等级功率轨(Codex `_Track` / `_Tick` / `_Thumb` 逐字几何);只画 `.codex-mp-*` 与席位的一条隐藏规则,不碰宿主菜单。验收 `scripts/verify.mjs model-picker` | | `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` | +| `sidebar-surface.css` | L3 侧栏面 | 侧栏滚动渐隐:把宿主那条 24px 覆盖层让位,改用 Codex 的 40px 四段 mask 斜坡(挂在滚动容器上,右侧 12px 让开滚动条槽)。验收 `scripts/verify.mjs sidebar` | | `window-shadow.css` | L3 窗口边缘 | 会话窗口 0.5px 发丝线加 24px 环境影;右栏面板左沿只留 0.5px 发丝线、影只往上泄;右分界线拖拽柄悬停渐变 | | `composer.css` | L3 输入区 | 卡片几何与表面、编辑区 44px、底部控件行 28px、候选菜单、hero 布局。本层允许使用 `[class*=…]` 后缀锚点 | | `settings.css` | L3 设置页 | 插件管理 → 组合包页那张配置卡的版式(`.cx-*`) | @@ -22,8 +22,8 @@ Skin v2 清单格式,可由皮肤加载器单独收录。 ## 设计规则 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`。 +2. 灰阶承担层级。浅色 `#FFFFFF → #F1F1EF → #E5E5E5`;深色 `#111111 → #181818 → #212121 → #282828`(窗口背景 `#111111`、**表面** `#181818`;输入卡坐在表面上,分隔靠 0.5px 发丝线)。侧栏是另一条链:浅 `#F6F6F6` / 深 `#0F0F0F`,两套里都比主区低一档。 +3. 浅色侧栏 `#F6F6F6` = Codex 的 `--color-surface-tertiary`(`#F3F3F3`)以 70% 叠白(Codex 没有侧栏令牌,那一面是半透明遮罩),激活行 `#E9E9E9`,悬停 `#F0F0F0`。 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` 下直接跳到终态。 @@ -40,17 +40,17 @@ Skin v2 清单格式,可由皮肤加载器单独收录。 ## 验收 ```bash -node scripts/audit-codex-ink.mjs +node scripts/check.mjs ``` -脚本校验 `skin.json` 结构、实测 36 组 WCAG 对比度、审计 `patches.css` 的彩色白名单。 +其中与本目录有关的几项:`skin.json` 结构、实测 36 组 WCAG 对比度、`patches.css` 的彩色白名单。 当前结果:36/36 通过,19 组 AAA,白名单外彩色 0 个。 ## 安装 ```powershell -node scripts/install-plugin.mjs --write # 插件路径,主用法 -node scripts/install-skin.mjs --write # 皮肤加载器路径:同步到 $DSH_HOME/skins/codex-ink +dsh plugin --profile web add link:<仓库绝对路径> # 插件路径,主用法 +node scripts/install-skin.mjs --write # 皮肤加载器路径:同步到 $DSH_HOME/skins/codex-ink ``` ## 未覆盖 diff --git a/skins/codex-ink/composer.css b/skins/codex-ink/composer.css index 555625f..2603d83 100644 --- a/skins/codex-ink/composer.css +++ b/skins/codex-ink/composer.css @@ -1,99 +1,34 @@ -/* ============================================================================ - codex-ink(墨白终端)· L3 输入区完整层 - ---------------------------------------------------------------------------- - 源文件:skins/codex-ink/composer.css - 由 src/build.mjs 与其余各层一起拼接,并作用域化到 html[data-codex-ui]。 - 本文件写「裸选择器」——不要自己再加 html[...] 前缀,那会被 scopeCss 直通。 - - 来源(上游仓库 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 - - 兼容性核对(对 rc1 0.1.7-rc.1 的 shipped 包逐条 grep,不是照抄): - 上游 README 只声明兼容到 0.1.6-alpha.2。逐条核对后,以下锚点在本机 rc1 上 - 不存在,一律**不移植**,免得留死规则: - ✗ [data-input-mirror] rc1 无此属性(conversation 包 0 命中) - ✗ [class*="_rail"](附件轨道补偿) rc1 无此类名段 - ✗ [class*="_selected"] / [class*="_footer"] rc1 无此类名段 - ✗ [data-dcu-tool-menu] / [data-gitgraph-popover] 由上游 JS 观察器打标, - 本插件不含该半,规则永远不会命中 → 弹窗换肤留在「待移植 JS 半」清单 - ✗ .dcu-home-* 上游自有组件的类名,本皮肤没有这些节点 - 实测存在的锚点(本条全部基于它们): - ✓ [data-composer-card] [data-input-scroll] [data-composer-placeholder] - [data-lexical-editor=true] [data-conversation-scroll] [data-composer-seat] - [data-trigger-menu] [data-phase](取值 settling / hero / active) - - 锚点纪律例外(必须说清): - ⑭·4 ⑭·5 用到 [class*="_heroWorkspaceRow"] / [class*="_composerHero"] / - [class$="_workspace"] / [class$="_primary"]。这类 CSS-Modules 哈希类名在 - patches.css 头部是被禁止的——那条禁令针对 **skin-center 加载器**的 - "reliance on CSS-Modules hash class names" 告警。本层走**插件路径** - (client.js 直接注入 style 标签),不经加载器,故放行。 - 已尽量取「后缀锚点」写法([class$=_primary]),只依赖语义后缀、不依赖哈希前缀; - 语义名若被上游改掉,由 scripts/hero-verify.mjs 的实测值暴露。 - hero 区没有 data-* 可用:jYtg_G_heroWorkspaceRow / jYtg_G_composerHero 只有类名。 - ============================================================================ */ - -/* ── ⑭·1 卡片几何与表面 ─────────────────────────────────────────────────── - 上游把输入区放宽到 chat 内容宽 + 32px,左右留白 24px;卡片 20px 圆角(浏览器 - 支持 corner-shape 时升到 25px 超椭圆)。 - - 表面:**亮暗两套机制相反** —— 这是本层最容易搞错的地方。 - - 暗色 = 填充分层。卡片比页面亮一档:5% 白叠 surface #181818 = 35.6 → 36 - (Codex 暗色参考裁图 layer-codex.png / codex-card-ref.png 众数实测:卡内 - #232323 = 35、页面 #111111 = 17,落差 18)。暗色**没有投影**,见下方 inset 高光。 - - 亮色 = 阴影分层,卡片**与页面同色**(都是 #ffffff)。三份独立实测同向: - codex-composer-reference.png 卡内众数 #fffeff/#ffffff/#fefdfe 合计 62%; - 用户 2026-09-28 同屏截图卡内众数 #fffeff 73.8% + #fefdfe 23.0% = 96.8%; - codex-app-reference.png 整窗 #ffffff 76.5%。 - 0.5.6 及以前在亮色也叠了 5% 墨(= #f4f4f4,比实测暗 11 级),是把暗色模型错套到 - 亮色上。照抄的 --card: 5% foreground 属于 **widget 层**(codex.exe 的 artifact - 契约),不是应用外壳的输入卡;外壳令牌里亮色 --gray-fixed-0 = #ffffff 与实测吻合 - (--color-surface-elevated-secondary = gray-50 = #f9f9f9 差 5 级,不采用)。 - 该令牌映射本身仍是推断,见 CHANGELOG 0.5.8 的边界说明。 - - 阴影按源码逐字取 Codex 的 --elevation-composer(见下方声明)。0.5.4 那轮把 - 0 4px 80px 8px 判为「比实测宽三倍」并删掉,是误判 —— Codex 确有该层(第三层, - 仅 2.4% 墨),缺的是它前面的 8px 近场。0.5.7 已恢复三层。 - 注:亮色参考图里卡片边缘实测约 12% 墨,而源码的环只有 #0000000a = 4%,两者不一致; - 本皮肤按源码取 4%,该差异未解释,见 CHANGELOG 0.5.8 边界说明。 */ +/* codex-ink · L3 输入区:卡片几何、表面与阴影,底栏控件带,建议菜单,新建页(hero)布局。 + 几何移植自 MichengAI/dsh-codex-ui(CodexSidebar / composer-tool-menus / new-conversation-style),只保留在 + 0.1.7 宿主上真实存在的锚点:[data-composer-card] [data-input-scroll] [data-composer-placeholder] + [data-lexical-editor] [data-conversation-scroll] [data-composer-seat] [data-trigger-menu] [data-phase]。 + hero 区没有 data-* 可用,用 CSS-Modules 类名的后缀写法(_heroWorkspaceRow / _composerHero / _workspace / _primary)。 */ + +/* ── ⑭·1 卡片 ── + 亮暗两套机制相反:亮色 = 阴影分层,卡片与页面同色(都是表面色 #fff;三份实测卡内众数都是白); + 暗色 = 填充分层,卡片比页面亮一档(5% 白叠表面 #181818 = 36,实测卡内 35、页面 17),而且完全没有外投影。 + 阴影是 Codex --elevation-composer 逐字(app.asar @67930943)三层:环 + 8px 近场 + 80px 远场,缺一不可 + (只有远场会糊,只有环像贴纸)。环取 10% = Codex 实际走的 compact 路径 border: 1px var(--color-border)。 */ [data-conversation-scroll] { --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px); --dsh-composer-side-clearance: 24px; - /* 亮色**不叠罩**:直接取表面色(= #ffffff)—— Codex 亮色卡与页面同色,见上。 - 仍写成不透明实色、不用 transparent:DSH 的输入卡浮在会话滚动内容之上, - 留 transparent 会让身后的文件列表透出来(实测已复现)。 */ + /* 不透明实色:卡片浮在会话滚动内容之上,transparent 会透出身后的列表 */ --dcu-composer-bg: var(--dsw-composer-surface); - /* 阴影逐字取 Codex 的 --elevation-composer(app.asar @67930943): - 0 0 0 1px ... 环:源码里输入卡有**两条互斥路径** —— - 路径A(默认/large/single-line)box-shadow: var(--elevation-composer) - 路径B([data-composer-radius-variant=compact]) - box-shadow: none; border: 1px solid var(--color-border) !important - 实测走的是 B。而 --color-border = --alpha-10(亮)/ --alpha-12(暗)。 - 两种方法同时指向 10%: - · 同图内比较(DPR 无关):Codex 环像素比页面低 26 级; - 本皮肤 4% 环低 16 级 → 反推 α ≈ 10.4%; - · 标定渲染(DPR 1.25):4% → Δ14、12% → Δ29 → 插值 α ≈ 10.4%。 - 0.5.8 取 12% 偏重(渲成 Δ29~35);0.5.10 按上两法收到 10%。 - 0.5.7 曾按路径A 取 4%,那是只看了三层里的第一层。 - 0 2px 8px 0 #0000000a 近场:8px 模糊 / 2px 位移,墨压在这一层 - 0 4px 80px 8px #00000006 远场:80px 宽晕、仅 2.4%,负责"浮起来"的空气感 - 三层缺一不可:只有远场会糊,只有环会像贴纸(本皮肤此前正是后者)。 */ - --dcu-composer-shadow: 0 0 0 1px rgba(13, 13, 13, 0.1), 0 2px 8px 0 #0000000a, 0 4px 80px 8px #00000006; -} - -/* 暗色:Codex 的 --elevation-composer-dark 是 inset 0 0 1px 0 #fff3 —— **完全外无投影**, - 改用 1px 内嵌高光把顶边点亮(实测 Codex 卡体 36、顶边 40,本皮肤此前内侧全平 36/36)。 - 机制差异:外描边读作"画了一条线",内嵌高光读作"边被光打到",后者才是 Codex 的层次来源。 - 外层 0.5px 发丝线一并撤掉:暗色下 Codex 卡外就是页面底色,无任何环。 */ + /* 阴影:0.6.3 起按**实测像素**拟合,不再照抄源码 token —— 源码 token 与真机像素对不上 + (--elevation-composer 的 0 4px 80px 8px 远场在参考图里量不到,环的 1px 在 DPR 2 下也不是 + 参考图里的 1 个设备像素)。判据与拟合过程见 CHANGELOG 0.6.3 与 composer-shadow-verify。 + Codex 参考 codex-composer-reference.png 与用户同屏截图同为 DPR 2,同一采样口径量到: + 环 1 个设备像素(= 0.5 CSS px),最深 Δ33 + 卡下沿外沿 Δ13,12,11,10,9,8,7,6,5,4,4,4,2,2,2,2,2,1,1,1,1,1,1,0(24 个设备像素 = 12 CSS px 归零) + 本皮肤旧值 0 0 0 1px + 8px + 80px 量到的是:环 2 个设备像素、Δ29, + 卡下沿 Δ4 起、到 56 个设备像素才归零 —— 环偏粗一倍、近场偏弱、尾巴偏长。 + 现值为 headless Chrome 夹具里扫出来的最优拟合(环项与 Codex 完全相同)。 */ + --dcu-composer-shadow: 0 0 0 0.5px rgba(13, 13, 13, 0.1), 0 2px 12px 0 rgba(0, 0, 0, 0.09); +} + +/* 暗色:Codex --elevation-composer-dark 是 1px 内嵌高光(把顶边点亮),卡外零投影。 + 叠罩在 sRGB 里混(浏览器的 alpha 合成就是 sRGB,oklab 会压到 34),基色是表面 #181818 不是窗口背景。 */ body[data-ds-dark-theme] [data-conversation-scroll] { - /* 叠罩**只用于暗色**:5% 白叠 surface #181818 = 35.6 → 36(Codex 实测 35)。 - 用 srgb 插值不用 oklab —— 浏览器那边就是 sRGB alpha 合成,oklab 会压到 34。 - 基色取 --dsw-composer-surface(#181818 = Codex 的 surface),**不是** - --dsw-alias-bg-base:后者是窗口背景 #111111,叠上去只有 29,卡片就不分层了。 */ --dcu-composer-bg: color-mix(in srgb, var(--dsw-alias-label-primary) 5%, var(--dsw-composer-surface)); --dcu-composer-shadow: inset 0 0 1px 0 #fff3; } @@ -107,27 +42,20 @@ body[data-ds-dark-theme] [data-conversation-scroll] { box-shadow: var(--dcu-composer-shadow); } +/* 超椭圆时圆角由 skin.css 同一个 @supports 把 --dsw-radius-card 升到 25px */ @supports (corner-shape: superellipse(1.5)) { [data-conversation-scroll] [data-composer-card] { - border-radius: var(--dsw-radius-card); corner-shape: superellipse(1.5); } } -@media (max-width: 639px) { - [data-conversation-scroll] { - /* Codex 在 width<40rem 时把远场 80px 收到 40px(阈值同为 640,与本查询一致) */ - --dcu-composer-shadow: 0 0 0 1px rgba(13, 13, 13, 0.1), 0 2px 8px 0 #0000000a, 0 4px 40px 8px #00000006; - } -} - @media (forced-colors: active) { [data-conversation-scroll] [data-composer-card] { outline: 1px solid CanvasText; } } -/* ── ⑭·2 输入区几何:44px 编辑区 + 12px 内缩,占位元素与正文同轴 ────────── */ +/* ── ⑭·2 编辑区:44px、12px 内缩,占位与正文同轴 ── */ [data-conversation-scroll] [data-composer-card] [data-input-scroll] { margin-right: 0; } @@ -141,8 +69,7 @@ body[data-ds-dark-theme] [data-conversation-scroll] { inset: 0 12px auto; } -/* ── ⑭·3 底栏行与发送键:28px 控件带,发送键改墨色圆点、无位移 ──────────── - 卡内结构是 card > [data-input-scroll] + row,故用相邻兄弟定位底栏。 */ +/* ── ⑭·3 底栏(卡内结构 card > [data-input-scroll] + row):28px 控件带,发送键是墨色圆点 ── */ [data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div { padding: 0 8px 8px; gap: 5px; @@ -156,7 +83,8 @@ body[data-ds-dark-theme] [data-conversation-scroll] { gap: 4px; } -[data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div :is(button, select):not([role]) { +[data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div button:not([role]), +[data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div select:not([role]) { min-height: 28px; height: 28px; } @@ -172,15 +100,13 @@ body[data-ds-dark-theme] [data-conversation-scroll] { background: var(--dsw-alias-label-secondary); } -[data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div :is(button, select):focus-visible { +[data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div button:focus-visible, +[data-conversation-scroll] [data-composer-card] > [data-input-scroll] + div select:focus-visible { outline: 2px solid var(--dsw-focus-ring-color); outline-offset: 2px; } -/* ── ⑭·4 工具条与建议菜单(只保留官方锚点能命中的部分) ──────────────────── - hero 的工作区行按钮 → Codex 胶囊;@ / 指令建议菜单 → 与输入区同宽, - 圆角走浮层刻度 --dsw-radius-menu(18px),与模型菜单同一族; - 旧值 12px(--dsw-radius-m)与菜单族的 18 不同源,已收回。 */ +/* ── ⑭·4 hero 工作区行的按钮是胶囊;@ / 指令建议菜单与输入区同宽、走浮层菜单那一族(18 / 行 13) ── */ [class*="_heroWorkspaceRow"] button { min-height: 28px; border-radius: var(--dsw-radius-pill); @@ -202,13 +128,8 @@ body[data-ds-dark-theme] [data-conversation-scroll] { max-width: none; padding: 5px; border-radius: var(--dsw-radius-menu); - background: #fff; - box-shadow: 0 8px 32px #0002, 0 0 0 1px #0000000a; -} - -body[data-ds-dark-theme] [data-conversation-scroll] [data-trigger-menu] { - background: #292929; - box-shadow: 0 8px 32px #0003, 0 0 0 1px #ffffff08; + background: var(--dsw-codex-suggest-fill); + box-shadow: var(--dsw-codex-suggest-shadow); } [data-conversation-scroll] [data-trigger-menu] > [role=listbox] { @@ -219,7 +140,6 @@ body[data-ds-dark-theme] [data-conversation-scroll] [data-trigger-menu] { scrollbar-width: thin; } -/* 选项行圆角同「浮层 18 − 内衬 5 = 13」,与 ⑫·3 的菜单行同一公式 */ [data-conversation-scroll] [data-trigger-menu] [role=option] { box-sizing: border-box; width: 100%; @@ -236,9 +156,7 @@ body[data-ds-dark-theme] [data-conversation-scroll] [data-trigger-menu] { background: color-mix(in srgb, var(--dsw-alias-label-primary) 10%, transparent); } -/* ── ⑭·5 新建页(hero):输入区占满、工作区行贴在卡片上沿 ───────────────── - 上游的 .dcu-home-* 建议卡属于它自己的组件,本皮肤没有这些节点,不移植; - container 声明只为那些卡片服务,一并省掉。 */ +/* ── ⑭·5 新建页:输入区占满,工作区行贴在卡片上沿 ── */ [data-phase=hero] [data-conversation-scroll][class] { --dsh-composer-side-clearance: 16px; justify-content: flex-start; @@ -250,7 +168,8 @@ body[data-ds-dark-theme] [data-conversation-scroll] [data-trigger-menu] { min-height: 100%; } -[data-phase=hero] [data-composer-seat] > :has([class*="_composerHero"]) { +/* hero 在席位下第三层(seat > 包裹 > 一层 > hero):固定深度的子代 :has(),编辑区打字不再触发整棵子树的重判 */ +[data-phase=hero] [data-composer-seat] > :has(> * > [class*="_composerHero"]) { display: flex; flex: 1; flex-direction: column; @@ -267,6 +186,7 @@ body[data-ds-dark-theme] [data-conversation-scroll] [data-trigger-menu] { padding-bottom: 32px; } +/* 工作区行(上栏条):圆角就是卡片圆角本身(同一个令牌),探在卡片背后与之同心叠放 */ [data-phase=hero] [class*="_heroWorkspaceRow"] { box-sizing: border-box; flex: none; @@ -278,9 +198,6 @@ body[data-ds-dark-theme] [data-conversation-scroll] [data-trigger-menu] { min-height: 48px; margin: 0 28px -10px; padding: 6px 12px 16px; - /* 上栏条圆角 = 卡片圆角(同一个 --dsw-radius-card):它探在卡片背后,只有上面两角可见, - 与卡片同心叠放。旧值 16px(卡片 20)时 条/卡 = 0.79,而 Codex 实测 1.03 - —— 条比卡片紧 5px 就是那两条对不上的角弧。 */ border-radius: var(--dsw-radius-card) var(--dsw-radius-card) 0 0; background: color-mix(in srgb, var(--dsw-alias-label-primary) 4%, var(--dsw-alias-bg-base)); } diff --git a/skins/codex-ink/model-picker.css b/skins/codex-ink/model-picker.css index 59c6e72..3b8a427 100644 --- a/skins/codex-ink/model-picker.css +++ b/skins/codex-ink/model-picker.css @@ -1,30 +1,16 @@ -/* ============================================================================ - 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 照抄,好让我们的触发器在工具条里与原生那一格同位同高); - 本皮肤 = 已有令牌(⑫ 菜单面板契约、⑰ 胶囊、同心圆角)。 - ============================================================================ */ +/* codex-ink · L3 模型选择器组件(B 面:模型列表 + 推理等级功率轨),消费者 src/client/model-picker/component.js。 + 纪律(0.5.0 卡顿的教训,scripts/check.mjs 守着):只画自建节点 .codex-mp-* 加席位的一条隐藏规则, + 不挂宿主菜单 portal(body 直属的 role=menu),不改宿主菜单的 display / height / padding。 + 取值:Codex = app.asar 模型选择器实现块 _Root / _Track / _Tick / _Thumb 逐字值; + 宿主 = dsh-client-ui-model-selection 的 ModelSelect.module.css(触发器几何、z-index,与原生那一格同位同高)。 */ /* ── ① 席位顶替 ───────────────────────────────────────────────────────── - 我们的触发器在席时,宿主那一格隐藏(仍在 React 树里,生命周期不动)。 - 直接子代 :has() 的失效范围只有席位自己的几个子节点,不是 0.5.0 那种挂在 - portal 菜单上的长链。触发器一摘(设置里关掉、或组件卸载),宿主那一格立刻复原。 */ -[data-slot="conversation.input.model"]:has(> .codex-mp-trigger) > :not(.codex-mp-trigger) { + 我们的触发器在席时宿主那一格隐藏(仍在 React 树里)。组件在把触发器放进席位的同一步给席位出口打 + data-codex-ui-seated,撤走触发器时摘掉 —— 宿主换掉自己的子节点也不影响。 + 不用 :has(> .codex-mp-trigger):它落在祖先位置,会话区每插入一个带 data-slot 的节点都会让 + Chromium 重配整片子树,实测流式插入时样式重算 ~50ms → ~1000ms。 */ +[data-slot="conversation.input.model"][data-codex-ui-seated] > :not(.codex-mp-trigger) { display: none; } @@ -132,17 +118,13 @@ 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); + box-shadow: var(--dsw-codex-menu-shadow); 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; } @@ -310,6 +292,20 @@ body[data-ds-dark-theme] .codex-mp-popover { display: none; } +/* 顶档紫(Ultracode):dsh-claude-style 亮 #8b7ad0 / 暗 #9d8ce0,各自一套 flash 与 ink。 + 定义在**卡片**上而不是轨上:档位名在 head 里、点阵在轨里,两者都要读到它。 */ +.codex-mp-effort { + --codex-mp-apex: #8b7ad0; + --codex-mp-apex-flash: #d9d2f5; + --codex-mp-apex-ink: #5f51b5; +} + +body[data-ds-dark-theme] .codex-mp-effort { + --codex-mp-apex: #9d8ce0; + --codex-mp-apex-flash: #e6e0fb; + --codex-mp-apex-ink: #b9adf0; +} + .codex-mp-effort-head { display: flex; align-items: center; @@ -340,20 +336,22 @@ body[data-ds-dark-theme] .codex-mp-popover { padding-inline: 6px; } -/* Codex _Root:高 28、整宽、居中;touch-action: none 必须写,否则移动端手势被滚动抢走。 - 动效令牌逐字:主曲线 .3s cubic-bezier(.23, 1, .32, 1),档位变换 .12s, - 拇指跟随时长首帧 0s(脚本 16ms 后打 data-armed 抬到 .3s,打开时不从 0 滑过来)。 */ +/* ── 形态基准:dsh-claude-style 的推理强度滑杆 ───────────────────────────── + 它的注释写明对标 Claude Desktop 的 Effort slider,几何逐条取自 + dsh-claude-style/lib/client.js 的 .dsh-claude-effort-* 规则(不是目测): + 槽 26px 厚 / 圆角 8px(圆角矩形,不是胶囊);旋钮 16×30 / 圆角 5px; + 填充 = 主文本墨 26%,止于旋钮中心;顶档换成紫色点阵。 + 驱动仍是 --codex-mp-pos(连续比例),所以拖动跟手的性质不变。 */ .codex-mp-root { - --codex-mp-motion-duration: 0.3s; - --codex-mp-motion-easing: cubic-bezier(0.23, 1, 0.32, 1); + --codex-mp-motion-duration: 0.18s; + --codex-mp-motion-easing: cubic-bezier(0.22, 0.61, 0.36, 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; + height: 30px; outline: none; touch-action: none; cursor: pointer; @@ -378,43 +376,123 @@ body[data-ds-dark-theme] .codex-mp-popover { --codex-mp-thumb-motion-duration: 0s; } -/* Codex _Track:24px 高、12px 圆角、前景 10% 底、0.5px 内描边(--color-border) */ +/* 热区 = 旋钮高(30px);槽是它中间那条 26px 的圆角矩形 */ .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); + height: 30px; +} + +.codex-mp-track::before { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 26px; + margin-top: -13px; + border-radius: 8px; + background: var(--dsw-alias-interactive-bg-hover); } -/* Codex _Range:强调色,只圆左侧两角,止于拇指中线。 - 位置公式与拇指、圆点同一条:calc(14px + (100% − 28px) × pos)。 */ +/* 填充:主文本墨 26% —— 一条规则同时压暗亮槽、提亮暗槽。 + 右端方角:填充止于旋钮中心,圆角会在槽的上下行露出裸轨(claude-style 的原话)。 */ .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); + left: 0; + top: 50%; + height: 26px; + margin-top: -13px; + width: calc(8px + (100% - 16px) * var(--codex-mp-pos, 0)); + border-radius: 8px 0 0 8px; + background: color-mix(in srgb, var(--dsw-alias-label-primary) 26%, transparent); transition: width var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing); + pointer-events: none; +} + +/* ══ 可复用动画单元:顶档紫色点阵 ══════════════════════════════════════════════════ + 移植自 dsh-claude-style 的 .dsh-claude-effort-matrix*(MIT)。单元边界:容器 + 格 + + 方块 + 两个 keyframes;只读 --codex-mp-apex / --codex-mp-apex-flash,不读轨的任何几何, + 格子的行列与 pitch 由脚本按设备像素算好后写成内联样式。 */ +.codex-mp-matrix { + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 26px; + margin-top: -13px; + border-radius: 8px; + overflow: hidden; + box-sizing: border-box; + display: none; + pointer-events: none; } -/* Codex _Tick:4px 圆点,热区 16px(::before 外扩 6px);走过的 30% 白,没走到的 description 50% */ +.codex-mp-root[data-tier="max"] .codex-mp-matrix { + display: grid; + grid-auto-flow: row; +} + +.codex-mp-matrix-cell { + animation: codex-mp-cell-in 0.15s ease both; +} + +@keyframes codex-mp-cell-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.codex-mp-matrix-sq { + width: 100%; + height: 100%; + border-radius: 1px; + background-color: var(--codex-mp-apex); + --codex-mp-flash-light: var(--codex-mp-apex-flash); + animation: codex-mp-flash 1.45s infinite ease-in-out; +} + +.codex-mp-matrix-sq[data-tone="0"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 62%, var(--codex-mp-apex-flash)); } +.codex-mp-matrix-sq[data-tone="1"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 54%, var(--codex-mp-apex-flash)); } +.codex-mp-matrix-sq[data-tone="2"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 46%, var(--codex-mp-apex-flash)); } +.codex-mp-matrix-sq[data-tone="3"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 38%, var(--codex-mp-apex-flash)); } +.codex-mp-matrix-sq[data-tone="4"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 30%, var(--codex-mp-apex-flash)); } +.codex-mp-matrix-sq[data-tone="5"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 22%, var(--codex-mp-apex-flash)); } +.codex-mp-matrix-sq[data-tone="6"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 14%, var(--codex-mp-apex-flash)); } +.codex-mp-matrix-sq[data-tone="7"] { --codex-mp-flash-light: color-mix(in srgb, var(--codex-mp-apex) 6%, var(--codex-mp-apex-flash)); } + +@keyframes codex-mp-flash { + 0%, + 17.24% { + background-color: var(--codex-mp-apex); + } + + 17.25%, + 50.34% { + background-color: var(--codex-mp-flash-light); + } + + 100% { + background-color: var(--codex-mp-apex); + } +} + +/* 刻度:4px 圆点,与填充同墨。走过去的被填充吞掉、当前那档被旋钮吞掉, + 所以圆点只在裸槽上露出来 —— 不需要再分选中/未选中两色。 */ .codex-mp-tick { position: absolute; top: 50%; width: 4px; height: 4px; + margin-top: -2px; 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); + background: color-mix(in srgb, var(--dsw-alias-label-primary) 26%, transparent); + transform: translateX(-50%); + pointer-events: none; } .codex-mp-tick::before { @@ -423,25 +501,16 @@ body[data-ds-dark-theme] .codex-mp-popover { 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 原样如此。 */ +/* 旋钮:16×30 的圆角矩形(不是圆珠)。白片亮暗同色 —— 它是槽里唯一那颗亮石子。 */ .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%); + left: calc(8px + (100% - 16px) * var(--codex-mp-pos, 0)); + width: 16px; + height: 30px; + margin-top: -15px; + border-radius: 5px; + transform: translateX(-50%); pointer-events: none; will-change: left; transition: left var(--codex-mp-thumb-motion-duration) var(--codex-mp-motion-easing); @@ -452,20 +521,117 @@ body[data-ds-dark-theme] .codex-mp-popover { 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; + border-radius: 5px; + background: var(--dsw-static-neutral-bluish-00, #ffffff); + box-shadow: + 0 1px 3px rgb(20 20 19 / 28%), + 0 0 0 1px rgb(20 20 19 / 6%); +} + +body[data-ds-dark-theme] .codex-mp-thumb { + box-shadow: + 0 1px 3px rgb(0 0 0 / 55%), + 0 0 0 1px rgb(0 0 0 / 40%); +} + +/* 顶档:填充与刻度隐去,交给点阵;旋钮染紫 + 光晕;档位名转紫。 */ +.codex-mp-root[data-tier="max"] .codex-mp-range, +.codex-mp-root[data-tier="max"] .codex-mp-tick { + opacity: 0; +} + +.codex-mp-root[data-tier="max"] .codex-mp-thumb { + background: color-mix(in srgb, var(--codex-mp-apex) 22%, #ffffff); + box-shadow: + 0 0 10px color-mix(in srgb, var(--codex-mp-apex) 80%, transparent), + 0 0 0 1px color-mix(in srgb, var(--codex-mp-apex) 35%, transparent); } -/* 键盘焦点:Codex outline 2px + offset 0,画在拇指上(不是全局那条 2px + 2px) */ +.codex-mp-effort[data-tier="max"] .codex-mp-effort-value { + color: var(--codex-mp-apex-ink); +} + +/* 键盘焦点:2px 环画在旋钮上(不是全局那条 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; } + +/* 两端刻度名:它们命名的是**方向**(更快 / 更强),不是取值,所以保持 caption 色 */ +.codex-mp-ends { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 9px 2px; + font-size: 11px; + line-height: 14px; + color: var(--dsw-alias-label-caption); +} + +/* 档位名交换:进来的从下方模糊上浮,出去的(ghost,绝对定位压在原位)向上模糊飘走。 + 用**自己的**类名:.codex-mp-effort-text 是触发器上那组叠层档名用的,别撞。 */ +.codex-mp-value-text { + animation: codex-mp-value-in 0.28s ease; +} + +.codex-mp-effort-ghost { + position: absolute; + left: 0; + top: 0; + white-space: nowrap; + pointer-events: none; + color: inherit; + animation: codex-mp-value-out 0.28s ease forwards; +} + +@keyframes codex-mp-value-in { + from { + filter: blur(4px); + opacity: 0; + transform: translateY(5px); + } + + to { + filter: blur(0); + opacity: 1; + transform: translateY(0); + } +} + +@keyframes codex-mp-value-out { + from { + filter: blur(0); + opacity: 1; + transform: translateY(0); + } + + to { + filter: blur(4px); + opacity: 0; + transform: translateY(-5px); + } +} + +/* 关动效:系统偏好,以及脚本在打开弹层时打的 data-reduced-motion。 */ +@media (prefers-reduced-motion: reduce) { + .codex-mp-matrix-cell, + .codex-mp-matrix-sq, + .codex-mp-value-text, + .codex-mp-effort-ghost { + animation: none; + } +} + +.codex-mp-popover[data-reduced-motion="true"] .codex-mp-matrix-cell, +.codex-mp-popover[data-reduced-motion="true"] .codex-mp-matrix-sq, +.codex-mp-popover[data-reduced-motion="true"] .codex-mp-value-text, +.codex-mp-popover[data-reduced-motion="true"] .codex-mp-effort-ghost { + animation: none; +} diff --git a/skins/codex-ink/patches.css b/skins/codex-ink/patches.css index 1ae94e8..8f411c5 100644 --- a/skins/codex-ink/patches.css +++ b/skins/codex-ink/patches.css @@ -1,29 +1,16 @@ -/* ============================================================================ - codex-ink(墨白终端)· L3 组件层契约 - ---------------------------------------------------------------------------- - 治理目标(对应方案 §7): - ① 卡片统一契约:纯描边 + 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)。 - 宿主没有 data-* 的位置(⑰ 加号与两个触发器)才退到 CSS-Modules 类名的 - **后缀**写法(方括号 class 属性后缀匹配),只依赖语义后缀、不依赖随构建变化的哈希前缀; - 哈希全名一律不用。计数由 src/build.mjs 每次构建自报。 - ========================================================================== */ - -/* ── ① 焦点环:2px Codex 强调蓝 + 2px offset(--color-border-focus,亮 #339cff / 暗 70%) - 颜色读宿主的 --dsw-focus-ring-color(skin.css 已把它接到 --dsw-codex-focus):宿主在指针 - 操作时就地把它置成 transparent,读令牌才能继承这条「鼠标点击不出环」的约定。 ── */ +/* codex-ink · L3 组件契约 + ①–⑪ 通用:焦点环、链接、可点元素、卡片、徽标、tag tone、侧栏、元信息、中文豁免、reduced-motion。 + 其中 data-dsh-part / data-dsh-surface / data-tag-tone 由 skin-center 适配器打(宿主本身不打),皮肤加载器路线用得到。 + ⑫ 宿主模型菜单(A 面:设置里关掉模型选择器时生效) ⑬ 输入区顶栏 ⑮ 侧栏开合动效 ⑯ 右栏展开引导 ⑰ composer 底部控件 + 锚点优先用语义属性;宿主没有 data-* 的地方用 CSS-Modules 类名的后缀写法([class$=…]),不用哈希全名。 */ + +/* ① 焦点环:2px 强调蓝 + 2px offset;读宿主令牌,指针操作时宿主就地置透明 */ :focus-visible { outline: 2px solid var(--dsw-focus-ring-color); outline-offset: 2px; } -/* ── ② 链接 = 强调色(--dsw-alias-link)+ hover 下划线 ──────────────────── */ +/* ② 链接:强调色,hover 下划线 */ a { color: var(--dsw-alias-link); text-decoration: none; @@ -36,7 +23,7 @@ a:focus-visible { text-decoration: underline; } -/* ── ③ 可点击元素:cursor + 150ms 干脆过渡(--dsw-motion-fast) ─────────── */ +/* ③ 可点元素:手型光标 + 150ms 过渡 */ button, a, summary, @@ -68,7 +55,7 @@ button[aria-disabled="true"], opacity: 0.45; } -/* ── ④ 卡片统一契约:描边即层级 ─────────────────────────────────────────── */ +/* ④ 卡片:描边即层级 */ [data-slot="web-ui.plugin.item"] > *, [data-dsh-part="card"], [data-dsh-part="column"], @@ -85,13 +72,12 @@ 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); } -/* ── ⑤ 徽标 / chip:统一 mono pill ──────────────────────────────────────── */ +/* ⑤ 徽标 / chip:mono pill */ [data-dsh-part="composer-chip"], [data-dsh-part="chip"], [data-dsh-part="ref"], @@ -104,7 +90,7 @@ button[aria-disabled="true"], border-radius: var(--dsw-radius-pill); } -/* ── ⑥ 彩色白名单归一:task-board 六档 tag tone 收敛到 state 三色 + 墨灰 ── */ +/* ⑥ 任务板六档 tag tone 收敛到 state 三色 + 墨灰 */ [data-dsh-part="tag-chip"][data-tag-tone="0"], [data-dsh-part="tag-badge"][data-tag-tone="0"] { background: var(--dsw-alias-bg-layer-2); @@ -141,24 +127,18 @@ button[aria-disabled="true"], color: var(--dsw-alias-label-tertiary); } -/* ── ⑦ 侧栏:比主区深/浅一档的整面 ─────────────────────────────────────── */ +/* ⑦ 侧栏整面 */ [data-dsh-surface="sidebar"], [data-slot="sidebar.workspaces"] { background-color: var(--dsw-alias-bg-sidebar); } -/* 侧栏行 hover = 背景升一档,无位移无阴影 */ [data-dsh-part="sidebar-entry"], [data-dsh-surface="sidebar"] [role="button"] { border-radius: var(--dsw-radius-s); } -/* ── ⑧ 输入区:工具不是主角,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] 内),⑭ 层全覆盖。 */ +/* ⑧ 输入区(输入卡本身的圆角与阴影只在 composer.css 声明,这里不重复) */ [data-dsh-surface="composer"] [data-dsh-part="composer-input"] { border-radius: var(--dsw-radius-l); } @@ -168,7 +148,7 @@ button[aria-disabled="true"], line-height: var(--dsw-leading-base); } -/* ── ⑨ 元信息 mono 化:token 数 / 模型名 / 时间戳 / 路径 / 快捷键 ───────── */ +/* ⑨ 元信息 mono */ [data-dsh-part="turn-tail"], [data-dsh-part="queue-dock"], [data-dsh-part="usage-chart"], @@ -176,13 +156,12 @@ button[aria-disabled="true"], font-family: var(--dsw-font-meta); } -/* 微标签:11px / 大写 / .08em —— 仅作用于纯拉丁元信息锚点 */ [data-dsh-part="composer-chip"] { text-transform: uppercase; letter-spacing: var(--dsw-meta-tracking); } -/* ── ⑩ CJK 豁免:中文不做字距加宽、不做大写(:lang(zh) 选择器) ────────── */ +/* ⑩ 中文不加宽字距、不大写 */ :lang(zh) [data-dsh-part="composer-chip"], :lang(zh) [data-dsh-part="chip"], :lang(zh) [data-dsh-part="ref"], @@ -192,7 +171,7 @@ button[aria-disabled="true"], text-transform: none; } -/* ── ⑪ reduced-motion:瞬时终态 ─────────────────────────────────────────── */ +/* ⑪ reduced-motion:0.01ms 而不是 none —— 宿主靠 transitionend 摘 data-animating,none 不触发它 */ @media (prefers-reduced-motion: reduce) { *, *::before, @@ -204,60 +183,26 @@ button[aria-disabled="true"], } } -/* ═══════════════════════════════════════════════════════════════════════════ - ⑫ Codex 模型选择器(composer 模型位 conversation.input.model) - --------------------------------------------------------------------------- - 形态对齐 MichengAI/dsh-codex-ui: - · 触发器 = 宿主原生形态(模型名 + 强度 + chevron),本层不重绘, - 只把焦点环归一成一条细线。旧版藏 chevron、改 mono 字体、压 24px 高, - 实测与仓库截图 conversation-light.png 的触发器不符,全部撤掉。 - · 菜单面板 = 仓库 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% 透明度 —— 点了像没反应,这就是「选择推理 - 强度时出现延迟」的全部成因。宿主 selectModel 的往返时长 CSS 改不了, - 但反馈必须即时可见。 - 锚点纪律:触发器 data-slot;菜单 body > div[role=menu][aria-busy] - (菜单经 createPortal 挂 document.body;aria-busy 恒在,用于排除 - primitives 的通用菜单)。不用 CSS-Modules 哈希类名。 - ═══════════════════════════════════════════════════════════════════════════ */ - -/* ── ⑫·1 触发器:只归一焦点环(官方 box-shadow 环 + 全局 2px outline → 1px 细线) ── */ +/* ⑫ 宿主模型菜单(A 面)。锚点:body 直属的 role=menu 且带 aria-busy(排除 primitives 的通用菜单)。 + 面板:不透明 layer-1 + 18px 圆角 + 内衬 5px + 菜单软影;行:28px / 4px 9px / 圆角 18 − 5 = 13(同心)。 */ [data-slot="conversation.input.model"] button:focus-visible { outline: 1px solid var(--dsw-focus-ring-color); outline-offset: 1px; box-shadow: none; } -/* ── ·2 菜单面板:不透明白 + 圆角 + 软影(参考仓库 composer-tool-menus 实测 18px - = --dsw-radius-menu。旧版把它折到刻度 l=16,结果菜单 16 与输入卡 20/25 叠在 - 一起时两条角弧肉眼可辨;改回实测值后与卡片只差 2px,正是 Codex 自己 - 菜单 18 / 卡片 20 的关系。影 0 8px 32px #0002 + 环 1px #0000000a)────── */ body > div[role="menu"][aria-busy] { + /* rc.1 的填充在菜单元素自身,rc.2 在 MenuSurface 的 material 子层 —— 两边都压成不透明 */ --dsw-menu-surface-fill: var(--dsw-alias-bg-layer-1); --dsw-menu-backdrop-filter: none; - /* rc.1 的填充挂在菜单元素自身(specific-menu 半透明),rc.2 挂在 - MenuSurface 的 material 子层 —— 两边都压成不透明白。 */ + background: var(--dsw-alias-bg-layer-1); padding: 5px; border-radius: var(--dsw-radius-menu); - box-shadow: 0 8px 32px rgba(13, 13, 13, 0.13), 0 0 0 1px rgba(13, 13, 13, 0.04); + box-shadow: var(--dsw-codex-menu-shadow); color: var(--dsw-alias-label-primary); } -body[data-ds-dark-theme] > div[role="menu"][aria-busy] { - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(236, 236, 236, 0.05); -} - -/* ── ⑫·3 行契约:根面板两 cell(模型 / 推理强度)与选项行共用 ────────────── - 圆角按同心纪律取「面板 18 − 内衬 5 = 13」。旧版写 8px(--dsw-radius-s): - 面板 16、行 8 的两条角弧不同心,行看起来比面板"方"——宿主原生是同心的 - (MenuSurface 16 − 列表内衬 4 = 行 12),这条是皮肤自己改散的,已收回。 */ body > div[role="menu"][aria-busy] button[role="menuitem"], body > div[role="menu"][aria-busy] button[role="menuitemradio"] { box-sizing: border-box; @@ -279,7 +224,7 @@ body > div[role="menu"][aria-busy] button[role="menuitemradio"][aria-checked="tr background: var(--dsw-alias-interactive-bg-active); } -/* 键盘焦点:全局 2px 环在菜单内不贴行盒,改 1px 内描线 */ +/* 菜单内的键盘焦点是 1px 内描线(2px 外环不贴行盒) */ 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-focus-ring-color); @@ -287,19 +232,12 @@ body > div[role="menu"][aria-busy] button[role="menuitemradio"]:focus-visible { background: var(--dsw-alias-interactive-bg-active); } -/* pending:全局 button:disabled 的 45% 透明度会把整列洗灰(看起来像卡住), - 还原为 1,反馈交给行尾转圈 + 勾选;非当前行靠官方 dimmed 色区分 */ +/* pending(宿主 selectModel 往返 ~1.1s,整列 disabled + aria-busy,但一个转圈都不画): + 不许被全局 disabled 的 45% 洗灰;勾选已乐观前移,行尾换成转圈,光标换 progress。 */ body > div[role="menu"][aria-busy] button[role="menuitemradio"]:disabled { opacity: 1; } -/* pending 指示器:宿主在 aria-busy 窗口内把整列 disabled,但**不渲染任何转圈**。 - 2026-09-26 在真实 GUI(0.1.7-rc.x,headless Chromium 打 127.0.0.1:3099)实测: - 点「推理等级」后第 60ms 菜单 aria-busy=true、6 行全 disabled、checked 已乐观 - 移到新值,而 document.getAnimations() 在菜单内为 0 —— 面板冻着不动约 1.1s 才关闭。 - 宿主 selectModel 的往返时长 CSS 改不了,能改的是这 1.1s 里必须看得见「在写」。 - 做法:pending 行行尾的勾选换成转圈(勾选已乐观前移,此处正好空出来), - 并把 disabled 的 not-allowed 光标换成 progress。 */ body > div[role="menu"][aria-busy="true"] button[role="menuitemradio"][aria-checked="true"] > span:last-child { position: relative; } @@ -319,8 +257,7 @@ body > div[role="menu"][aria-busy="true"] button[role="menuitemradio"][aria-chec border: 1.5px solid var(--dsw-alias-border-l3); border-top-color: var(--dsw-alias-label-primary); border-radius: 50%; - /* Codex --animate-spin = spin 1s linear infinite(本插件保留自己的 keyframe 名)。 */ - animation: codex-ui-spin 1s linear infinite; + animation: codex-ui-spin 1s linear infinite; /* Codex --animate-spin */ } body > div[role="menu"][aria-busy="true"] button[role="menuitemradio"]:disabled { @@ -333,91 +270,19 @@ body > div[role="menu"][aria-busy="true"] button[role="menuitemradio"]:disabled } } -/* ═══════════════════════════════════════════════════════════════════════════ - ⑬ Codex 式输入区(圆角 / 阴影 / 顶栏消隐 / 面板按钮图标) - --------------------------------------------------------------------------- - 参考图 assets/reference/codex-composer-reference.png(1208×263)实测: - · 页面底 #ffffff;卡片填充 #ffffff - · 卡片下沿阴影 ≈ #fbfbfb(极淡、宽而散);卡片左描边 ≈ #e1e1e1 发丝线 - · 卡片上栏(PROJECT 条)填充 #f5f5f5,顶圆角、左右各内缩 ≈24px - · 卡片圆角:白底白卡无法逐像素定值,按角弧轮廓落在 24–28px,取下沿 24px - ═══════════════════════════════════════════════════════════════════════════ */ - -/* ⑬·1 输入卡:几何与表面已移交 ⑭ 层(skins/codex-ink/composer.css)。 - 那一层从 MichengAI/dsh-codex-ui 原样移植:20px 圆角(corner-shape 可用时 25px 超椭圆)、 - 无边框、三层极淡投影;选择器优先级与层序都在本条之上,故此处不再重复声明,避免双真源。 - 旧值留档:24px 圆角 / 1px border-l1 / 0 1px 2px + 0 14px 36px。 */ - -/* ⑬·2 输入卡上方的 Codex 条(文件/工作区选择栏)形态契约 - seat: conversation.input.dock(「Full-width entries above the composer card」)。 - 内容由客户端插件填入并打 [data-codex-filebar] 标记——只按标记上形, - 避免把既有 QueueDock / TodoDock / GoalDock 也画成灰条。 */ -[data-codex-filebar] { - box-sizing: border-box; - width: calc(100% - 48px); - margin: 0 auto -12px; - padding: 8px 16px 22px; - border-radius: var(--dsw-radius-card) var(--dsw-radius-card) 0 0; - background: #f5f5f5; - color: var(--dsw-alias-label-secondary); - font-family: var(--dsw-font-meta); - font-size: 12px; - line-height: 20px; -} - -body[data-ds-dark-theme] [data-codex-filebar] { - background: #262626; -} - -/* ⑬·3 顶栏瘦身:分割线上只留会话名 + 右上角按钮 - 官方 ConversationSessionHeader 的真实结构(rc1,读 dsh-client-ui-conversation 的 JSX): - div.titleRow - ├ div.titleCluster - │ ├ nav.crumbs ← 会话名(crumbSeg / crumbCurrent),保留 - │ └ div.headerActions ← renderSlot("conversation.session.header.actions"):预设徽标 / 任务列表,隐去 - ├ div.headerUtilities ← renderSlot("...utilities"),隐去 - └ div.headerCorner[data-conversation-header-corner] ← 右栏展开按钮 / 会话日志,保留 - div.tabs[role=tablist][data-conversation-tabs] ← 视图页签,隐去 - titleRow 的官方布局是 titleCluster(flex:1)+ utilities + corner, - 故去掉旧版的 justify-content:flex-end —— 标题要留在左边。 - - ⚠ 本注释块里**不能出现花括号**(左右都不行):src/build.mjs 的 - scopeCss 是按「下一个左花括号」做括号配对的,注释里混进一个花括号就会把 - 后面的规则整段错位 —— 实测表现为紧随其后的规则被重复加 html[data-codex-ui] - 前缀而失效、整份 theme.css 花括号数不平衡。所以注释里只写行式记法。 */ -/* ⑬·3c 顶栏两格**放开**(0.3.0):槽出口里的条目恢复渲染。 - 0.2.x 为了对齐参考图「只留会话名 + 右上角按钮」,这里把两格的槽出口内容整块 display:none。 - 代价是派出去的子代理在界面上完全看不见在跑:官方子代理包把「后代数量触发器」注册在 - conversation.session.header.actions(id subagent-catalog,order -30),同一格还有 - jobs 的 roster、预设徽标、在应用中打开 —— 一起被关掉了。 - 现在放开。这些条目**本身都是条件渲染**(没有子代理 / 没有 job / 没有预设 / 没有工作目录时 - 各自返回 null),所以常态顶栏仍旧是「会话名 + 右上角按钮」,只有真有东西时才多出条目。 - 配色不动:条目只用 --dsw-* 语义令牌(--dsw-alias-label-tertiary、--dsw-alias-interactive-bg-hover、 - --dsw-alias-border-l4 …),本皮肤已重锚这些令牌,因此跟着现在的配色走,不需要另配一套色。 - 出口恒为 div[data-slot=槽名][style=display:contents],不需要也不应该改出口本身。 - ⚠ 本部注释里不能出现花括号(src/build.mjs 的 scopeCss 按花括号配对)。 */ +/* ⑬ 输入区顶栏:视图页签隐去 */ [data-slot="conversation.session.header"] > [data-conversation-tabs] { display: none; } -/* ⑬·3b 顶栏**真正**瘦身:把被隐藏页签仍占着的那条 min-height 收掉。 - 官方规则 .wSkVaW_header:where(:not(:has(.wSkVaW_tabs))) 用 :has() 判断有没有页签, - 但 :has() 只看元素树、**不看 display** —— 上面那条 display:none 之后 .tabs 还在树里, - 于是 min-height:76px 与 padding-bottom:0 一直生效:页签没了,76px 空带还在, - 分割线被顶到空带下方,看起来就是"上方空了一大块 / 分割线太高"。 - 这里按同一语义手动补上。header 元素本身没有 data-*,用它内部稳定的子锚点 :has 定位 - (data-conversation-header-leading,见 dsh-client-ui-conversation/lib/client.js:16223)。 - 必须再带 :has([data-conversation-tabs]):hero/空白态下 showTabs=false,页签根本不渲染, - 官方 .wSkVaW_headerBlank 给的 min-height:0 与 padding-bottom:0 才是对的; - 不带这个条件会反过来给空白顶栏塞 10px 下内边距(实测踩到过)。 */ +/* 页签 display:none 后仍在树里,宿主 :has() 判定的 76px 空带还在 —— 按同一语义收掉。 + 必须带 :has([data-conversation-tabs]):hero 态页签根本不渲染,宿主的 min-height:0 才是对的。 */ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { min-height: 0; padding-bottom: 10px; } -/* ⑬·6 「新会话」:参考图里 New chat 是**无边框行**(图标 + 文字,hover 浅填充), - DSH 默认是带描边的整宽按钮 → 去掉描边与底色,改成行。 - 锚点 data-dsh-part="new-session" 由 skin-center 兼容适配器补打(不依赖本地化文案)。 */ +/* 「新会话」是无边框行(skin-center 适配器补打 data-dsh-part) */ [data-dsh-part="new-session"] { border: 0; background: transparent; @@ -431,26 +296,12 @@ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { background: var(--dsw-alias-interactive-bg-hover); } -/* ⑬·5 卡下方统计行(DSH 独有:轮数/步数 · tok/s · 缓存命中)——参考图没有这一行。 - 要恢复:把下面这条注释掉即可。 */ +/* 卡下方的统计行(轮数 / tok/s / 缓存命中)Codex 没有 */ [data-slot="conversation.composer.dock"] { display: none; } -/* ⑬·4 右上角面板按钮:**用官方图标**,不再手绘。 - 旧版把官方 svg display:none 掉、用纯 CSS 画了个「圆角方框 + 竖分割线」—— - 1.2px 描边配 16px 图标的视觉重量对不上,比官方图标糙。 - 查了 MichengAI/dsh-codex-ui:**它也没换这个图标**。它只做两件事 - (src/client/conversation-header.ts:26): - ① 归零角落插槽的边距补偿 —— 官方 .headerCorner 是 margin-left:8px + margin-right:-16px, - 配合 header 的 padding-right:28px 把按钮顶到距右 12px;归零后回到 28px 内缩。 - ② 用 order 重排,绝不物理搬移 React 节点(搬了会 NotFoundError 白屏)。 - 所以这里:撤掉手绘方框、恢复官方 svg,只补两点 —— 位置归零 + 展开态也镜像成 panel-right。 - - 为什么镜像:官方展开按钮用**未镜像**的 IconPanelLeftOutlineRegular,收起按钮用 - collapseGlyph 的 scaleX(-1) 镜像版 —— 同一块面板的两个状态图标朝向不一致。 - 参考图 assets/reference/codex-app-reference.png 右上角是 panel-right,故把展开态也镜像过来。 - 锚点 data-sidebar-right-expand 见 dsh-client-ui-sidebar-right 的 ExpandButton。 */ +/* 右上角面板按钮:用官方图标,归零角落插槽的边距补偿;展开态也镜像成 panel-right(与收起态朝向一致) */ [data-conversation-header-corner] { margin-left: 0; margin-right: 0; @@ -460,29 +311,13 @@ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { transform: scaleX(-1); } -/* ═══════════════════════════════════════════════════════════════════════════ - ⑮ 侧栏开合动效参数(对齐 Codex 手感) - --------------------------------------------------------------------------- - 宿主本来就animate:AppFrame 是 grid-template-columns: 侧栏 / 1fr / 右栏, - 开合时给 frame 打 [data-animating],grid-template-columns 的 transitionend 到了 - 才摘掉(dsh-client-ui-layout/lib/client.js:270-298)。 - 默认参数 --ds-transition-duration-slow = .3s、--ds-ease-in-out = cubic-bezier(.4,0,.2,1)。 - Codex 实测手感是 500ms + expo-out(MichengAI/dsh-codex-ui 用同值),这里换掉。 - 锚点全用稳定 data 属性:data-animating / data-dragging / data-side。 - ⚠ 不要用 [class*=…]:frame 的类名是 CSS-Modules 哈希,随构建变。 - ═══════════════════════════════════════════════════════════════════════════ */ -/* 只改**参数**,不改宿主"关掉动画"的开关。宿主有四个状态要求 transition:none: - data-dragging(拖拽中,逐帧跟随指针)、data-rightbar-instant(右栏退出全屏,瞬时)、 - data-rightbar-fullscreen、以及 reduced-motion。 - 注意本层被作用域化成 html[data-codex-ui] [data-animating] = (0,2,1), - 会**盖过**宿主的 .pI_x6G_frame[data-rightbar-instant] = (0,2,0) —— 所以必须显式还回去, - 否则右栏退出全屏会被强行加上动画(实测踩到过)。 */ +/* ⑮ 侧栏开合:500ms expo-out(Codex 手感)。只改参数;宿主要求无动画的四个状态显式还回去 —— + 本层作用域化后特异性 (0,2,1) 会盖过宿主的 [data-rightbar-instant] (0,2,0)。 */ [data-animating] { transition: grid-template-columns 500ms cubic-bezier(.16, 1, .3, 1); } -/* 两侧拖拽柄都要覆盖:只写 [data-side="sidebar"] 会让右栏柄停在宿主的 .3s, - 与 frame 的 500ms 脱同步(柄和面板各走各的)。 */ +/* 两侧拖拽柄都要跟 frame 同速,否则柄和面板各走各的 */ [data-animating] [data-side] { transition: left 500ms cubic-bezier(.16, 1, .3, 1); } @@ -503,30 +338,16 @@ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { } } -/* ═══════════════════════════════════════════════════════════════════════════ - ⑯ 右栏「展开时选择组件」= 空栏 guide(对齐 Codex 右栏展开参考图实测) - --------------------------------------------------------------------------- - 参考图实测(488×1120,DPR=1,侧栏面板宽 474): - · 条目行:宽 380 居中、行高 52、行距 14(pitch 66)、无描边无底色、圆角 12 - · 图标列 20×20(条目左沿内缩 20),图标 ↔ 文字 gap 14 - · 标题 14px 单行;参考图没有描述行 - · 快捷键:右侧灰底 pill,68×24、圆角 12、底 ≈ #f3f3f4、字 12px 辅墨 - · 无罗盘 hero;内容块真·垂直居中(宿主底部 10% 占位要去掉) - 锚点:data-sidebar-right-guide / data-sidebar-right-guide-entry(官方稳定 - 属性),行内结构用位置伪类 + :has(kbd),不用哈希类名。 - ═══════════════════════════════════════════════════════════════════════════ */ - -/* 罗盘 hero:参考图无 */ +/* ⑯ 右栏展开引导(codex-rightbar-expand-reference.png 实测):无罗盘 hero、真居中;条目宽 380 / 高 52 / + 无描边无底色 / 圆角 12;图标 20px、间距 14;无描述行;快捷键为 24px 高灰底 pill。 */ [data-sidebar-right-guide] > span[aria-hidden="true"] { display: none; } -/* 底部 10% 占位 → 真居中 */ [data-sidebar-right-guide]::after { display: none; } -/* 条目:胶囊 → 平行 */ [data-sidebar-right-guide-entry] { box-sizing: border-box; width: 380px; @@ -545,7 +366,6 @@ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { background: var(--dsw-alias-interactive-bg-hover); } -/* 图标列 20×20(宿主按有无描述画 22/26,拉回参考值) */ [data-sidebar-right-guide-entry] > span:first-child { width: 20px; height: 20px; @@ -560,15 +380,11 @@ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { gap: 0; } -/* 描述行:仅当它存在时隐藏(nth-child(2) 不存在即标题独占) */ [data-sidebar-right-guide-entry] > span:nth-child(2) > span:nth-child(2) { display: none; } -/* 终端行是 terminal 插件注册的自定义卡(TerminalGuide),不是标准卡: - 子序为 button.main(absolute 点击层)→ span.icon → span.text → keys, - 上面按标准卡子序写的图标/描述规则对它全部落空,按 kind 属性另点。 - 标题旁的 chevron 是选 Shell 菜单入口,功能件保留。 */ +/* 终端条目是 terminal 插件的自定义卡,子序多一个点击层,按 kind 另点 */ [data-sidebar-right-guide-entry="terminal"] > span:nth-child(2) { width: 20px; height: 20px; @@ -583,7 +399,6 @@ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { display: none; } -/* 快捷键 → 灰底 pill(宿主 plain 变体是无底文字键) */ [data-sidebar-right-guide-entry] > span:has(kbd) { display: inline-flex; flex: none; @@ -610,51 +425,21 @@ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { font: inherit; } -/* 收起按钮(▢):参考图常驻灰底圆方(参考 42px,受页签条行高限制取 28, - 形态对齐、尺寸如实记录) */ +/* 收起按钮常驻灰底圆方(参考图 42px,受页签条行高限制取 28) */ [data-sidebar-right-toggle] { background: var(--dsw-alias-bg-layer-2); border-radius: var(--dsw-radius-m); } -/* ═══════════════════════════════════════════════════════════════════════════ - ⑰ composer 底部控件:默认无框,悬停整块淡底 - --------------------------------------------------------------------------- - 需求:Codex 的加号控件默认**没有**背景框,只有鼠标悬停才出现; - 模型选择器与权限控件照它的悬停形态复刻。 - 参考图 assets/reference/codex-composer-plus.png(65×84)与 codex-composer-chip-hover.png(144×58): - - · 加号:40px 直径圆形,悬停时才填 --dsw-codex-hover-fill(实测 #F2F2F3)。 - · 悬停胶囊:整块填充 242,242,243,**没有描边**(逐像素量了外沿:白 255 → 243 → 242, - 没有更深的环);左右弧顶到弧顶 41px,全圆角。 - · 圆角:胶囊**全圆角**。2026-09-27 逐像素复测 codex-composer-chip-hover.png: - 胶囊高 42px、最左点 x=13,而最上一行的边界在 x=29 —— 离最左点 16px,正合 - R=h/2=21 的预测值 16.4;若真是 12px 圆角则应为 8.6。旧版只搬了填充色, - 圆角留在宿主的 --dsw-radius-sm=8px,于是 28px 高的控件配 20/25px 的输入卡 - 不成同心(同心值 20−8=12、25−8=17,都 > 控件高的一半 14)—— 已补 pill。 - - 锚点:加号按钮没有 data-* 只有哈希类名,按 composer.css 已备案的「后缀锚点」写法取 - button[class$="_add"];两个触发器(模型 _7KE1Ra_trigger / 权限 iWlSmW_trigger) - 同样按后缀取 button[class$="_trigger"],整条规则限定在 [data-composer-card] 内。 - 实测(真 GUI,2026-09-26):加号 idle 与 hover 同为 rgb(241,241,239) —— 常驻灰底, - 就是「多了个背景框」;两个触发器 idle 透明、hover rgba(13,13,13,.04),与参考图同形。 - 圆角不交给宿主:宿主 rc.2 的 .u91W7W_trigger / ._5Tq8wa_trigger 都是 - --dsw-radius-sm=8px(且历史版本换过值),皮肤显式写 pill 才与宿主版本无关。 - ═══════════════════════════════════════════════════════════════════════════ */ - +/* ⑰ composer 底部控件(codex-composer-plus.png / codex-composer-chip-hover.png):加号默认无底色框,悬停才填; + 模型与权限触发器同一副悬停胶囊,全圆角(实测 R = h/2)。圆角显式写 pill,不跟宿主版本走。 + 加号只有哈希类名,按后缀取 button[class$="_add"];两个触发器取 button[class$="_trigger"]。 */ [data-composer-card] button[class$="_add"], [data-composer-card] button[class$="_trigger"] { transition: background-color var(--dsw-motion-fast) var(--dsw-ease); -} - -/* 圆角:Codex 的 composer chip 是全圆角胶囊(参考图实测 R = h/2)。加号宿主自带 - 999px,一并显式声明,免得宿主改值后悄悄变方。 */ -[data-composer-card] button[class$="_add"], -[data-composer-card] button[class$="_trigger"] { border-radius: var(--dsw-radius-pill); } -/* 加号:撤掉宿主常驻的 --dsw-alias-bg-layer-2 灰底 */ [data-composer-card] button[class$="_add"] { background: transparent; } @@ -665,4 +450,3 @@ header:has([data-conversation-header-leading]):has([data-conversation-tabs]) { [data-composer-card] button[class$="_trigger"][aria-expanded="true"] { background: var(--dsw-codex-hover-fill); } - diff --git a/skins/codex-ink/settings.css b/skins/codex-ink/settings.css index f1fb24e..29e6490 100644 --- a/skins/codex-ink/settings.css +++ b/skins/codex-ink/settings.css @@ -1,13 +1,5 @@ -/* ============================================================================ - codex-ink · L3 插件设置页层 - ---------------------------------------------------------------------------- - 官方插件管理 → 组合包页里那块配置卡的版式(座位 plugins.bundle.config)。 - 取色只用 --dsw-alias-* / --dsw-specific-* 令牌,**不引入任何彩色** —— 与 - skin.css 的「无彩色 chrome」纪律一致;唯一的彩色是强调色本身,由设置值给。 - - 为什么放在皮肤层而不是卡片内联:这一层同样受 html[data-codex-ui] 作用域约束, - 进同一份 theme.css,可被体检脚本与审计脚本一起看见。 - ========================================================================== */ +/* codex-ink · L3 设置卡(插件管理 → 组合包页的 .cx-* 配置卡,src/client/settings-card.js)。 + 只用语义令牌,不引入彩色;唯一的彩色是用户设的强调色本身。 */ .cx-form { display: flex; @@ -55,16 +47,12 @@ justify-self: end; } -.cx-row--stack .cx-row__control { - justify-self: stretch; -} - .cx-swatch { width: 24px; height: 24px; padding: 0; border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: var(--dsw-radius-s, 6px); + border-radius: var(--dsw-radius-s); background: none; cursor: pointer; } @@ -75,15 +63,16 @@ .cx-swatch::-webkit-color-swatch { border: none; - border-radius: calc(var(--dsw-radius-s, 6px) - 2px); + border-radius: calc(var(--dsw-radius-s) - 2px); } +/* 十六进制输入与数值回显走元信息的 mono 规约 */ .cx-hex { width: 96px; height: 24px; padding: 0 8px; border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: var(--dsw-radius-s, 6px); + border-radius: var(--dsw-radius-s); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-family: var(--ds-font-family-code); @@ -101,7 +90,7 @@ height: 24px; padding: 0 8px; border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: var(--dsw-radius-s, 6px); + border-radius: var(--dsw-radius-s); background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 12px; @@ -134,8 +123,6 @@ .cx-error { margin: 8px 0 0; - /* 旧值读的是 --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 e9d6e1b..bb1edc2 100644 --- a/skins/codex-ink/sidebar-align.css +++ b/skins/codex-ink/sidebar-align.css @@ -1,91 +1,24 @@ -/* ============================================================================ - codex-ink(墨白终端)· L3 侧栏对齐层 - ---------------------------------------------------------------------------- - 源文件:skins/codex-ink/sidebar-align.css - 由 src/build.mjs 与 skin.css / patches.css 一起拼接,并作用域化到 - html[data-codex-ui]。因此本文件写「裸选择器」——不要自己再加 html[...] 前缀, - 那会被 scopeCss 判为已作用域而直通,永不命中。 - - 目标(三条,不多做): - ① 品牌行 —— 完全不动。保留官方 fish 标与名称,不注册 sidebar.brand.* 洞。 - ② New Session 行 —— 压成列表行,图标/文字落到「工作区」下方列表行的两条竖线上。 - ③ 全局面板行(插件…)—— 同一组竖线。 - - 目标列(相对侧栏根 div 左边缘,实测值见 scripts/sidebar-align-verify.mjs): - 图标列 20px = 列表行左边缘 12px + padding-inline-start 8px - 文字列 42px = 20px + 图标 16px + gap 6px - 对照:工作区行(projectRow)实测 20 / 42,会话行(sessionRow)实测 20 / 40。 - - 锚点纪律(遵守 patches.css 头部的约定): - 本层只用 skin-center 语义契约 data-slot + 结构伪类, - 不使用 CSS-Modules 哈希类名,不触发 - "reliance on CSS-Modules hash class names" 警告。 - - 官方侧栏的 New Session 按钮不是 slot 内容,没有 data-slot 可挂; - 但 Tooltip 只克隆锚点、不加包装元素,故它仍是侧栏根 div 唯一的直接 - button 子元素,用 - div:has([data-slot="sidebar.workspaces"]) > button - 定位。折叠态用 sidebar.toggle.badge 出口的存在与否排除 —— - 官方只在 !wide(折叠)时才 renderSlot 该出口。 - - 官方真实值(rc1 0.1.7-rc.1,取自 dsh-client-ui-sidebar 内联 css)。 - 为免触发 scopeCss 的括号解析,这里用行式记法,不写花括号: - - _newSession 高度 height:38px - 边框 .5px solid var(--dsw-alias-border-l3) - 底色 var(--dsw-alias-button-elevated-fill) - 对齐 justify-content:center - gap 6px / margin 0 2px 12px / padding 8px 16px - 字体 14px / 500 / line-height 22px - _newSession:hover 底色 var(--dsw-alias-button-floating-hover) - _newSessionLabel white-space:nowrap / max-width:200px / overflow:hidden - - _panelRow box-sizing:border-box / min-height:36px - 底色 0 0 / 边框 none / 圆角 12px - 对齐 justify-content 未设(即 flex-start) - gap 8px / margin 0 2px / padding 7px 8px - 字体 font:inherit / line-height:22px / text-align:left - _panelRow:hover 底色 var(--dsw-alias-interactive-bg-hover) - _panelRow:focus-visible 2px solid var(--dsw-alias-label-primary),offset -2px - _panelTitle text-overflow:ellipsis / white-space:nowrap - min-width:0 / overflow:hidden - _panelList flex-direction:column / gap:4px / margin-bottom:8px - ============================================================================ */ - - -/* ── ① 品牌行:刻意留空 ────────────────────────────────────────────────── - 官方 logoRow / brandIdentity / brandMark / brandName / fallbackBrandName - 全部原样生效。本层不写任何相关规则。 */ - - -/* ── ② New Session → 压成列表行,落到「工作区」下方列表行的两条竖线 ──────── - 展开态判定:侧栏根 div 含 sidebar.workspaces 出口, - 且不含 sidebar.toggle.badge 出口。 - - 官方 New Session 是 38px 居中主按钮:margin 0 2px 12px / padding 8px 12px / - justify-content:center,图标停在中线(实测 108px),与列表行差 88px。 */ - -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button { +/* codex-ink · L3 侧栏对齐:「新会话」与面板行(插件…)压成列表行,落到工作区列表行的两条竖线上 + (图标列 20px = 行左沿 12 + 内衬 8;文字列 42px = 20 + 图标 16 + 间距 6)。品牌行不动。 + 锚点:侧栏根 = sidebar 席位出口的子 div;折叠态排除用根上的 _collapsed 类(类名后缀写法)—— + rc.1 / rc.2 的渲染代码里它与 sidebar.toggle.badge 出口是同一个条件(!wide)。 + 不用 :has()::has 落在祖先位置时,会话区每插入一个带 data-slot 的节点,Chromium 都得把受影响 + 祖先下的全部 span 重新匹配一遍 —— 实测流式插入时整页样式重算从 ~60ms 涨到 ~900ms。 + 选择器特异性与旧写法相同(0,3,3 起),与宿主规则的先后关系不变。 */ + +/* 新会话按钮:38px 居中主按钮 → 36px 左对齐列表行(rc.1 扁平结构) */ +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > button { box-sizing: border-box; - - /* 高度:38px → min-height:36px */ height: auto; min-height: 36px; flex: none; - - /* 外观:主按钮 → 列表行 */ background: 0 0; border: none; border-radius: var(--dsw-radius-s); - - /* 排版:居中主按钮 → 左对齐列表行 */ justify-content: flex-start; align-items: center; gap: 6px; - /* 左边距归零:行左边缘与列表行同为 12px,图标才落到 20px。 - 桌面壳(html[data-windows-titlebar])官方自己也写 margin-left:0,那条 - (0,4,0) 比本层 (0,3,3) 更高 —— 桌面端本来就吃到 0。这里显式写 0, - 让 web profile(无 titlebar)与桌面端落到同一条竖线上。 */ + /* 左边距归零:桌面壳宿主自己就是 0,web profile 也落到同一条竖线 */ margin: 0 2px 4px 0; padding: 7px 8px; font: inherit; @@ -96,65 +29,49 @@ div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.ba overflow: hidden; } -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button:hover { +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > button:hover { background: var(--dsw-alias-interactive-bg-hover); } -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button:focus-visible { +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > button:focus-visible { outline: 2px solid var(--dsw-focus-ring-color); outline-offset: -2px; } -/* 图标:官方 newSession 用 14px,panelGlyph 用 16px → 统一 16px(rc.1 扁平结构) */ -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button > svg { +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > button > svg { width: 16px; height: 16px; flex: none; } -/* 标签:官方只设了 max-width:200px → 补齐 panelTitle 的省略行为(rc.1 扁平结构) */ -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button > span { +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > button > span { max-width: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; } - -/* ── ②·b New Session · rc.2 嵌套结构 ───────────────────────────────────── - 0.1.7-rc.2(桌面壳在跑的那一代)在按钮里又套了两层: - button > span(newSessionLabelMask) > span(newSessionContent) > svg + span(newSessionLabel) - 官方在 newSessionContent 上写死 width:100cqw + justify-content:center, - 并把按钮设成 container-type:inline-size —— 于是只把 button 改成 flex-start - 压不住它,图标仍旧停在按钮中线。这里把这一层拉回左对齐。 */ - -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button > span > span:has(> svg) { +/* rc.2 在按钮里多套了两层(labelMask > content,content 写死 width:100cqw + 居中),拉回左对齐 */ +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > button > span > span:has(> svg) { justify-content: flex-start; width: auto; } -/* 图标(rc.2 嵌在内容容器里,与 rc.1 同一条 16px 契约) */ -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button > span > span > svg { +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > button > span > span > svg { width: 16px; height: 16px; flex: none; } -/* 标签(rc.2 又深一层;span.keys 的键帽是 kbd,不会命中这条) */ -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > button > span > span > span { +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > button > span > span > span { max-width: none; min-width: 0; overflow: hidden; text-overflow: ellipsis; } - -/* ── ③ 全局面板行(插件…)→ 同两条竖线 ─────────────────────────────────── - 侧栏根 div 的直接 nav 子元素就是 panelList;它的每个直接 button 是一行面板。 - 官方 panelRow 是 margin:0 2px / padding:7px 8px / gap:8px - → 图标 22px、文字 46px,比列表行右移 2px / 4px。 */ - -div:has([data-slot="sidebar.workspaces"]):not(:has([data-slot="sidebar.toggle.badge"])) > nav > button { +/* 面板行(侧栏根 div 的直接 nav 里的按钮):宿主 gap 8 / margin 0 2px 比列表行右移 2px / 4px */ +[data-slot="sidebar"] > div:not([class*="_collapsed"]) > nav > button { gap: 6px; margin-left: 0; } diff --git a/skins/codex-ink/sidebar-surface.css b/skins/codex-ink/sidebar-surface.css index 0acc4f3..410e818 100644 --- a/skins/codex-ink/sidebar-surface.css +++ b/skins/codex-ink/sidebar-surface.css @@ -1,97 +1,18 @@ -/* ============================================================================ - codex-ink(墨白终端)· L3 侧栏面层 - ---------------------------------------------------------------------------- - 源文件:skins/codex-ink/sidebar-surface.css - 由 scripts/build.mjs 与 skin.css / patches.css 一起拼接,并作用域化到 - html[data-codex-ui]。因此本文件写「裸选择器」——不要自己再加 html[...] 前缀。 - - 组件:**侧栏滚动渐隐**(Codex 的 --sidebar-scroll-mask-image)。 - 验收:scripts/sidebar-surface-verify.mjs(夹具 A/B + 像素斜坡)。 - - ── 为什么是「面」而不是「样式」 ────────────────────────────────────────── - 宿主对同一件事有自己的实现:滚动容器旁边放一个 24px 的绝对定位覆盖层, - 底色是 linear-gradient(transparent → var(--dsw-specific-sidebar-fill))。 - 覆盖层成立的前提是**底色不透明**;本皮肤支持侧栏半透明 - (src/override.js 的 SIDEBAR_ALPHA 会改写 --dsw-specific-sidebar-fill 的 alpha), - 一旦半透明,覆盖层就只能糊上一层半透明的色,内容在它下面仍然看得见。 - Codex 用的是 mask:直接对内容做 alpha 遮罩,与底色是否透明无关。 - 所以本层把宿主那条覆盖层让位,换成 Codex 的 mask 形状。 - - ── Codex 原文(codex-app-initial.css,_headerFadeMask_n9nga_1) ────────── - 为免触发 scopeCss 的括号解析,这里用行式记法,不写花括号: - - --edge-fade-distance calc(var(--spacing) * 10) - --sidebar-scroll-footer-edge calc(100% - var(--sidebar-footer-height)) - --sidebar-scroll-footer-fade-distance calc(var(--spacing) * 10) → 40px - --sidebar-scroll-footer-fade-start calc(footer-edge - fade-distance) - --sidebar-scroll-header-mask-distance var(--sidebar-scroll-header-fade-distance, - var(--sidebar-scroll-header-spacing, spacing*2)) - --sidebar-scroll-mask-image linear-gradient(to bottom, - transparent 0, - transparent , - black , - black , - #000000e0 , - #00000085 , - #0000002e , - transparent , - transparent 100%) - -webkit-mask-image / mask-image var(--sidebar-scroll-mask-image) - mask-size 100% 100% / mask-repeat: no-repeat - - --spacing 是 Tailwind 的 4px 刻度(Codex 根块 --padding-panel-base: spacing*3 = 12px - 可反推)。于是底部那段斜坡在 40px 内四段收敛:1 → 0.878 → 0.522 → 0.180 → 0。 - - ── 两处按 DSH 现场的改写(逐条给依据) ────────────────────────────────── - ① footer-edge 取 100%,不取 calc(100% - footer 高度)。 - Codex 的滚动视口与底部固定区**同层**(footer 压在滚动内容之上), - 所以它的斜坡要在 footer 顶边就收敛完;DSH 的 regionArea 与 footArea - 是 flex 列的**上下两块**,滚动容器底边本身就是 footArea 的顶边 - (真 GUI 实测:regionArea y=154 h=690 → 底边 844;footArea y=844)。 - 所以 100% 就是 Codex 的 footer-edge,不需要再减。 - ② 顶部不做渐入。 - Codex 的 header-mask-start 默认 0、距离 8px,是给「吸顶分组标题」用的: - 内容从标题下面滚过去时在顶部渐隐。DSH 的分组标题(工作区表头)不在滚动 - 容器里,是常驻的一行,滚动视口顶上没有任何覆盖层——照抄这 8px 只会让 - 列表第一行永远发虚。Codex 自己在 _stickySectionHeaders 上也把这段距离 - 收到 spacing/2(2px)。 - - ── 锚点纪律 ──────────────────────────────────────────────────────────── - 宿主没有给侧栏列表发任何语义锚点(真 GUI 实测:整个 [data-slot] 集合里 - 只有 sidebar.*、settings.*、conversation.* 这些席位出口,滚动容器与覆盖层 - 都没有 data-*)。本层因此分两类: - · 作用域根:div:has(> [data-slot="sidebar.workspaces"]) —— 语义锚点, - 唯一命中 regionArea;沿用 sidebar-align.css 已经建立的用法。 - · 容器/覆盖层:副作用后缀锚点 class 后缀 _list / _fade(选择器写作方括号 - class 属性后缀匹配)。 - 后缀是 CSS-Modules 的**源类名**(哈希前缀每次构建会变:桌面壳 asar 是 - n_2Q3W_/MnE-JG_,npm 全局安装是 hHd-Xa_/bhn1Oq_),所以后缀形式 - 跨版本反而比哈希全名稳。代价是 build.mjs 的 hash 锚点计数 +2, - 与 patches.css / composer.css 已有的 29 处同一类,写在这里备查。 - ============================================================================ */ - - -/* ── ① 宿主那条 24px 覆盖层让位 ─────────────────────────────────────────── - 它刷的是 --dsw-specific-sidebar-fill。半透明底时它挡不住内容, - 且与下面的 mask 叠加会变成双渐隐(实测见验收脚本的 A/B 像素读数)。 - 只在本层作用域内摘除,区域外一律不动。 */ - -div:has(> [data-slot="sidebar.workspaces"]) [class$="_fade"] { +/* codex-ink · L3 侧栏滚动渐隐:宿主那条 24px 覆盖层让位,换成 Codex 的 40px mask 斜坡 + (codex-app-initial.css 的 --sidebar-scroll-mask-image:最后 40px 内四段 1 → .878 → .522 → .180 → 0)。 + 为什么不用覆盖层:它刷的是不透明侧栏底色,侧栏开了半透明(设置页)就挡不住内容;mask 与底色是否透明无关。 + 与 Codex 的两处差别:footer-edge 取 100%(DSH 的滚动区与底栏是上下两块,滚动区底边就是底栏顶边); + 顶部不做渐入(DSH 的分组标题不在滚动容器里,照抄 8px 只会让第一行永远发虚)。 + 宿主没给滚动容器与覆盖层 data-*:从 sidebar.workspaces 出口往下按类名后缀 _list / _fade 取 + (写成 div > 出口,特异性与旧的 div:has(> 出口) 相同;不用 :has,理由见 sidebar-align.css)。 */ + +div > [data-slot="sidebar.workspaces"] [class$="_fade"] { display: none; } - -/* ── ② 滚动容器:Codex 的 mask 斜坡 ────────────────────────────────────── - 两层 mask,默认 mask-composite: add 叠加: - 第一层 = 斜坡本体,横向只铺到「列表右内边距」为止; - 第二层 = 右侧那一条不透明遮罩。 - 为什么分两层:mask-image 配 mask-repeat: no-repeat 时,图像没铺到的区域 - mask 值是 0(= 隐藏),所以只写第一层会把右侧 12px(宿主的滚动条槽, - 由 --dsh-session-list-edge-inset / scrollbar-gutter: stable 预留) - 整条抹掉。第二层把那一条补回不透明,宿主「渐隐不压滚动条」的既有取舍 - 就被保住了 —— 宿主的覆盖层也是 right: var(--dsh-session-list-edge-inset)。 */ - -div:has(> [data-slot="sidebar.workspaces"]) [class$="_list"] { +/* 两层 mask:第一层是斜坡,横向只铺到列表右内边距为止;第二层把右侧滚动条槽补回不透明 + (no-repeat 的 mask 没铺到的地方 = 隐藏,只写第一层会把滚动条槽整条抹掉)。 */ +div > [data-slot="sidebar.workspaces"] [class$="_list"] { -webkit-mask-image: linear-gradient( to bottom, diff --git a/skins/codex-ink/skin.css b/skins/codex-ink/skin.css index abaf335..60bd681 100644 --- a/skins/codex-ink/skin.css +++ b/skins/codex-ink/skin.css @@ -1,34 +1,24 @@ -/* ============================================================================ - codex-ink(墨白终端)· L1 令牌层 + L2 排版层 - ---------------------------------------------------------------------------- - 设计母题:OpenAI Codex / ChatGPT 系 —— 界面消失,只剩内容与光标。 - 黑白灰是 chrome,彩色只配给状态。 - - 本文件只做两件事: - 1) 把官方 --dsw-alias-* / --dsw-specific-* 语义令牌重映射到墨白灰阶; - 2) 补齐官方缺失的 spacing / radius / typography-scale / motion / elevation 令牌层。 - - 纪律: - · 不出现任何 url()、@import、远程地址、内联 JS(skin-center 安全管线白名单); - · 亮色令牌同时落在 :root 与 body —— 官方亮色令牌声明在 body 上, - 只写 :root 会被 body 自身声明盖掉(加载器会把本文件强制限定在 - html[data-dsh-skin="codex-ink"] 之下,从而取得高于官方的特异性); - · 彩色白名单:state 三色 + diff 红绿 + 徽标底色 + 焦点环(Codex --color-border-focus 蓝 #339CFF), - 其余一律墨/灰。 - ========================================================================== */ - -/* ── 亮色主题 ────────────────────────────────────────────────────────────── */ +/* codex-ink · L1 令牌 + L2 排版 + 把宿主的 --dsw-alias-* / --dsw-specific-* 语义令牌重映射到墨白灰阶,并补齐间距 / 圆角 / 字阶 / 动效 / elevation。 + 黑白灰是 chrome,彩色只给 state 三色、diff 红绿与强调色。取值依据见 README「实测值」「与 Codex 源码对账」。 + 亮色令牌同时落 :root 与 body:宿主把令牌声明在 body 上,只写 :root 会被 body 自身声明盖掉。 */ + +/* ── 亮色 ── */ :root, body { color-scheme: light; - - /* 画布自持:底色由本皮肤自己画在 html 与 body 上,不指望宿主那一帧是否已把 token 换好。 - 主题切换时的闪,十有八九是「某一帧没人画底」—— 那时露出的是 UA/宿主的默认画布色。 */ + /* 画布自持:底色自己画在 html 与 body 上,不指望宿主那一帧已换好令牌(露出默认画布就是闪)。 */ background-color: #ffffff; - /* 底色:白 → #F9F9F9 → #F1F1EF → #E5E5E5 四档灰阶即层级 */ --dsw-alias-bg-base: #ffffff; - --dsw-alias-bg-sidebar: #eef4f9; /* Codex 亮色侧栏实测取样(350×1377 截图点采样 = #EEF4F9) */ + /* 侧栏底:Codex **没有**「侧栏专用色」令牌。侧栏 = --color-surface-tertiary(gray-75 #F3F3F3) + 以 70% 不透明度叠在窗口底上 —— app-shared-*.css 原文(electron 窗口、左侧板外观非 content-surface): + .app-shell-left-panel:not([data-app-shell-left-panel-appearance=content-surface]) + { background: color-mix(in srgb, var(--color-surface-tertiary) 70%, transparent) } + 叠白 = 0.7×243 + 0.3×255 = 246.6 → #F6F6F6,与 Codex 亮色同屏实测 246 同档。 + 那 30% 透明意味着**渲染值随窗口背后的底而变**:旧值 #EEF4F9 是某次蓝底下的读数, + 不是基准,已作废(见 CHANGELOG 0.6.4)。 */ + --dsw-alias-bg-sidebar: #f6f6f6; --dsw-alias-bg-layer-1: #ffffff; --dsw-alias-bg-layer-2: #f1f1ef; --dsw-alias-bg-layer-3: #e5e5e5; @@ -37,16 +27,13 @@ body { --dsw-alias-bg-multi-select: #e5e5e5; --dsw-alias-bg-skeleton: rgba(13, 13, 13, 0.05); - /* 描边:发丝即层级 */ --dsw-alias-border-l1: rgba(13, 13, 13, 0.07); --dsw-alias-border-l2: rgba(13, 13, 13, 0.12); --dsw-alias-border-l3: rgba(13, 13, 13, 0.18); --dsw-alias-border-l4: rgba(13, 13, 13, 0.24); --dsw-alias-border-l2-darkmode-thin: rgba(13, 13, 13, 0.09); - /* 文字:墨 / 次墨 / 辅墨。前景取 Codex 浅色主题的「前景」实测值 #1A1C1F - (冷调,不是纯黑;取色面板截图 assets/reference/codex-theme-light.png)。 */ - --dsw-alias-label-primary: #1a1c1f; + --dsw-alias-label-primary: #1a1c1f; /* Codex 前景(codex-theme-light.png;源码 --color-text-foreground) */ --dsw-alias-label-primary-dimmed: #5d5d5d; --dsw-alias-label-primary-inverted: #ffffff; --dsw-alias-label-primary-foreground: #ffffff; @@ -56,14 +43,12 @@ body { --dsw-alias-label-caption: #767676; --dsw-alias-label-dimmed: #c9c9c9; - /* 主行动 = 墨块(全界面视觉重量最高的元素,无需任何品牌色) */ + /* 主行动是墨色胶囊;强调色只上链接与焦点环 */ --dsw-alias-brand-primary: #0d0d0d; --dsw-alias-brand-primary-invert: #ffffff; --dsw-alias-brand-text: #ffffff; --dsw-alias-brand-primary-new-colorprimary-new-color: #0d0d0d; - /* 强调色 = Codex 浅色主题「强调色」实测值 #339CFF,只上链接; - 主行动按钮在 Codex 里仍是墨色胶囊,故 brand-primary 不动。 */ - --dsw-alias-link: #339cff; + --dsw-alias-link: #339cff; /* Codex 强调色(codex-theme-light.png) */ --dsw-alias-button-primary-fill: #0d0d0d; --dsw-alias-button-primary-hover: #2a2a2a; @@ -81,24 +66,14 @@ body { --dsw-alias-button-tool-bar-fill-invisible: rgba(13, 13, 13, 0.18); --dsw-alias-button-tool-bar-hover: rgba(13, 13, 13, 0.36); - /* 交互底色:背景升一档,无位移无阴影 */ + /* hover 只把背景升一档,不加投影 */ --dsw-alias-interactive-bg-hover: rgba(13, 13, 13, 0.04); - /* composer 控件悬停底:Codex 实测 #F2F2F3(冷中性,不是本层灰阶的暖 #F1F1EF)。 */ - --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-codex-hover-fill: #f2f2f3; /* composer 控件悬停底(codex-composer-chip-hover.png 实测,冷中性) */ + --dsw-codex-focus: #339cff; /* Codex --color-border-focus = --blue-300 */ + /* 宿主的焦点环令牌接到 Codex 焦点色:宿主 :focus-visible 与 box-shadow 画环的组件都读它;宿主在指针 + 操作时就地把它置成 transparent,皮肤的环也读它,于是同样「鼠标点击不出环」。在 body 上解析,深色自动跟随。 */ --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%(暗) - 输入卡环与功率轨描边用前者,功率轨拇指用后者。 */ + /* Codex --color-border(--alpha-10 / 暗 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); @@ -106,7 +81,6 @@ body { --dsw-alias-interactive-bg-hover-accent: rgba(13, 13, 13, 0.06); --dsw-alias-interactive-bg-hover-danger: rgba(209, 36, 47, 0.08); - /* Markdown / 代码 */ --dsw-alias-markdown-code-block: #f1f1ef; --dsw-alias-markdown-code-block-banner: #e5e5e5; --dsw-alias-markdown-inline-code: #f1f1ef; @@ -116,7 +90,6 @@ body { --dsw-alias-markdown-placeholder: #e5e5e5; --dsw-alias-markdown-tag: #f1f1ef; - /* 状态:彩色白名单的唯一合法来源 */ --dsw-alias-state-success-primary: #1a7f37; --dsw-alias-state-success-secondary: #2e9e4c; --dsw-alias-state-success-tertiary: rgba(26, 127, 55, 0.08); @@ -131,7 +104,6 @@ body { --dsw-alias-state-business-tertiary: rgba(13, 13, 13, 0.08); --dsw-alias-state-idle-primary: #767676; - /* diff:白名单内的红绿 */ --dsw-alias-code-diff-added: rgba(26, 127, 55, 0.1); --dsw-alias-code-diff-deleted: rgba(209, 36, 47, 0.1); --dsw-alias-file-diff-added-bg: #e6f4e7; @@ -141,28 +113,24 @@ body { --dsw-alias-file-diff-deleted-gutter: #fdece9; --dsw-alias-file-diff-deleted-marker: #d1242f; - /* 浮层 / 提示 */ --dsw-alias-bg-mask-drop: rgba(255, 255, 255, 0.7); --dsw-alias-toast-bg: #1e1e1e; --dsw-alias-tooltip-bg: #1e1e1e; --dsw-alias-tooltip-fg: #ffffff; --dsw-alias-hovercard-bg: #ffffff; - /* 滚动条:thumb = label-tertiary 30% / 50% */ --dsw-alias-scrollbar-bg-l1: rgba(118, 118, 118, 0.3); --dsw-alias-scrollbar-bg-l2: rgba(118, 118, 118, 0.5); --dsw-alias-scrollbar-hover-l1: rgba(13, 13, 13, 0.35); --dsw-alias-scrollbar-hover-l2: rgba(13, 13, 13, 0.5); /* 具体面 */ - --dsw-specific-sidebar-fill: #eef4f9; /* 与 --dsw-alias-bg-sidebar 同源:Codex 亮色侧栏实测 */ - --dsw-specific-sidebar-nav-item-hover: #e8eef3; /* 侧栏底 ↔ 选中行的中点(推导值,非取样) */ - --dsw-specific-sidebar-nav-item-active: #e2e9ed; /* Codex 选中行(PROJIECT)实测取样 */ + --dsw-specific-sidebar-fill: #f6f6f6; /* 与 --dsw-alias-bg-sidebar 同源:Codex 侧栏叠白后的合成值 */ + --dsw-specific-sidebar-nav-item-hover: #f0f0f0; /* 侧栏底 ↔ 选中行的中点(推导值,非取样) */ + --dsw-specific-sidebar-nav-item-active: #e9e9e9; /* Codex 选中行实测 233 = 侧栏底 246 − 13 */ --dsw-specific-sidebar-nav-item-active-accent: rgba(13, 13, 13, 0.1); --dsw-specific-input-major: #ffffff; - /* 输入卡所坐的「表面色」。卡片底 = 5% 前景叠这个色(Codex 的 --card 公式)。 - 亮色 = 5% 墨叠白 = 243.55(参考图 codex-composer-reference.png 实测 240~246)。 */ - --dsw-composer-surface: #ffffff; + --dsw-composer-surface: #ffffff; /* 输入卡所坐的表面 */ --dsw-specific-login-input: #f9f9f9; --dsw-specific-menu: rgba(255, 255, 255, 0.88); --dsw-specific-selector: #f1f1ef; @@ -175,43 +143,52 @@ body { --dsw-shadow-lv3: 0 8px 24px rgba(13, 13, 13, 0.1); --dsw-linear-gradient-think: linear-gradient(180deg, #ffffff 20.19%, rgba(255, 255, 255, 0) 100%); --dsw-linear-think-select: linear-gradient(180deg, #f1f1ef 20.19%, rgba(241, 241, 239, 0) 100%); + + /* 组件阴影的明暗两档都在这里定,组件层只引用(复刻规格 §3.2 / §3.6) */ + --dsw-codex-ambient: rgba(13, 13, 13, 0.05); /* 窗口 24px 环境影、右栏上沿 */ + --dsw-codex-menu-shadow: 0 8px 32px rgba(13, 13, 13, 0.13), 0 0 0 1px rgba(13, 13, 13, 0.04); /* 菜单面板、模型选择器弹层 */ + --dsw-codex-suggest-shadow: 0 8px 32px #0002, 0 0 0 1px #0000000a; /* 输入区 @ / 指令建议菜单 */ + --dsw-codex-suggest-fill: #fff; } -/* 切主题的那一两帧关掉全部过渡:交叉淡出(元素一半旧色一半新色)看起来就是「闪」。 - 属性由浏览器半在 ctx.on('theme/change') 时打上,两个 rAF 后摘掉。 */ +/* 切主题的两帧关掉全部过渡:整页交叉淡出看起来就是闪(属性由 src/client/theme-preview.js 打)。 */ html[data-codex-ui-switching] *, html[data-codex-ui-switching] *::before, html[data-codex-ui-switching] *::after { transition: none !important; } -/* html 在最外层,读不到 body 上那份深色令牌,只能用 :has() 自己取一次。 */ +/* html 读不到 body 上的深色令牌,用 :has() 自取一次,字面值照抄。 */ :root:has(body[data-ds-dark-theme]) { background-color: #111111; } /* ── 暗色主题 ───────────────────────────────────────────────────────────── - 两个来源各管一半,别再把它们合成一个(0.1.2~0.5.5 就是合错了): + 三个来源各管一段,别再把它们合成一个(0.1.2~0.5.5 把背景与表面合错了, + 0.6.4 之前又把侧栏与表面合错了): · 窗口**背景** = 主题取色面板(assets/reference/codex-theme-dark.png)里写的 「背景 #111111 / 前景 #FCFCFC / 强调色 #0169CC」; - · **表面** = app.asar 的 jdi.dark.surface #181818(卡片、侧栏这类抬起来的面)。 - 实测对账:用户给的 Codex 截图里,输入卡四周的背景恒为 17 = #111111 ✓, - 卡片内部 35 = 5% 前景叠 #181818 ✓ —— 两个值同时成立,只有「背景/表面分层」能解释。 + · **表面**(卡片坐的那一层)= app.asar 的 jdi.dark.surface #181818; + · **侧栏**(左侧板 + 标题栏条 + frame 底)= 与亮色同一套机制,Codex **没有侧栏令牌**: + 那一面是 --color-surface-tertiary 以 70% 叠在窗口底上。暗色实测 **15 = #0F0F0F**, + 与亮色的 246 是同一档关系(亮 255→246、暗 17→15,侧栏都低一档)。 + 实测对账(用户给的 Codex 暗色截图,避开文字取样): + 输入卡四周背景 17 = #111111 ✓ · 卡片内部 35 = 5% 前景叠 #181818 ✓ + 侧栏与标题栏条 15 = #0F0F0F ✓ · 侧栏选中行 31 = #1F1F1F ✓ 0.5.5 及以前把背景也写成 #181818,于是卡片与背景只差 12 级(Codex 差 18 级), - 层次感就是这么丢的。 - 层级:背景 #111111 → 侧栏/表面 #181818 → 层1 #212121 → 层2 #282828 → 层3 #303030。 + 层次感就是这么丢的;0.6.4 之前侧栏被当成表面(#181818 = 24),比背景还亮 7 级 —— + 方向反了。 + 两条链别混:背景 #111111 → 侧栏 #0F0F0F(侧栏更低);背景 #111111 → 表面 #181818 + → 层1 #212121 → 层2 #282828 → 层3 #303030(面越高越亮)。 alpha 家族由 252 抬到 255(Codex 深色 --alpha-base = #fff)。 */ body[data-ds-dark-theme] { color-scheme: dark; - - /* 画布自持(深色):宿主只把 data-ds-dark-theme 打在 body 上,所以 html 那一份用 :has() 跟上。 - 这里重复一次字面值而不是引用令牌 —— 令牌就定义在 body 上,html 自己读不到它的深色值。 */ background-color: #111111; - /* 窗口背景取主题面板的 #111111;侧栏/表面取 jdi.dark.surface #181818。 - 输入卡坐在**表面**上(--dsw-composer-surface),不是坐在窗口背景上 —— 见下。 */ + /* 窗口背景取主题面板的 #111111;侧栏取实测 #0F0F0F(比背景低一档,与亮色同向)。 + 输入卡坐在**表面**上(--dsw-composer-surface = #181818),不是坐在窗口背景上 —— 见下。 */ --dsw-alias-bg-base: #111111; - --dsw-alias-bg-sidebar: #181818; + --dsw-alias-bg-sidebar: #0f0f0f; --dsw-alias-bg-layer-1: #212121; --dsw-alias-bg-layer-2: #282828; --dsw-alias-bg-layer-3: #303030; @@ -240,7 +217,7 @@ body[data-ds-dark-theme] { --dsw-alias-brand-primary-invert: #0d0d0d; --dsw-alias-brand-text: #0d0d0d; --dsw-alias-brand-primary-new-colorprimary-new-color: #ffffff; - --dsw-alias-link: #0169cc; /* Codex 深色主题「强调色」实测值 */ + --dsw-alias-link: #0169cc; /* Codex 深色强调色(codex-theme-dark.png) */ --dsw-alias-button-primary-fill: #ffffff; --dsw-alias-button-primary-hover: #d9d9d9; @@ -259,10 +236,8 @@ body[data-ds-dark-theme] { --dsw-alias-button-tool-bar-hover: rgba(255, 255, 255, 0.28); --dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.06); - /* 深色悬停底:Codex --color-background-button-secondary-hover = --gray-0 8% = #ffffff14。 */ - --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-hover-fill: rgba(255, 255, 255, 0.08); /* Codex --color-background-button-secondary-hover */ + --dsw-codex-focus: rgba(51, 156, 255, 0.7); /* Codex 深色 --color-border-focus = --blue-300 70% */ --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); @@ -313,14 +288,12 @@ body[data-ds-dark-theme] { --dsw-alias-scrollbar-hover-l1: rgba(255, 255, 255, 0.35); --dsw-alias-scrollbar-hover-l2: rgba(255, 255, 255, 0.5); - --dsw-specific-sidebar-fill: #181818; - --dsw-specific-sidebar-nav-item-hover: #212121; - --dsw-specific-sidebar-nav-item-active: #282828; + --dsw-specific-sidebar-fill: #0f0f0f; /* 与 --dsw-alias-bg-sidebar 同源:Codex 暗色侧栏实测 15 */ + --dsw-specific-sidebar-nav-item-hover: #171717; /* 侧栏底 ↔ 选中行的中点(推导值,非取样) */ + --dsw-specific-sidebar-nav-item-active: #1f1f1f; /* Codex 暗色选中行实测 31 = 侧栏底 15 + 16 */ --dsw-specific-sidebar-nav-item-active-accent: rgba(255, 255, 255, 0.14); --dsw-specific-input-major: #212121; - /* 深色表面 = jdi.dark.surface #181818(**不是**窗口背景 #111111): - 5% 白叠 #181818 = 35.55 → 36,实测 Codex 卡片内部 35 ✓。 */ - --dsw-composer-surface: #181818; + --dsw-composer-surface: #181818; /* 表面,不是窗口背景:5% 白叠它 = 36,实测卡内 35 */ --dsw-specific-login-input: #1f1f1f; --dsw-specific-menu: rgba(24, 24, 24, 0.88); --dsw-specific-selector: #282828; @@ -333,12 +306,16 @@ body[data-ds-dark-theme] { --dsw-shadow-lv3: 0 8px 24px rgba(0, 0, 0, 0.6); --dsw-linear-gradient-think: linear-gradient(180deg, #212121 20.19%, rgba(33, 33, 33, 0) 100%); --dsw-linear-think-select: linear-gradient(180deg, #1f1f1f 20.19%, rgba(40, 40, 40, 0) 100%); + + --dsw-codex-ambient: rgba(0, 0, 0, 0.5); + --dsw-codex-menu-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(236, 236, 236, 0.05); + --dsw-codex-suggest-shadow: 0 8px 32px #0003, 0 0 0 1px #ffffff08; + --dsw-codex-suggest-fill: #292929; } -/* ── 官方缺失的令牌层:空间 / 圆角 / 字阶 / 动效 ─────────────────────────── */ +/* ── 间距 / 圆角 / 字阶 / 动效(宿主没有的刻度层) ── */ :root, body { - /* 4px 网格(新增 12/20 密档,比 Claude 方向紧一档) */ --dsw-space-1: 4px; --dsw-space-2: 8px; --dsw-space-3: 12px; @@ -347,26 +324,9 @@ body { --dsw-space-6: 24px; --dsw-space-8: 32px; - /* 圆角刻度(唯一真源):所有可见边框的圆角都从这里取,各层只准引用、不准再写 px。 - 角色 → 刻度: - xs 行内小片 / 菜单项 - s 控件(按钮 / 列表行 / 页签) - m 卡片 / 浮层(菜单、下拉、tooltip) - l 面板 / 模态(取 16px,与宿主窗口内容块同值 —— dsh-client-ui-layout 的 - --dsh-windows-content-radius: 16px) - xl 输入卡(无 corner-shape 时的回退值) - 2xl 输入卡(corner-shape: superellipse 版;超椭圆要更大数值才视觉等圆) - menu 浮层菜单(模型菜单 / 触发菜单:参考仓库 composer-tool-menus 实测 18px, - 比输入卡基准 20px 小 2px —— 与 Codex 自身 18/20 的关系一致) - pill 徽标 / 圆形按钮 - - 同心纪律(2026-09-27 定,改圆角前先读这一段): - 嵌套圆角 = 外圆角 − 内衬。浮层菜单内衬 5px ⇒ 行圆角取 - calc(var(--dsw-radius-menu) - 5px) = 13px。宿主原生菜单本来就是同心的 - (MenuSurface 16px − 列表内衬 4px = 行 12px),本层不得再把它改散。 - 卡片内控件(模型 / 权限 chip)到卡片边的内衬是 8px,20/25 − 8 = 12~17, - 大于控件高(28px)的一半 ⇒ 按同心规则控件必须取全圆角(pill), - 与 assets/reference/codex-composer-chip-hover.png 实测 R = h/2 一致。 */ + /* 圆角刻度是唯一真源,各层只引用。xs 行内小片 / s 控件 / m 卡片与浮层 / l 面板与模态(= 宿主窗口内容块 16px)/ + xl 输入卡 / 2xl 输入卡超椭圆版 / menu 浮层菜单(参考仓库实测 18)/ pill 徽标与圆形按钮。 + 同心:嵌套圆角 = 外圆角 − 内衬(菜单 18 − 5 = 行 13);卡内控件到卡边 8px,20/25 − 8 大于控件高的一半 ⇒ 控件取 pill。 */ --dsw-radius-xs: 4px; --dsw-radius-s: 8px; --dsw-radius-m: 12px; @@ -374,25 +334,13 @@ body { --dsw-radius-xl: 20px; --dsw-radius-2xl: 25px; --dsw-radius-menu: 18px; - /* 输入卡圆角(单一真源):卡片本体与它的上栏条共用这一个令牌,谁都不许再自己写数。 */ + /* 输入卡与它的上栏条共用这一个令牌(条与卡同心叠放,取卡片圆角本身)。不另立 --dsw-radius-bar 别名: + var() 在声明处解析,中层再改写 --dsw-radius-card 时别名不会跟着变。 */ --dsw-radius-card: var(--dsw-radius-xl); - /* 上栏条(⑬·2 filebar / ⑭ hero 工作区行)只有上面两角是圆角,它探出在卡片背后, - 与卡片同心叠放 —— 所以它取的是卡片的圆角本身,不是「卡片 − 4」。 - - 2026-09-28 复核(用户提供的 DSH / Codex 同屏对照图,两块同一缩放): - 用合成真值标定过的角弧曲线法(±0.4px)+ 角亏损面积法逐像素量四条角弧, - Codex 条/卡 = 1.03(条 29 / 卡 28),本皮肤旧值 条/卡 = 0.79(条 16 / 卡 20)。 - 旧的「卡片 − 4」来自另一张参考图上的目视弧长,量错了:条并不比卡片紧, - 它和卡片一样圆。比值 0.79 就是用户看到的那两条对不上的角弧。 */ - /* 条不另立令牌:⑬·2 filebar 与 ⑭ hero 工作区行直接引用 --dsw-radius-card。 - 若写成 --dsw-radius-bar: var(--dsw-radius-card) 这种别名,var() 会在声明它的 - body 上就解析完,之后再有人在中层元素上改写 --dsw-radius-card,条不会跟着变 - —— 实测(scripts 之外的靶场)能复现「条 25 / 卡 20」的分家。 */ --dsw-radius-pill: 999px; --dsw-content-max-width: 768px; - /* 字阶(§3.2):11 / 12.5 / 14 / 16 / 20 / 28 */ --dsw-text-2xs: 11px; --dsw-text-xs: 12.5px; --dsw-text-sm: 13px; @@ -407,32 +355,28 @@ 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 覆盖的就是这两个令牌。 */ + /* 字体栈与宿主 dsh-client-ui-theme 的 base_css_default 逐字一致;自己声明一次,不随宿主版本漂。 + 设置页的 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 血统) */ + /* 元信息:等宽 + 大写 + 加宽字距(中文豁免见 patches.css) */ --dsw-font-meta: var(--ds-font-family-code); --dsw-meta-size: 11px; --dsw-meta-weight: 500; --dsw-meta-tracking: 0.08em; --dsw-meta-tracking-pill: 0.04em; - /* 动效:取 Codex 应用自身的时长与曲线(26.727.4816.0 的 app-*.css) - fast ← --transition-duration-basic: .15s - base ← DSH 自带 --ds-transition-duration: .2s(Codex 用 duration-200 工具类) - slow ← --transition-duration-relaxed: .3s - ease ← --ease-in-out 与 --default-transition-timing-function,两者同值 */ + /* 动效取 Codex 自身:fast = --transition-duration-basic .15s,slow = --transition-duration-relaxed .3s, + base = duration-200,ease = --ease-in-out */ --dsw-motion-fast: 150ms; --dsw-motion-base: 200ms; --dsw-motion-slow: 300ms; --dsw-ease: cubic-bezier(0.4, 0, 0.2, 1); - /* 输入区配件(skin-center 共享适配器消费的变量) */ + /* 输入区配件(skin-center 共享适配器消费) */ --dsh-composer-accessory-radius: var(--dsw-radius-l); --dsh-composer-accessory-bg: var(--dsw-alias-bg-layer-2); --dsh-composer-accessory-color: var(--dsw-alias-label-secondary); @@ -441,8 +385,7 @@ body { --dsh-composer-accessory-blur: 0px; } -/* corner-shape 分支:卡片升到 25px 超椭圆,上栏条因为引用的是同一个令牌而自动跟随, - 不需要第二处数字。卡片那一条 @supports 在 composer.css ⑭·1,两条必须同开同关。 */ +/* 支持 corner-shape 时卡片升到 25px 超椭圆;条引用同一令牌自动跟随(卡片那条 @supports 在 composer.css,两条同开同关)。 */ @supports (corner-shape: superellipse(1.5)) { :root, body { @@ -450,45 +393,27 @@ body { } } -/* ── elevation:描边即层级,阴影近零 ─────────────────────────────────────── */ -/* 官方把 --dsw-elevation-* 声明在 body, body * 上,因此必须同样覆盖到 body *, - 否则每个后代元素都会用自身声明盖掉从 body 继承下来的值。 */ +/* ── elevation:描边即层级 ── + 宿主把 --dsw-elevation-* 声明在 body 与 body * 上,所以这里也得落到 body *,否则后代元素用宿主那份盖掉继承值。 + 颜色单独一个令牌(stroke-color)也必须落 body *:宿主有 24 个组件就地改写它,本皮肤统一压回。 */ body, body * { --dsw-elevation-stroke-color: 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 - 旧值 0 4px 16px rgba(13,13,13,.08) 是自造单层,几何与层数都与 Codex 不同。 - 这个令牌是**宿主菜单与面板**的主阴影(.QsffPG_menu / ._7KE1Ra_menu / .JObwrW_panel - 等 14 处消费),不是边角料。 */ + /* Codex --elevation-prominent 逐字(app.asar @67930856),宿主菜单与面板的主阴影;Codex 没有暗色变体。 + --dsw-elevation-soft 不覆盖:Codex 无同名令牌,交还宿主值。 */ --dsw-elevation-prominent: var(--dsw-elevation-stroke), 0 3px 7.5px #0000000a, 0 0 20px #0000000d; - /* --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 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) 无任何取证。 */ } -/* ── 排版层:标题字重 600 封顶 ───────────────────────────────────────────── */ -/* 官方 markdown 标题是 700;Codex 无衬线无重标题,层级靠灰阶 + 字号。 - 官方把字排令牌声明在 body 上,故同样写在 body 上(字号仍挂 - --dsh-content-font-delta,用户调字号不破比例)。 */ +/* ── 排版:markdown 标题字重 600 封顶 ── + 宿主是 700;Codex 无重标题,层级靠灰阶与字号。字号挂 --dsh-content-font-delta,调字号不破比例。 + 宿主只在 body 上声明这组令牌,亮色这份在暗色下同样胜出,不需要再写深色副本。 */ :root, body { --dsw-font-markdown-h1: 600 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family); @@ -498,12 +423,3 @@ body { --dsw-font-markdown-h3: 600 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family); --dsw-font-markdown-h3-font-weight: 600; } - -body[data-ds-dark-theme] { - --dsw-font-markdown-h1: 600 calc(21px + var(--dsh-content-font-delta)) / calc(30px + var(--dsh-content-font-delta)) var(--dsw-font-family); - --dsw-font-markdown-h1-font-weight: 600; - --dsw-font-markdown-h2: 600 calc(19px + var(--dsh-content-font-delta)) / calc(28px + var(--dsh-content-font-delta)) var(--dsw-font-family); - --dsw-font-markdown-h2-font-weight: 600; - --dsw-font-markdown-h3: 600 calc(18px + var(--dsh-content-font-delta)) / calc(26px + var(--dsh-content-font-delta)) var(--dsw-font-family); - --dsw-font-markdown-h3-font-weight: 600; -} diff --git a/skins/codex-ink/window-shadow.css b/skins/codex-ink/window-shadow.css index 6fa8428..573a1f5 100644 --- a/skins/codex-ink/window-shadow.css +++ b/skins/codex-ink/window-shadow.css @@ -1,13 +1,12 @@ -/* ============================================================================ - codex-ink · L3 窗口边缘阴影层 - ---------------------------------------------------------------------------- - 源文件:skins/codex-ink/window-shadow.css。src/build.mjs 把本文件的选择器 - 作用域化到 html[data-codex-ui] 后拼进 client.js 的内嵌样式表,所以这里写裸选择器: - 自己再加 html[...] 前缀会被 scopeCss 判为已作用域而直通,永远不命中。 - 注释里禁止出现花括号 —— scopeCss 按「下一个左花括号」做括号配对。 +/* codex-ink · L3 窗口边缘(codex-app-reference-1x.png 逐像素实测,读数见 README「实测值」) + 发丝线写 0.5px:参考图里是 1 个设备像素,150% 缩放下 1px 会栅格化成 2 个。 + 只对 Windows 桌面壳(data-windows-titlebar)与 win32 浏览器形态生效:macOS 是 vibrancy 透底,加外投影会脏。 + 锚点:中列 = div:has(> [data-slot=main]),main 面板未注册时退到侧栏列的下一个兄弟;右栏 = push 模式且展开的面板。 + 环境影**不能只用一个令牌**:0.6.3 起亮色是 13px @7% 的实测拟合,暗色仍是 24px @50%(暗色没有参考图可拟合, + 维持原值);右栏上沿那一支明暗同值,才用令牌 --dsw-codex-ambient 合一。 - 目标(三条,不多做): - 1. 会话窗口(AppFrame 中列):0.5px 发丝线 + 24px 全向环境影(左沿糊到侧栏、上沿糊进标题栏条); + ── 目标(三条,不多做)── + 1. 会话窗口(AppFrame 中列):0.5px 发丝线 + 13px 全向环境影(左沿糊到侧栏、上沿糊进标题栏条;0.6.3 起按实测收窄); 2. 右栏面板:同样的顶沿,但**左边框只留发丝线、不带影**; 3. 右栏分界线拖拽柄悬停时的中段渐变聚焦。 @@ -35,7 +34,14 @@ 切勿再叠 position: relative:会覆盖 absolute、面板塌成内容高度(已出过一次事故)。 ============================================================================ */ -/* ── ① 会话窗口(中列):0.5px 发丝线 + 24px 全向环境影 ───────────────────── +/* 0.6.3:环境影由 0 0 24px @5% 收到 0 0 13px @7%(blur 24→13、alpha 5%→7%)。 + 依据是 codex-app-reference.png(DPR 2)与用户同屏截图按同一采样口径量出的侧栏侧衰减: + Codex dev 8,8,6,6,5,5,4,3,3,3,2,2,2,2,2,1… 到第 26 个设备像素归零 + 旧皮肤 dev 6,6,5,5,5,5,5,4,4,4,4,4,3,3,3,3,3,3,2… 到第 32 个设备像素还没归零 + —— 影摊得过开、近处又偏浅。现值为同一夹具里 SSE 最小的拟合(47 → 22)。 + 发丝线不动:实测差在 ±4 级以内,属于采样噪声。 */ + +/* ── ① 会话窗口(中列):0.5px 发丝线 + 13px 全向环境影 ───────────────────── 范围:Windows 桌面壳(html[data-windows-titlebar])与 win32 的浏览器形态。 macOS 是 vibrancy 透底,加外投影会脏,不生效。 */ html[data-codex-ui][data-windows-titlebar] div:has(> [data-slot="main"]), @@ -45,9 +51,10 @@ html[data-codex-ui][data-platform="win32"]:not([data-windows-titlebar]) div:has( position: relative; box-shadow: 0 0 0 0.5px var(--dsw-alias-border-l2), - 0 0 24px rgba(13, 13, 13, 0.05); + 0 0 13px rgba(13, 13, 13, 0.07); } +/* 暗色中列:亮色那支的实测拟合只对亮色成立(暗色参考图里量不到同一口径),维持 24px @50%。 */ html[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] div:has(> [data-slot="main"]), html[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] div:has(> [data-slot="sidebar"]) + div, html[data-codex-ui][data-platform="win32"]:not([data-windows-titlebar]) body[data-ds-dark-theme] div:has(> [data-slot="main"]), @@ -57,41 +64,21 @@ html[data-codex-ui][data-platform="win32"]:not([data-windows-titlebar]) body[dat 0 0 24px rgba(0, 0, 0, 0.5); } -/* ── ② 右栏(push 模式):左边框只留发丝线,影只往上泄 ───────────────────── - 参考图实测:面板左沿 x=1437 是单像素 237,237,237、两侧纯白 —— 左沿不糊影, - 所以这里不给全向影;顶沿仍与中列同套,用负 spread 把水平方向与下方收掉, - 只在面板上沿留一段约 12px 的渐变(影盒左沿被收进面板 12px 内,左沿残余覆盖约 - 0.008,肉眼为零)。栏内 dockkit 叠的那条 .5px l4 深边框一并压成 0。 */ +/* 右栏面板:左沿只留 l1 发丝线(实测两侧纯白、无影),负 spread 把影收成只从上沿泄出约 12px。 + 宿主 absolute 拉伸的元素 —— 切勿再叠 position: relative,会塌成内容高度。 */ html[data-codex-ui][data-windows-titlebar] [data-sidebar-right-panel="push"][data-sidebar-right-open], html[data-codex-ui][data-platform="win32"]:not([data-windows-titlebar]) [data-sidebar-right-panel="push"][data-sidebar-right-open] { box-shadow: 0 0 0 0.5px var(--dsw-alias-border-l1), - 0 -12px 24px -12px rgba(13, 13, 13, 0.05); -} - -html[data-codex-ui][data-windows-titlebar] body[data-ds-dark-theme] [data-sidebar-right-panel="push"][data-sidebar-right-open], -html[data-codex-ui][data-platform="win32"]:not([data-windows-titlebar]) body[data-ds-dark-theme] [data-sidebar-right-panel="push"][data-sidebar-right-open] { - box-shadow: - 0 0 0 0.5px var(--dsw-alias-border-l1), - 0 -12px 24px -12px rgba(0, 0, 0, 0.5); + 0 -12px 24px -12px var(--dsw-codex-ambient); } -/* 宿主 dockkit 给右栏的 pane 画了一条 1px l4(24% 墨,实测合成值 197 —— 比参考图的 - 单像素 237 深两档),它落在面板左沿外侧,就是「边框又粗又重」的来源之一。 - 真实锚点是 pane 自身:SECTION[data-dockkit-pane][data-dockkit-column="0"], - 插在 [data-dockkit-host="dock"] 的 tabCell 里 —— 旧版按 host 的 div 直选子元素, - 选择器从来没命中过。压成 0,左沿只留面板自己的 0.5px l1 发丝线。 */ +/* dockkit 给 pane 画的 1px l4 深边框(实测 197,比参考图 237 深两档)压掉 */ html[data-codex-ui] [data-sidebar-right-panel="push"][data-sidebar-right-open] [data-dockkit-pane][data-dockkit-column="0"] { border-left: 0; } -/* ── ③ 右栏分界线拖拽柄:悬停时的中段渐变聚焦 ───────────────────────────── - 静态:边界只有那 0.5px 发丝线,淡且细。 - 悬停:柄内居中画 2px 竖向线,中段最深、两端淡出,入场自中间向两端展开。 - 实测 assets/reference/codex-rightbar-edge-hover.png(111x842,DPR 1): - 柄心列 y=560 处 x=65/66 = 213,214,214 / 198,198,199(中段最深) - 同一列 y=20 处 x=65/66 = 239,239,240 / 222,222,222(向两端淡出) - 左分界线(侧栏 ↔ 中列)没有这条悬停线:参考图里它始终是一条静态发丝线。 */ +/* 右分界线拖拽柄:悬停时柄内 2px 竖线,中段最深、两端淡出(codex-rightbar-edge-hover.png)。左分界线没有这条。 */ html[data-codex-ui] [data-side="rightbar"]::before { content: ""; position: absolute; @@ -118,9 +105,7 @@ html[data-codex-ui] body[data-ds-dark-theme] [data-side="rightbar"]::before { background: linear-gradient(to bottom, rgba(236, 236, 236, 0.08), rgba(236, 236, 236, 0.24) 50%, rgba(236, 236, 236, 0.08)); } -/* ── ④ 浏览器形态(win32,无标题栏条)──────────────────────────────────── - 宿主给侧栏列画的是 .5px l3(18% 墨),比桌面壳(border-right: none)深一档, - 与中列的 0.5px l2 发丝线叠在一起会变成双层深线,压到 l1。 */ +/* win32 浏览器形态:宿主给侧栏列的 .5px l3 与中列发丝线叠成双层深线,压到 l1 */ html[data-codex-ui][data-platform="win32"]:not([data-windows-titlebar]) div:has(> [data-slot="sidebar"]) { border-right-color: var(--dsw-alias-border-l1); } diff --git a/src/build.mjs b/src/build.mjs deleted file mode 100644 index 45f5e35..0000000 --- a/src/build.mjs +++ /dev/null @@ -1,190 +0,0 @@ -/** - * build.mjs — 由 skins/codex-ink 的源样式生成 theme.css 与 client.js。 - * - * 只有这一处实现作用域化与拼装。安装脚本(scripts/install-plugin.mjs)与 CI 校验 - * (scripts/check-repo.mjs)都调用它,产物一致性与安装结果由同一条代码路径决定。 - * - * 四样东西拼进 client.js: - * 1. skins/codex-ink 的样式(SKIN_PARTS 那八份,作用域化后内嵌); - * 2. src/override.js —— 设置页的覆盖层纯函数(构建期去掉 export 关键字,包成 IIFE); - * 3. src/model-picker.js —— 模型选择器 B 面组件(同上,包成 IIFE); - * 4. src/settings-card.js —— 组合包页那张配置卡(直接拼进 factory 体内)。 - * - * 作用域化规则: - * :root → html[data-codex-ui] - * html[...] → 原样(已带作用域) - * 其它选择器 → html[data-codex-ui] 前缀 - * @keyframes / @font-face / @property / @page / @counter-style → 原样直通,不加前缀 - */ -import fs from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -/** src/ 目录。 */ -export const HERE = dirname(fileURLToPath(import.meta.url)); -/** 插件根目录。 */ -export const PLUGIN_DIR = dirname(HERE); -/** 皮肤源目录。 */ -export const SKIN_DIR = join(PLUGIN_DIR, 'skins', 'codex-ink'); -/** 作用域根选择器。 */ -export const ATTR = 'html[data-codex-ui]'; -/** 模板里的样式占位符(连同 null 一起替换,未替换的模板本身也是可解析的 JS)。 */ -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 的源文件,顺序即层叠顺序。 - * [文件名, 该层的说明] —— 说明写进分隔注释。 - */ -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 窗口边缘阴影层'], - ['composer.css', 'L3 输入区完整层'], - ['settings.css', 'L3 插件设置页层'], -]; - -/** 单条选择器作用域化。 */ -export const scopeSelector = (sel) => { - const s = sel.trim(); - if (s === '') return sel; - if (s === ':root') return ATTR; - if (s.startsWith('html[')) return s; - return ATTR + ' ' + s; -}; - -/** 按顶层逗号切分选择器组(括号、方括号内的逗号不算)。 */ -export const splitSelectors = (text) => { - const out = []; let depth = 0, cur = ''; - for (const ch of text) { - if (ch === '(' || ch === '[') depth += 1; - else if (ch === ')' || ch === ']') depth -= 1; - if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; } - cur += ch; - } - out.push(cur); - return out; -}; - -/** 内容本身就是选择器或规则体、不能加前缀的 at 规则。 */ -const PASSTHROUGH = new Set(['keyframes', 'font-face', 'property', 'page', 'counter-style']); - -/** 递归作用域化一段 CSS。 */ -export function scopeCss(css) { - let i = 0, out = ''; - while (i < css.length) { - const open = css.indexOf('{', i); - if (open < 0) { out += css.slice(i); break; } - const prelude = css.slice(i, open); - let depth = 1, j = open + 1; - while (j < css.length && depth > 0) { - if (css[j] === '{') depth += 1; - else if (css[j] === '}') depth -= 1; - j += 1; - } - const body = css.slice(open + 1, j - 1); - const close = prelude.lastIndexOf('*/'); - const comments = close >= 0 ? prelude.slice(0, close + 2) : ''; - const selText = close >= 0 ? prelude.slice(close + 2) : prelude; - const trimmed = selText.trim(); - if (trimmed.startsWith('@')) { - const name = trimmed.slice(1).split(/[\s({]/)[0].toLowerCase(); - out += comments + selText + '{' + (PASSTHROUGH.has(name) ? body : scopeCss(body)) + '}'; - } else if (trimmed === '') { - out += comments + '{' + body + '}'; - } else { - out += comments + splitSelectors(selText).map(scopeSelector).join(',') + '{' + body + '}'; - } - i = j; - } - return out; -} - -/** 哈希类名锚点计数([class=、[class*=、[class$=、[class^=])。 */ -export const hashAnchors = (text) => (text.match(/\[class[$*^]?=/g) ?? []).length; - -/** - * 去掉顶层 export 关键字,好把 ESM 模块包成 IIFE 拼进客户端产物。 - * 只认 `export const` / `export function` 两种形式;出现别的 export 形式就抛 —— - * 静默漏掉一个导出会变成运行时的 undefined,比构建失败难查得多。 - * @param source - 模块源码。 - * @param name - 报错时用的文件名。 - * @returns 去掉 export 的源码。 - */ -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(name + ' 里有不支持的导出形式:' + leftovers.join(' / ')); - return rest; -} - -/** 覆盖层模块暴露给客户端的名字(settings-card.js 按它取用)。 */ -export const OVERRIDE_EXPORTS = [ - 'OVERRIDE_ATTR', 'SKIN_ATTR', 'SETTINGS_ENTRY_ID', 'DEFAULT_CONTRAST', 'SKIN_DEFAULTS', - 'LABEL_TIERS', 'ALPHA_LADDER', 'SIDEBAR_ALPHA', 'SCALE_RANGE', 'MIX_RANGE', 'HEX_RE', - '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}} - */ -export function build() { - const sources = {}; - const anchors = {}; - const blocks = SKIN_PARTS.map(([file, label]) => { - const text = fs.readFileSync(join(SKIN_DIR, file), 'utf8'); - sources[file] = text.length; - anchors[file] = hashAnchors(text); - return '/* ==== ' + label + '(源:skins/codex-ink/' + file + ')==== */\n' + text; - }); - const themeCss = scopeCss(blocks.join('\n\n')); - const tplSrc = fs.readFileSync(TEMPLATE, 'utf8'); - 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 __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 deleted file mode 100644 index 471b2e3..0000000 --- a/src/client.template.js +++ /dev/null @@ -1,278 +0,0 @@ -/** - * codex-ui — Browser half(唯一样式源是 skins/codex-ink,经 src/build.mjs 生成;本文件是模板,勿手改)。 - * - * 职责四件: - * 1. 把 skins/codex-ink 的整套 Codex 化样式(L1/L2 令牌 + L3 组件层 + 设置页层) - * 以作用域 html[data-codex-ui] 注入到文档,并在卸载时完整收回; - * 2. 把设置页的取值渲染成一层覆盖(多一个属性 ⇒ 特异性压过皮肤,源样式一个字不动); - * 3. 在官方插件管理的组合包页(座位 plugins.bundle.config)注册那张配置卡; - * 4. 用自建组件顶替 composer 的模型位(Codex 模型列表 + 推理等级功率轨),设置里可关。 - * 不依赖 skin-center / dsh-web-all:样式文本随本文件一起下发,无外部请求。 - */ -window.__ModuleLoader__.load({ - id: 'codex-ui', - factory: (require) => { - /** 插件 id:style 标签归属标记,同时也是组合包座位的键。 */ - const PLUGIN_ID = 'codex-ui'; - /** 作用域根属性:所有规则都挂在它下面,卸载即整层失效。 */ - const ROOT_ATTR = 'data-codex-ui'; - /** - * 切主题时临时打的属性:皮肤里有一条 `html[data-codex-ui-switching] * { transition: none }`。 - * 主题切换会让整个页面的 token 同时换值,若各处还有 background/color 过渡,就是一次交叉淡出 —— - * 元素半旧半新的那几帧看起来就是「闪」。打上它、过两个 rAF 摘掉,切换变成一次到位。 - */ - const SWITCH_ATTR = 'data-codex-ui-switching'; - /** - * 本地预览标记:用户点了主题之后,宿主要把偏好写进设置文档、再回读,实测往返 ~0.8s。 - * 这一层让「点下去」与「变颜色」之间不等那个往返:立刻按目标主题应用, - * 等 `theme/change` 带着同一个结果回来就交还给宿主;超时没等到就按真值回滚。 - * 它只写宿主本来就会写的两处(body[data-ds-dark-theme] 与 html 的 color-scheme), - * 因此确认到达时是幂等的,不会出现第二次跳变。 - */ - const PREVIEW_ATTR = 'data-codex-ui-preview'; - /** 预览等待上限:够一次文档往返(实测 ~0.8s)再多给一倍余量。 */ - const PREVIEW_TIMEOUT_MS = 2500; - /** 生成期注入的样式表文本。 */ - const CSS = /*__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 - - /** - * 这里导出的是 cordis **服务名**,与 package.json 的 dsh.client.inject 不是一回事: - * · package.json 的 dsh.client.inject 列**包名**,只用于客户端模块图排序; - * · 本处导出的 inject 列**服务名**,loader 拿它逐个 ctx.get() 判断依赖是否就绪, - * 缺一个这条 entry 就永远停在 pending,整个 web boot 报 - * `1 entry did not activate` 直接起不来。 - * 曾把包名 `@deepseek-ai/dsh-client-ui-slots` 写在这里。而 0.1.7 的客户端里它是 - * 静态模块、不注册同名服务(服务名是 `slots`,由 @deepseek-ai/dsh-client-ui-renderer - * 提供),于是 entry 卡死、桌面端白屏报错。对照官方与第三方插件(dshmarket、 - * dsh-chatgpt-subscription、dsh-client-ui-model-capabilities):导出的 inject - * 一律是 ["slots", "locale", "remote", …] 这种短服务名。 - * - * `configForms` 由 @deepseek-ai/dsh-client-ui-settings 提供(其构造器里 super(ctx, "configForms")), - * 是组合包页那张配置卡的读写通道;本插件的 engines 锁定了带它的宿主版本,故直接声明。 - * - * `theme` 由 @deepseek-ai/dsh-client-ui-theme 提供(`ctx.provide("theme", …)`),是宿主主题偏好的 - * **唯一写入口**:卡片上那一行「主题」写的就是它,与「设置 → 通用 → 外观」同一处, - * 所以切完整个应用一起变,不是卡片自己的界面状态。 - */ - const inject = ['slots', 'configForms', 'theme']; - - /** - * 注入样式表并打上作用域根属性。 - * - * 关键:**没有 effect 也要把样式留着**。旧版这里写的是 else dispose(), - * 即「ctx.effect 不可用 → 注入完立刻删掉,连 data-codex-ui 一起收回」—— - * 表现就是插件完全没生效、控制台一行报错都没有,是最难查的一种失败。 - * 现在降级为「注入但不可回收 + 一条 warn」。 - * - * @param ctx - 客户端上下文。 - */ - function apply(ctx) { - const root = document.documentElement; - root.setAttribute(ROOT_ATTR, ''); - const tag = document.createElement('style'); - tag.dataset.plugin = PLUGIN_ID; - tag.dataset.pluginCss = PLUGIN_ID + '/theme.css'; - tag.textContent = CSS; - document.head.appendChild(tag); - const dispose = () => { - tag.remove(); - root.removeAttribute(ROOT_ATTR); - }; - if (typeof ctx.effect === 'function') { - ctx.effect(() => dispose, 'codex-ui: stylesheet'); - } else { - console.warn('[codex-ui] ctx.effect 不可用:样式已注入,但不会随 fiber 卸载回收。'); - } - /* 设置页那一半:任何一步失败都只降级,不能连皮肤一起拖下水。 */ - let formScope = null; - try { - 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; - const scope = forms === null ? null : forms.get(__override.SETTINGS_ENTRY_ID); - if (scope === null || scope === undefined) { - /* 没有设置服务:不注册座位、不加覆盖层,皮肤照常。 */ - console.warn('[codex-ui] 没有 configForms 服务:设置页不可用,皮肤照常。'); - return null; - } - const tag = document.createElement('style'); - tag.dataset.plugin = PLUGIN_ID; - tag.dataset.pluginCss = PLUGIN_ID + '/settings-override.css'; - document.head.appendChild(tag); - const render = () => { - const snapshot = scope.getSnapshot(); - /* 文档还没到(status=loading / 连接刚重连)时 value 是 undefined。 - 这时**保持现状**:把已生效的覆盖撤掉会让用户看到自己的设置闪一下没了。 - 真正的「没有覆盖」是 value 存在且字段为空 —— 那条路径照旧清空。 */ - if (snapshot !== undefined && snapshot !== null && snapshot.value === undefined) return; - const values = snapshot === undefined || snapshot === null || snapshot.value === null ? {} : snapshot.value; - const css = __override.themeOverrideCss(values); - tag.textContent = css; - /* 没有覆盖时连属性一起摘掉:默认态与「没装设置页」逐字节相同。 */ - if (css === '') root.removeAttribute(__override.OVERRIDE_ATTR); - else root.setAttribute(__override.OVERRIDE_ATTR, ''); - }; - render(); - const off = typeof scope.subscribe === 'function' ? scope.subscribe(render) : null; - if (typeof ctx.effect === 'function') { - ctx.effect(() => () => { - if (typeof off === 'function') off(); - tag.remove(); - root.removeAttribute(__override.OVERRIDE_ATTR); - }, 'codex-ui: settings override'); - } - /* 切主题时关掉过渡:交叉淡出看起来就是「闪」。两帧后摘掉,切换变成一次到位。 */ - const suppressTransitions = () => { - root.setAttribute(SWITCH_ATTR, ''); - requestAnimationFrame(() => requestAnimationFrame(() => root.removeAttribute(SWITCH_ATTR))); - }; - - /* ── 主题的本地预览 ────────────────────────────────────────────────── - 点下去到变色原本要等一次设置文档往返(实测 780–824ms)。这里把目标主题先应用掉, - 宿主稍后带着同样结果回来时幂等地交还给它。 */ - let preview = null; - let previewTimer = 0; - /** 按宿主自己的方式打主题:body 上的 palette 属性 + html 的 color-scheme。 */ - const applyScheme = (scheme) => { - if (typeof document.body.toggleAttribute === 'function') document.body.toggleAttribute('data-ds-dark-theme', scheme === 'dark'); - root.style.colorScheme = scheme; - }; - /** 目标偏好 → 实际那一套(system 跟随系统,与宿主同一套解析规则)。 */ - const schemeOf = (target) => { - if (target === 'dark' || target === 'light') return target; - return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }; - /** - * 结束预览。 - * @param confirmed - true 表示宿主已经带着同一个结果发布过,文档就是真相; - * false 表示等超时了,按宿主当前的真值回滚,绝不把预览当成结果。 - */ - const endPreview = (confirmed) => { - if (preview === null) return; - if (previewTimer !== 0) { clearTimeout(previewTimer); previewTimer = 0; } - preview = null; - root.removeAttribute(PREVIEW_ATTR); - if (confirmed) return; - try { - const active = themeServiceActive(); - if (active !== null) applyScheme(active); - } catch { /* 读不到服务就保持现状,不做二次猜测 */ } - }; - /** 宿主主题服务当前解析出的那一套;读不到返回 null。 */ - const themeServiceActive = () => { - const snap = ctx.theme === undefined || ctx.theme === null ? null : ctx.theme.getTheme(); - const scheme = snap === undefined || snap === null || snap.active === undefined || snap.active === null ? null : snap.active.colorScheme; - return scheme === 'dark' || scheme === 'light' ? scheme : null; - }; - /** - * 立刻按目标主题显示(不等文档往返)。 - * @param target - 'light' | 'dark' | 'system'。 - */ - const previewTheme = (target) => { - if (target !== 'light' && target !== 'dark' && target !== 'system') return; - const scheme = schemeOf(target); - preview = { target, scheme }; - root.setAttribute(PREVIEW_ATTR, scheme); - suppressTransitions(); - applyScheme(scheme); - if (previewTimer !== 0) clearTimeout(previewTimer); - previewTimer = setTimeout(() => endPreview(false), PREVIEW_TIMEOUT_MS); - }; - - if (typeof ctx.on === 'function' && typeof ctx.effect === 'function') { - ctx.effect(() => ctx.on('theme/change', (snapshot) => { - suppressTransitions(); - const scheme = snapshot === undefined || snapshot === null || snapshot.active === undefined || snapshot.active === null ? null : snapshot.active.colorScheme; - /* 发布结果与预览一致 ⇒ 文档已落地,交还给宿主(幂等,看不见第二次跳变)。 */ - if (preview !== null && scheme === preview.scheme) endPreview(true); - }), 'codex-ui: theme change'); - ctx.effect(() => () => endPreview(false), 'codex-ui: theme preview cleanup'); - } - registerSettingsCard(ctx, CodexUiSettingsCard, { - theme: ctx.theme, - /* 主题插件自己的设置表单(命名空间 ui-theme,字段 preference)。 - 卡片写主题偏好走它,而不是 theme.setTheme() —— 原因见 settings-card.js 里的注释。 */ - themeForm: typeof ctx.configForms.get === 'function' ? ctx.configForms.get('ui-theme') : null, - /* 主题变更走宿主事件:layout 侧也是 ctx.on("theme/change", …) 这一个口子。 */ - watchTheme: (listener) => (typeof ctx.on === 'function' ? ctx.on('theme/change', listener) : () => {}), - /* 点下去立刻按目标主题显示,文档往返在背后跑。 */ - previewTheme, - }); - return scope; - } - - return { apply, inject, PLUGIN_ID }; - }, -}); diff --git a/src/client/constants.js b/src/client/constants.js new file mode 100644 index 0000000..46be9a0 --- /dev/null +++ b/src/client/constants.js @@ -0,0 +1,17 @@ +/** + * 全插件共用的名字。PLUGIN_ID 同时是:npm 包名、__ModuleLoader__ 的 id、设置表单命名空间(profile 条目 id)、 + * 组合包页座位 plugins.bundle.config 的键、style[data-plugin] 的归属标记 —— DSH 要求它们一致。 + */ +export const PLUGIN_ID = 'codex-ui'; + +/** 作用域根:theme.css 的每条规则都挂在 html[data-codex-ui] 下,摘掉即整层失效。 */ +export const ROOT_ATTR = 'data-codex-ui'; + +/** 设置页有覆盖时打在 上:覆盖层选择器比皮肤多这一个属性,特异性稳赢。 */ +export const OVERRIDE_ATTR = 'data-codex-ui-theme'; + +/** 切主题的那两帧打上:皮肤据此关掉全部过渡,免得整页交叉淡出(看起来就是闪)。 */ +export const SWITCH_ATTR = 'data-codex-ui-switching'; + +/** 主题本地预览期间打上(值为预览的那一套)。 */ +export const PREVIEW_ATTR = 'data-codex-ui-preview'; diff --git a/src/client/host.js b/src/client/host.js new file mode 100644 index 0000000..575c88e --- /dev/null +++ b/src/client/host.js @@ -0,0 +1,26 @@ +/** + * 读宿主服务的两个小工具(设置覆盖层、设置卡、模型选择器共用)。 + */ + +/** + * 设置表单的当前值。设置文档还没送达(status=loading、连接刚重连)时 value 是 undefined, + * 这时返回 undefined,调用方应**保持现状** —— 撤掉已生效的覆盖会让用户的设置闪一下没了。 + * @param form - configForms.get(id) 的结果。 + * @returns 取值对象;未送达时 undefined。 + */ +export function formValue(form) { + const value = form.getSnapshot().value; + return value === undefined ? undefined : (value ?? {}); +} + +/** + * 界面语言是不是英文:先问宿主 locale 服务,再问浏览器;认不出就当中文。 + * @param locale - ctx.reflect.get('locale'),可能缺席。 + * @returns true 为英文。 + */ +export function isEnglish(locale) { + let active = null; + try { active = 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'); +} diff --git a/src/client/index.js b/src/client/index.js new file mode 100644 index 0000000..1f45b42 --- /dev/null +++ b/src/client/index.js @@ -0,0 +1,31 @@ +/** + * codex-ui 浏览器半入口(DSH 客户端插件:导出 inject 与 apply,由 scripts/build.mjs 打成 client.js)。 + * + * 功能按顺序装配:皮肤样式 → 设置(覆盖层、主题预览、配置卡)→ 模型选择器。 + * 样式先挂且不包 try:它是插件本体;其余任何一项失败都只降级,不连累皮肤。 + * 新功能 = src/client/ 下一个模块导出 install(ctx) + 这里一行。 + */ +import { installModelPicker } from './model-picker/index.js'; +import { installSettings } from './settings.js'; +import { installStylesheet } from './stylesheet.js'; + +/** + * cordis **服务名**(不是包名):loader 逐个等它们就绪,缺一个本插件就不激活。 + * 只放必需的;可有可无的服务(modelDirectories)走 ctx.inject 子作用域。 + */ +export const inject = ['slots', 'configForms', 'theme']; + +export function apply(ctx) { + installStylesheet(ctx); + let form = null; + try { + form = installSettings(ctx); + } catch (error) { + console.warn('[codex-ui] 设置页挂载失败,皮肤照常:', error); + } + try { + installModelPicker(ctx, form); + } catch (error) { + console.warn('[codex-ui] 模型选择器挂载失败,宿主原生菜单照常:', error); + } +} diff --git a/src/model-picker.js b/src/client/model-picker/component.js similarity index 58% rename from src/model-picker.js rename to src/client/model-picker/component.js index c40681d..30a9f7a 100644 --- a/src/model-picker.js +++ b/src/client/model-picker/component.js @@ -1,46 +1,62 @@ /** - * model-picker.js — Codex 模型选择器 B 面(浏览器半,构建期拼进 client.js)。 + * 模型选择器 B 面的 DOM 组件:Codex 模型列表 + 推理等级功率轨,顶替 composer 的模型位。 * * 为什么是**自己的 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() 只看「我们在不在席」,摘掉触发器宿主立刻复原。 + * 3. 席位里有我们的触发器时,给**席位出口**打 SEATED_ATTR,样式表据此把宿主那一格 display:none + * (model-picker.css ①)。标记打在出口上而不是宿主子节点上:React 换掉自己的子节点时标记不丢; + * 撤走触发器时摘掉,宿主立刻复原。 * 数据与提交全部走宿主唯一真源 ctx.modelDirectories,本模块不缓存模型列表。 + * 纯逻辑(几何、快照 → 视图、列表签名)在 ./view.js,本文件只管 DOM 与挂载。 * - * 驱动契约(读 @deepseek-ai/dsh-client-ui-model-selection 0.1.7-rc.2 源码得到): + * 驱动契约(读 @deepseek-ai/dsh-client-ui-model-selection 0.1.7-rc.1 / rc.2 源码得到): * models.directoryFor(sessionId) → ModelDirectory(会话作用域未物化时**会抛**) - * dir.store.getSnapshot() → { current, retainedEffort, groups, status, pending, error } + * dir.store.getSnapshot() → { current, routable, groups, failures, status, 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(主视图那一个会话)。 * + * **实测更正**:安装的 0.1.7-rc.1 的 ModelDirectoryState(lib/types/client/directory.d.ts:13-32) + * 只有上面六个字段 —— 既没有 pending 也没有 retainedEffort(两个词在该包 client.js 里各出现 0 次)。 + * 所以「等宿主的 pending」这条路在这版上恒不触发,乐观显示必须是本模块自己的状态: + * 席位级 seat.pending(见 settlePending / viewOfSeat)。宿主将来补上 pending 时仍优先用宿主的。 + * * 卡顿的解码:宿主把目录在**整个 selectModel 往返**(实测 ~1.1s)里标成 selecting。 * 列表的签名里只放「长什么样」的东西,selecting 不在里面 —— 改档位时卡片不重画、不清空; - * 往返期间轨与触发器按 pending 那一档乐观显示,并在档位名旁转圈(宿主菜单那里一个都不画)。 + * 往返期间轨与触发器按**本地 pending** 那一档乐观显示,并在档位名旁转圈(宿主菜单那里一个都不画)。 + * + * 本地 pending 的因果链(0.5.11 修):提交时记下目标 selection,宿主的 current 追平或提交失败即撤。 + * 没有它时,store 的第一次通知(status→selecting)会用**旧** current 重画轨,于是松手回弹一帧、 + * 底部触发器整段往返都停在旧档 —— 实测 t=234ms 轨回到旧档、t=252ms 才到新档。 */ - -/** 席位名。 */ -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; +import { isEnglish } from '../host.js'; +import { THUMB_SIZE, domSessionOf, indexRatio, listSignature, offsetRatio, sessionIdOf, snapIndex, viewOf } from './view.js'; + +/** 席位选择器(与 view.js 的 MODEL_SLOT 同源;这里只需要字符串)。 */ +const SLOT_SELECTOR = '[data-slot="conversation.input.model"]'; +/** 我们的触发器在席时打在席位出口上(样式表据此隐藏宿主那一格)。 */ +const SEATED_ATTR = 'data-codex-ui-seated'; +/** 自建节点的类名根(样式表只画 .codex-mp-*,不碰宿主任何节点)。 */ +const TRIGGER_CLASS = 'codex-mp-trigger'; +const POPOVER_CLASS = 'codex-mp-popover'; /** 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; +const MOTION_ARM_MS = 16; +/** 本地 pending 的兜底寿命:宿主迟迟不回显(丢包 / 被更晚的提交取代)也不能把界面钉死。 */ +const PENDING_ECHO_MS = 12000; +/** 弹层定位照宿主 ModelSelect 的 place():右沿对齐触发器、上方留 8px、视口留 12px。 */ +const POPOVER_GAP = 8; +const POPOVER_MARGIN = 12; +const CHEVRON = ''; +const CHECK = ''; + /** 宿主给内置模型的说明做了本地化,按同一张表取(宿主 BUILTIN_DESCRIPTION_KEYS)。 */ const BUILTIN_DESCRIPTION_KEYS = { 'deepseek-account/deepseek-v4-flash': 'option.deepseekV4Flash.description', @@ -63,6 +79,8 @@ const COPY = { 'menu.aria': '模型与推理等级', 'menu.model': '模型', 'menu.effort': '推理等级', + 'effort.faster': '更快', + 'effort.smarter': '更强', 'effort.providerDefault': 'Default', 'error.action': '模型操作失败:{message}', 'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的(如其他 dsh web、桌面端),请退出其他正在运行的 DSH 后重试。', @@ -80,6 +98,8 @@ const COPY = { 'menu.aria': 'Model and reasoning effort', 'menu.model': 'Model', 'menu.effort': 'Effort', + 'effort.faster': 'Faster', + 'effort.smarter': 'Smarter', '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.', @@ -89,150 +109,15 @@ const COPY = { }; /** - * 松手对齐:连续比例 → 最近档位下标。 - * @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? }。 + * 挂上模型选择器。 + * @param env - { models: ctx.modelDirectories, sessionFallback: () => 主视图会话 id, locale, enabled? }。 * enabled: false 表示先不接管,等 setEnabled(true)(设置文档还没到时用,免得先接管再撤回闪一下)。 - * @returns 句柄:setEnabled / refresh / isActive / dispose。 + * @returns 句柄:setEnabled / dispose。 */ -export function installModelPicker(env) { - const doc = env.document ?? globalThis.document; - const win = env.window ?? globalThis.window ?? globalThis; +export function mountModelPicker(env) { const models = env.models; - const slotSelector = '[data-slot="' + MODEL_SLOT + '"]'; + /* 计时器与 devicePixelRatio 走 env.window:夹具里没有真 window(组件在页面里跑,但定时器要能被夹住)。 */ + const win = env.window ?? globalThis.window ?? globalThis; /** slot 元素 → 席位状态。 */ const seats = new Map(); let enabled = env.enabled !== false; @@ -244,6 +129,14 @@ export function installModelPicker(env) { let errorBox = null; let effortBox = null; let effortValue = null; + let effortText = null; + let effortGhost = null; + /** 上一次画出来的档位名(换档时它变成 ghost 飘走)。 */ + let lastEffortLabel = null; + let endsBox = null; + let matrix = null; + /** 点阵已解出的网格签名(设备像素宽@dpr),空串表示还没建。 */ + let matrixSig = ''; let rail = null; let railSignature = ''; let lastListSignature = ''; @@ -252,18 +145,10 @@ export function installModelPicker(env) { /** 最近一次提交失败的文案(弹层顶上那条)。 */ 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'; - }; + /* ── 文案:先借宿主 model 命名空间(返回键名本身即没有),再落本表 ─────── */ + let hostT = null; + try { hostT = env.locale?.bind?.('model') ?? null; } catch { hostT = null; } 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 { @@ -271,8 +156,7 @@ export function installModelPicker(env) { if (typeof hosted === 'string' && hosted !== key) return hosted; } catch { /* 宿主字典缺席就用本表 */ } } - const own = COPY[lang()][key] ?? COPY.zh[key] ?? key; - return fill(own, params); + return fill(COPY[isEnglish(env.locale) ? 'en' : 'zh'][key] ?? COPY.zh[key] ?? key, params); }; const groupName = (group) => (group.id === 'deepseek-account' ? t('provider.account') : (group.name || group.id)); /* 内置模型的说明只有宿主字典里有中文;宿主字典缺席时 t() 会把键名原样还回来 —— 那就用目录自带的原文。 */ @@ -283,16 +167,12 @@ export function installModelPicker(env) { return localized === key ? model.description : localized; }; - /* ── DOM 小工具 ─────────────────────────────────────────────────────── */ const el = (tag, className, text) => { - const node = doc.createElement(tag); + const node = document.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) { @@ -306,7 +186,8 @@ export function installModelPicker(env) { 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 }; + /* pending:本席位正在往返的那次 selection(宿主快照没有 pending 时的乐观来源)。 */ + const seat = { slot, button, model, layers, layerKey: '', sessionId: null, fromFallback: false, dir: null, off: null, pending: null, pendingSettled: false, pendingTimer: 0 }; button.addEventListener('click', (event) => { event.stopPropagation(); if (openSeat === seat) close(false); @@ -329,24 +210,54 @@ export function installModelPicker(env) { /** 换绑会话:退订旧目录,解析新目录并订阅。会话作用域未物化时宿主会抛 —— 吞掉,下一趟再试。 */ function bind(seat, sessionId) { - if (seat.off !== null) { try { seat.off(); } catch { /* 目录已随会话作用域销毁 */ } seat.off = null; } + seat.off?.(); + 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)); + seat.dir = dir; } catch { seat.dir = null; } } - const snapshotOf = (seat) => { - if (seat.dir === null) return null; - try { return seat.dir.store.getSnapshot(); } catch { return null; } - }; + const snapshotOf = (seat) => seat.dir?.store.getSnapshot() ?? null; + + /** 两个 selection 是不是同一档(reasoningEffort 缺失与 undefined 等价)。 */ + const sameSelection = (a, b) => a !== null && b !== null + && a.provider === b.provider && a.model === b.model + && (a.reasoningEffort ?? undefined) === (b.reasoningEffort ?? undefined); + + /** 撤掉本地 pending 并重画(幂等;顺带收掉兜底计时器)。 */ + function clearPending(seat) { + if (seat.pendingTimer !== 0) { win.clearTimeout(seat.pendingTimer); seat.pendingTimer = 0; } + if (seat.pending === null) return; + seat.pending = null; + seat.pendingSettled = false; + syncSeat(seat.slot); + if (openSeat === seat) renderPopover(); + } + + /** + * 宿主的 current 追平本地 pending 就撤掉它。 + * + * **只有本次提交的 RPC 已经落地之后才允许撤**(seat.pendingSettled)—— 0.6.2 修的: + * 宿主的 current 是「durable next-request projection」(directory.d.ts:14),它**落后于**提交。 + * 于是「切回当前那一档」时 current 与 pending 天然相等,早先那版会立刻撤掉乐观值, + * 先前那次提交一落地就把显示拽回旧档 —— 就是「加载期间怎么滑都回弹」。 + */ + function settlePending(seat) { + if (seat.pending === null || !seat.pendingSettled) return; + const snap = snapshotOf(seat); + const current = snap === null || snap.current === undefined ? null : snap.current; + if (sameSelection(current, seat.pending)) clearPending(seat); + } + + /** 席位视图:宿主快照 + 席位自己的 pending。所有读席位状态的地方都走这里。 */ + const viewOfSeat = (seat) => viewOf(snapshotOf(seat), seat.pending); /** 可以接管:宿主确实渲染了这一格(子代理会话里宿主返回 null,我们也不出头),且目录已有可显示的内容。 */ const canSeat = (seat, view) => { @@ -354,40 +265,53 @@ export function installModelPicker(env) { return hostChild && seat.dir !== null && (view.current !== null || view.groups.length > 0); }; + /** 对账一个席位;返回算好的视图(onStore 接着拿去画弹层,不再算第二遍)。 */ function syncSeat(slot) { let seat = seats.get(slot); if (seat === undefined) { seat = createSeat(slot); seats.set(slot, seat); } - const id = sessionIdOf(slot, env.sessionFallback); + const domId = domSessionOf(slot); + seat.fromFallback = domId === null; + const id = domId ?? sessionIdOf(null, env.sessionFallback); if (id !== seat.sessionId || seat.dir === null) bind(seat, id); - const view = viewOf(snapshotOf(seat)); + settlePending(seat); + const view = viewOfSeat(seat); if (!canSeat(seat, view)) { - if (seat.button.parentElement !== null) seat.button.remove(); + unseat(seat); if (openSeat === seat) close(false); - return; + return view; } paintTrigger(seat, view); if (seat.button.parentElement !== slot) slot.appendChild(seat.button); + if (!slot.hasAttribute(SEATED_ATTR)) slot.setAttribute(SEATED_ATTR, ''); + return view; + } + + /** 撤下触发器并摘掉席位标记:宿主那一格立刻复原。 */ + function unseat(seat) { + if (seat.button.parentElement !== null) seat.button.remove(); + seat.slot.removeAttribute(SEATED_ATTR); } function dropSeat(seat) { + if (seat.pendingTimer !== 0) { win.clearTimeout(seat.pendingTimer); seat.pendingTimer = 0; } if (openSeat === seat) close(false); - if (seat.off !== null) { try { seat.off(); } catch { /* ignore */ } } - seat.button.remove(); + seat.off?.(); + unseat(seat); seats.delete(seat.slot); } /** 全量对账:新席位接上、失联席位撤掉。 */ function scan() { if (disposed || !enabled) return; - const live = new Set(doc.querySelectorAll(slotSelector)); + const live = new Set(document.querySelectorAll(SLOT_SELECTOR)); 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(); + const view = syncSeat(seat.slot); + if (openSeat === seat) renderPopover(view); } /* ── 触发器 ──────────────────────────────────────────────────────────── */ @@ -405,8 +329,8 @@ export function installModelPicker(env) { : 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 —— 改档是模糊交叉淡入,不是换文本;格宽 = 最宽的那个名字,不跳宽。 */ + /* 档位文字叠层(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'); @@ -415,7 +339,7 @@ export function installModelPicker(env) { 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); @@ -444,6 +368,9 @@ export function installModelPicker(env) { const head = el('div', 'codex-mp-effort-head'); head.appendChild(el('span', 'codex-mp-effort-label')); effortValue = el('span', 'codex-mp-effort-value'); + effortText = el('span', 'codex-mp-value-text'); + effortGhost = el('span', 'codex-mp-effort-ghost'); + effortValue.append(effortText, effortGhost); head.appendChild(effortValue); const container = el('div', 'codex-mp-container'); rail = el('div', 'codex-mp-root'); @@ -451,16 +378,21 @@ export function installModelPicker(env) { rail.tabIndex = 0; const track = el('div', 'codex-mp-track'); track.appendChild(el('div', 'codex-mp-range')); + matrix = el('div', 'codex-mp-matrix'); + track.appendChild(matrix); 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); + /* 两端命名的是方向(更快 / 更强),不是取值 —— 形态基准里它们在槽下方一行。 */ + endsBox = el('div', 'codex-mp-ends'); + endsBox.append(el('span', 'codex-mp-ends-faster', t('effort.faster')), el('span', 'codex-mp-ends-smarter', t('effort.smarter'))); + effortBox.append(head, container, endsBox); popover.append(errorBox, listBox, effortBox); popover.addEventListener('keydown', onPopoverKey); popover.addEventListener('focusout', onFocusOut); wireRail(); - doc.body.appendChild(popover); + document.body.appendChild(popover); } function open(seat, viaKeyboard) { @@ -474,20 +406,18 @@ export function installModelPicker(env) { 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'); + popover.setAttribute('data-reduced-motion', matchMedia('(prefers-reduced-motion: reduce)').matches ? '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 { /* 子代理会话会抛:不刷新即可 */ } - } + clearTimeout(armTimer); + armTimer = setTimeout(() => { armTimer = 0; rail?.setAttribute('data-armed', 'true'); }, MOTION_ARM_MS); + /* 宿主在打开菜单时刷新一次目录(reload()),这里同样做一次;结果经 store 订阅回来。子代理会话会拒绝,不刷新即可。 */ + seat.dir?.load().catch(() => {}); if (viaKeyboard) { const checked = listBox.querySelector('[aria-checked="true"]') ?? listBox.querySelector('.codex-mp-row'); - if (checked !== null) checked.focus(); + checked?.focus(); } } @@ -509,15 +439,14 @@ export function installModelPicker(env) { 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); + if (w > 0) x = Math.min(Math.max(x, POPOVER_MARGIN), innerWidth - w - POPOVER_MARGIN); + if (h > 0) y = Math.min(Math.max(y, POPOVER_MARGIN), innerHeight - h - POPOVER_MARGIN); popover.style.left = Math.round(x) + 'px'; popover.style.top = Math.round(y) + 'px'; } - function renderPopover() { + function renderPopover(view = openSeat === null ? null : viewOfSeat(openSeat)) { if (openSeat === null || popover === null) return; - const view = viewOf(snapshotOf(openSeat)); errorBox.hidden = failure === null; errorBox.textContent = failure ?? ''; const signature = listSignature(view); @@ -537,7 +466,7 @@ export function installModelPicker(env) { 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(() => {}); }); + retry.addEventListener('click', () => { openSeat?.dir?.load().catch(() => {}); }); status.appendChild(retry); } else { status.textContent = view.status === 'loading' ? t('trigger.loading') : t('empty.models'); @@ -588,9 +517,17 @@ export function installModelPicker(env) { 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) ?? '')); + const label = effortLabelOf(view) ?? ''; + /* 换档是**交换**不是替换:进来的从下方模糊上浮,出去的(ghost 压在原位)向上飘走。 */ + if (label !== lastEffortLabel) { + if (lastEffortLabel !== null) { effortGhost.textContent = lastEffortLabel; rearm(effortGhost); } + lastEffortLabel = label; + rearm(effortText); + } + effortText.textContent = label; + const spin = effortValue.querySelector('.codex-mp-spinner'); + if (view.pendingEffort && spin === null) effortValue.insertBefore(el('span', 'codex-mp-spinner'), effortText); + else if (!view.pendingEffort && spin !== null) spin.remove(); 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) { @@ -601,7 +538,7 @@ export function installModelPicker(env) { const tick = el('span', 'codex-mp-tick'); tick.setAttribute('data-index', String(i)); tick.title = level.name; - /* 档位等距落在拇指行程 [14, 宽 − 14] 上 —— 与拇指、色条同一条 calc,零布局读。 */ + /* 档位等距落在旋钮行程 [8, 宽 − 8] 上 —— 与旋钮、填充同一条 calc,零布局读。 */ tick.style.left = 'calc(' + THUMB_SIZE / 2 + 'px + (100% - ' + THUMB_SIZE + 'px) * ' + indexRatio(i, view.efforts.length) + ')'; rail.insertBefore(tick, thumbScale); }); @@ -621,6 +558,97 @@ export function installModelPicker(env) { if (drag === null) paintRail(view.index < 0 ? null : indexRatio(view.index, view.efforts.length)); } + /* ── 顶档点阵(形态基准 dsh-claude-style 的 .dsh-claude-effort-matrix)────────── + 算法逐条照搬:5 行方块、1 设备像素缝、0.5 设备像素外边距,在**整设备像素**上解网格 + (小数 pitch 会被栅格化成交替的缝);每格的相位/周期/色调由坐标 hash 散开,不成序; + 左侧的羽化是静态 opacity,颜色闪动从它上面走过,两者不打架。 */ + + /** 坐标 hash → 0..1:先把两个坐标雪崩再取,免得邻居落进规则格点被看成斜带。 */ + function cellUnit(r, c, seed) { + let h = Math.imul(r + 1, 0x9e3779b1) ^ Math.imul(c + 1, 0x85ebca6b) ^ Math.imul(seed + 1, 0x27d4eb2f); + h = Math.imul(h ^ (h >>> 15), 0x2545f491); + h ^= h >>> 13; + return (h >>> 0) / 4294967296; + } + + /** 左端溶回裸槽、右端实心的羽化(smoothstep)。 */ + function cellFade(fx) { + if (fx <= 0.05) return 0; + if (fx >= 0.75) return 1; + const t = (fx - 0.05) / 0.7; + return t * t * (3 - 2 * t); + } + + /** 按设备像素解网格:5 行 ~4px 方块,余数回填到四边留白,方块保持正方且居中。 */ + function solveMatrix(wDev, dpr) { + const ROWS = 5; + const gap = Math.max(1, Math.round(1 * dpr)); + const margin = Math.max(1, Math.round(0.5 * dpr)); + const hDev = Math.round(26 * dpr); + const block = Math.max(3, Math.floor((hDev - 2 * margin - (ROWS - 1) * gap) / ROWS)); + const cols = Math.max(1, Math.floor((wDev + gap) / (block + gap))); + const restX = wDev - (cols * block + (cols - 1) * gap); + const restY = hDev - (ROWS * block + (ROWS - 1) * gap); + return { + cols, + rows: ROWS, + sq: block / dpr, + gap: gap / dpr, + padTop: Math.floor(restY / 2) / dpr, + padBottom: (restY - Math.floor(restY / 2)) / dpr, + padLeft: Math.floor(restX / 2) / dpr, + padRight: (restX - Math.floor(restX / 2)) / dpr, + }; + } + + /** 给一颗粒子它自己的闪烁:色调、相位、周期全部 hash 散开。 */ + function paintParticle(sq, r, c) { + sq.setAttribute('data-tone', String(Math.floor(cellUnit(r, c, 1) * 8) % 8)); + sq.style.setProperty('animation-delay', ((cellUnit(r, c, 2) * 1.38 + 0.3).toFixed(3)) + 's', 'important'); + sq.style.setProperty('animation-duration', (1.45 * (0.92 + cellUnit(r, c, 3) * 0.16)).toFixed(3) + 's', 'important'); + } + + /** 轨宽变了或 dpr 变了才重建;轨还没布局(隐藏中)时返回 false,调用方下次再试。 */ + function ensureMatrix() { + if (matrix === null) return false; + const track = rail === null ? null : rail.querySelector('.codex-mp-track'); + if (track === null) return false; + const w = track.clientWidth; + if (!w) return false; + const dpr = win.devicePixelRatio > 0 ? win.devicePixelRatio : 1; + const sig = Math.round(w * dpr) + '@' + dpr; + if (sig === matrixSig) return true; + const lay = solveMatrix(Math.round(w * dpr), dpr); + matrix.textContent = ''; + matrix.style.gap = lay.gap + 'px'; + matrix.style.padding = lay.padTop + 'px ' + lay.padRight + 'px ' + lay.padBottom + 'px ' + lay.padLeft + 'px'; + matrix.style.gridTemplateColumns = 'repeat(' + lay.cols + ', ' + lay.sq + 'px)'; + matrix.style.gridAutoRows = lay.sq + 'px'; + for (let r = 0; r < lay.rows; r += 1) { + for (let c = 0; c < lay.cols; c += 1) { + const fx = lay.cols > 1 ? c / (lay.cols - 1) : 1; + const cell = el('div', 'codex-mp-matrix-cell'); + /* 入场从右端扫进来(旋钮够到的那一端),带一点散相,免得读成一次擦除。 */ + cell.style.setProperty('animation-delay', (((1 - fx) * 0.45 + cellUnit(r, c, 4) * 0.08).toFixed(3)) + 's', 'important'); + const sq = el('div', 'codex-mp-matrix-sq'); + sq.style.opacity = cellFade(fx).toFixed(3); + paintParticle(sq, r, c); + cell.appendChild(sq); + matrix.appendChild(cell); + } + } + matrixSig = sig; + return true; + } + + /** 让 CSS 动画重新起跑(换档时名字交换要重播,否则第二次换档不动)。 */ + function rearm(node) { + if (node === null) return; + node.style.animation = 'none'; + void node.offsetWidth; + node.style.animation = ''; + } + /** * 画到连续位置:拇指、色条、「已走过」的圆点都由同一个 --codex-mp-pos 驱动。 * @param ratio - 0..1;null 表示没有生效档(跟随提供方默认),此时拇指与色条藏起、圆点全按未选画。 @@ -630,11 +658,22 @@ export function installModelPicker(env) { 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')); + /* 顶档(形态基准里的 apex):拖动中也要亮 —— 拉到最右端即触发,不等松手,所以判据挂在 + **连续比例**上而不是生效档 index 上。单档模型没有「最高档」可言。 + data-tier 同时打在轨上(点阵、旋钮、填充、刻度)与 effortBox 上(档位名转紫)。 */ + const atMax = ratio !== null && count > 1 && ratio >= 1 - 1e-6; + if (atMax && ensureMatrix()) { + rail.setAttribute('data-tier', 'max'); + if (effortBox !== null) effortBox.setAttribute('data-tier', 'max'); + } else { + rail.removeAttribute('data-tier'); + if (effortBox !== null) effortBox.removeAttribute('data-tier'); + } } function currentEfforts() { if (openSeat === null) return null; - const view = viewOf(snapshotOf(openSeat)); + const view = viewOfSeat(openSeat); return view.choice === null || view.efforts.length === 0 ? null : view; } @@ -690,30 +729,72 @@ export function installModelPicker(env) { 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('focus', () => rail.setAttribute('data-keyboard-focused', rail.matches(':focus-visible') ? 'true' : 'false')); rail.addEventListener('blur', () => rail.setAttribute('data-keyboard-focused', 'false')); } /* ── 提交 ────────────────────────────────────────────────────────────── */ + /** + * 提交一次 selection。 + * + * 提交前先把目标记进 seat.pending 并立刻重画:轨 / 触发器 / 弹层档位名从这一帧起就是目标档, + * 不再等 durable projection。撤除点有三个,全都幂等 —— + * 1. 宿主的 current 追平(settlePending,随 store 通知与每轮 scan 跑); + * 2. 提交成功返回(此时宿主已 syncInputs,current 就是目标档); + * 3. 提交失败或抛异常。 + * 只有在 seat.pending 还是本次那一笔时才撤,晚到的旧响应不会清掉更新的提交。 + * + * @param seat - 席位。 + * @param selection - { provider, model, reasoningEffort? }。 + * @param closeOnOk - 成功后是否收起弹层(换模型收,改档位不收)。 + */ function submit(seat, selection, closeOnOk) { if (seat.dir === null) return; failure = null; + const submitted = { + provider: selection.provider, + model: selection.model, + ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }), + }; + seat.pending = submitted; + seat.pendingSettled = false; + if (seat.pendingTimer !== 0) { win.clearTimeout(seat.pendingTimer); seat.pendingTimer = 0; } + syncSeat(seat.slot); + if (openSeat === seat) renderPopover(); + /** + * 本次提交的 RPC 落地了(不是本次的那一笔就留着)。 + * 成功时**不直接撤**:先允许撤,再看宿主有没有追平;没追平就留着 pending 等它, + * 并挂一条兜底寿命 —— 否则界面会卡在一个宿主永远不回显的档上。 + * @param force - 失败路径:无论如何撤掉(错就是错,不能让乐观值盖住失败)。 + */ + const release = (force) => { + if (seat.pending !== submitted) return; + if (force === true) { clearPending(seat); return; } + seat.pendingSettled = true; + settlePending(seat); + if (seat.pending === null) return; + seat.pendingTimer = win.setTimeout(() => { seat.pendingTimer = 0; clearPending(seat); }, PENDING_ECHO_MS); + }; let pending; - try { pending = seat.dir.select(selection); } catch (error) { failure = t('error.action', { message: String(error && error.message ? error.message : error) }); renderPopover(); return; } + try { pending = seat.dir.select(selection); } catch (error) { + release(true); + 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) { + release(false); if (closeOnOk && openSeat === seat) close(true); return; } + release(true); 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) => { + release(true); failure = t('error.action', { message: String(error && error.message ? error.message : error) }); if (openSeat === seat) renderPopover(); }); @@ -723,10 +804,10 @@ export function installModelPicker(env) { function choose(group, model) { const seat = openSeat; if (seat === null) return; - const view = viewOf(snapshotOf(seat)); + const view = viewOfSeat(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; + const effort = model.reasoning?.defaultEffort; submit(seat, { provider: group.id, model: model.id, ...(effort === undefined ? {} : { reasoningEffort: effort }) }, true); } @@ -746,18 +827,17 @@ export function installModelPicker(env) { close(true); return; } - if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && event.target instanceof win.HTMLElement && event.target.classList.contains('codex-mp-row')) { + if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && event.target instanceof 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(); + rows[(at + (event.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length]?.focus(); } } function onFocusOut(event) { const to = event.relatedTarget; - if (openSeat === null || !(to instanceof win.Node)) return; + if (openSeat === null || !(to instanceof Node)) return; if (popover.contains(to) || openSeat.button.contains(to)) return; close(false); } @@ -771,34 +851,41 @@ export function installModelPicker(env) { 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; } + /* 对账只在「和席位有关的变动」上做全量扫描:新席位出现(加进来的元素是/含席位)、席位离场(删掉的元素是/含席位)、 + 会话标记改变;席位自己的子节点变了只对那一个席位。流式输出每段都会插元素,逐次全量扫描是白做。 + 还没落定的席位(目录没解析出来、或会话 id 来自主视图回退)仍在任何变动时重试,与全量扫描时同效。 + 回调同步执行(不挪到 rAF):React 换出新席位的那一帧就接上,宿主控件不会先闪一下。 */ + const ours = (node) => popover !== null && (node === popover || popover.contains(node)); + const addsSeat = (node) => node.nodeType === 1 && (node.matches(SLOT_SELECTOR) || node.querySelector(SLOT_SELECTOR) !== null); + const holdsSeat = (node) => node.nodeType === 1 && [...seats.keys()].some((slot) => node === slot || node.contains(slot)); + const observer = new MutationObserver((records) => { + const touched = new Set(); + for (const record of records) { + if (ours(record.target)) continue; + if (record.type === 'attributes' || [...record.addedNodes].some(addsSeat) || [...record.removedNodes].some(holdsSeat)) { + scan(); + return; } - }); - } + const seat = seats.get(record.target); + if (seat !== undefined) touched.add(seat); + } + for (const seat of [...seats.values()]) { + if (touched.has(seat) || seat.dir === null || seat.fromFallback) syncSeat(seat.slot); + } + }); 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); + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-conversation-session'] }); + document.addEventListener('pointerdown', onPointerDown, true); + addEventListener('resize', onViewport); + 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); + observer.disconnect(); + document.removeEventListener('pointerdown', onPointerDown, true); + removeEventListener('resize', onViewport); + removeEventListener('scroll', onViewport, true); for (const seat of [...seats.values()]) dropSeat(seat); }; if (enabled) start(); @@ -812,16 +899,12 @@ export function installModelPicker(env) { 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(); + clearTimeout(armTimer); + popover?.remove(); popover = null; }, }; diff --git a/src/client/model-picker/index.js b/src/client/model-picker/index.js new file mode 100644 index 0000000..4442355 --- /dev/null +++ b/src/client/model-picker/index.js @@ -0,0 +1,40 @@ +/** + * 模型选择器 B 面接到宿主上。 + * + * 等 modelDirectories 服务就绪再挂:ctx.inject(deps, fn) 是宿主自己挂 composer 模型位的同一个口子。 + * 它不能进插件的 inject 列表 —— 那里的名字全是必需的,缺一个整个皮肤都不激活;服务撤走时 fn 的 + * 作用域连同我们的节点一起回收。 + * 设置卡的「Codex 模型选择器」(modelPicker,默认开)关掉即 setEnabled(false):自建节点全撤,宿主那一格立刻复原。 + */ +import { formValue } from '../host.js'; +import { mountModelPicker } from './component.js'; + +/** + * @param ctx - 客户端上下文。 + * @param form - 本插件的设置表单;设置那一半没挂上时为 null(此时按默认开)。 + */ +export function installModelPicker(ctx, form) { + /** 设置里的开关;设置文档还没到时返回 null(保持现状,不先接管再撤回)。 */ + const wanted = () => { + if (form === null) return true; + const value = formValue(form); + return value === undefined ? null : value.modelPicker !== false; + }; + ctx.inject(['modelDirectories'], (scope) => { + const picker = mountModelPicker({ + models: scope.modelDirectories, + /* 席位祖先上没有 data-conversation-session 时退到主视图会话(uiSession 投影)。 */ + sessionFallback: () => ctx.reflect.get('uiSession')?.current?.value?.key ?? null, + locale: ctx.reflect.get('locale'), + enabled: wanted() === true, + }); + const off = form?.subscribe(() => { + const next = wanted(); + if (next !== null) picker.setEnabled(next); + }); + scope.effect(() => () => { + off?.(); + picker.dispose(); + }, 'codex-ui: model picker'); + }); +} diff --git a/src/client/model-picker/view.js b/src/client/model-picker/view.js new file mode 100644 index 0000000..906f696 --- /dev/null +++ b/src/client/model-picker/view.js @@ -0,0 +1,161 @@ +/** + * 模型选择器的纯逻辑(无 DOM;check.mjs 直接单测):功率轨几何、目录快照 → 视图、列表签名。 + * + * 数据契约(@deepseek-ai/dsh-client-ui-model-selection 0.1.7-rc.1 / rc.2 的 ModelDirectory): + * store.getSnapshot() → { current, routable, groups, failures, status, error } + * status : 'loading' | 'ready' | 'selecting' | 'error'(整个 selectModel 往返都是 selecting) + * groups : [{ id, name, models: [{ id, name, description?, reasoning?: { defaultEffort?, efforts: [{ id, name }] } }] }] + * current : { provider, model, reasoningEffort? } | null + * **实测更正**:rc.1 的 ModelDirectoryState 只有上面六个字段 —— 既没有 pending 也没有 retainedEffort + * (两个词在该包 client.js 里各出现 0 次)。所以乐观显示的实际来源是 viewOf 的第二个参数 localPending + * (席位自己记的那一笔);宿主将来补上 pending 时 snap.pending 会自动接管。 + */ + +/** 旋钮宽(形态基准 dsh-claude-style 的 16×30 旋钮):中心的行程是 [8, 宽 − 8]。 */ +export const THUMB_SIZE = 16; + +/** + * 松手对齐:连续比例 → 最近档位下标。 + * @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(THUMB_SIZE/2 + (100% − THUMB_SIZE) × 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); +} + +/** + * 席位祖先上宿主 ConversationRoot 打的会话 id;没有则 null。 + * @param slot - 席位元素。 + */ +export function domSessionOf(slot) { + const id = slot?.closest?.('[data-conversation-session]')?.getAttribute('data-conversation-session'); + return typeof id === 'string' && id !== '' ? id : null; +} + +/** + * 席位所属的会话 id:先看祖先上的标记(右栏侧边聊天有自己的席位),取不到再退到主视图会话。 + * @param slot - 席位元素。 + * @param fallback - 返回主视图会话 id 的函数。 + * @returns 会话 id 或 null。 + */ +export function sessionIdOf(slot, fallback) { + const fromDom = domSessionOf(slot); + if (fromDom !== null) return fromDom; + try { + const key = fallback?.(); + return typeof key === 'string' && key !== '' ? key : null; + } catch { + return null; + } +} + +/** + * 目录快照 → 这一席位要显示的一切(纯函数,宿主 ModelSelect 的派生逻辑逐条对齐)。 + * pending 若是同一模型上的改档,档位按 pending 乐观显示 —— 往返 ~1.1s 里轨不回弹。 + * @param snap - dir.store.getSnapshot()。 + * @param localPending - 席位自己的待生效 selection(宿主快照没有 pending 时用它)。 + * 宿主给了就用宿主的,两者都缺即 null。 + * @returns 视图模型。 + */ +export function viewOf(snap, localPending) { + 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; + /* 宿主快照优先(将来补上 pending 时自动接管),缺了才用席位自己记的那一笔。 */ + const hosted = snap.pending === undefined || snap.pending === null ? null : snap.pending; + const pending = hosted !== null ? hosted : (localPending === undefined || localPending === null ? null : localPending); + 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(';'); +} diff --git a/src/override.js b/src/client/override.js similarity index 80% rename from src/override.js rename to src/client/override.js index 378816b..0f3a836 100644 --- a/src/override.js +++ b/src/client/override.js @@ -1,41 +1,26 @@ /** - * override.js — 设置页写进页面的那一层覆盖(纯函数:无 DOM、无副作用、可单测)。 + * 设置页写进页面的那一层覆盖(纯函数:无 DOM、无副作用;check.mjs 直接单测)。 * - * 为什么要单独一层:skins/*.css 是**默认值**,设置页改的是**用户覆盖**。 - * 把用户值写回源样式会让「默认长什么样」无法回答,也无法一键还原, - * 所以覆盖只在运行时以多一个属性的选择器(特异性 +1)压过皮肤,源文件一个字不动。 - * - * 取值纪律: - * · 空串 = 不覆盖(退默认层),默认值下本模块输出空串 —— 「装上不动一个字也不改外观」是夹具断言; - * · 颜色只认 6 位十六进制(与 Codex schema 的 /^#[0-9a-fA-F]{6}$/ 一致); - * · 本模块**不产生任何彩色**:除强调色(用户显式指定)外只有中性 rgba,与皮肤的「无彩色 chrome」纪律一致。 - * - * Codex 侧依据(app.asar 的 jdi / IOe): - * dark { accent:#339cff, contrast:60, ink:#ffffff, surface:#181818 } - * light { accent:#339cff, contrast:45, ink:#1a1c1f, surface:#ffffff } - * 对比度是**简化实现**:Codex 用线性 RGB 混合把文本往 ink 拉、并按同一常量抬升灰阶; - * 这里只做两件可解释的事 —— 文本档位按同一方向混合、发丝线与色调家族按比例缩放。 - * 差距写在 README 的「与 Codex 源码对账」一节,不含糊。 + * skins/*.css 是默认值,设置页改的是用户覆盖:覆盖只在运行时以多一个属性的选择器(特异性 +1) + * 压过皮肤,源样式一个字不动。取值纪律: + * · 空串 = 不覆盖;默认值下输出空串 —— 装上不动一个字,外观与没有设置页逐字节相同; + * · 颜色只认 6 位十六进制(Codex schema 同为 /^#[0-9a-fA-F]{6}$/); + * · 除用户显式给的强调色外只产出中性 rgba,不引入彩色。 + * 对比度是简化实现(Codex 的 jdi:亮 45 / 暗 60 为原样):文本档位按比例往 ink 或底色混合, + * 中性 alpha 阶梯按比例缩放;差距写在 README「与 Codex 源码对账」。 */ - -/** 覆盖层生效时打在 上的第二个属性(比皮肤自身多一个属性 ⇒ 特异性稳赢)。 */ -export const OVERRIDE_ATTR = 'data-codex-ui-theme'; -/** 皮肤自身的根属性,与 src/build.mjs 的 ATTR 同源。 */ -export const SKIN_ATTR = 'data-codex-ui'; -/** 设置表单的命名空间 = profile 条目 id(= 包名)。 */ -export const SETTINGS_ENTRY_ID = 'codex-ui'; +import { OVERRIDE_ATTR, ROOT_ATTR } from './constants.js'; /** 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。 + * 必须与 skin.css 一致:check.mjs 逐字段对账,漂移即 FAIL。 */ export const SKIN_DEFAULTS = { - light: { accent: '#339cff', focus: '#339cff', surface: '#ffffff', ink: '#1a1c1f', sidebar: '#eef4f9' }, - dark: { accent: '#0169cc', focus: '#339cff', surface: '#111111', ink: '#ffffff', sidebar: '#181818' }, + light: { accent: '#339cff', focus: '#339cff', surface: '#ffffff', ink: '#1a1c1f', sidebar: '#f6f6f6' }, + dark: { accent: '#0169cc', focus: '#339cff', surface: '#111111', ink: '#ffffff', sidebar: '#0f0f0f' }, }; /** @@ -221,11 +206,11 @@ export function themeOverrideCss(values = {}) { const fontDecls = fonts.map(([k, v]) => ' ' + k + ': ' + v + ';').join('\n'); if (light.length > 0 || fontDecls !== '') { const decls = [...light.map(([k, v]) => ' ' + k + ': ' + v + ';'), fontDecls].filter((s) => s !== '').join('\n'); - blocks.push(sel(SKIN_ATTR, 'light') + ',\n' + sel(SKIN_ATTR, 'light-body') + ' {\n' + decls + '\n}'); + blocks.push(sel('light') + ',\n' + sel('light-body') + ' {\n' + decls + '\n}'); } if (dark.length > 0) { const decls = dark.map(([k, v]) => ' ' + k + ': ' + v + ';').join('\n'); - blocks.push(sel(SKIN_ATTR, 'dark') + ' {\n' + decls + '\n}'); + blocks.push(sel('dark') + ' {\n' + decls + '\n}'); } return blocks.join('\n\n'); } @@ -251,12 +236,11 @@ export function isFontStack(value) { /** * 选择器生成:覆盖层比皮肤多一个属性,特异性 +1,因此不依赖样式表先后顺序。 - * @param attr - 皮肤根属性名。 * @param which - 'light' | 'light-body' | 'dark'。 * @returns CSS 选择器。 */ -export function sel(attr, which) { - const root = 'html[' + attr + '][' + OVERRIDE_ATTR + ']'; +function sel(which) { + const root = 'html[' + ROOT_ATTR + '][' + OVERRIDE_ATTR + ']'; if (which === 'light') return root; if (which === 'light-body') return root + ' body'; return root + ' body[data-ds-dark-theme]'; diff --git a/src/client/settings-card.js b/src/client/settings-card.js new file mode 100644 index 0000000..0bb8892 --- /dev/null +++ b/src/client/settings-card.js @@ -0,0 +1,309 @@ +/** + * 组合包页(插件管理 → codex-ui)上的配置卡。宿主只以 view:'page' 渲染这个座位。 + * 改一下即写、没有保存按钮;文本框回车或失焦提交,写后回读确认落地;已覆盖的行显示徽标与「重置」。 + * 下面三行颜色编辑的是**当前生效的那一套**(theme.getTheme().active.colorScheme)。 + */ +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; +import { jsx, jsxs } from 'react/jsx-runtime'; +import { Button, SegmentedControl, Switch, Tag } from '@deepseek-ai/dsh-client-ui-primitives'; +import { isEnglish } from './host.js'; +import { DEFAULT_CONTRAST, SKIN_DEFAULTS, isFontStack, isHex } from './override.js'; + +const COPY_ZH = { + intro: '这里的值只覆盖本皮肤(codex-ink)的默认外观,空值即跟随皮肤。', + theme: '主题', + themeDesc: '切换应用外观:写的是宿主的主题偏好,与「设置 → 通用 → 外观」同一处', + light: '亮色', + dark: '深色', + system: '跟随系统', + accent: '强调色', + accentDesc: '链接与焦点环', + surface: '背景', + surfaceDesc: '应用底色', + ink: '前景', + inkDesc: '主文本', + fontUi: 'UI 字体', + fontUiDesc: '留空跟随 dsh;填字体栈,如 "Segoe UI", sans-serif', + fontCode: '代码字体', + fontCodeDesc: '留空跟随 dsh', + translucent: '半透明侧边栏', + translucentDesc: '侧栏填充转为半透明;Web 没有窗口层,深色下侧栏与内容同面,看不出差别', + modelPicker: 'Codex 模型选择器', + modelPickerDesc: '输入区的模型位换成 Codex 式卡片:模型列表 + 可拖动的推理等级功率轨;关掉则交回宿主自带的菜单', + contrast: '对比度', + contrastDesc: '按 Codex 默认档位归一:45(亮)/ 60(暗)即原样', + overridden: '已覆盖', + reset: '重置', + follow: '跟随皮肤', + failed: '保存没生效,请重试。', + unavailable: '这个 dsh 没有把 codex-ui 的配置开放给本页:条目可能在本 profile 里被停用,或连接把偏好留在页面进程内。', + readOnly: '设置文档是只读的,改动无法保存。', +}; +const COPY_EN = { + intro: 'These values only override the codex-ink skin defaults. Empty means follow the skin.', + theme: 'Theme', + themeDesc: 'Switches the app appearance: the host theme preference, the same one as Settings → General → Appearance', + light: 'Light', + dark: 'Dark', + system: 'System', + accent: 'Accent', + accentDesc: 'Links and focus ring', + surface: 'Background', + surfaceDesc: 'App surface', + ink: 'Foreground', + inkDesc: 'Primary text', + fontUi: 'UI font', + fontUiDesc: 'Empty follows dsh; e.g. "Segoe UI", sans-serif', + fontCode: 'Code font', + fontCodeDesc: 'Empty follows dsh', + translucent: 'Translucent sidebar', + translucentDesc: 'Sidebar fill becomes translucent; the Web has no window layer, so in dark it is invisible', + modelPicker: 'Codex model picker', + modelPickerDesc: 'Replaces the composer model seat with a Codex-style card: model list plus a draggable reasoning power rail. Off hands the seat back to the host menu.', + contrast: 'Contrast', + contrastDesc: 'Normalised to the Codex defaults: 45 (light) / 60 (dark) is unchanged', + overridden: 'Overridden', + reset: 'Reset', + follow: 'Follow skin', + failed: 'The save did not take effect. Please try again.', + unavailable: 'This dsh does not expose codex-ui configuration to this page: the entry may be disabled in this profile, or the connection keeps preferences inside the page process.', + readOnly: 'The settings document is read-only, so changes cannot be saved.', +}; + +/** 用户层是否含这一格(= 已覆盖)。 */ +const hasUserField = (snapshot, field) => snapshot.user != null && Object.hasOwn(snapshot.user, field); +/** 当前生效值。 */ +const fieldValue = (snapshot, field) => snapshot.value?.[field]; + +/** + * @param props - 座位注入:scope(本插件表单)、theme(宿主主题服务)、themeForm(ui-theme 表单)、 + * watchTheme、previewTheme、locale。 + */ +export function SettingsCard({ scope, theme, themeForm, watchTheme, previewTheme, locale }) { + const snapshot = useSyncExternalStore( + useCallback((listener) => scope.subscribe(listener), [scope]), + () => scope.getSnapshot(), + ); + /* 宿主的 getTheme() 在两次变更之间返回同一个冻结对象,可直接当快照(useSyncExternalStore 按引用比较)。 */ + const themeSnapshot = useSyncExternalStore( + useCallback((listener) => watchTheme(() => listener()), [watchTheme]), + useCallback(() => theme.getTheme(), [theme]), + ); + const copy = useMemo(() => (isEnglish(locale) ? COPY_EN : COPY_ZH), [locale]); + /* preference 是偏好档(light/dark/system);variant 是它当前解析出的那一套。 */ + const preference = themeSnapshot.preference; + const variant = themeSnapshot.active?.colorScheme === 'dark' ? 'dark' : 'light'; + const [drafts, setDrafts] = useState({}); + const [saving, setSaving] = useState(false); + const [failed, setFailed] = useState(false); + /* 分段控件先显示用户点的那个值:写文档是异步的,不暂存的话控件会滞后一拍。 */ + const [pendingTheme, setPendingTheme] = useState(null); + useEffect(() => { + if (pendingTheme !== null && pendingTheme === preference) setPendingTheme(null); + }, [pendingTheme, preference]); + + const fieldOf = useCallback((base) => base + (variant === 'light' ? 'Light' : 'Dark'), [variant]); + /** + * 切主题。不调 theme.setTheme(id):它先本地乐观发布、再由 adopt() 从设置文档回读,文档往返慢时会画出 + * 新值 → 旧值 → 新值(「黑 → 白 → 黑」)。这里先把偏好写进主题插件自己的设置文档(与服务内部 host.set + * 同一命名空间 ui-theme、同一字段 preference),发布方只剩 adopt(),一次点击只发布一次;写入未被接受 + * 才退回服务入口。等往返的那 ~0.8s 由 previewTheme 立刻按目标主题显示。 + */ + const switchTheme = useCallback(async (id) => { + if (id !== 'light' && id !== 'dark' && id !== 'system') return; + setPendingTheme(id); + previewTheme(id); + try { + if ((await themeForm.set('preference', id)) !== false) return; + } catch (error) { + console.warn('[codex-ui] 直接写主题偏好失败,退回服务入口:', error); + } + setPendingTheme(null); + try { + theme.setTheme(id); + } catch (error) { + console.warn('[codex-ui] 切主题失败:', error); + } + }, [theme, themeForm, previewTheme]); + const unavailable = snapshot.status === 'unavailable'; + const readOnly = snapshot.writable === false; + const disabled = saving || unavailable || readOnly; + + /** 写一格或清一格,再回读确认落地。 */ + const persist = useCallback(async (op, landedCheck) => { + setSaving(true); + setFailed(false); + let landed = false; + try { landed = await landedCheck(await op()); } catch { landed = false; } + setFailed(!landed); + setSaving(false); + }, []); + const write = useCallback((field, next) => persist( + () => scope.set(field, next), + (accepted) => accepted !== false && fieldValue(scope.getSnapshot(), field) === next, + ), [scope, persist]); + const clear = useCallback((field) => persist( + () => scope.unset(field), + () => !hasUserField(scope.getSnapshot(), field), + ), [scope, persist]); + + /** 提交一个文本/色值草稿:空串写的是清除。 */ + const commitText = useCallback(async (field, draft, validate) => { + const text = String(draft).trim(); + if (text !== '' && validate(text) === false) return; + setDrafts((prev) => { const next = { ...prev }; delete next[field]; return next; }); + if (text === '') { await clear(field); return; } + await write(field, text); + }, [clear, write]); + + const draftOf = (field) => (Object.hasOwn(drafts, field) ? drafts[field] : undefined); + const setDraft = (field, value) => setDrafts((prev) => ({ ...prev, [field]: value })); + + if (unavailable) { + return jsx('p', { className: 'cx-note', role: 'status', children: copy.unavailable }); + } + + /** 一行:标签 + 说明 + 覆盖徽标 + 控件。 */ + const row = (key, label, desc, control, field) => { + const overridden = field !== undefined && hasUserField(snapshot, field); + return jsxs('div', { + className: 'cx-row', + children: [ + jsxs('div', { + className: 'cx-row__text', + children: [ + jsxs('div', { + className: 'cx-row__label', + children: [ + jsx('span', { children: label }), + overridden ? jsx(Tag, { tone: 'outline', children: copy.overridden }) : null, + ], + }, 'label'), + desc === null ? null : jsx('div', { className: 'cx-row__desc', children: desc }), + ], + }, 'text'), + jsxs('div', { + className: 'cx-row__control', + children: [ + control, + overridden ? jsx(Button, { variant: 'ghost', size: 'sm', disabled, onClick: () => clear(field), children: copy.reset }) : null, + ], + }, 'control'), + ], + }, key); + }; + + /** 颜色行:色块 + 十六进制文本框。 */ + const colorRow = (base, label, desc) => { + const field = fieldOf(base); + const current = fieldValue(snapshot, field); + const draft = draftOf(field); + const text = draft !== undefined ? draft : (isHex(current) ? current : ''); + return row(base, label, desc, [ + jsx('input', { + key: 'swatch', + className: 'cx-swatch', + type: 'color', + disabled, + 'aria-label': label, + value: isHex(current) ? current : SKIN_DEFAULTS[variant][base], + onChange: (event) => write(field, event.target.value), + }), + jsx('input', { + key: 'hex', + className: 'cx-hex', + type: 'text', + disabled, + spellCheck: false, + 'aria-label': label + ' hex', + placeholder: copy.follow, + value: text, + onChange: (event) => setDraft(field, event.target.value), + onBlur: () => { if (draftOf(field) !== undefined) commitText(field, draftOf(field), isHex); }, + onKeyDown: (event) => { if (event.key === 'Enter') commitText(field, draftOf(field) ?? text, isHex); }, + }), + ], field); + }; + + /** 字体行:一条文本输入。 */ + const fontRow = (field, label, desc) => { + const current = fieldValue(snapshot, field); + const draft = draftOf(field); + const text = draft !== undefined ? draft : (typeof current === 'string' ? current : ''); + return row(field, label, desc, jsx('input', { + className: 'cx-text', + type: 'text', + disabled, + spellCheck: false, + 'aria-label': label, + placeholder: copy.follow, + value: text, + onChange: (event) => setDraft(field, event.target.value), + onBlur: () => { if (draftOf(field) !== undefined) commitText(field, draftOf(field), isFontStack); }, + onKeyDown: (event) => { if (event.key === 'Enter') commitText(field, draftOf(field) ?? text, isFontStack); }, + }), field); + }; + + /** 对比度行:滑杆 + 读数,拖动时只改草稿,松手/失焦才写。 */ + const contrastRow = () => { + const field = fieldOf('contrast'); + const shown = draftOf(field) ?? fieldValue(snapshot, field) ?? DEFAULT_CONTRAST[variant]; + const commit = () => { if (draftOf(field) !== undefined) write(field, draftOf(field)); }; + return row('contrast', copy.contrast, copy.contrastDesc, [ + jsx('input', { + key: 'range', + className: 'cx-range', + type: 'range', + min: 0, + max: 100, + step: 1, + disabled, + 'aria-label': copy.contrast, + value: shown, + onChange: (event) => setDraft(field, Number(event.target.value)), + onPointerUp: commit, + onKeyUp: commit, + onBlur: commit, + }), + jsx('span', { key: 'value', className: 'cx-range__value', children: String(shown) }), + ], field); + }; + + /** 开关行。 */ + const switchRow = (key, field, label, desc, checked) => row(key, label, desc, jsx(Switch, { + checked, + disabled, + label, + onChange: (next) => write(field, next), + }), field); + + return jsxs('div', { + className: 'cx-form', + children: [ + jsx('p', { className: 'cx-note', children: copy.intro }), + row('theme', copy.theme, copy.themeDesc, jsx(SegmentedControl, { + id: 'codex-ui-theme', + value: pendingTheme ?? preference, + label: copy.theme, + disabled, + options: [ + { value: 'light', label: copy.light }, + { value: 'dark', label: copy.dark }, + { value: 'system', label: copy.system }, + ], + onChange: switchTheme, + })), + colorRow('accent', copy.accent, copy.accentDesc), + colorRow('surface', copy.surface, copy.surfaceDesc), + colorRow('ink', copy.ink, copy.inkDesc), + fontRow('fontUi', copy.fontUi, copy.fontUiDesc), + fontRow('fontCode', copy.fontCode, copy.fontCodeDesc), + switchRow('translucent', 'translucentSidebar', copy.translucent, copy.translucentDesc, fieldValue(snapshot, 'translucentSidebar') === true), + /* 默认开:用户层没有这一格(或值不是 false)都算开着。 */ + switchRow('modelPicker', 'modelPicker', copy.modelPicker, copy.modelPickerDesc, fieldValue(snapshot, 'modelPicker') !== false), + contrastRow(), + readOnly ? jsx('p', { className: 'cx-note', role: 'status', children: copy.readOnly }) : null, + failed ? jsx('p', { className: 'cx-error', role: 'status', children: copy.failed }) : null, + ], + }, 'form'); +} diff --git a/src/client/settings.js b/src/client/settings.js new file mode 100644 index 0000000..c196f7e --- /dev/null +++ b/src/client/settings.js @@ -0,0 +1,61 @@ +/** + * 设置:覆盖层(用户在设置卡改的值 → 一条运行时