dsh-theme-palette 1.6.0 → 1.7.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/CHANGELOG.md CHANGED
@@ -4,6 +4,32 @@
4
4
 
5
5
  ## 版本一览
6
6
 
7
+ ### 1.7.0
8
+ 依据 2026-10-23 审计反哺(对 DSH 0.2.1-rc.2 实际 token 与承载面核实):
9
+
10
+ - **A01 极端强调色**:`accentL` 钳位到 0.30–0.64;链接求解器改为双向搜索并对 00/60/150 同时达标;
11
+ 新增 `--dsw-alias-button-info-fill` 覆盖(与主按钮一样按白字 4.5 压暗),不再出现白底白链/白勾
12
+ - **A02 自定义色订阅**:`readCustom` 改为按存储内容缓存的冻结快照,新增 `subscribeCustom`;
13
+ 取色/重置/导入会更换引用并触发重渲染,空闲时快照稳定(`useSyncExternalStore` 语义)
14
+ - **A03 分享完整**:`dshs1.` 短码新增 `x`(三色),旧码仍可导入并提示未含自定义色;
15
+ `applyConfigCode` 返回 `{ok,hasCustom}`,非法码不部分写入
16
+ - **A04 承载面求解**:深色正文(50)、次级(300)、caption(500)补上求解;目标按承载面可达上限
17
+ 钳位(0.93 余量),`palette-dev` 打印「实际/目标/可达」并标注「表面上限」,不再虚报 12/15;
18
+ caption 注释里的 6.5:1 更正为实测值
19
+ - **A05 文字色相**:自定义文字色相进入 700/750/1000/50 档(此前只取色度)
20
+ - **A06 HEX 校验**:改为 draft + 提交,非法值显示 `aria-invalid` 与行内错误,Enter 提交、Esc 回滚
21
+ - **A07 键盘模型**:分段控件 roving tabindex + 方向键/Home/End
22
+ - **A08 标签结构**:自定义色改为 `role=group` + 色盘/HEX 两个独立 label
23
+ - **A09 健康检测**:探针比对期望值(支持 `#rgb/#rrggbb/rgb()/rgba()`),报 `mismatched`;mono 覆盖更多 token
24
+ - **A10 测试门**:`palette-dev.mjs` 改用生产 `floors()`,支持 `--hc`,硬失败退出码 1
25
+ - **A11 边界**:grid/swatches 改 `minmax(min(100%,N),1fr)`;说明 `_markdown_`/`.md-code-block`
26
+ 在当前宿主构建 CSS 中仍存在(属契约风险而非失效);ramp 缓存上限 48
27
+ - **建议落地**:自定义面板新增**解释性校正**(强调色归一化 / 链接压暗,带实际对比度);
28
+ 导入改为**先预览后确认**(预览不写存储);新增按版本一次的**升级提示**(`notice.v1`,不碰配色键;
29
+ 仅升级前已有配置的用户可见);高对比从「更多选项」移到阅读区;新增 `tools/check-host-contract.mjs`
30
+ 契约测试与 `tools/check-ui.mjs` 结构回归,并把它们接入 `npm run check` 与发版预检
31
+ - 新增 `tools/check-audit.mjs`:覆盖极端色、分享往返、文字色相、caption、深色正文可达上限、HC 卡层
32
+
7
33
  ### 未发版 · 发版预检补强
8
34
  - `tools/release.mjs` 新增 **engines.dsh 兼容性预检**:按宿主 app-boot 的同一口径
9
35
  (`semver.satisfies(v, range, { includePrerelease: true })`)计算,守住两条底线——
package/README-zh.md CHANGED
@@ -24,8 +24,9 @@ DeepSeek Harness(Web / 桌面)的主题配色插件:**4 套配色 × 明
24
24
  - 色阶**整体重算**而不是替换:OKLCH 重建 → 角色化色度 → **sRGB 色域钳位** → 在真实承载面上求解对比度;
25
25
  - 覆盖 27,000+ 处 token 引用:正文、次级、三级、注释、链接、侧栏、卡片、主人气泡、主按钮、滚动条、开关、Markdown 标题/加粗、代码块;
26
26
  - 可调:文字着色三档、侧栏三档、右栏两档、Markdown 强调、滚动条、开关配色;
27
- - **自定义配色**(给 底色/文字/强调 三色,对比度保证不变)、**配置导入导出**(`dshs1.` 短码)、**高对比**档、4 种界面语言;
27
+ - **自定义配色**(给 底色/文字/强调 三色,文字色相也参与派生;过亮/过暗的强调色会归一化)、**配置导入导出**(`dshs1.` 短码含三色)、**高对比**档、4 种界面语言;
28
28
  - **自愈**:宿主丢弃 override 层时自动重放;
29
+ - 高对比的 15:1 以浅色卡层/深色 950 为承诺面;深色模块面 800 受表面亮度限制,报表会标“表面上限”。
29
30
  - 界面支持 简体中文 / 繁體中文 / English / 日本語(跟随宿主,也可手选)。
30
31
 
31
32
  ## Install
@@ -68,8 +69,8 @@ OKLCH(L, C, hue) ──► 色域钳位(对 C 二分) ──► sRGB hex
68
69
 
69
70
  - 语义别名保留 `var(--dsw-static-neutral-bluish-NN)` 引用链,跟 token 的元素就跟着配色走;
70
71
  - 浅色面用色阶 00–300,深色面用 800–950;
71
- - 文字下限在构建期强制:正文 ≥ 12、次级 ≥ 6.1、三级/链接 ≥ 4.6、侧栏内 ≥ 7、气泡内 ≥ 7;
72
- - `node palette-dev.mjs` 跑 432 组配置。
72
+ - 文字下限在构建期强制:正文 ≥ 12、次级 ≥ 6.2、三级/链接 ≥ 4.62、侧栏内 ≥ 7、气泡内 ≥ 7;深色模块面按可达上限钳位并标注;
73
+ - `node palette-dev.mjs` 跑标准档承载面报表,`--hc` 跑高对比档;硬失败时退出码 1。
73
74
 
74
75
  逐组件设计取舍与对比度表见 [docs/DESIGN.md](docs/DESIGN.md),版本记录见 [CHANGELOG.md](CHANGELOG.md)。
75
76
 
