dsh-theme-endfield 0.0.0-stage → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ # dsh-theme-endfield bundle patch
2
+ #
3
+ # This file is the `dsh.bundle.patch` layer of the package: when installed
4
+ # through the official CLI —
5
+ #
6
+ # dsh plugin --profile web add github:ymh0000123/dsh-theme-endfield
7
+ #
8
+ # — the command reconciles `dsh.profile.bundles` against installed packages
9
+ # and, seeing this declaration, appends `dsh-theme-endfield` to the bundle
10
+ # stack. Profile boot then merges THIS patch (a single `insert` of the plugin
11
+ # row) exactly like a manual cordis.patch.yml mount line. No profile file
12
+ # edits needed — one command installs and mounts. The same row is also the
13
+ # `dsh.client` browser-roster entry the web modules half scans for the client
14
+ # bundle (exports["./client"] -> client.js).
15
+ - insert:
16
+ - id: theme-endfield
17
+ name: 'dsh-theme-endfield'
@@ -0,0 +1,141 @@
1
+ # PR: 音频通知(Audio notifications)
2
+
3
+ > 这份文件是给上游作者的 PR 描述草稿,可直接粘贴到 GitHub。分支:`feat/audio-notifications`
4
+
5
+ ## What this adds
6
+
7
+ Four audio notifications for the web profile, played **by the host process** (not the browser),
8
+ so a minimized window or another app in front still gets the sound:
9
+
10
+ | Slot | Moment | Trigger |
11
+ | --- | --- | --- |
12
+ | `boot` | the boot plate plays | client reports the once-per-page-load run of `runLoader()` |
13
+ | `turn-start` | the user submits from the composer | `agent/inbox/claimed` where the message carries a composer submission credential (`source.rpcId`) and the agent is a root |
14
+ | `turn-done` | the assistant produced a final answer | `agent/turn-stopping` where that turn's last assistant message had visible text |
15
+ | `attention` | a confirmation box that needs the user appears | the page observes the three panels, **and** the host-side `approval/request` / `user-questions/request` waterfalls |
16
+ | `turn-fail` | — | **deliberately unwired**: an error that needs no human decision stays silent |
17
+
18
+ Plus a `05 音频` settings section (master switch, per-slot switches, volume, four preview buttons,
19
+ diagnostics), a loopback bridge (`/theme-endfield/audio/*`), and a zero-dependency tone synthesizer
20
+ so the package ships no binary audio assets.
21
+
22
+ ## Why the host plays the sound
23
+
24
+ A browser `AudioContext` is suspended until a user gesture and dies with the tab, so a task that
25
+ finishes while the window is minimized stays silent — which is exactly the case worth notifying.
26
+ `lib/audio.js` hands the file to the platform player instead (`powershell.exe` / `afplay` /
27
+ `paplay`), rescales PCM for volume without touching the system volume, and degrades to a no-op when
28
+ the `subprocess` service is absent.
29
+
30
+ ## Two things worth a reviewer's attention
31
+
32
+ ### 1. The page observes the UI for `attention` — and only via `data-*` attributes
33
+
34
+ In *some* deployments (including the one this was developed against) `ask_user_question` is provided
35
+ outside the profile's plugin stack, so `dsh-tool-ask-user` never runs and `user-questions/request` is
36
+ never raised. Measured: a question was answered while the host-side counter stayed at `0`.
37
+
38
+ So the client watches for the three confirmation panels. The anchors are the panels' own data
39
+ attributes — `[data-approval-key]`, `[data-plan-review-key]`, `[data-question-key]`.
40
+
41
+ A class-based first attempt was **removed**: `[class*='_card']` matches 15 different components in the
42
+ installed client packages and `[class*='_frame']` matches 8, so opening an unrelated card rang the
43
+ sound. A test now asserts **no marker contains `class`**.
44
+
45
+ Detection is a `MutationObserver` coalesced onto the next animation frame, with a 1 s poll as the
46
+ backstop (an observer bound to a container the app later replaces stops delivering silently; a poll
47
+ cannot). The host still owns the switch, the volume and the debounce; the page only reports "a box is
48
+ on screen".
49
+
50
+ ### 2. `tools/execute` is a waterfall — do not listen to it naively
51
+
52
+ An earlier revision of this branch added a `tools/execute` listener as a redundant `ask_user_question`
53
+ trigger. It broke **every tool call in the profile** (`Cannot read properties of undefined (reading
54
+ 'isError')`) because `tools/execute` is a waterfall:
55
+
56
+ ```js
57
+ // @deepseek-ai/dsh-tools
58
+ const result = await this.ctx.waterfall(carrier, 'tools/execute', mutableExec, () => this.dispatchToolBody(mutableExec));
59
+ ```
60
+
61
+ A listener that inspects the call and returns without calling `next()` reports "no result", so the
62
+ tool body never runs. The listener was removed and a structural test now guards it:
63
+ `the plugin never subscribes to the tools/execute waterfall`.
64
+
65
+ ## Duration handling
66
+
67
+ The host-derived "still speaking" gate (`playUntil` = the file's own duration + 250 ms) means a long
68
+ user-supplied announcement is never clipped and a short one is not held back — no per-slot setting to
69
+ keep in sync with whatever audio is dropped in. Volume scaling is channel-aware (the first version
70
+ assumed mono and would have left the right channel untouched on stereo input).
71
+
72
+ ## Tests
73
+
74
+ ```bash
75
+ node test/audio-notify.test.js # host half
76
+ node test/audio-attention-watch.test.js # client half
77
+ node scripts/build-sounds.js --check # generated tones match their definitions
78
+ ```
79
+
80
+ The suite covers: composer-credential classification, "only a turn that produced text ends with a
81
+ sound", subagents staying silent, switch/volume gating, silent degradation without `subprocess`,
82
+ user-file override, the volume cache, the duration gate, and `agent/error` staying silent.
83
+
84
+ Full run: 14/14 test files pass, plus `node check.js` and `node selftest.js`.
85
+ **One pre-existing failure is untouched by this branch**: `test/contour-perf.test.js` reports
86
+ `p95 over 120fps budget` on the pristine upstream tree too (verified by stashing this branch).
87
+
88
+ ## A note on the commit history
89
+
90
+ Two pairs of commits in this branch show their own development and cancel out:
91
+
92
+ - `290e5dc` adds a `tools/execute` listener, `f77beda` removes it (the waterfall regression explained above)
93
+ - `8d9aaf3` introduces the notification feature and the later commits refine the same code
94
+
95
+ The **aggregate diff is what matters** and is clean; the branch was left linear and honest rather
96
+ than rewritten, so the reasoning is auditable. Squash on merge if you prefer.
97
+
98
+ ### Re-authoring the commits
99
+
100
+ Everything in this branch was committed under a placeholder identity (`dsh agent <agent@local>`),
101
+ because the work went through an assistant session. Two ranges need different authors — the
102
+ upstream-only baseline, and this contribution:
103
+
104
+ ```bash
105
+ # the two baseline commits contain ONLY upstream code -> keep the upstream author
106
+ git rebase --exec 'git commit --amend --no-edit --author="没用的小废鼠 <107793048+ymh0000123@users.noreply.github.com>"' 6375836
107
+ # then the audio commits
108
+ git rebase --exec 'git commit --amend --no-edit --reset-author --ignore-date' 292e8a0
109
+ ```
110
+
111
+ `C:\TestBox\reauthor-and-push.ps1` does exactly this — one amend per commit, because amending a
112
+ commit twice makes the second amend's parent the first one's result and git aborts the rebase
113
+ mid-way — then force-pushes with `--force-with-lease` and verifies the tree hash is unchanged, so the
114
+ rewrite can never silently alter file content.
115
+
116
+ ## One unrelated fix included
117
+
118
+ `contourRenderer` was written and read by the settings panel but was **never declared** in
119
+ `FIELD_DEFAULTS`, so it lived only in the page and `test/settings-namespace.test.js` failed on it
120
+ (`client maps keys onto fields the host does NOT declare`). This branch declares it
121
+ (`contourRenderer: 'canvas'`), which is what made that test pass. Happy to split it into its own
122
+ commit/PR if you prefer.
123
+
124
+ ## Files
125
+
126
+ | Path | What |
127
+ | --- | --- |
128
+ | `lib/tone.js` | PCM WAV synthesizer + WAV parsing + volume rescale (no deps) |
129
+ | `lib/slots.js` | the four slot definitions (notes, envelopes, durations) |
130
+ | `lib/audio.js` | resolution order, volume cache, platform players, debounce + duration gate |
131
+ | `scripts/build-sounds.js` | regenerates `sounds/*.wav` from `lib/slots.js` (`--check` verifies) |
132
+ | `index.js` | slots/fields/settings registration, host event wiring, `/theme-endfield/audio/*` routes |
133
+ | `client.js` | `05 音频` panel (switches, volume, preview, source read-out) |
134
+ | `docs/audio-notifications.md` | the feature's own documentation |
135
+
136
+ ## Not included on purpose
137
+
138
+ The voice-pack wav used in development is **not** committed (1.3 MB of generated binary). Users point
139
+ `audioSoundDir` at their own directory, or drop `<slot>.wav` into the workspace; the resolution order
140
+ is custom dir → workspace → Desktop → bundled tone. This keeps the repository free of binary assets
141
+ while making replacement a one-file drop.
@@ -0,0 +1,133 @@
1
+ # 音频通知(fork 增量)— 实现说明
2
+
3
+ 本文件描述 `dsh-theme-endfield` 这个工作树相对上游 `b3e60dc` 新增的**音频通知**功能。
4
+ 上游原版的说明在 `README.md`(主题本身的用法)。
5
+
6
+ > 播报词(Terminal 语音文案)见工作区外层的 `endfield-audio-voice-lines.md`;
7
+ > 试玩与还原步骤见 `endfield-audio-tryout.md`。
8
+
9
+ ---
10
+
11
+ ## 1. 它做什么
12
+
13
+ 在四个**真实发生**的时刻各播一声,声音由**宿主进程**播放(不是浏览器),因此页面最小化、切到别的应用、甚至关掉标签页都还能听见:
14
+
15
+ | 槽位 | 时刻 | 判据 |
16
+ | --- | --- | --- |
17
+ | `boot` | 页面加载播放 ENDFIELD 启动加载板 | 客户端在 `runLoader()` 里报告一次,**每次页面加载只报一次**(点「预览」重播不报) |
18
+ | `turn-start` | 用户从会话框提交指令 | 宿主 `agent/inbox/claimed`,且消息带**会话框提交凭据**(`source.rpcId`)且发起者是根 agent |
19
+ | `turn-done` | 助手产出最终结果 | 宿主 `agent/turn-stopping`,且该回合最后一条助手消息**含可见文本** |
20
+ | `attention` | **出现需要人点一下的确认框** | 客户端观察界面上的三个面板(见下),或宿主侧的 `approval/request` / `user-questions/request` 瀑布 |
21
+ | `turn-fail` | ⛔ **不接任何事件** | 故意的:不需要人工干预的错误保持静音 |
22
+
23
+ ## 2. 为什么 `attention` 要观察界面
24
+
25
+ 在**本部署**里,`ask_user_question` 由 harness 提供,**不在 profile 的插件栈里**(`dsh-tool-ask-user` 未被组合),
26
+ 因此 `ctx.userQuestions.ask()` 从不执行、`user-questions/request` 瀑布从不派发。
27
+
28
+ 实测证据:确认框在屏幕上并被回答的同时,宿主的 `question` 计数**始终为 0**。
29
+
30
+ 所以改成从界面观察——它不关心是谁发起的,只要"需要你点一下"的东西出现就算。
31
+ 宿主侧那两条瀑布监听**仍然保留**(在真实 dsh profile 里它们是对的),两条路都汇到同一个槽位,
32
+ 宿主侧防抖会把重复上报合并成一声。
33
+
34
+ **锚点只用面板专属的 data 属性,不用类名**:
35
+
36
+ | 面板 | 锚点 |
37
+ | --- | --- |
38
+ | 审批 | `[data-approval-key]` |
39
+ | 计划求批 | `[data-plan-review-key]` |
40
+ | 提问 | `[data-question-key]` |
41
+
42
+ > 类名方案试过并已废弃:`[class*='_card']` 在已安装的客户端包里命中 **15 个**不同组件、`[class*='_frame']` 命中 **8 个**,
43
+ > 打开任意一张卡片都会误响。现在有一条测试断言**任何锚点都不得包含 `class`**。
44
+
45
+ 检测方式:`MutationObserver` 即时触发 + 合并到下一帧;**1000ms 轮询作为兜底**
46
+ (观察者若绑定在被替换的容器上会静默失效,轮询不会)。
47
+
48
+ ## 3. 防噪规则
49
+
50
+ - **同一槽位 2.5 秒防抖**(`audioDebounceMs`),由宿主独家裁决;
51
+ - **仅根 agent**:子代理、后台驱动不单独发声;
52
+ - **边沿触发**:确认框一直开着不会反复响;
53
+ - 流式输出、每次工具调用、状态栏变化一律不响。
54
+
55
+ ## 4. 配置
56
+
57
+ 设置页 **设置 › 终末地主题设置 › 05 音频**(字段在 `dsh-theme-endfield` 命名空间;0.1.7 起持久化在 profile 的 `cordis.patch.yml`,旧版宿主回落 `<dshHome>/settings.yaml`):
58
+
59
+ **总开关 `audioEnabled` 默认关闭**:音效是选择加入(opt-in)的功能,升级到带本功能的版本不会自己开始出声。
60
+ 总开关打开后,下面的槽位开关(默认开启)决定具体哪几种情形响。
61
+
62
+ | 字段 | 默认 | 含义 |
63
+ | --- | --- | --- |
64
+ | `audioEnabled` | `'0'` | 总开关(**默认关闭**,需手动开启) |
65
+ | `audioVolume` | `'100'` | 音量,对 PCM 采样缩放,**不改系统音量** |
66
+ | `audioBoot` | `'1'` | 启动加载动画音 |
67
+ | `audioTurnStart` | `'1'` | 任务开始音 |
68
+ | `audioTurnDone` | `'1'` | 任务结束音 |
69
+ | `audioAttention` | `'1'` | 需要你回应 |
70
+ | `audioTurnFail` | `'1'` | 出错音(**不接事件**) |
71
+ | `audioDebounceMs` | `'2500'` | 同槽位最小间隔 |
72
+ | `audioSoundDir` | `''` | 自定义音效目录 |
73
+ | `audioHumanOnly` | `'1'` | 开始音只认会话框提交(关掉=宽松模式,调试用) |
74
+ | `audioDiag` | `'0'` | 诊断日志(宿主控制台 + `/state`) |
75
+
76
+ ## 5. 自定义音效(覆盖内置电子音)
77
+
78
+ 把 wav 按槽位名放进 **自定义目录 → 工作区根目录 → 桌面 → 内置合成音** 的第一处命中位置:
79
+
80
+ ```
81
+ boot.wav turn-start.wav turn-done.wav attention.wav turn-fail.wav
82
+ ```
83
+
84
+ 要求 **16-bit PCM WAV**、44.1kHz、单声道或立体声;推荐 0.5–1.5 秒,**上限 2.5 秒**(防抖窗)。
85
+ 设置页「当前音源」行显示每个槽位实际解析到的文件路径,可确认是否已替换成功。
86
+
87
+ 内置音不是二进制素材,而是由 `lib/tone.js` 按 `lib/slots.js` 的音符定义**算出来**的:
88
+
89
+ ```bash
90
+ node scripts/build-sounds.js # 重新生成 sounds/*.wav
91
+ node scripts/build-sounds.js --check # 校验生成物与定义一致
92
+ ```
93
+
94
+ ## 6. 桥接路由(宿主提供,页面调用)
95
+
96
+ | 方法 | 路径 | 作用 |
97
+ | --- | --- | --- |
98
+ | `GET` | `/theme-endfield/audio/state` | 槽位快照、解析到的文件、计数、诊断环形日志 |
99
+ | `GET`/`POST` | `/theme-endfield/audio/preview?slot=<id>` | 试听:**强制播放**(跳过防抖),但仍尊重槽位开关 |
100
+ | `POST` | `/theme-endfield/audio/attention` | 页面报告"看到确认框了":**不强制**,尊重开关/音量/防抖 |
101
+
102
+ ## 7. 平台与性能
103
+
104
+ | 平台 | 播放器 |
105
+ | --- | --- |
106
+ | Windows | `pwsh.exe` → `powershell.exe`(`-EncodedCommand` + `Media.SoundPlayer`) |
107
+ | macOS | `afplay` |
108
+ | Linux | `paplay` → `aplay` |
109
+
110
+ **实测(同一台开发机,已扣除音频时长)**:`powershell.exe` 5.1 启动 **0.55–0.8 s**;
111
+ `pwsh` 7.6.6(MSI 版)**0.92–0.96 s**;`pwsh` 7(Store 版)**约 1.33 s**。
112
+ 即 **PowerShell 7 反而更慢**,`playerCommands()` 的回退顺序在只有 5.1 的机器上正好落到最快的那个。
113
+ 要再压缩只能上"常驻播放器",收益(约 0.6 s)不值得其复杂度与静默失效风险。
114
+
115
+ ## 8. 不要碰 `tools/execute`
116
+
117
+ `tools/execute` 是 **waterfall**(`dsh-tools`: `await this.ctx.waterfall(carrier, 'tools/execute', mutableExec, () => this.dispatchToolBody(mutableExec))`)。
118
+ 一个"检查参数后直接 `return`"的监听器**不调用 `next()`** 就等于宣布"这次工具调用没有结果":
119
+ 工具体不执行,失败以空结果回传,**会话里所有后续工具调用都会坏**(表现为 `Cannot read properties of undefined (reading 'isError')`)。
120
+
121
+ 本插件因此**不监听**该事件,并有结构性断言守着:`the plugin never subscribes to the tools/execute waterfall`。
122
+
123
+ ## 9. 测试
124
+
125
+ ```bash
126
+ node test/audio-notify.test.js # 宿主半边:判据、门控、防抖、降级、路由
127
+ node test/audio-attention-watch.test.js # 客户端:锚点、即时检测、边沿、开关
128
+ node scripts/build-sounds.js --check # 内置音与定义一致
129
+ ```
130
+
131
+ `test/audio-notify.test.js` 覆盖的关键断言:会话框凭据判定、只有"含可见文本的回合"才播结束音、
132
+ 子代理不响、开关与音量 0 静音、无 `subprocess` 时静默降级、自定义文件覆盖、音量缩放走缓存副本、
133
+ 以及 `agent/error` **必须不发声**(用户的规则:不需要人工干预的错误静音)。
@@ -0,0 +1,24 @@
1
+ # Exact bounds for contour extraction
2
+
3
+ Each row is divided into blocks of 16 cells. A block records the exact minimum
4
+ and maximum of its two vertex rows, including its shared right boundary. One
5
+ range pass is reused for all contour levels; blocks wholly on one side of a
6
+ level skip the original per-cell scan. Retained cells keep their original order,
7
+ interpolation, saddle handling, path stitching and filters. The grid, level
8
+ count, spatial smoothing and drawing functions are unchanged (with one
9
+ exception, noted after the buffer paragraph below).
10
+
11
+ The range buffers are allocated with the field and reused each frame. Calling
12
+ `contourEvaluate()` invalidates the ranges; direct level extraction falls back
13
+ to the full scan until `contourExtract()` prepares matching bounds.
14
+
15
+ > Since v1.1.6 the sampling STEP may coarsen on very large canvases
16
+ > (`contourStepFor()` / `CONTOUR_MAX_CELLS`), and the block layout follows
17
+ > `contourGeom.step` rather than a hard-coded 6. Every ordinary window still
18
+ > samples at the shipped 6px grid, which is what the comparison below covers.
19
+
20
+ Run `npm run test:bounds` for complete coordinate-array comparisons against a
21
+ forced full scan, including partial blocks, equality, plateaus and saddle cells.
22
+ The existing browser render, smoothness, coverage and performance runners remain
23
+ applicable. CPU extraction measurements do not establish GPU presentation FPS or
24
+ video dropped-frame improvements.
@@ -0,0 +1,61 @@
1
+ # Optional mouse trail
2
+
3
+ Enable the contour background, its animation, and **Mouse trail** in the theme
4
+ settings. The persisted `contourTrail` string defaults to `'0'`; `'1'` enables a
5
+ local deformation that follows the mouse and fades. Chinese and English labels
6
+ follow the host locale. Touch and pen input do not produce a trail.
7
+
8
+ The idea and bounded decay model are adapted from the Endfield Glass plugin.
9
+ This port integrates into the current upstream Canvas pipeline and preserves
10
+ the original ambient field, smoothing, contour levels and curve drawing.
11
+
12
+ ## Cost and lifecycle
13
+
14
+ - Reuses the existing contour animation loop and selected FPS. It consumes only
15
+ the latest pointer event per field frame, with 8 ms / 2 CSS px admission gates.
16
+ - The pointer handler only stores coordinates and timestamps. A frame reads the
17
+ host rectangle once when consuming a pending point, not on every pointer event
18
+ or streaming DOM mutation. Stored coordinates never leave page memory.
19
+ - At most 24 samples, a 900 ms exponential decay constant and 2700 ms lifetime.
20
+ Head quota is .9; older samples share a fixed .6 geometric tail budget. Expiry
21
+ cannot renormalize or brighten remaining samples. Only bounded neighborhoods
22
+ around samples receive field contributions.
23
+ - Smooth Gaussian contributions are added after the ambient smoothing/temporal
24
+ blend. They never enter ambient history, so expiry leaves no temporal ghost.
25
+ This port uses a 28 CSS px kernel; it does not claim pixel identity with the
26
+ Glass plugin's raw-field overlay followed by spatial smoothing.
27
+ - Static mode, reduced motion, hidden pages, scrolling (when pause is enabled)
28
+ and the startup loader suspend sampling and clear deformation. Resize, blur,
29
+ disabling and disposal also clear history. Mouse leave drops the pending event
30
+ while already drawn samples decay normally.
31
+ - Disabling removes mouse listeners. Unmount/disposal also removes visibility,
32
+ blur and media-query listeners. No second timer or animation loop is added.
33
+
34
+ This branch targets current upstream main independently of the proposed scan,
35
+ Worker/WebGL and glass PRs. Combining it with a Worker renderer requires carrying
36
+ the bounded samples and frame clock into that renderer's frame messages before
37
+ contour extraction; main-thread state must not be read inside a Worker.
38
+
39
+ ## Validation
40
+
41
+ `npm run test:trail` runs dependency-free Node checks for bounded history,
42
+ timestamps, local support, monotonic decay, fixed total weight and expiry.
43
+
44
+ `npm run test:trail-browser` uses Chrome/Edge and Node 22+ (the same Node major
45
+ used by CI), with an isolated temporary browser profile. A controlled animation
46
+ clock and fixed terrain seed compare real Canvas pixels against a trail-off
47
+ control: idle and expired trails yield exactly the control image, while an active
48
+ mouse sample visibly deforms it. It also checks pointer-handler layout reads,
49
+ static mode, live reduced-motion changes, visibility/scroll pause, repeated
50
+ enable/disable and complete listener/canvas cleanup. This is not an FPS test.
51
+
52
+ The existing settings tests cover the default, disabled UI state, persisted
53
+ camelCase key and rereading that preference. Both new tests join `test:ci`.
54
+
55
+ Local validation (2026-09-19, Windows Chrome / Node 24.12): the new browser and
56
+ four logic checks pass. The final complete CI run passed 23 of 24 scripts; the
57
+ existing random-layout speck/coverage test reported one sparse region among five
58
+ random layouts. Its isolated rerun passed all five layouts. The original terrain
59
+ generator, coverage scorer, ambient evaluator, level extractor and curve painter
60
+ were also compared with the base commit and are unchanged. The initial random
61
+ failure is recorded rather than presented as an entirely green single CI run.
@@ -0,0 +1,38 @@
1
+ # Optional Worker / WebGL contours
2
+
3
+ `contourRenderer` defaults to `canvas`, retaining the original rendering path.
4
+ Select **Worker / WebGL** in the contour settings to compute and draw the same
5
+ terrain in a dedicated Worker using a transferred OffscreenCanvas. WebGL2 batches
6
+ the smoothed curve segments in a single instanced draw. Canvas2D in the worker is
7
+ the fallback when WebGL2 is unavailable. Unsupported workers, transfer failure,
8
+ render timeouts, message errors or context loss fall back to the main Canvas2D
9
+ path on a fresh canvas; a transferred canvas is never reused for getContext.
10
+
11
+ The worker source is generated from the exact build/evaluate/extract/draw
12
+ functions in client.js, preserving field generation, smoothing, phase policy,
13
+ level filters, stroke colors and DPR. The GPU painter approximates cubic curves
14
+ within 0.04 backing pixels (with a bounded subdivision depth), so antialiasing
15
+ is not claimed to be pixel-identical to Canvas2D. No external URL, CDN, eval or
16
+ runtime dependency is used; a local Blob contains the embedded worker source.
17
+
18
+ The queue has one in-flight job plus at most one latest pending job. Pending
19
+ geometry work survives a later paint-only update. Hidden pages stop submitting
20
+ work; static/reduced-motion/scroll policies retain the existing switches.
21
+ Teardown terminates the worker, revokes its Blob URL and removes its canvas.
22
+ Failure is latched until the backend preference changes, preventing retry loops.
23
+
24
+ The contour wrapper exposes `data-endfield-renderer` (starting, worker-webgl2,
25
+ worker-canvas2d or main-canvas2d) and an explanatory fallback reason attribute.
26
+ CPU/worker completion rate is not display FPS; external video dropped frames
27
+ and total GPU cost require separate measurement on the target hardware.
28
+
29
+ ## Developing
30
+
31
+ - `npm run build:worker`: regenerate the embedded source after kernel edits.
32
+ - `npm run check:worker`: fail if the embedded source is stale.
33
+ - `npm run test:worker`: real Chrome test (Node 22+, `CHROME_PATH` supported).
34
+
35
+ The browser test checks the actual backend, paused/static behavior, visibility
36
+ handler, injected message failure with a fresh-canvas fallback, explicit retry,
37
+ repeated enable/disable and final teardown. The temporary profile contains only
38
+ fixture data. The legacy CI suite continues to validate the default backend.
@@ -0,0 +1,212 @@
1
+ # 设计语言
2
+
3
+ 本文是这套主题的设计说明:**颜色是什么、为什么是这个值、落在哪个界面角色上**。想拿它当设计语言学习入口的,读这一篇就够。
4
+
5
+ 工程实现细节在 [engineering-notes.md](engineering-notes.md),测试在 [testing.md](testing.md)。
6
+
7
+ ---
8
+
9
+ ## 一、核心概念:工业编辑风
10
+
11
+ 终末地官网的视觉不是「深色科技感」,而是**印刷编辑风**:一张有质感的纸、压在上面的墨黑字、以及一个用来做信号的高饱和色。三条规则贯穿全篇:
12
+
13
+ 1. **纸与墨是主体,强调色是信号。** 强调色只用于「需要被看见的那一处」——按钮、悬停、选中行、焦点环——而不是大面积铺色。
14
+ 2. **全直角。** 圆角会把界面推向「柔和产品」的观感;直角把它推向「技术文档 / 工程图纸」。只有形状本身是圆的(状态点、头像、加载圈)才保留圆形。
15
+ 3. **数字等宽。** 开启 `tnum`,让表格、耗时、百分比在跳动时不产生横向抖动。
16
+
17
+ ---
18
+
19
+ ## 二、色板
20
+
21
+ ### 中性色(纸 / 墨 / 线)
22
+
23
+ 两套模式共用同一套语义,只换取值。
24
+
25
+ | 角色 | 令牌 | 亮色 | 暗色 |
26
+ | --- | --- | --- | --- |
27
+ | 纸底(应用背景、侧栏) | `--dsw-alias-bg-base` | `#e8e8e2` | `#101110` |
28
+ | 面板 / 气泡 | `--dsw-alias-bg-layer-1` | `#f2f2ec` | `#181a18` |
29
+ | 沉底块 | `--dsw-alias-bg-layer-2` | `#dcddd6` | `#1e201d` |
30
+ | 浮层(菜单、弹层) | `--dsw-alias-bg-overlay` | `#f2f2ec` | `#1c1e1c` |
31
+ | 细线 | `--dsw-alias-border-l1` | `#d8d9d5` | `#343633` |
32
+ | 重线 | `--dsw-alias-border-l2` | `#b6b8b3` | `#4a4d49` |
33
+ | 正文 | `--dsw-alias-label-primary` | `#101110` | `#f5f5f0` |
34
+ | 次要文字 | `--dsw-alias-label-secondary` | `#4a4c48` | `#898d89` |
35
+
36
+ 亮色不用纯白 `#fff`、暗色不用纯黑 `#000`:`#e8e8e2` 带一点暖灰,是「纸」而不是「屏幕」;`#101110` 留一点绿倾向,避免深灰界面常见的死黑。
37
+
38
+ ### 强调色:两套配色
39
+
40
+ 设置页「主题配色」在两者间切换,**默认谷地黄**。
41
+
42
+ | 配色 | 强调色 | 相对亮度 | 说明 |
43
+ | --- | --- | --- | --- |
44
+ | **谷地黄**(默认) | `#fff500` | 86.6% | 终末地官网的信号黄 |
45
+ | **武陵青** | `#14d0d0` | 49.8% | 青碧色 |
46
+
47
+ 存储字段 `palette`(UI/存储键 `dsh-theme-endfield-palette`,由 `PREFS_KEY_TO_FIELD` 映射),取值 `valley` / `wuling`。只有精确值 `wuling` 选中武陵青,未设置或异常值都回落到默认。
48
+
49
+ ### 语义状态色
50
+
51
+ 不参与配色切换——错误就该是红的。
52
+
53
+ | 角色 | 亮色 | 暗色 |
54
+ | --- | --- | --- |
55
+ | 错误 | `#ff3b30` | `#ff6b61` |
56
+ | 成功 | `#2f9e44` | `#4fbf5c` |
57
+ | 警告 | `#d9822b` | `#ffb700` |
58
+
59
+ ---
60
+
61
+ ## 三、强调色的派生角色
62
+
63
+ 一个强调色不够用。同一个色在不同表面上需要不同的取值,这些派生量在样式表里都是 CSS 变量,**声明在 `body` 上**(原因见[工程笔记](engineering-notes.md#变量必须声明在-body-而不是-root))。
64
+
65
+ | 变量 | 谷地黄 | 武陵青 | 用途 |
66
+ | --- | --- | --- | --- |
67
+ | `--edge-accent` | `#fff500` | `#14d0d0` | 实心底、图标、焦点环、光标 |
68
+ | `--edge-accent-rgb` | `255, 245, 0` | `20, 208, 208` | 约 30 处 `rgba(var(--edge-accent-rgb), α)` 半透明色块 |
69
+ | `--edge-accent-deep` | `#e8e000` | `#10b8b8` | 悬停 / 按下加深 |
70
+ | `--edge-accent-onpaper` | `#d9c700` | `#14d0d0` | 亮色模式下唯一需要「作填充压在纸上」的槽位 |
71
+ | `--edge-status-light` | `#6b5d00` | `#006a6a` | 亮色模式回合状态文字(0.2 经 `--dsw-alias-label-deep-diving` 生效) |
72
+ | `--edge-status-light-mid` | `#3f3600` | `#003f3f` | 亮色模式流光亮带(0.2 经 `--dsw-alias-label-deep-diving-shimmer` 生效) |
73
+ | `--edge-status-dark` | `#fff500` | `#14d0d0` | 暗色模式回合状态文字(同上) |
74
+ | `--edge-status-dark-mid` | `#a08a00` | `#7ee7e7` | 暗色模式流光亮带(同上) |
75
+ | `--edge-glow-light` | `0.08` | `0.08` | hero 背景光晕透明度 |
76
+ | `--edge-glow-dark` | `0.05` | `0.04` | 同上,暗色 |
77
+
78
+ ### 为什么亮色模式的强调色要「下沉」
79
+
80
+ 这是最容易踩的一条。**在奶油纸底上,`#fff500` 不是一种配色选择,而是一次抹除**——黄色文字压在纸底上只有 1.07:1(面板 `#f2f2ec` 上更低,1.02:1),完全不可见。同理青色也过不了:`#14d0d0` 在纸底上仅 1.56:1。
81
+
82
+ 所以亮色模式里凡是「强调色作为文字」的地方,都换成同一色相的**深色档**:
83
+
84
+ - 谷地黄 → `#6b5d00`(5.35:1,亮色下第一个达标的金)
85
+ - 武陵青 → `#006a6a`(5.22:1)
86
+
87
+ 在纸上,`#007070` 以上的青都过不了双底 AA——这是**纸的属性**,不是强调色亮度的问题,所以武陵青提亮后这两个值完全没变。
88
+
89
+ 而暗色模式方向相反:底是近黑,强调色本身就够亮,直接用原色。
90
+
91
+ ### 为什么暗色的「亮带」方向不一致
92
+
93
+ 回合状态标签(0.1.x 是渐变流光文字,0.2 是遮罩扫光文字,见[工程笔记](engineering-notes.md#四类回合状态标签))中间有一条亮带扫过字形。
94
+
95
+ - 谷地黄的亮带是**压暗**的 `#a08a00`——黄色向上已经到顶,再亮就成白色。
96
+ - 武陵青的亮带是**提亮**的 `#7ee7e7`——青色在这个亮度向下的余量比黄色小,更深的青(`#0a7d7d`)只有 3.54:1 不达标。
97
+
98
+ 同一个规则(AA 4.5:1),两个相反的方向。这类「不能照抄」的地方是本主题最主要的复杂度来源。
99
+
100
+ ### 武陵青为什么是 `#14d0d0`
101
+
102
+ 初版用了 `#0daaaa`,相对亮度只有 31.7%——不到谷地黄(86.6%)的四成,在近黑页面上存在感明显弱一截。`#14d0d0` 保持同一条色轴(R 低、G = B,仍是青碧而非灰青),亮度提高到 49.8%。
103
+
104
+ 上界同样有约束:到 `#16dcdc` 时,亮色模式下色块与纸底只剩 1.39:1(现行 1.56:1),开始「融进纸里」,而且那么浅的青读起来偏白。**`#14d0d0` 是两种模式下都还站得住的最亮一档。**
105
+
106
+ 这两条边界写成了断言(亮度下限 45%、上限 56%、必须留在青碧色轴上)。
107
+
108
+ ---
109
+
110
+ ## 四、对比度规则
111
+
112
+ **每个角色都要对它实际落在的表面验算,不达标就构建失败。** `test/palette-contrast.test.js` 覆盖全部 27 项。
113
+
114
+ | 角色 | 谷地黄 | 武陵青 | 门槛 |
115
+ | --- | --- | --- | --- |
116
+ | 实心强调底 + 墨色字 | 16.50:1 | 9.88:1 | AA 4.5 |
117
+ | 悬停加深底 + 墨色字 | 13.60:1 | 7.72:1 | AA 4.5 |
118
+ | 暗色图标 / 链接强调色 | 15.26:1 | 9.14:1 | ≥3 |
119
+ | 回合状态·亮色主色 | 5.35:1 | 5.22:1 | AA 4.5 |
120
+ | 回合状态·亮色亮带 | 9.82:1 | 9.58:1 | AA 4.5 |
121
+ | 回合状态·暗色主色 | 15.26:1 | 9.14:1 | AA 4.5 |
122
+ | 回合状态·暗色亮带 | 5.11:1 | 12.06:1 | AA 4.5 |
123
+
124
+ 三条附加规则:
125
+
126
+ - **回合状态要对两种底色都达标**(`bg-base` 与 `bg-layer-1`),因为亮带会扫过字形;0.1.x 的渐变文字在 `prefers-reduced-motion` 下被上游把 `background-size` 钉成 100%,那条亮带会**永久留在**字里,0.2 的遮罩扫光同样要把两个令牌都按「会压在字形上」来取值。
127
+ - **两配色的等高线合成对比度相差 ≤20%**:相同 alpha ≠ 相同存在感,所以 alpha 按「合成后对比度对齐」分别调参,而不是照抄。
128
+ - **hero 光晕只许更轻,不许更响**:判据是单侧的——比它替换的 `#6187D8` @8% 更安静永远可接受。
129
+
130
+ > **装饰性表面的对比度描述的是「有多抢眼」,不是可读性。** 水印永远在正文之后,等高线在所有内容之下,它们的对比度数值用来控制干扰强度。经验下限约 `1.06:1`,低于此形同不存在。
131
+
132
+ ---
133
+
134
+ ## 五、令牌映射:中和残留品牌蓝
135
+
136
+ DSH 自带 DeepSeek 品牌蓝。主题把这些令牌整组重映射,否则会在纸墨界面里留下零散的蓝点。
137
+
138
+ | 令牌组 | 处理 |
139
+ | --- | --- |
140
+ | `--dsw-static-deepseek-50…900` | 整组换成中性灰阶(亮色为纸阶,暗色为墨阶) |
141
+ | `--dsw-static-deepseek-450` | 亮色 → `--edge-accent-onpaper`,暗色 → `--edge-accent` |
142
+ | `--dsw-static-deepseek-500/600` | → 墨 / 纸色(**不可动**,见下) |
143
+ | `--dsw-alias-button-info-fill` | 亮色 → 墨黑,暗色 → 强调色 |
144
+ | `--dsw-alias-state-business-primary` | 同上 |
145
+ | `--dsw-specific-sidebar-nav-item-active-accent` | 同上 |
146
+ | `--dsw-alias-label-primary-bluish` | → 正文色 |
147
+ | `--dsw-specific-bubble` / `-highlight` | → 面板 / 沉底块 |
148
+
149
+ `--dsw-static-deepseek-500/200` 是**共享令牌**:同时支撑 `button-info-fill`、`state-business-primary` 与 `bubble-highlight`。要改回合状态标签的颜色时不能动它们,只能覆盖那条规则自己的 `background-image`。
150
+
151
+ ### 强调色交互一览
152
+
153
+ | 表面 | 声明 |
154
+ | --- | --- |
155
+ | 文本选中 | `::selection { color:#000; background: var(--edge-accent) }` |
156
+ | 输入光标 | `caret-color: var(--edge-accent)` |
157
+ | 焦点环 | `outline: 2px solid var(--edge-accent)`,`offset: 1px` |
158
+ | 滚动条 | 轨道透明,滑块 `--edge-line`,悬停 `--edge-accent` |
159
+ | 链接悬停 | 下划线着色;暗色模式下文字也变强调色 |
160
+ | 表格行悬停 | 实心强调底 + 墨色字 |
161
+ | 半透明色块 | `rgba(var(--edge-accent-rgb), α)`,亮色 α 略低于暗色 |
162
+
163
+ 亮色与暗色的 α 不同:**在近黑底上叠加亮度,比在纸底上减去亮度显得响得多**。同一个 α 在暗色下更吵,所以暗色的半透明块普遍取更高的 α(因为强调色本身在暗色下要更突出),而装饰性图层(水印)在暗色下反而要**降低** α。
164
+
165
+ ---
166
+
167
+ ## 六、直角化怎么实现
168
+
169
+ 直角不是逐个组件改,而是**先全部清零,再恢复该圆的**:
170
+
171
+ ```css
172
+ body:not(.theme-endfield-round) [class] { border-radius: 0 !important; }
173
+ body:not(.theme-endfield-round) [class*='avatar'],
174
+ body:not(.theme-endfield-round) [class*='spinner'],
175
+ body:not(.theme-endfield-round) [class*='dot'],
176
+ body:not(.theme-endfield-round) [class$='_iconButton'] { border-radius: 50% !important; }
177
+ ```
178
+
179
+ 用 `[class]` 而非 `*`,避免命中无类名的结构性节点;用 `body:not(.theme-endfield-round)` 作前缀,让「主题圆角」开关只是 `<body>` 上加减一个 class,不需要重新注册样式表。
180
+
181
+ ---
182
+
183
+ ## 七、配色切换是一个 class
184
+
185
+ 整份样式表里所有强调色都读**调色板变量**,所以切换配色只是 `<body>` 上加减一个 class:不重新注册令牌层、不重算样式、无 JS 重绘。
186
+
187
+ 这一点能成立依赖一个事实:**应用是把主题令牌写成 `<body>` 的行内样式的**(`dsh-client-ui-layout` 对每个令牌调 `body.style.setProperty`)。因此主题把 `--dsw-alias-brand-primary` 的暗色值声明为 `var(--edge-accent)`,该令牌就随 class 自动重解析。
188
+
189
+ 唯一需要 JS 介入的表面是**等高线画布**——canvas 描边不能是 CSS 变量,所以它由一个监听 `<body>` class 的 `MutationObserver` 触发重绘(顺带让「别的标签页改了配色」也能跟着变)。
190
+
191
+ 配色切换后的实测像素占比(`test/verify-shots.js` 解码真实截图统计):
192
+
193
+ | 截图 | 黄色像素 | 青色像素 |
194
+ | --- | --- | --- |
195
+ | 谷地黄 · 亮色 | **5.47%** | 0.44% |
196
+ | 武陵青 · 亮色 | 0.39% | **6.51%** |
197
+ | 谷地黄 · 暗色 | **2.51%** | 0.42% |
198
+ | 武陵青 · 暗色 | 0.34% | **5.40%** |
199
+
200
+ 约 93% 的中性像素(纸底、墨字、边框)在两版之间基本不变——切换只动强调色。
201
+
202
+ ---
203
+
204
+ ## 八、可复用的几条经验
205
+
206
+ 如果只带走几句话:
207
+
208
+ 1. **颜色是量出来的,不是挑出来的。** 每个色值都对它实际落在的表面算过对比度;不达标的写法(哪怕看起来「就该是这个颜色」)一律改掉。
209
+ 2. **同一个色在亮/暗模式下往往需要相反的处理。** 亮色下沉、暗色提亮,或者反过来,取决于该色相在那个亮度还剩多少余量。
210
+ 3. **「装饰」和「前景」用两套标准。** 前景守 AA 4.5:1;装饰守「不抢眼但可见」的区间(约 1.06 ~ 1.6:1)。
211
+ 4. **主题令牌自身可以是 `var()` 引用。** 这是把「换配色」从一次重绘降级成一次 class 切换的关键。
212
+ 5. **底色归主题、文字归应用 = 裂缝。** 只接管背景令牌而不接管前景,会造出「白字压在黄底上」这类 1.05:1 的不可见组合。改一个背景令牌时,要一起检查谁在上面写字。