dsh-theme-palette 1.3.2 → 1.4.0

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.
package/docs/DESIGN.md ADDED
@@ -0,0 +1,228 @@
1
+ # 设计说明
2
+
3
+ 逐组件的取舍与标定过程。更新记录见 [../CHANGELOG.md](../CHANGELOG.md)。
4
+
5
+ ## 深色模式
6
+
7
+ 深色这一侧是单独调过的,四件事:
8
+
9
+ 1. **层与底错开明度**(`darkLift` 0.026 / 0.032 / 0.042):让三套在深色下分得开,
10
+ 同时给每套一个可辨的「夜色」(暖砂更深、苔绿更亮)。
11
+ 2. **色度按色相归一化**(`darkC` 0.014 / 0.028 / 0.015):落在各自可用色域的 30% / 19% / 24%。
12
+ 3. **层内色度恒定**(`ROLE` 深色权重 1.0 起):卡片与模块不再比画布灰,
13
+ 层间只差明度(ΔE 5.9 / 3.9 / 3.7),像「同一材质的不同高度」。
14
+ 4. **正文单独一条色度**(`paperC` 0.014 / 0.009 / 0.0125):近白但不透亮,
15
+ 且不会顶到 8-bit 天花板(L 0.953 处蓝的可用色度只有 0.0235)。
16
+
17
+ 正文对比度 14.40–14.98(宿主 17.45,提亮底色必然的代价,仍是 AAA 的两倍)。
18
+ 深色下正文一律 ≥ 8.95(在 `800` 面上),三级 ≥ 4.79,链接 ≥ 4.65。
19
+
20
+ ## 区块底色:左侧栏
21
+
22
+ `--dsw-specific-sidebar-fill` 内置是 `bluish-50/-900`,与画布几乎同色,左栏和会话区看不出边界。
23
+ 本插件把它解耦,三档(默认「更深」):
24
+
25
+ | 档位 | 浅色:画布↔侧栏 / 悬停 / 选中 | 深色:画布↔侧栏 / 悬停 / 选中 |
26
+ |---|---|---|
27
+ | 更浅 | 1.74–2.13 / 0.30–0.59 ⚠ / 0.90 | **4.66–5.05** / 2.85–3.08 / 5.94–6.03 |
28
+ | 同色 | 0 / 2.40 / 4.83 | 0 / 2.93–3.00 / 5.86–6.05 |
29
+ | **更深(默认)** | 2.06–2.10 / 2.42 / 4.85 | **4.03–4.30** / 2.97–3.12 / 5.99–6.09 |
30
+
31
+ 深色侧原来沿用浅色步长(×0.6),画布↔侧栏只有 ΔE 1.9–2.3 —— 正好卡在 JND 上,于是「左栏和中栏
32
+ 看不出界线」。v0.13.0 起深色模式用**独立的 `darkLift`**(0.048 / 0 / −0.042),三套配色在深色下都到
33
+ ΔE 4 以上;侧栏内正文对比 12.48–16.40,仍满足 AAA。
34
+
35
+ ΔE 为 OKLab 欧氏距离 ×100,并排大色块的 JND ≈ 2。
36
+
37
+ **默认给「更深」是因为几何**:侧栏在画布之上时,悬停/选中只能往下走,两档就会穿过画布
38
+ (选中项像在页面里挖了个洞)。「更浅」仍保留在第一档,但它离纯白的余量太小 ——
39
+ 悬停只能压到 ΔE 0.3–0.59,几乎看不出,所以那条路径加了护栏:步进自动收缩,宁可浅也不穿层。
40
+
41
+ 深色侧的步长与浅色**解耦**(见上表 `darkLift`),不再按比例缩:
42
+ 旧写法把 `-0.020×0.6` 夹成 `-0.004`,深色导轨与画布只剩 ΔE 0.49 —— 边界直接消失,
43
+ 而同一档在浅色侧是 2.10。现在两侧都是 1.9–2.1,同一个档位在明暗两模式下强度一致。
44
+
45
+ ## 文字着色
46
+
47
+ 正文原来只给 0.004 的色度(近中性),视觉上就是「黑字 / 白字」。现在有第三行「文字着色」,
48
+ 三档按倍率作用于**正文色度**(浅色 `textC` / 深色 `paperC`),底色不动,对比度仍由求解器兜底:
49
+
50
+ | 档位 | 倍率(浅 / 深) | 暖砂正文 | 暖砂深色正文 | 对比度 |
51
+ |---|---|---|---|---|
52
+ | 中性 | 1 / 1 | `#12110f` | `#f3efe5` | 17.21 / 14.97 |
53
+ | **跟随(默认)** | 1.8 / 1.5 | `#12110d` | `#f5efe0` | 17.23 / 14.98 |
54
+ | 浓一些 | 2.6 / 2.2 | `#13110c` | `#f8efd9` | 17.20 / 15.01 |
55
+
56
+ 靛蓝的「浓一些」深正文是 `#e9effd`(冷白透蓝)、苔绿是 `#e3f5e6`(灰绿白)。
57
+ 三档在承诺面上的对比度全部不变(三级 4.63–4.68、深正文 14.40–15.01),
58
+ **加的是色味不是明度**,所以不会牺牲可读性。
59
+
60
+ **深色侧三档是「温度」而不是「强度」**:近白处的色度台阶受 sRGB 上限约束(L 0.953 时蓝最多
61
+ 0.0235、黄 0.038),三档之间的 ΔE 只有 0.4–1.0,切换时是"细看才分得出"的差别。
62
+ `paperC` 就是按这个约束定的:暖砂 0.014(跟随 36% 可用色域)、靛蓝 0.009(60%)、
63
+ 苔绿 0.0125(23%),三套的「浓一些」都还在色域内、不会被 8-bit 夹取。
64
+ 早期版本 `paperC` 给到 0.015–0.016,靛蓝的中性档就已经吃掉 66% 色域、「浓一些」请求 0.033
65
+ 实得 0.0241 —— 第三档等于不存在。
66
+
67
+ 次级与三级文字跟着同一档位走,但它们的色度基数更低(`midC 0.006`),所以仍是「带一点味的灰」,
68
+ 不会变成彩色字 —— 正文的层级靠明度建立,不靠饱和度。
69
+
70
+ ## 对话条目 / 正文的文字着色
71
+
72
+ 左栏的**会话条目**(标题、描述、时间戳)与正文用的是同一组 token ——
73
+ `label-primary` / `-secondary` / `-tertiary` → 色阶 `1000 / 700 / 600`(深色 `50 / 300 / 400`),
74
+ 所以「文字着色」那一行同时管它们,不存在“正文变了、条目没变”。
75
+
76
+ v0.8.0 那版三档**实际是失效的**:实测 ΔE 只有 0.35–1.66,而并排色块的 JND ≈ 2 —— 看不见。
77
+ 原因是物理限制:浅色模式正文落在近黑区(OKLab L≈0.19),那里 sRGB 色域极窄,
78
+ 只加色度到 ΔE 1.6 就顶到 8-bit 量化上限。
79
+
80
+ v0.12.0 因此给文字着色加了**明度**这一维(`textLift`),把正文从近黑抬到能显出颜色的位置:
81
+
82
+ | 档位 | textScale / paperScale / textLift | 标题 ΔE(暖砂 / 靛蓝 / 苔绿) | 正文对比度 |
83
+ |---|---|---|---|
84
+ | 中性 | 1 / 1 / 0 | 0 / 0 / 0 | 17.2–17.3 |
85
+ | **跟随(默认)** | 2.2 / 1.8 / +0.030 | **2.77 / 3.15 / 3.25** | 16.2–16.3 |
86
+ | 浓一些 | 3.6 / 3.0 / +0.065 | **6.41 / 6.68 / 6.79** | 14.9–16.3 |
87
+
88
+ 落色(暖砂):中性 `#12110f` → 跟随 `#191713` → 浓一些 `#231f18`;
89
+ 深色正文 `#f3efe5` → `#f6efdd` → `#fbeed0`。次级与三级文字仍比标题淡一档 ——
90
+ 层级靠明度建立,着色只做「温度」,不把描述行也染色。
91
+
92
+ MIN_MAIN 从 15.5 放宽到 12:给“带色正文”留出明度空间,仍远高于 AAA 的 7:1。
93
+
94
+ ## 右侧栏可以跟左栏同色
95
+
96
+ 宿主的三栏布局里,左右是不对称的:
97
+
98
+ | 列 | 背景 |
99
+ |---|---|
100
+ | 左栏 `.sidebarCol` | `var(--dsw-specific-sidebar-fill)` |
101
+ | 中栏 `.centerCol` / 整框 `.frame` | `var(--dsw-alias-bg-base)` |
102
+ | **右栏 `.rightbarCol`** | **没有背景**,露出的是 frame 的 `bg-base` = 会话区画布色 |
103
+
104
+ 所以右栏永远和中间一样,v0.9.0 起设置页多一行「右侧栏底色:**同左栏** / 跟随画布」(默认同左栏):
105
+
106
+ ```css
107
+ /* 插件注入到 <head>,用官方同一套 data-plugin-css 标记 */
108
+ [data-rightbar-col]{background:var(--dsh-rightbar-fill,var(--dsw-alias-bg-base))}
109
+ [data-rightbar-fullscreen] [data-rightbar-col]{background:var(--dsw-alias-bg-base)}
110
+ ```
111
+
112
+ - 钩子是 `data-rightbar-col` —— ui-layout 里 `RightbarColumn` 上的**语义属性**,不是哈希类名,
113
+ 宿主升级时不会跟着变;
114
+ - 颜色不经硬编码:token 层只给一个 `--dsh-rightbar-fill`,值是 `var(--dsw-specific-sidebar-fill)`
115
+ 或 `var(--dsw-alias-bg-base)`,所以它会自动跟着「左侧栏底色」三档一起变;
116
+ - 全屏右栏时退回画布色 —— 那时右栏就是整块内容区,染成侧栏色会像只剩一条侧栏。
117
+
118
+ ## 滚动条等中性组件
119
+
120
+ 宿主的四条滚动条 token 全指向 `neutral` 色阶:
121
+
122
+ | token | 浅色 | 深色 |
123
+ |---|---|---|
124
+ | `--dsw-alias-scrollbar-bg-l1 / l2` | `neutral-200` | `neutral-700 / 600` |
125
+ | `--dsw-alias-scrollbar-hover-l1 / l2` | `neutral-300` | `neutral-600 / 550` |
126
+
127
+ 我本来就在染那条色阶,但它的色度只有 0.006 —— 肉眼看仍是灰。所以 v0.10.0 起设置页多一行
128
+ 「**滚动条:中性 / 跟随 / 强调**」(默认跟随),**只抬色度、明度完全沿用宿主**:
129
+
130
+ | 档位 | 暖砂(浅 / 深) |
131
+ |---|---|
132
+ | 中性 | 不覆盖,走宿主中性色阶 |
133
+ | 跟随 | `#e2dac8` / `#423c2d`(配色色相,色度 0.026) |
134
+ | 强调 | `#f9d1ba` / `#533420`(强调色色相,色度 0.055) |
135
+
136
+ 因为明度没动,滚动条与背景的明暗关系不变,不会出现"滚动条比内容还抢眼"。
137
+
138
+ 黑白配色没有色相可跟随,那一行的说明文字会自己变成「当前是黑白配色,没有色相可跟随」。
139
+
140
+ **本来就跟随配色的中性组件**:边框(用色阶两端的墨/纸做 alpha)、分隔线、骨架屏、菜单表面、
141
+ `interactive-bg-*` 悬停层、`bg-document-selection`(选择高亮)、`markdown-code-segment-*`。
142
+ **故意不跟**:遮罩 `bg-mask-*`(保持中性黑)、代码 diff 的红绿、`state-error/warn` 语义色。
143
+
144
+ ## 开关 / 勾选 / 单选
145
+
146
+ 宿主把这些控件全部压在 **`--dsw-alias-brand-primary`** 上,而它是 `bluish-1000 / 50` ——
147
+ 也就是近黑 / 近白:
148
+
149
+ ```css
150
+ ._switch[aria-checked=true]{background:var(--dsw-alias-brand-primary)}
151
+ .cardCheckbox input:checked+.indicator{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-brand-primary)}
152
+ .registryOption input[type=radio]{accent-color:var(--dsw-alias-brand-primary)} /* 原生 radio/checkbox */
153
+ .primary{background:var(--dsw-alias-brand-primary)} /* 插件页主按钮 */
154
+ ```
155
+
156
+ 所以开态一直是「黑白拨杆」,跟配色无关。这个 token 共 13 处引用,覆盖开关、复选框、单选
157
+ (含原生 `accent-color`)、插件页 `.primary` 按钮、两处动作文本与表单焦点边 —— 一个支点全带过来。
158
+
159
+ 设置页新增一行「**开关 / 勾选:主题色 / 黑白**」(默认主题色)。改的只有这个 token:
160
+
161
+ | | 浅色 | 深色 | 对画布(非文本要求 3:1) |
162
+ |---|---|---|---|
163
+ | 暖砂 | `#a9683e` | `#c8a28b` | 4.04 / 7.36 |
164
+ | 靛蓝 | `#3f66a3` | `#95acd0` | 5.27 / 7.37 |
165
+ | 苔绿 | `#4f7a42` | `#9cb695` | 4.58 / 7.47 |
166
+
167
+ **按钮本来就跟**:`button-info-fill`(信息按钮)= 强调色,`button-primary/-ghost/-floating`、
168
+ `button-elevated/contrast`、`button-tool-bar-*` 全部走色阶或已被覆盖。
169
+
170
+ ## 交互态
171
+
172
+ 内置的交互态是蓝灰墨(`#263148`)与冷灰写死的,配色一换就露馅。已按配色重算,
173
+ alpha **浅色与内置一致、深色压到 0.6 倍**(见下):
174
+
175
+ | token | 内置 | 暖砂(浅 / 深) |
176
+ |---|---|---|
177
+ | `interactive-bg-hover` | `#2631480f` | `#12110d0f` / `#f5efe00d` |
178
+ | `interactive-bg-active` | `#2631481a` | `#12110d1a` / `#f5efe017` |
179
+ | `interactive-bg-hover-accent` | 无(自定) | `#12110d24` / `#f5efe024` |
180
+ | `menu-surface-fill` | `#f8f9fa94` / `#43454a73` | `#f5efe094` / `#46454173` |
181
+ | `button-tool-bar-hover` | `#54555799` | `#57555199` |
182
+
183
+ **为什么深色 alpha 要压**:深色叠的是近白纸色,8% 的纸色在 L 0.22 上抬起的明度,约等于 6% 的墨色
184
+ 在 L 0.93 上压下的两倍。旧值(`0.08 / 0.14 / 0.24`)实测深色 hover / active / accent
185
+ ΔE `7.05 / 12.19 / 20.08`,是浅色(`3.92 / 6.76 / 9.51`)的 1.8–2.1 倍 —— 深色下悬停一行会「闪」,
186
+ 按下像换了一块面。收到 `0.05 / 0.09 / 0.14` 后是 `4.6 / 7.9 / 12.2`,两个模式的手感对齐。
187
+
188
+ 本来就跟(官方是 `var()` 引用):`interactive-bg-hover-solid`、`button-ghost-active-*`、
189
+ `button-primary-hover`、滚动条、`markdown-code-segment-*`、`bg-document-selection`、`state-business-*`(焦点环)。
190
+ 不跟的:`interactive-bg-hover-danger`(红)、`bg-mask-*`(遮罩中性黑)、代码 diff 红绿。
191
+
192
+ ## 品牌基准色与链接
193
+
194
+ 强调色由三要素定,设计稿给的是**基准色**,对应色阶中段 `500`:
195
+
196
+ | 套 | 基准色 | rgb | OKLCH(L / C / h) | 白字对比 | 在浅色 `150` 上 |
197
+ |---|---|---|---|---|---|
198
+ | 暖砂 | `#a9683e` | 169, 104, 62 | 0.580 / 0.101 / 52.4° | 4.43 | 3.40 |
199
+ | 靛蓝 | `#3f66a3` | 63, 102, 163 | 0.511 / 0.107 / 259.0° | 5.77 | 4.41 |
200
+ | 苔绿 | `#4f7a42` | 79, 122, 66 | 0.534 / 0.096 / 138.7° | 5.01 | 3.84 |
201
+
202
+ `500` 这一档的明度**被定死在设计值**(`accentL` 直接给 L,不乘 `accentScale`):宿主和本插件
203
+ 都拿它当品牌面 —— `--dsw-alias-button-info-fill`、`--dsw-alias-state-business-primary`(焦点环)、
204
+ `label-deep-diving` 的混色基色、以及设置页每张卡片的预览色。所以它必须是设计稿那个数,
205
+ 不能被求解器按承载面改写。
206
+
207
+ 用 `accentScale` 也能把 500 压到设计明度,但那会连带把 `deepseek-50/100/200`(宿主的选中态软底)
208
+ 整条压暗 13%,软底就不软了 —— 这是 `accentL` 单独存在的理由。
209
+
210
+ **但基准色不能当正文链接**(上表最后一列):暖砂 3.40、苔绿 3.84 都低于 AA 4.62,靛蓝 4.41 也压线。
211
+ 链接是当文字用的,所以 `--dsw-alias-link` 由基准色**派生**:同色相、同色度、明度往远处收到达标
212
+ (最多 0.13,与其它求解同一上限)。
213
+
214
+ | 套 | 链接(浅) | 对 `150` | 链接(深) | 对 `800` |
215
+ |---|---|---|---|---|
216
+ | 暖砂 | `#915227` | 4.68 | `#c8a28b` | 4.70 |
217
+ | 靛蓝 | `#3c629f` | 4.67 | `#95acd0` | 4.64 |
218
+ | 苔绿 | `#436d36` | 4.63 | `#9cb695` | 4.64 |
219
+
220
+ 这是本插件唯一一条「不是标准值、而是从标准值派生」的颜色。所以报表里的 `link500` 读的是
221
+ `--dsw-alias-link`,不是 `--dsw-static-deepseek-500` —— 后者是基准色,界面上只当填充/焦点环用。
222
+
223
+ ## 为什么不用「注册新主题」
224
+
225
+ `ctx.theme.register()` 注册的是**一个 colorScheme 固定的主题**,一套配色只能一个 id;要做
226
+ 「同一套配色各自适配明暗」就得注册两个 id + 自己盯 `prefers-color-scheme` 反复 `setTheme()`,
227
+ 还会撞三个官方约束(自定义 id 不持久化、`adopt()` 回灌覆盖、dispose 生效中的主题会重置 preference)。
228
+ 所以用更弱的 `ctx.theme.overrideTokens(source, tokens)`:明暗归官方「外观」,插件只换色。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-theme-palette",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "DSH 主题配色:4 套配色 × 明暗两模式,OKLCH 色域安全重建 + 真实承载面对比度求解,可调强度与面板表面。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,7 +23,9 @@
23
23
  "tools/preview-settings.mjs",