package/client.js CHANGED
@@ -27,7 +27,7 @@ window.__ModuleLoader__.load({
27
27
  const React = require('react')
28
28
  const h = React.createElement
29
29
 
30
- const PLUGIN_VERSION = '1.6.0'
30
+ const PLUGIN_VERSION = '1.7.0'
31
31
  const STORE_KEY = 'dsh-theme-palette.v1'
32
32
  const OVERRIDE_SOURCE = 'dsh-theme-palette'
33
33
 
@@ -141,20 +141,50 @@ window.__ModuleLoader__.load({
141
141
  const CUSTOM_DEFAULT = { bg: '#f5efe6', text: '#1d1a17', accent: '#a9683e' }
142
142
  const HEX_RE = /^#[0-9a-f]{6}$/i
143
143
 
144
+ /**
145
+ * 自定义色的**不可变快照**。
146
+ *
147
+ * useSyncExternalStore 要求 getSnapshot 在数据未变时返回同一引用,否则每次渲染都会
148
+ * 得到新对象并陷入重渲染;但也不能让快照永远不变 —— 取色/重置/导入都必须换新对象。
149
+ * 所以这里缓存一份冻结对象,写入时替换缓存。
150
+ */
151
+ let customCache = null
152
+ // undefined = 内存里的写入没落盘(隐私模式),缓存仍然有效;字符串 = 与存储内容对应
153
+ let customCacheRaw
154
+
144
155
  function readCustom() {
156
+ let raw = null
157
+ try { raw = localStorage.getItem(CUSTOM_KEY) } catch (err) { /* 读取失败按空处理 */ }
158
+ if (customCache !== null && (customCacheRaw === undefined || raw === customCacheRaw)) return customCache
145
159
  const out = Object.assign({}, CUSTOM_DEFAULT)
146
- try {
147
- const raw = localStorage.getItem(CUSTOM_KEY)
148
- if (raw) {
160
+ if (raw) {
161
+ try {
149
162
  const parsed = JSON.parse(raw)
150
- for (const k of CUSTOM_FIELDS) if (parsed && typeof parsed[k] === 'string' && HEX_RE.test(parsed[k])) out[k] = parsed[k]
151
- }
152
- } catch (err) { /* 退回默认 */ }
153
- return out
163
+ for (const k of CUSTOM_FIELDS) if (parsed && typeof parsed[k] === 'string' && HEX_RE.test(parsed[k])) out[k] = parsed[k].toLowerCase()
164
+ } catch (err) { /* 退回默认 */ }
165
+ }
166
+ customCache = Object.freeze(out)
167
+ customCacheRaw = raw
168
+ return customCache
154
169
  }
155
170
 
156
171
  function writeCustom(next) {
157
- try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(next)) } catch (err) { /* 忽略 */ }
172
+ const clean = {}
173
+ for (const k of CUSTOM_FIELDS) {
174
+ const v = next && typeof next[k] === 'string' && HEX_RE.test(next[k]) ? next[k].toLowerCase() : CUSTOM_DEFAULT[k]
175
+ clean[k] = v
176
+ }
177
+ const serialized = JSON.stringify(clean)
178
+ // 值没变就保持同一引用:useSyncExternalStore 的快照在数据未变时必须稳定
179
+ const unchanged = customCache !== null && CUSTOM_FIELDS.every((k) => customCache[k] === clean[k])
180
+ try {
181
+ localStorage.setItem(CUSTOM_KEY, serialized)
182
+ customCacheRaw = serialized
183
+ } catch (err) {
184
+ customCacheRaw = undefined
185
+ }
186
+ if (!unchanged) customCache = Object.freeze(clean)
187
+ return customCache
158
188
  }
159
189
 
160
190
  function customScheme(colors) {
@@ -166,10 +196,14 @@ window.__ModuleLoader__.load({
166
196
  id: 'custom', label: '自定义', desc: '三色派生', tinted: true, custom: true,
167
197
  hue: bg[2], darkHue: bg[2],
168
198
  lightC: clampN(bg[1] * 0.9, 0.008, 0.045), midC: 0.010,
169
- textC: clampN(tx[1], 0.002, 0.030),
199
+ // 文字色相必须真的来自「文字」这一项:只用色度会让「同色度、不同色相」完全同效,
200
+ // 与 UI 上「三色的色相与色度」的承诺不符。
201
+ textC: clampN(tx[1], 0.002, 0.030), textHue: tx[2],
170
202
  darkC: clampN(bg[1] * 0.75, 0.006, 0.035), darkLift: 0.030,
171
- paperC: clampN(tx[1], 0.004, 0.030),
172
- accentHue: ac[2], accentC: clampN(ac[1], 0.04, 0.18), accentL: ac[0], accentScale: 1.0,
203
+ paperC: clampN(tx[1], 0.004, 0.030), paperHue: tx[2],
204
+ // accentL 必须钳位:500 档同时是 --dsw-alias-link、button-info-fill 与勾选支点的来源。
205
+ // 纯白/荧光黄会让填充与白字同色;只在极端输入时归一化,预设色不受影响。
206
+ accentHue: ac[2], accentC: clampN(ac[1], 0.04, 0.18), accentL: clampN(ac[0], 0.30, 0.64), accentScale: 1.0,
173
207
  }
174
208
  }
175
209
 
@@ -190,6 +224,8 @@ window.__ModuleLoader__.load({
190
224
  : { main: MIN_MAIN, secondary: MIN_SECONDARY, tertiary: MIN_TERTIARY, link: MIN_LINK, linkDark: MIN_LINK_DARK }
191
225
  }
192
226
  const HC_KEY = 'dsh-theme-palette.hc.v1'
227
+ // 只记录“已读哪个版本”的升级提示,不参与配色,也不迁移任何用户数据
228
+ const NOTICE_KEY = 'dsh-theme-palette.notice.v1'
193
229
 
194
230
  /**
195
231
  * 区块底色:左侧栏相对主画布的深浅档位。
@@ -436,7 +472,11 @@ window.__ModuleLoader__.load({
436
472
  const lifted = role === 'dark' ? L + scheme.darkLift : L + liftText
437
473
  // 深色层单独给色相:暖砂的 88° 是「黄」,在 L≈0.22 的暗部读成橄榄绿;
438
474
  // 换成 60°(橙棕)才是咖啡色。浅色侧仍是 88°(米白)。
439
- const hue = role === 'dark' && scheme.darkHue !== undefined ? scheme.darkHue : scheme.hue
475
+ // 文字档(含深色正文 paper)跟随「文字」这一项自己的色相;预设没有 textHue,行为不变。
476
+ const tintRole = role === 'text' || role === 'paper'
477
+ const hue = tintRole && scheme.textHue !== undefined
478
+ ? scheme.textHue
479
+ : (role === 'dark' && scheme.darkHue !== undefined ? scheme.darkHue : scheme.hue)
440
480
  bluish[step] = fromOklch(clamp(compressL(lifted), 0, 0.995), roleChroma(role, weight, scheme, scales), hue)
441
481
  }
442
482
  const neutral = {}
@@ -472,47 +512,74 @@ window.__ModuleLoader__.load({
472
512
  }
473
513
 
474
514
  /**
475
- * 对比度求解:前景达不到 target 就沿亮度往「离背景更远」的方向微调,
476
- * 最多挪 0.09(保持原有明暗层次,不把正文推成纯黑)。
515
+ * 对比度求解:前景达不到 target 就沿亮度微调,**两个方向都找**(取离原色最近的可行解)。
516
+ *
517
+ * 只朝「远离背景」一个方向走会漏掉可行解:当强调色比浅色背景还亮(纯白/荧光黄),
518
+ * 继续往白走只会更不可读,正确解在暗的一侧。bgs 可以是多个承载面,要求同时达标。
477
519
  */
478
- function solveForeground(hexes, step, bgHex, target, cap) {
479
- const maxShift = typeof cap === "number" ? cap : 0.13
520
+ function solveForeground(hexes, step, bgs, target, cap) {
521
+ const list = Array.isArray(bgs) ? bgs : [bgs]
522
+ const maxShift = typeof cap === 'number' ? cap : 0.13
480
523
  const [L0, C, hue] = toOklch(hexes[step])
481
- const darker = luminance(hexes[step]) < luminance(bgHex)
482
- let L = L0
483
- for (let i = 0; i < Math.ceil(maxShift / 0.004) + 4; i++) {
484
- const candidate = fromOklch(L, C, hue)
485
- if (contrast(candidate, bgHex) >= target) return candidate
486
- L += darker ? -0.004 : 0.004
487
- // 上限 0.13:三级文字要从 4.41 抬到 4.62 需要 ΔL≈0.096,旧的 0.09 会静默截断
488
- if (Math.abs(L - L0) > maxShift) break
524
+ const passes = (candidate) => list.every((bg) => contrast(candidate, bg) >= target)
525
+ if (passes(hexes[step])) return hexes[step]
526
+ const steps = Math.ceil(maxShift / 0.004)
527
+ for (let i = 1; i <= steps; i++) {
528
+ // 同距离先试暗侧:浅色模式下正文/链接的可行解绝大多数在暗侧
529
+ for (const dir of [-1, 1]) {
530
+ const L = L0 + dir * i * 0.004
531
+ if (L < 0 || L > 1) continue
532
+ const candidate = fromOklch(L, C, hue)
533
+ if (passes(candidate)) return candidate
534
+ }
489
535
  }
490
- return fromOklch(L, C, hue)
536
+ // 位移上限内无解:黑/白里取「最差承载面」更高的那个,保证可读性优先
537
+ const score = (candidate) => Math.min(...list.map((bg) => contrast(candidate, bg)))
538
+ return score('#000000') >= score('#ffffff') ? '#000000' : '#ffffff'
491
539
  }
492
540
 
493
541
  function tuneRamps(ramps, scheme, hc) {
494
542
  const F = floors(hc)
495
543
  // 靶点必须用**真实承载面**,不能用画布:三级文字与链接大量落在卡层(60)、
496
- // 悬浮层(150)、深色模块层(800)上,只对 00/950 求解会得到「画布达标、卡层不达标」。
544
+ // 深色模块层(800)上,只对 00/950 求解会得到「画布达标、卡层不达标」。
545
+ //
546
+ // 深色侧还要面对**物理上限**:白色对 800 面最多只有 10.1–11.0:1,
547
+ // 12:1(HC 15:1)不可能达成。这里按「目标与可达上限取小」求解,并留 7% 色彩余量,
548
+ // 由 palette-dev 报告「承诺 / 可达 / 实际」,不再假装能到 15。
497
549
  const lightCanvas = ramps.bluish['00']
498
550
  const lightCard = ramps.bluish['60']
499
551
  const darkCanvas = ramps.bluish['950']
500
552
  const darkModule = ramps.bluish['800']
553
+ const darkCard = ramps.bluish['850']
554
+ const maxOn = (bg, lightFg) => contrast(lightFg ? '#ffffff' : '#000000', bg)
555
+ // 0.93 而不是 0.995:贴着上限求解会把深色正文推成纯白,文字色味(paperC)全丢;
556
+ // 留出余量才能既达到可见的最高档,又保住「近白但有色味」的设计意图。
557
+ const clampTarget = (bg, target, lightFg) => Math.min(target, Math.max(1, maxOn(bg, lightFg) * 0.93))
501
558
  const jobs = [
502
- ['1000', lightCanvas, F.main],
503
- ['700', lightCard, F.secondary],
504
- ['600', lightCard, F.tertiary],
505
- ['400', darkModule, F.tertiary],
559
+ ['1000', [lightCard, lightCanvas], F.main, false],
560
+ ['700', [lightCard], F.secondary, false],
561
+ ['600', [lightCard], F.tertiary, false],
562
+ // 深色次级:(300) 之前从未被求解;800 是最亮的深色面,对浅色文字最难
563
+ ['300', [darkModule, darkCard, darkCanvas], F.secondary, true],
564
+ ['400', [darkModule], F.tertiary, true],
506
565
  ]
507
- for (const [step, bg, target] of jobs) {
508
- if (contrast(ramps.bluish[step], bg) >= target) continue
509
- ramps.bluish[step] = solveForeground(ramps.bluish, step, bg, target, hc ? 0.30 : 0.13)
566
+ for (const [step, bgs, target, lightFg] of jobs) {
567
+ const effective = bgs.reduce((acc, bg) => Math.min(acc, clampTarget(bg, target, lightFg)), target)
568
+ ramps.bluish[step] = solveForeground(ramps.bluish, step, bgs, effective, hc ? 0.30 : 0.13)
569
+ }
570
+ // 深色正文:以最亮的深色面(800)为求解面。它对浅色文字最难;把它抬到可达上限后,
571
+ // 850/875/950 会自然更高。12/15 在 800 上物理不可达,所以目标按该面上限钳位,
572
+ // 由 palette-dev 报告「承诺 / 可达 / 实际」,不再假装能到 15。
573
+ {
574
+ const target = Math.min(F.main, maxOn(darkModule, true) * 0.93)
575
+ ramps.bluish['50'] = solveForeground(ramps.bluish, '50', darkModule, target, hc ? 0.30 : 0.13)
510
576
  }
511
577
  // 链接按「最亮的深色承载面(800)」兜底。
512
578
  // 注意这里**只有 400**:浅色链接不走这条色阶的中段(500 是设计稿的标准色,
513
579
  // 不能被求解器按承载面改写),浅色链接在 aliasOverrides 里从 500 派生。
514
580
  if (contrast(ramps.deepseek['400'], darkModule) < F.linkDark) {
515
- ramps.deepseek['400'] = solveForeground(ramps.deepseek, '400', darkModule, F.linkDark, hc ? 0.30 : 0.13)
581
+ const effective = clampTarget(darkModule, F.linkDark, true)
582
+ ramps.deepseek['400'] = solveForeground(ramps.deepseek, '400', darkModule, effective, hc ? 0.30 : 0.13)
516
583
  }
517
584
  return ramps
518
585
  }
@@ -522,21 +589,29 @@ window.__ModuleLoader__.load({
522
589
  *
523
590
  * 为什么不能直接用 500:设计稿给的标准值(暖砂 `#A9683E`)在浅色悬浮面 `150` 上只有
524
591
  * 3.46:1、画布上 4.06 —— 那是「品牌色」不是「链接色」。链接要 4.62 起,所以同色相同色度、
525
- * 明度往远处收,收到达标为止(最多 0.13,与其它求解一致)。
592
+ * 明度往可行的一侧收(两个方向都找),收到达标为止。
526
593
  * 这样宿主里 500 的其它用途(info 按钮填充、焦点环、deep-diving 文字、卡片预览色)
527
594
  * 全部保持标准值,只有「当文字用」的链接换一条。
528
595
  */
529
596
  function lightLink(r, hc) {
530
597
  const target = floors(hc).link
531
598
  if (r === BASE) return BASE.deepseek['500'] // 黑白配色没被覆盖,走宿主原值
532
- const bg = r.bluish['150']
533
- if (contrast(r.deepseek['500'], bg) >= target) return r.deepseek['500']
534
- return solveForeground(r.deepseek, '500', bg, target, hc ? 0.30 : 0.13)
599
+ // 链接出现在卡层(60)、画布(00) 与悬浮层(150) 上;150 最难,要求同时达标。
600
+ const surfaces = [r.bluish['150'], r.bluish['60'], r.bluish['00']]
601
+ if (surfaces.every((bg) => contrast(r.deepseek['500'], bg) >= target)) return r.deepseek['500']
602
+ // 链接允许比正文移动更大:语义色必须可读,宁可偏离品牌色
603
+ return solveForeground(r.deepseek, '500', surfaces, target, hc ? 0.60 : 0.45)
535
604
  }
536
605
 
537
606
  /* ================= token 组装 ================= */
538
607
  /** 重建结果缓存:一个配色只算一次(渲染时每张卡片都会取预览色)。 */
539
608
  const rampCache = new Map()
609
+ // 自定义取色会持续产生新键;给一个小上限,避免长会话里只增不减
610
+ function rememberRamp(key, value) {
611
+ rampCache.set(key, value)
612
+ while (rampCache.size > 48) rampCache.delete(rampCache.keys().next().value)
613
+ return value
614
+ }
540
615
  function scalesFor(textMode) {
541
616
  const m = TEXT_MODES.find((x) => x.id === textMode) || TEXT_MODES[1]
542
617
  return { textScale: m.textScale, paperScale: m.paperScale, textLift: m.textLift || 0 }
@@ -563,7 +638,7 @@ window.__ModuleLoader__.load({
563
638
  scheme,
564
639
  hc,
565
640
  )
566
- rampCache.set(monoKey, mono)
641
+ rememberRamp(monoKey, mono)
567
642
  }
568
643
  return mono
569
644
  }
@@ -571,7 +646,7 @@ window.__ModuleLoader__.load({
571
646
  let cached = rampCache.get(key)
572
647
  if (cached === undefined) {
573
648
  cached = tuneRamps(buildRamps(params, scalesFor(textMode)), params, hc)
574
- rampCache.set(key, cached)
649
+ rememberRamp(key, cached)
575
650
  }
576
651
  return cached
577
652
  }
@@ -626,8 +701,18 @@ window.__ModuleLoader__.load({
626
701
  },
627
702
  '--dsw-alias-bg-mask-drop': { light: withAlpha(r.bluish['00'], 0.70), dark: withAlpha(r.bluish['800'], 0.70) },
628
703
  // 注释文字:宿主浅色用 400、深色用 600。深色的 600 被「浅色三级文字要 AA」的求解压暗过,
629
- // 在深底上只剩 3.0:1,所以深色这一侧改用 500(约 6.5:1),浅色保持宿主值。
630
- '--dsw-alias-label-caption': { light: r.bluish['400'], dark: r.bluish['500'] },
704
+ // 直接用在深底上只有 3.x:1;这里改用 500 并对**深色模块面(800)/卡层(850)** 求解到 AA。
705
+ // 实测旧注释「约 6.5:1」不成立(实际 4.4–4.7),所以既改值也改注释。
706
+ '--dsw-alias-label-caption': {
707
+ light: r.bluish['400'],
708
+ dark: solveForeground(
709
+ r.bluish,
710
+ '500',
711
+ [r.bluish['800'], r.bluish['850']],
712
+ Math.min(floors(hc).tertiary, contrast('#ffffff', r.bluish['800']) * 0.995),
713
+ 0.30,
714
+ ),
715
+ },
631
716
  // 主人气泡(`--dsw-specific-bubble`)宿主指向 deepseek-50:那是「品牌色的极浅底」,
632
717
  // 我的强调色色度高,浅端直接算出 #ffede0(R 顶满 255)→ 看着发粉发红。
633
718
  // 改成「强调色 8–10% 混画布」,气泡是有色味的浅底而不是一块彩色。
@@ -745,6 +830,12 @@ window.__ModuleLoader__.load({
745
830
  light: darkenUntil(ramps.deepseek['500'], ramps.bluish['00'], 4.5),
746
831
  dark: ramps.deepseek['400'],
747
832
  }
833
+ // info 按钮同样是「强调色填充 + 白字」,宿主直接指向 deepseek-500。
834
+ // 极端自定义强调色下会变成浅底白字;与主按钮一样按白字 4.5 压暗。
835
+ pairs['--dsw-alias-button-info-fill'] = {
836
+ light: darkenUntil(ramps.deepseek['500'], ramps.bluish['00'], 4.5),
837
+ dark: ramps.deepseek['400'],
838
+ }
748
839
  }
749
840
  // 选中卡片的淡底:必须按模式给,原写法(强调色混**浅色**画布)在深色模式下
750
841
  // 会得到一张近白卡片,而卡片文字是 label-primary(深色下近白)→ 白底白字。
@@ -862,7 +953,12 @@ window.__ModuleLoader__.load({
862
953
  .dsh-tp-head h2{margin:0;font-size:18px;font-weight:600;letter-spacing:.2px}
863
954
  .dsh-tp-badge{margin-left:auto;font:11px/1.7 ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-tertiary);border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;padding:0 8px}
864
955
  .dsh-tp-sub{margin:-6px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
865
- .dsh-tp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(196px,1fr));gap:10px}
956
+ .dsh-tp-notice{display:flex;align-items:flex-start;gap:10px;justify-content:space-between;padding:10px 12px;border-radius:12px;border:.5px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-1)}
957
+ .dsh-tp-notice p{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
958
+ .dsh-tp-explain{margin:0;display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
959
+ .dsh-tp-preview{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border-radius:10px;border:.5px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-1)}
960
+ .dsh-tp-preview p{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
961
+ .dsh-tp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,196px),1fr));gap:10px}
866
962
  .dsh-tp-card{position:relative;display:flex;flex-direction:column;gap:8px;text-align:left;font:inherit;color:var(--dsw-alias-label-primary);cursor:pointer;padding:9px;border-radius:14px;border:.5px solid var(--dsw-alias-border-l4);background:transparent;transition:background .12s ease,border-color .12s ease}
867
963
  .dsh-tp-card:hover{background:var(--dsw-alias-interactive-bg-hover)}
868
964
  .dsh-tp-card:focus-visible{outline-offset:2px}
@@ -904,7 +1000,7 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
904
1000
  .dsh-tp-custom{display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:14px;border:.5px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-1)}
905
1001
  .dsh-tp-custom-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
906
1002
  .dsh-tp-custom-head b{font-size:13.5px;font-weight:500}
907
- .dsh-tp-swatches{display:grid;grid-template-columns:repeat(auto-fit,minmax(86px,1fr));gap:10px}
1003
+ .dsh-tp-swatches{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,86px),1fr));gap:10px}
908
1004
  .dsh-tp-swatch{display:flex;flex-direction:column;gap:6px;cursor:pointer}
909
1005
  .dsh-tp-color{width:100%;height:44px;padding:0;border:.5px solid var(--dsw-alias-border-l4);border-radius:10px;background:transparent;cursor:pointer}
910
1006
  .dsh-tp-color::-webkit-color-swatch-wrapper{padding:3px}
@@ -913,6 +1009,8 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
913
1009
  .dsh-tp-hex{font:11.5px/1 ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-tertiary)}
914
1010
  .dsh-tp-hexinput{width:100%;box-sizing:border-box;font:11.5px/16px ui-monospace,Consolas,monospace;padding:3px 6px;border-radius:6px;border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}
915
1011
  .dsh-tp-hexinput:focus-visible{outline-offset:1px}
1012
+ .dsh-tp-hexinput[aria-invalid="true"]{border-color:var(--dsw-alias-state-error-primary)}
1013
+ .dsh-tp-hexerror{font-size:11.5px;line-height:16px;color:var(--dsw-alias-state-error-primary)}
916
1014
  .dsh-tp-custom-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
917
1015
  .dsh-tp-note{font-size:11.5px;color:var(--dsw-alias-label-tertiary)}
918
1016
  .dsh-tp-status{margin:0;font-size:11.5px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
@@ -963,24 +1061,57 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
963
1061
  */
964
1062
  const TOKEN_PROBE = [
965
1063
  '--dsw-static-neutral-bluish-00',
1064
+ '--dsw-static-neutral-bluish-600',
966
1065
  '--dsw-static-neutral-bluish-1000',
1066
+ '--dsw-static-deepseek-500',
967
1067
  '--dsw-alias-link',
968
1068
  '--dsw-specific-sidebar-fill',
969
1069
  '--dsw-alias-label-caption',
970
1070
  ]
971
1071
 
1072
+ /** 只用于比对的颜色解析:支持 #rgb/#rrggbb/#rrggbbaa 与 rgb()/rgba()。 */
1073
+ function parseColor(value) {
1074
+ const s = String(value).trim().toLowerCase()
1075
+ if (s.charAt(0) === '#') {
1076
+ if (s.length === 4) return [parseInt(s[1], 16) * 17, parseInt(s[2], 16) * 17, parseInt(s[3], 16) * 17, 1]
1077
+ if (s.length === 7) return [parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16), 1]
1078
+ if (s.length === 9) return [parseInt(s.slice(1, 3), 16), parseInt(s.slice(3, 5), 16), parseInt(s.slice(5, 7), 16), parseInt(s.slice(7, 9), 16) / 255]
1079
+ }
1080
+ const m = s.match(/^rgba?\(([^)]+)\)$/)
1081
+ if (m) {
1082
+ const p = m[1].split(/[,\s/]+/).filter(Boolean).map((x) => (x.charAt(x.length - 1) === '%' ? parseFloat(x) * 2.55 : parseFloat(x)))
1083
+ return [p[0], p[1], p[2], p[3] === undefined ? 1 : p[3]]
1084
+ }
1085
+ return null
1086
+ }
1087
+
1088
+ function sameColor(actual, expected) {
1089
+ const a = parseColor(actual)
1090
+ const b = parseColor(expected)
1091
+ if (!a || !b) return String(actual).trim().toLowerCase() === String(expected).trim().toLowerCase()
1092
+ return Math.abs(a[0] - b[0]) <= 1 && Math.abs(a[1] - b[1]) <= 1 && Math.abs(a[2] - b[2]) <= 1 && Math.abs(a[3] - b[3]) <= 0.02
1093
+ }
1094
+
1095
+ /**
1096
+ * 不只验证「非空」:宿主原始 token 本来就有值,覆盖层丢失时非空检查会报假健康。
1097
+ * 这里比对计算值与本层期望值(var()/inherit 这类间接值跳过),并把不一致的 token 报出来。
1098
+ */
972
1099
  function probeApplied(tokens) {
973
1100
  try {
974
1101
  if (typeof getComputedStyle !== 'function') return null
975
1102
  const style = getComputedStyle(document.body)
976
1103
  let checked = 0
977
- let ok = true
1104
+ const mismatched = []
978
1105
  for (const name of TOKEN_PROBE) {
979
- if (!(name in tokens)) continue
1106
+ const pair = tokens[name]
1107
+ if (!pair) continue
980
1108
  checked++
981
- if (style.getPropertyValue(name).trim() === '') ok = false
1109
+ const actual = style.getPropertyValue(name).trim()
1110
+ if (actual === '') { mismatched.push(name); continue }
1111
+ const expected = [pair.light, pair.dark].filter((v) => typeof v === 'string' && v.charAt(0) === '#')
1112
+ if (expected.length > 0 && !expected.some((v) => sameColor(actual, v))) mismatched.push(name)
982
1113
  }
983
- return checked === 0 ? null : { ok, checked }
1114
+ return checked === 0 ? null : { ok: mismatched.length === 0, checked, mismatched }
984
1115
  } catch (err) {
985
1116
  return null
986
1117
  }
@@ -995,6 +1126,11 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
995
1126
  return 'mono'
996
1127
  }
997
1128
 
1129
+ /** 是否已经有用户配置:新装用户不需要看“行为变更”提示。 */
1130
+ function hasStoredConfig() {
1131
+ try { return localStorage.getItem(STORE_KEY) !== null } catch (err) { return false }
1132
+ }
1133
+
998
1134
  function writeStored(id) {
999
1135
  try { localStorage.setItem(STORE_KEY, id) } catch (err) { /* 记不住不影响本次生效 */ }
1000
1136
  }
@@ -1083,14 +1219,30 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1083
1219
  try { localStorage.setItem(HC_KEY, id) } catch (err) { /* 同理 */ }
1084
1220
  }
1085
1221
 
1086
- /* ---------- 配置导入/导出(反哺 A-2)----------
1087
- * 短码格式:`dshs1.` + base64url(JSON),字段全部用单字母,
1088
- * 导入时逐项对照模式表校验,任何一项不认识就整体拒绝(不猜、不部分应用)。
1222
+ function readNoticeSeen() {
1223
+ try {
1224
+ const raw = localStorage.getItem(NOTICE_KEY)
1225
+ return typeof raw === 'string' ? raw : ''
1226
+ } catch (err) { return '' }
1227
+ }
1228
+
1229
+ function writeNoticeSeen(version) {
1230
+ try { localStorage.setItem(NOTICE_KEY, version) } catch (err) { /* 记不住就每次都提示,不阻断使用 */ }
1231
+ }
1232
+
1233
+ /* ---------- 配置导入/导出(反哺 A-2 / A03)----------
1234
+ * 短码格式:`dshs1.` + base64url(JSON),字段全部用单字母。
1235
+ * v1 早期短码没有 `x`(自定义三色),解码时兼容:旧码仍可导入,只是不带自定义色。
1089
1236
  */
1090
1237
  const CONFIG_PREFIX = 'dshs1.'
1091
1238
 
1092
1239
  function encodeConfig(state) {
1093
- const raw = { v: 1, p: state.palette, s: state.sidebar, t: state.text, r: state.rightbar, c: state.scroll, k: state.control, m: state.md, h: state.hc, l: state.lang }
1240
+ const custom = state.custom || readCustom()
1241
+ const raw = {
1242
+ v: 1, p: state.palette, s: state.sidebar, t: state.text, r: state.rightbar,
1243
+ c: state.scroll, k: state.control, m: state.md, h: state.hc, l: state.lang,
1244
+ x: { bg: custom.bg, text: custom.text, accent: custom.accent },
1245
+ }
1094
1246
  try {
1095
1247
  const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(raw))))
1096
1248
  return CONFIG_PREFIX + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
@@ -1119,7 +1271,12 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1119
1271
  hc: hasId(HC_MODES, raw.h) ? raw.h : null,
1120
1272
  lang: raw.l === 'auto' || LOCALES.indexOf(raw.l) !== -1 ? raw.l : null,
1121
1273
  }
1122
- return Object.values(next).every((v) => v !== null) ? next : null
1274
+ // 三色要么全合法、要么整体忽略(旧码没有这一项)
1275
+ const x = raw.x
1276
+ const custom = x && CUSTOM_FIELDS.every((k) => typeof x[k] === 'string' && HEX_RE.test(x[k]))
1277
+ ? { bg: x.bg.toLowerCase(), text: x.text.toLowerCase(), accent: x.accent.toLowerCase() }
1278
+ : null
1279
+ return Object.values(next).every((v) => v !== null) ? Object.assign(next, { custom }) : null
1123
1280
  } catch (err) { return null }