24
24
  "tools/shoot-desktop.mjs",
25
25
  "tools/compare.mjs",
26
- "tools/release.mjs"
26
+ "tools/release.mjs",
27
+ "CHANGELOG.md",
28
+ "docs/DESIGN.md"
27
29
  ],
28
30
  "peerDependencies": {
29
31
  "@deepseek-ai/cordis": "^4.0.0"
@@ -11,6 +11,9 @@ import { fileURLToPath } from 'node:url'
11
11
 
12
12
  const here = path.dirname(fileURLToPath(import.meta.url))
13
13
  const root = path.resolve(here, '..')
14
+ const argv = process.argv.slice(2)
15
+ const li = argv.indexOf('--lang')
16
+ const LANG = li >= 0 ? argv[li + 1] : 'auto' // auto | zh-CN | zh-TW | en | ja
14
17
  const source = fs.readFileSync(path.join(root, 'client.js'), 'utf8')
15
18
 
16
19
  let engine = null
@@ -20,7 +23,7 @@ const sandbox = {
20
23
  __DSH_THEME_PALETTE_DEV__: (api) => { engine = api },
21
24
  },
22
25
  localStorage: { getItem: () => null, setItem: () => {} },
23
- document: { head: { appendChild() {} }, createElement: () => ({ dataset: {}, remove() {} }), querySelector: () => null, documentElement: { lang: 'zh' } },
26
+ document: { head: { appendChild() {} }, createElement: () => ({ dataset: {}, remove() {} }), querySelector: () => null, documentElement: { lang: LANG === 'auto' ? 'zh-CN' : LANG } },
24
27
  navigator: { language: 'zh' },
25
28
  }