1124
1281
  }
1125
1282
 
@@ -1193,17 +1350,26 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1193
1350
  lang: '语言',
1194
1351
  hc: '高对比',
1195
1352
  custom: '自定义配色',
1196
- customHint: '取三个色的色相与色度;明度由引擎按明暗模式算,对比度仍由求解器兜底。',
1353
+ customHint: '取三个色的色相与色度;过亮/过暗的强调色会归一化以保证可读,明度由引擎按模式计算。',
1354
+ notice: '1.7.0 调整了派生规则:极端强调色会归一化、文字色相参与派生、深色模块面按可达上限报告对比度。',
1355
+ dismiss: '知道了',
1356
+ explainClamp: '强调色已归一化:{from} → {to}(保证按钮与勾选可读)',
1357
+ explainLink: '链接已为可读性压暗:{ratio}:1(目标 {target}:1)',
1358
+ preview: '预览',
1359
+ cancel: '取消',
1360
+ importSummary: '将覆盖:',
1197
1361
  config: '配置',
1198
- configHint: '导出这台机器的配色选择,或粘贴短码导入;导入会覆盖全部 8 项。',
1362
+ configHint: '导出这台机器的配色选择(含自定义三色),或粘贴短码导入;导入会覆盖全部 9 项。',
1199
1363
  reset: '恢复默认',
1200
1364
  copy: '复制',
1201
1365
  applyImport: '导入',
1202
1366
  copied: '已复制',
1203
1367
  copyFail: '复制失败,请手选后 Ctrl+C',
1204
1368
  importOk: '已导入',
1369
+ importLegacy: '已导入(旧短码不含自定义三色)',
1205
1370
  importBad: '短码无效,已忽略',
1206
- hcSub: '把文字下限抬到 AAA(正文 ≥15、次文 ≥9、三级 ≥7)',
1371
+ badHex: 'HEX 需为 #RRGGBB',
1372
+ hcSub: '尽量把文字下限抬到 AAA(正文 ≥15、次文 ≥9、三级 ≥7);深色模块面的上限受表面亮度限制。',
1207
1373
  },
1208
1374
  'zh-TW': {
1209
1375
  nav: '主題色',
@@ -1229,17 +1395,26 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1229
1395
  lang: '語言',
1230
1396
  hc: '高對比',
1231
1397
  custom: '自訂配色',
1232
- customHint: '取三個色的色相與色度;明度由引擎依明暗模式計算,對比度仍由求解器兜底。',
1398
+ customHint: '取三個色的色相與色度;過亮/過暗的強調色會正規化以確保可讀性,明度由引擎依模式計算。',
1399
+ notice: '1.7.0 調整了衍生規則:極端強調色會正規化、文字色相參與衍生、深色模組面按可達上限回報對比度。',
1400
+ dismiss: '知道了',
1401
+ explainClamp: '強調色已正規化:{from} → {to}(確保按鈕與勾選可讀)',
1402
+ explainLink: '連結已為可讀性壓暗:{ratio}:1(目標 {target}:1)',
1403
+ preview: '預覽',
1404
+ cancel: '取消',
1405
+ importSummary: '將覆蓋:',
1233
1406
  config: '設定',
1234
- configHint: '匯出這台機器的配色選擇,或貼上短碼匯入;匯入會覆蓋全部 8 項。',
1407
+ configHint: '匯出這台機器的配色選擇(含自訂三色),或貼上短碼匯入;匯入會覆蓋全部 9 項。',
1235
1408
  reset: '恢復預設',
1236
1409
  copy: '複製',
1237
1410
  applyImport: '匯入',
1238
1411
  copied: '已複製',
1239
1412
  copyFail: '複製失敗,請手選後 Ctrl+C',
1240
1413
  importOk: '已匯入',
1414
+ importLegacy: '已匯入(舊短碼不含自訂三色)',
1241
1415
  importBad: '短碼無效,已忽略',
1242
- hcSub: '把文字下限抬到 AAA(正文 ≥15、次文 ≥9、三級 ≥7)',
1416
+ badHex: 'HEX 需為 #RRGGBB',
1417
+ hcSub: '盡量把文字下限抬到 AAA(正文 ≥15、次文 ≥9、三級 ≥7);深色模組面的上限受表面亮度限制。',
1243
1418
  },
1244
1419
  en: {
1245
1420
  nav: 'Color theme',
@@ -1265,17 +1440,26 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1265
1440
  lang: 'Language',
1266
1441
  hc: 'High contrast',
1267
1442
  custom: 'Custom palette',
1268
- customHint: 'Takes hue and chroma from three colours; lightness comes from the engine per mode and contrast is still solved.',
1443
+ customHint: 'Takes hue and chroma from three colours; extreme accents are normalised for readability; lightness comes from the engine per mode.',
1444
+ notice: '1.7.0 changes how colours are derived: extreme accents are normalised, text hue now affects derivation, and dark module surfaces report contrast against their achievable ceiling.',
1445
+ dismiss: 'Got it',
1446
+ explainClamp: 'Accent normalised: {from} → {to} (keeps buttons and checks readable)',
1447
+ explainLink: 'Link darkened for readability: {ratio}:1 (target {target}:1)',
1448
+ preview: 'Preview',
1449
+ cancel: 'Cancel',
1450
+ importSummary: 'Will overwrite:',
1269
1451
  config: 'Config',
1270
- configHint: 'Export this machine’s choices, or paste a short code to import. Import overwrites all 8 settings.',
1452
+ configHint: 'Export this machine’s choices (including the three custom colours), or paste a short code to import. Import overwrites all 9 settings.',
1271
1453
  reset: 'Reset',
1272
1454
  copy: 'Copy',
1273
1455
  applyImport: 'Import',
1274
1456
  copied: 'Copied',
1275
1457
  copyFail: 'Copy failed — select the code and press Ctrl+C',
1276
1458
  importOk: 'Imported',
1459
+ importLegacy: 'Imported (old code has no custom colours)',
1277
1460
  importBad: 'Invalid code, ignored',
1278
- hcSub: 'Raises text floors to AAA (body ≥15, secondary ≥9, tertiary ≥7)',
1461
+ badHex: 'HEX must be #RRGGBB',
1462
+ hcSub: 'Raises text floors toward AAA (body ≥15, secondary ≥9, tertiary ≥7); dark module surfaces are limited by surface luminance.',
1279
1463
  },
1280
1464
  ja: {
1281
1465
  nav: 'カラーテーマ',
@@ -1301,17 +1485,26 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1301
1485
  lang: '言語',
1302
1486
  hc: 'ハイコントラスト',
1303
1487
  custom: 'カスタム配色',
1304
- customHint: '3 色の色相と彩度だけを使います。明度はモードごとにエンジンが決め、コントラストは従来どおり求解します。',
1488
+ customHint: '3 色の色相と彩度を使用。極端なアクセント色は可読性のため正規化され、明度はモードごとにエンジンが決めます。',
1489
+ notice: '1.7.0 で派生ルールを変更しました:極端なアクセント色は正規化され、文字の色相が派生に使われ、暗いモジュール面は到達可能な上限でコントラストを報告します。',
1490
+ dismiss: '了解',
1491
+ explainClamp: 'アクセント色を正規化しました:{from} → {to}(ボタンとチェックの可読性のため)',
1492
+ explainLink: 'リンクを可読性のため暗くしました:{ratio}:1(目標 {target}:1)',
1493
+ preview: 'プレビュー',
1494
+ cancel: 'キャンセル',
1495
+ importSummary: '上書きする内容:',
1305
1496
  config: '設定の共有',
1306
- configHint: 'この端末の配色設定を書き出す/短いコードを貼り付けて読み込みます(8 項目すべて上書き)。',
1497
+ configHint: 'この端末の配色設定(カスタム 3 色を含む)を書き出す/短いコードを貼り付けて読み込みます(9 項目すべて上書き)。',
1307
1498
  reset: 'リセット',
1308
1499
  copy: 'コピー',
1309
1500
  applyImport: '読み込み',
1310
1501
  copied: 'コピーしました',
1311
1502
  copyFail: 'コピーできません。選択して Ctrl+C してください',
1312
1503
  importOk: '読み込みました',
1504
+ importLegacy: '読み込みました(旧コードにカスタム 3 色は含まれません)',
1313
1505
  importBad: '無効なコードです',
1314
- hcSub: '本文の下限を AAA に引き上げます(本文 ≥15、副次 ≥9、三次 ≥7)',
1506
+ badHex: 'HEX は #RRGGBB 形式で入力してください',
1507
+ hcSub: '本文の下限を AAA に近づけます(本文 ≥15、副次 ≥9、三次 ≥7)。暗いモジュール面は表面の明るさで上限が決まります。',
1315
1508
  },
1316
1509
  }
1317
1510
 
@@ -1372,6 +1565,11 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1372
1565
  }
1373
1566
 
1374
1567
  /* ================= 设置页(左侧导航独立一节) ================= */