26
29
  new Function('window', 'localStorage', 'document', 'navigator', source)(
@@ -106,6 +109,8 @@ const props = {
106
109
  getScrollMode: () => 'tinted', subscribeScroll: () => () => {}, setScrollMode: () => {},
107
110
  getControlMode: () => 'accent', subscribeControl: () => () => {}, setControlMode: () => {},
108
111
  getMdMode: () => 'on', subscribeMd: () => () => {}, setMdMode: () => {},
112
+ getLangPref: () => (LANG === 'auto' ? 'auto' : LANG), getLocale: () => (LANG === 'auto' ? 'zh-CN' : LANG),
113
+ subscribeLocale: () => () => {}, setLangPref: () => {},
109
114
  getHealth: () => ({ ok: true, checked: 5 }), subscribeHealth: () => () => {},
110
115
  }
111
116
 
@@ -123,7 +128,7 @@ html,body{margin:0;background:#0a0a0a;font-family:-apple-system,"Segoe UI","Micr
123
128
  .wrap{padding:18px;border-radius:16px;border:1px solid rgba(128,128,128,.3);background:${canvas};${style};color:var(--dsw-alias-label-primary)}
124
129
  </style><style>${engine.settingsCss}</style></head>
125
130
  <body${dark}><div class="page"><div class="wrap">${toHtml(body)}</div></div></body></html>`
126
- const out = path.join(here, `preview-${mode}.html`)
131
+ const out = path.join(here, `preview-${mode}${LANG === 'auto' ? '' : '-' + LANG}.html`)
127
132
  fs.writeFileSync(out, html)
128
133
  console.log('✓ ' + out + '(' + Math.round(html.length / 1024) + ' KB)')
129
134
  }