1568
+ /** 文案插值:只替换 {name},缺值留空,不做 HTML 解释。 */
1569
+ function fmt(template, vars) {
1570
+ return String(template).replace(/\{(\w+)\}/g, (m, key) => (vars[key] === undefined ? '' : String(vars[key])))
1571
+ }
1572
+
1375
1573
  function PaletteSection(props) {
1376
1574
  const current = React.useSyncExternalStore(props.subscribe, props.getCurrent)
1377
1575
  const sidebarMode = React.useSyncExternalStore(props.subscribeSidebar, props.getSidebarMode)
@@ -1384,8 +1582,16 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1384
1582
  const health = React.useSyncExternalStore(props.subscribeHealth, props.getHealth)
1385
1583
  const [importText, setImportText] = React.useState('')
1386
1584
  const [importState, setImportState] = React.useState(null)
1585
+ const [importPreview, setImportPreview] = React.useState(null)
1387
1586
  const [copyState, setCopyState] = React.useState(null)
1388
1587
  const [advanced, setAdvanced] = React.useState(false)
1588
+ // 升级提示按版本只出现一次
1589
+ const noticePending = React.useSyncExternalStore(props.subscribeNotice, props.getNoticePending)
1590
+ // HEX 输入需要「草稿」态:非法值不能写进主题,但用户还在输入时也不该被立刻回滚。
1591
+ const [hexDraft, setHexDraft] = React.useState({})
1592
+ const [hexError, setHexError] = React.useState({})
1593
+ // 自定义色有自己的不可变快照与订阅;只订阅 current 不会因三色变化而重渲染。
1594
+ const customColors = React.useSyncExternalStore(props.subscribeCustom, props.getCustomColors)
1389
1595
  const locale = React.useSyncExternalStore(props.subscribeLocale, props.getLocale)
1390
1596
  const langPref = React.useSyncExternalStore(props.subscribeLocale, props.getLangPref)
1391
1597
  const langAttr = langPref !== 'auto'
@@ -1393,11 +1599,42 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1393
1599
  : ((typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) || 'zh-CN')
1394
1600
  const txt = TEXT[locale] || TEXT['zh-CN']
1395
1601
  const lb = LABELS[locale] || LABELS['zh-CN']
1602
+ // 只在自定义面板显示;解释是只读的,不改变任何设置。取色异常不应拖垮设置页。
1603
+ let explain = null
1604
+ if (current === 'custom') {
1605
+ try { explain = props.getCustomExplain() } catch (err) { explain = null }
1606
+ }
1607
+
1608
+ // A07:radiogroup 的键盘模型 —— 组只占一个 Tab 位,方向键循环切换,Home/End 到首尾。
1609
+ const onSegKeyDown = (event, modes, value, onPick, disabled) => {
1610
+ if (disabled) return
1611
+ if (['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'].indexOf(event.key) === -1) return
1612
+ event.preventDefault()
1613
+ const currentIndex = Math.max(0, modes.findIndex((m) => m.id === value))
1614
+ let nextIndex = currentIndex
1615
+ if (event.key === 'Home') nextIndex = 0
1616
+ else if (event.key === 'End') nextIndex = modes.length - 1
1617
+ else {
1618
+ const delta = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1
1619
+ nextIndex = (currentIndex + delta + modes.length) % modes.length
1620
+ }
1621
+ if (nextIndex === currentIndex) return
1622
+ onPick(modes[nextIndex].id)
1623
+ const buttons = event.currentTarget.children
1624
+ const target = buttons && buttons[nextIndex]
1625
+ if (target && typeof target.focus === 'function') target.focus()
1626
+ }
1396
1627
 
1397
1628
  // A6:读屏需要知道"这是哪个设置的选项组";单选用 radio 语义更准
1398
1629
  const seg = (modes, value, onPick, labels, labelId, disabled) => h(
1399
1630
  'div',
1400
- { className: 'dsh-tp-seg', role: 'radiogroup', 'aria-labelledby': labelId, 'aria-disabled': disabled ? 'true' : undefined },
1631
+ {
1632
+ className: 'dsh-tp-seg',
1633
+ role: 'radiogroup',
1634
+ 'aria-labelledby': labelId,
1635
+ 'aria-disabled': disabled ? 'true' : undefined,
1636
+ onKeyDown: (event) => onSegKeyDown(event, modes, value, onPick, disabled),
1637
+ },
1401
1638
  modes.map((mode) => h(
1402
1639
  'button',
1403
1640
  {
@@ -1407,6 +1644,8 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1407
1644
  className: mode.id === value ? 'is-on' : undefined,
1408
1645
  'aria-checked': mode.id === value,
1409
1646
  'aria-disabled': disabled ? 'true' : undefined,
1647
+ // roving tabindex:只有选中项在 Tab 序列里,其余靠方向键进入
1648
+ tabIndex: mode.id === value ? 0 : -1,
1410
1649
  disabled: disabled === true,
1411
1650
  onClick: () => { if (!disabled) onPick(mode.id) },
1412
1651
  },
@@ -1423,7 +1662,18 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1423
1662
  control,
1424
1663
  )
1425
1664
 
1426
- const customColors = props.getCustomColors()
1665
+ // A06:HEX 以「草稿 + 提交」处理;非法值不写主题,也不假装保存成功。
1666
+ const commitHex = (field) => {
1667
+ const draft = hexDraft[field]
1668
+ const raw = String(draft !== undefined ? draft : customColors[field]).trim()
1669
+ if (HEX_RE.test(raw)) {
1670
+ props.setCustomColor(field, raw)
1671
+ setHexDraft(Object.assign({}, hexDraft, { [field]: undefined }))
1672
+ setHexError(Object.assign({}, hexError, { [field]: false }))
1673
+ } else {
1674
+ setHexError(Object.assign({}, hexError, { [field]: true }))
1675
+ }
1676
+ }
1427
1677
  const customPanel = current === 'custom' && h(
1428
1678
  'div',
1429
1679
  { className: 'dsh-tp-custom' },
@@ -1435,28 +1685,63 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1435
1685
  onClick: () => props.resetCustom(),
1436
1686
  }, txt.reset)),
1437
1687
  h('div', { className: 'dsh-tp-swatches' },
1438
- ['bg', 'text', 'accent'].map((field) => h('label', { key: field, className: 'dsh-tp-swatch' },
1688
+ ['bg', 'text', 'accent'].map((field) => {
1689
+ const name = (lb.custom && lb.custom[field]) || field
1690
+ const colorId = rowId('color-' + field)
1691
+ const hexId = rowId('hex-' + field)
1692
+ const errId = rowId('hexerr-' + field)
1693
+ const invalid = hexError[field] === true
1694
+ return h('div', {
1695
+ key: field,
1696
+ className: 'dsh-tp-swatch',
1697
+ role: 'group',
1698
+ 'aria-label': name,
1699
+ },
1439
1700
  h('input', {
1440
1701
  type: 'color',
1702
+ id: colorId,
1441
1703
  className: 'dsh-tp-color',
1442
1704
  value: customColors[field],
1443
1705
  // input 是"拖动过程中"就触发,change 是"确认后"触发;两个都接,
1444
1706
  // 拖动时实时看到整页变化,松手/确认后不会再跳一次
1445
1707
  onInput: (e) => props.setCustomColor(field, e.target.value),
1446
1708
  onChange: (e) => props.setCustomColor(field, e.target.value),
1447
- 'aria-label': (lb.custom && lb.custom[field]) || field,
1709
+ 'aria-label': name,
1448
1710
  }),
1449
- h('span', { className: 'dsh-tp-swatch-name' }, (lb.custom && lb.custom[field]) || field),
1711
+ h('label', { htmlFor: hexId, className: 'dsh-tp-swatch-name' }, name),
1450
1712
  h('input', {
1451
- key: customColors[field],
1713
+ id: hexId,
1452
1714
  className: 'dsh-tp-hexinput',
1453
- defaultValue: customColors[field],
1715
+ value: hexDraft[field] !== undefined ? hexDraft[field] : customColors[field],
1454
1716
  spellCheck: false,
1455
- 'aria-label': ((lb.custom && lb.custom[field]) || field) + ' HEX',
1456
- onBlur: (e) => props.setCustomColor(field, e.target.value.trim()),
1457
- onKeyDown: (e) => { if (e.key === 'Enter') props.setCustomColor(field, e.target.value.trim()) },
1458
- })))),
1717
+ 'aria-invalid': invalid ? 'true' : undefined,
1718
+ 'aria-describedby': invalid ? errId : undefined,
1719
+ 'aria-label': name + ' HEX',
1720
+ onChange: (e) => {
1721
+ setHexDraft(Object.assign({}, hexDraft, { [field]: e.target.value }))
1722
+ if (invalid) setHexError(Object.assign({}, hexError, { [field]: false }))
1723
+ },
1724
+ onBlur: () => commitHex(field),
1725
+ onKeyDown: (e) => {
1726
+ if (e.key === 'Enter') { e.preventDefault(); commitHex(field) }
1727
+ if (e.key === 'Escape') {
1728
+ setHexDraft(Object.assign({}, hexDraft, { [field]: undefined }))
1729
+ setHexError(Object.assign({}, hexError, { [field]: false }))
1730
+ e.currentTarget.blur()
1731
+ }
1732
+ },
1733
+ }),
1734
+ invalid && h('span', { id: errId, className: 'dsh-tp-hexerror', role: 'alert' }, txt.badHex))
1735
+ })),
1459
1736
  h('p', { className: 'dsh-tp-custom-hint' }, txt.customHint),
1737
+ explain && (explain.accentClamped || explain.linkShifted) && h(
1738
+ 'div',
1739
+ { className: 'dsh-tp-explain', role: 'status' },
1740
+ explain.accentClamped && h('span', null,
1741
+ fmt(txt.explainClamp, { from: explain.accentFrom, to: explain.accentTo })),
1742
+ explain.linkShifted && h('span', null,
1743
+ fmt(txt.explainLink, { ratio: explain.linkRatio.toFixed(2), target: explain.linkTarget })),
1744
+ ),
1460
1745
  )
1461
1746
 
1462
1747
  const card = (scheme) => {
@@ -1494,9 +1779,13 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1494
1779
  h('h2', null, txt.nav),
1495
1780
  h('span', { className: 'dsh-tp-badge' }, 'v' + PLUGIN_VERSION)),
1496
1781
  h('p', { className: 'dsh-tp-sub' }, txt.hint),
1782
+ noticePending && h('div', { className: 'dsh-tp-notice' },
1783
+ h('p', { role: 'status' }, txt.notice),
1784
+ h('button', { type: 'button', className: 'dsh-tp-mini', onClick: () => props.dismissNotice() }, txt.dismiss)),
1497
1785
  h('div', { className: 'dsh-tp-grid' }, SCHEMES.map(card)),
1498
1786
  customPanel,
1499
1787
  row('text', txt.text, txt.textSub, seg(TEXT_MODES, textMode, props.setTextMode, lb.text, rowId('text'))),
1788
+ row('hc', txt.hc, txt.hcSub, seg(HC_MODES, hcMode, props.setHcMode, lb.hc, rowId('hc'))),
1500
1789
  row('sidebar', txt.sidebar, txt.sidebarSub, seg(SIDEBAR_MODES, sidebarMode, props.setSidebarMode, lb.sidebar, rowId('sidebar'))),
1501
1790
  row('rightbar', txt.rightbar, txt.rightbarSub, seg(RIGHTBAR_MODES, rightbarMode, props.setRightbarMode, lb.rightbar, rowId('rightbar'))),
1502
1791
  health && !health.ok && h('p', { className: 'dsh-tp-status is-bad' }, txt.bad),
@@ -1551,19 +1840,57 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1551
1840
  placeholder: 'dshs1.…',
1552
1841
  value: importText,
1553
1842
  'aria-label': txt.applyImport,
1554
- onChange: (e) => { setImportText(e.target.value); setImportState(null) },
1843
+ onChange: (e) => {
1844
+ setImportText(e.target.value)
1845
+ setImportState(null)
1846
+ setImportPreview(null)
1847
+ },
1555
1848
  }),
1556
1849
  h('button', {
1557
1850
  type: 'button',
1558
1851
  className: 'dsh-tp-mini',
1559
- onClick: () => setImportState(props.applyConfigCode(importText) ? 'ok' : 'bad'),
1560
- }, txt.applyImport),
1561
- importState && h('span', { className: 'dsh-tp-note', role: importState === 'bad' ? 'alert' : 'status', 'aria-live': 'polite' },
1562
- importState === 'ok' ? txt.importOk : txt.importBad)),
1852
+ onClick: () => {
1853
+ const result = props.previewConfigCode(importText)
1854
+ if (result && result.ok) {
1855
+ setImportPreview(result.config)
1856
+ setImportState(null)
1857
+ } else {
1858
+ setImportPreview(null)
1859
+ setImportState('bad')
1860
+ }
1861
+ },
1862
+ }, txt.preview),
1863
+ importState === 'bad' && h('span', { className: 'dsh-tp-note', role: 'alert', 'aria-live': 'polite' }, txt.importBad)),
1864
+ importPreview && h('div', { className: 'dsh-tp-preview', role: 'group', 'aria-label': txt.importSummary },
1865
+ h('p', null, txt.importSummary + ' ' + [
1866
+ (lb.scheme[importPreview.palette] || importPreview.palette),
1867
+ txt.sidebar + ' ' + (lb.sidebar[importPreview.sidebar] || importPreview.sidebar),
1868
+ txt.text + ' ' + (lb.text[importPreview.text] || importPreview.text),
1869
+ txt.hc + ' ' + (lb.hc[importPreview.hc] || importPreview.hc),
1870
+ ].join(' · ')),
1871
+ importPreview.custom
1872
+ ? h('p', null, txt.custom + ' ' + importPreview.custom.bg + ' / ' + importPreview.custom.text + ' / ' + importPreview.custom.accent)
1873
+ : h('p', null, txt.importLegacy),
1874
+ h('div', { className: 'dsh-tp-field-row' },
1875
+ h('button', {
1876
+ type: 'button',
1877
+ className: 'dsh-tp-mini',
1878
+ onClick: () => {
1879
+ const result = props.applyConfigCode(importText)
1880
+ setImportPreview(null)
1881
+ setImportState(result && result.ok ? (result.hasCustom ? 'ok' : 'legacy') : 'bad')
1882
+ },
1883
+ }, txt.applyImport),
1884
+ h('button', {
1885
+ type: 'button',
1886
+ className: 'dsh-tp-mini',
1887
+ onClick: () => { setImportPreview(null); setImportState(null) },
1888
+ }, txt.cancel))),
1889
+ importState && importState !== 'bad' && h('span', { className: 'dsh-tp-note', role: 'status', 'aria-live': 'polite' },
1890
+ importState === 'ok' ? txt.importOk : txt.importLegacy),
1563
1891
  h('p', { className: 'dsh-tp-custom-hint' }, txt.configHint),
1564
1892
  ),
1565
1893
  row('md', txt.md, txt.mdSub, seg(MD_MODES, mdMode, props.setMdMode, lb.md, rowId('md'))),
1566
- row('hc', txt.hc, txt.hcSub, seg(HC_MODES, hcMode, props.setHcMode, lb.hc, rowId('hc'))),
1567
1894
  row('scroll', txt.scroll, current === 'mono' ? txt.scrollHintMono : txt.scrollSub, seg(SCROLL_MODES, scrollMode, props.setScrollMode, lb.scroll, rowId('scroll'), current === 'mono')),
1568
1895
  row('control', txt.control, txt.controlSub, seg(CONTROL_MODES, controlMode, props.setControlMode, lb.control, rowId('control'), current === 'mono')),
1569
1896
  ),
@@ -1589,7 +1916,7 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1589
1916
 
1590
1917
  // 开发期接口:palette-dev.mjs 通过它拿到同一套引擎(避免「报表好看、上线不一样」)
1591
1918
  if (DEV_OPT_IN && typeof window !== 'undefined' && typeof window.__DSH_THEME_PALETTE_DEV__ === 'function') {
1592
- window.__DSH_THEME_PALETTE_DEV__({ buildTokens, SCHEMES, SIDEBAR_MODES, TEXT_MODES, RIGHTBAR_MODES, SCROLL_MODES, CONTROL_MODES, MD_MODES, contrast, toOklch, rampsFor, settingsCss: SETTINGS_CSS, cardColors })
1919
+ window.__DSH_THEME_PALETTE_DEV__({ buildTokens, SCHEMES, SIDEBAR_MODES, TEXT_MODES, RIGHTBAR_MODES, SCROLL_MODES, CONTROL_MODES, MD_MODES, HC_MODES, contrast, toOklch, rampsFor, floors, encodeConfig, decodeConfig, customScheme, readCustom, writeCustom, settingsCss: SETTINGS_CSS, cardColors })
1593
1920
  }
1594
1921
 
1595
1922
  return {
@@ -1602,6 +1929,8 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1602
1929
  }
1603
1930
 
1604
1931
  let current = readStored()
1932
+ // 只有升级前已有配置的用户才需要“行为变更”提示
1933
+ const hadConfig = hasStoredConfig()
1605
1934
  let sidebarMode = readSidebarStored()
1606
1935
  let textMode = readTextStored()
1607
1936
  let rightbarMode = readRightbarStored()
@@ -1622,6 +1951,7 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1622
1951
  const controlListeners = new Set()
1623
1952
  const mdListeners = new Set()
1624
1953
  const hcListeners = new Set()
1954
+ const customListeners = new Set()
1625
1955
 
1626
1956
  const getCurrent = () => current
1627
1957
  const getSidebarMode = () => sidebarMode
@@ -1671,6 +2001,27 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1671
2001
  return () => { hcListeners.delete(fn) }
1672
2002
  }
1673
2003
  const emitHc = () => { for (const fn of hcListeners) { try { fn() } catch (err) { /* 同理 */ } } }
2004
+ // 自定义色的独立订阅:三个色变了但 current 没变,不能靠配色订阅触发重渲染
2005
+ const subscribeCustom = (fn) => {
2006
+ customListeners.add(fn)
2007
+ return () => { customListeners.delete(fn) }
2008
+ }
2009
+ const emitCustom = () => { for (const fn of customListeners) { try { fn() } catch (err) { /* 同理 */ } } }
2010
+
2011
+ // 升级提示:每个版本只出现一次;只读写 notice 键,不碰配色设置
2012
+ let noticeSeenVersion = readNoticeSeen()
2013
+ const noticeListeners = new Set()
2014
+ const subscribeNotice = (fn) => {
2015
+ noticeListeners.add(fn)
2016
+ return () => { noticeListeners.delete(fn) }
2017
+ }
2018
+ const getNoticePending = () => hadConfig && noticeSeenVersion !== PLUGIN_VERSION
2019
+ const emitNotice = () => { for (const fn of noticeListeners) { try { fn() } catch (err) { /* 同理 */ } } }
2020
+ const dismissNotice = () => {
2021
+ noticeSeenVersion = PLUGIN_VERSION
2022
+ writeNoticeSeen(noticeSeenVersion)
2023
+ emitNotice()
2024
+ }
1674
2025
 
1675
2026
  let disposeLayer = () => {}
1676
2027
  /** 按当前配色 + 侧栏档位重建覆盖层(换配色与换侧栏档位走同一条路)。 */
@@ -1760,7 +2111,8 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1760
2111
  const run = () => {
1761
2112
  const next = probeApplied(tokens)
1762
2113
  if (next === null) return
1763
- if (health !== null && health.ok === next.ok && health.checked === next.checked) return
2114
+ if (health !== null && health.ok === next.ok && health.checked === next.checked
2115
+ && String(health.mismatched) === String(next.mismatched)) return
1764
2116
  health = { ...next, reapplies: reapplyCount }
1765
2117
  if (!next.ok) console.warn('[dsh-theme-palette] 有 token 没写进 body,宿主可能改名了')
1766
2118
  emitHealth()
@@ -1817,10 +2169,10 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1817
2169
  applyAll()
1818
2170
  emitMd()
1819
2171
  }
1820
- /** 导入:整份校验通过才落地;返回 true/false 供 UI 显示结果 */
2172
+ /** 导入:整份校验通过才落地;返回结果供 UI 显示(旧码不含自定义三色时 hasCustom=false) */
1821
2173
  const applyConfigCode = (code) => {
1822
2174
  const cfg = decodeConfig(code)
1823
- if (!cfg) return false
2175
+ if (!cfg) return { ok: false, hasCustom: false }
1824
2176
  current = cfg.palette
1825
2177
  sidebarMode = cfg.sidebar
1826
2178
  textMode = cfg.text
@@ -1830,6 +2182,7 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1830
2182
  mdMode = cfg.md
1831
2183
  hcMode = cfg.hc
1832
2184
  langPref = cfg.lang
2185
+ if (cfg.custom) writeCustom(cfg.custom)
1833
2186
  writeStored(current)
1834
2187
  writeSidebarStored(sidebarMode)
1835
2188
  writeTextStored(textMode)
@@ -1841,21 +2194,51 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1841
2194
  writeLangPref(langPref)
1842
2195
  applyAll('import')
1843
2196
  emit(); emitSidebar(); emitText(); emitRightbar(); emitScroll(); emitControl(); emitMd(); emitHc(); emitLocale()
1844
- return true
2197
+ if (cfg.custom) emitCustom()
2198
+ return { ok: true, hasCustom: !!cfg.custom }
1845
2199
  }
1846
2200
  const getCustomColors = () => readCustom()
2201
+ /**
2202
+ * 解释性校正:告诉用户“你的取色被如何处理了”,而不是让颜色悄悄变掉。
2203
+ * 只读、可重复调用;ramp 缓存保证同一组设置不会重复计算。
2204
+ */
2205
+ const getCustomExplain = () => {
2206
+ const colors = readCustom()
2207
+ const scheme = customScheme(colors)
2208
+ const hc = hcMode === 'on'
2209
+ const t = buildTokens(scheme, sidebarMode, textMode, rightbarMode, scrollMode, controlMode, mdMode, hc)
2210
+ const link = t['--dsw-alias-link'].light
2211
+ const base = t['--dsw-static-deepseek-500'].light
2212
+ const surfaces = ['150', '60', '00'].map((k) => t['--dsw-static-neutral-bluish-' + k].light)
2213
+ const ratio = Math.min(...surfaces.map((bg) => contrast(link, bg)))
2214
+ const acL = toOklch(colors.accent)[0]
2215
+ const accentTo = Math.min(0.64, Math.max(0.30, acL))
2216
+ return {
2217
+ accentClamped: Math.abs(acL - accentTo) > 0.005,
2218
+ accentFrom: Math.round(acL * 100) / 100,
2219
+ accentTo: Math.round(accentTo * 100) / 100,
2220
+ linkShifted: link.toLowerCase() !== base.toLowerCase(),
2221
+ linkRatio: Math.round(ratio * 100) / 100,
2222
+ linkTarget: floors(hc).link,
2223
+ }
2224
+ }
2225
+ const previewConfigCode = (code) => {
2226
+ const cfg = decodeConfig(code)
2227
+ return cfg ? { ok: true, config: cfg } : { ok: false }
2228
+ }
1847
2229
  const resetCustom = () => {
1848
2230
  writeCustom(Object.assign({}, CUSTOM_DEFAULT))
1849
2231
  applyAll('custom:reset')
1850
- emit()
2232
+ emitCustom()
1851
2233
  }
1852
2234
  const setCustomColor = (field, hex) => {
1853
- if (CUSTOM_FIELDS.indexOf(field) === -1) return
1854
- if (typeof hex !== 'string' || !HEX_RE.test(hex)) return
1855
- const next = Object.assign(readCustom(), { [field]: hex })
2235
+ if (CUSTOM_FIELDS.indexOf(field) === -1) return false
2236
+ if (typeof hex !== 'string' || !HEX_RE.test(hex)) return false
2237
+ const next = Object.assign({}, readCustom(), { [field]: hex.toLowerCase() })
1856
2238
  writeCustom(next)
1857
2239
  applyAll('custom:' + field)
1858
- emit()
2240
+ emitCustom()
2241
+ return true
1859
2242
  }
1860
2243
  const setHcMode = (id) => {
1861
2244
  if (!HC_MODES.some((m) => m.id === id)) return
@@ -1990,6 +2373,12 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1990
2373
  getConfigCode,
1991
2374
  applyConfigCode,
1992
2375
  getCustomColors,
2376
+ getCustomExplain,
2377
+ previewConfigCode,
2378
+ subscribeNotice,
2379
+ getNoticePending,
2380
+ dismissNotice,
2381
+ subscribeCustom,
1993
2382
  setCustomColor,
1994
2383
  resetCustom,
1995
2384
  getLangPref,
package/docs/DESIGN.md CHANGED
@@ -56,6 +56,11 @@
56
56
  高对比档(`HC_MODES`,默认关)把文字下限整体抬到正文 ≥15 / 次文 ≥9 / 三级 ≥7 / 链接 ≥7,
57
57
  并把求解器位移上限从 0.13 放宽到 0.30(低档位下 4.6 → 7 需要 ΔL≈0.2,原上限够不着)。
58
58
 
59
+ > **2026-10-23 反哺修正**:深色模块面 `800` 对纯白也只有 10.1–11.0:1,12/15 在该面**物理不可达**。
60
+ > 求解器现在按承载面可达上限钳位(留 7% 色彩余量),`palette-dev.mjs` 同时打印
61
+ > 「实际 / 目标 / 可达」,不可达的配对标「表面上限」而不再算硬失败。深色正文以 `800` 为求解面,
62
+ > 850/875/950 自然更高;caption 也在 800/850 上求解到 AA,不再沿用未求解的 500。
63
+
59
64
  ## 文字着色
60
65
 
61
66
  正文原来只给 0.004 的色度(近中性),视觉上就是「黑字 / 白字」。现在有第三行「文字着色」,
@@ -240,3 +245,12 @@ alpha **浅色与内置一致、深色压到 0.6 倍**(见下):
240
245
  「同一套配色各自适配明暗」就得注册两个 id + 自己盯 `prefers-color-scheme` 反复 `setTheme()`,
241
246
  还会撞三个官方约束(自定义 id 不持久化、`adopt()` 回灌覆盖、dispose 生效中的主题会重置 preference)。
242
247
  所以用更弱的 `ctx.theme.overrideTokens(source, tokens)`:明暗归官方「外观」,插件只换色。
248
+
249
+ ## 设置页的反馈与迁移(2026-10-23)
250
+
251
+ - **解释性校正**:自定义面板在强调色被归一化、或链接被压暗时显示实际数值(`{from} → {to}`、`linkRatio:1`),
252
+ 让“颜色为什么变了”可解释,而不是静默改写。
253
+ - **导入预览**:短码先解码成摘要(配色 / 侧栏 / 文字 / 高对比 / 三色),确认后才写入;预览不触碰存储。
254
+ - **升级提示**:`notice.v1` 只记录“已读版本”,与配色存储完全分离;每个版本出现一次,可关闭,不阻断操作。
255
+ - **契约测试**:`tools/check-host-contract.mjs` 断言插件依赖的 token 与 `data-rightbar-col` / `_markdown_` /
256
+ `md-code-block` 仍存在于当前宿主构建;找不到 asar 时 SKIP。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-theme-palette",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "DSH 主题配色:4 套配色 × 明暗两模式,OKLCH 色域安全重建 + 真实承载面对比度求解,可调强度与面板表面。",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -38,6 +38,10 @@
38
38
  "node": "^22.19.0 || >=24.0.0",
39
39
  "dsh": ">=0.2.1-rc.1"
40
40
  },
41
+ "scripts": {
42
+ "check": "node tools/check-audit.mjs && node tools/check-custom.mjs && node tools/check-ui.mjs && node tools/check-host-contract.mjs",
43
+ "report": "node palette-dev.mjs && node palette-dev.mjs --hc"
44
+ },
41
45
  "dsh": {
42
46
  "bundle": {
43
47
  "patch": "./cordis.patch.yml"