dsh-theme-palette 1.5.3 → 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/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.5.3'
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(深色下近白)→ 白底白字。
@@ -807,16 +898,10 @@ window.__ModuleLoader__.load({
807
898
  return pairs
808
899
  }
809
900
 
810
- /* ================= 卡片预览色 ================= */
811
- function swatches(scheme, textMode) {
812
- if (!scheme.tinted) return ['#ffffff', '#e5e5e5', '#0f1115']
813
- const r = rampsFor(scheme, textMode || DEFAULT_TEXT)
814
- return [r.bluish['00'], r.bluish['100'], r.deepseek['500']]
815
- }
816
-
901
+
817
902
 
818
903
  /** 卡片缩略图用的一小组颜色(画布/侧栏/文字/强调),按当前强度档取。 */
819
- function cardColors(scheme, textMode) {
904
+ function cardColors(scheme, textMode, sidebarMode) {
820
905
  const r = rampsFor(scheme, textMode || DEFAULT_TEXT)
821
906
  if (!r) {
822
907
  return {
@@ -824,7 +909,7 @@ window.__ModuleLoader__.load({
824
909
  dark: { canvas: '#151517', side: '#1b1b1c', text: '#f9fafb', accent: '#7aaaff' },
825
910
  }
826
911
  }
827
- const side = sidebarTokens(r, DEFAULT_SIDEBAR, scheme.tinted)
912
+ const side = sidebarTokens(r, sidebarMode || DEFAULT_SIDEBAR, scheme.tinted)
828
913
  return {
829
914
  light: {
830
915
  canvas: r.bluish['00'],
@@ -868,10 +953,15 @@ window.__ModuleLoader__.load({
868
953
  .dsh-tp-head h2{margin:0;font-size:18px;font-weight:600;letter-spacing:.2px}
869
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}
870
955
  .dsh-tp-sub{margin:-6px 0 0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
871
- .dsh-tp-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
872
- @media (max-width:620px){.dsh-tp-grid{grid-template-columns:1fr}}
873
- .dsh-tp-card{position:relative;display:flex;flex-direction:column;gap:8px;text-align:left;font:inherit;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}
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}
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}
874
963
  .dsh-tp-card:hover{background:var(--dsw-alias-interactive-bg-hover)}
964
+ .dsh-tp-card:focus-visible{outline-offset:2px}
875
965
  .dsh-tp-card.is-on{background:var(--dshtp-card-picked,var(--dsw-alias-bg-module-platform));border-color:var(--t-accent-l)}
876
966
  body[data-ds-dark-theme] .dsh-tp-card.is-on{border-color:var(--t-accent-d)}
877
967
  .dsh-tp-thumb{display:flex;height:56px;border-radius:9px;overflow:hidden;border:.5px solid rgba(128,128,128,.28)}
@@ -885,20 +975,21 @@ body[data-ds-dark-theme] .dsh-tp-thumb-main b{background:var(--t-text-d)}
885
975
  .dsh-tp-thumb-main b:nth-of-type(3){width:44%;background:var(--t-accent-l);opacity:.9}
886
976
  body[data-ds-dark-theme] .dsh-tp-thumb-main b:nth-of-type(3){background:var(--t-accent-d)}
887
977
  .dsh-tp-card-meta{display:flex;align-items:baseline;gap:6px;flex-wrap:wrap}
888
- .dsh-tp-card-name{font-size:13.5px;font-weight:500}
889
- .dsh-tp-card-desc{font-size:11.5px;color:var(--dsw-alias-label-tertiary)}
978
+ .dsh-tp-card-name{font-size:14px;font-weight:500}
979
+ .dsh-tp-card-desc{font-size:12px;color:var(--dsw-alias-label-tertiary)}
890
980
  .dsh-tp-check{position:absolute;top:7px;right:7px;width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;line-height:1;background:var(--t-accent-l);color:var(--t-canvas-l);opacity:0;transition:opacity .12s ease}
891
981
  body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--t-canvas-d)}
892
982
  .dsh-tp-card.is-on .dsh-tp-check{opacity:1}
893
- .dsh-tp-row{display:flex;align-items:center;gap:12px;padding:11px 0;border-top:.5px solid var(--dsw-alias-border-l2)}
983
+ .dsh-tp-row{display:flex;align-items:center;gap:12px;padding:11px 0;border-top:.5px solid var(--dsw-alias-border-l2);flex-wrap:wrap}
894
984
  .dsh-tp-row-label{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
895
985
  .dsh-tp-row-label b{font-size:13.5px;font-weight:500}
896
986
  .dsh-tp-row-label span{font-size:11.5px;color:var(--dsw-alias-label-tertiary);line-height:16px}
897
- .dsh-tp-seg{display:inline-flex;gap:2px;padding:2px;border-radius:10px;background:var(--dsw-alias-interactive-bg-hover);flex:none}
987
+ .dsh-tp-seg{display:inline-flex;flex-wrap:wrap;gap:2px;padding:2px;border-radius:10px;background:var(--dsw-alias-interactive-bg-hover);flex:none;max-width:100%}
898
988
  .dsh-tp-seg button{font:inherit;font-size:12.5px;line-height:20px;cursor:pointer;border:0;border-radius:8px;padding:5px 12px;background:transparent;color:var(--dsw-alias-label-secondary);white-space:nowrap;transition:background .12s,color .12s}
899
989
  .dsh-tp-seg button:hover{color:var(--dsw-alias-label-primary)}
900
990
  .dsh-tp-seg button.is-on{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.10)}
901
- @media (max-width:520px){.dsh-tp-row{flex-direction:column;align-items:stretch;gap:8px}.dsh-tp-seg{justify-content:space-between}.dsh-tp-seg button{flex:1;text-align:center}}
991
+ .dsh-tp-seg button:focus-visible{outline-offset:2px}
992
+ .dsh-tp-seg button[disabled]{opacity:.45;cursor:default}
902
993
  .dsh-tp-more{display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:12.5px;color:var(--dsw-alias-label-tertiary);background:0 0;border:0;padding:6px 0 2px;cursor:pointer;align-self:flex-start;transition:color .12s}
903
994
  .dsh-tp-more:hover{color:var(--dsw-alias-label-primary)}
904
995
  .dsh-tp-adv{display:flex;flex-direction:column}
@@ -906,21 +997,29 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
906
997
  .dsh-tp-input::placeholder{color:var(--dsw-alias-label-tertiary)}
907
998
  .dsh-tp-mini{font:inherit;font-size:12.5px;line-height:20px;padding:5px 12px;border-radius:8px;border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);cursor:pointer}
908
999
  .dsh-tp-mini:hover{background:var(--dsw-alias-bg-layer-1)}
909
- .dsh-tp-custom{display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:14px;border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-interactive-bg-hover)}
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)}
910
1001
  .dsh-tp-custom-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
911
1002
  .dsh-tp-custom-head b{font-size:13.5px;font-weight:500}
912
- .dsh-tp-swatches{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
913
- @media (max-width:520px){.dsh-tp-swatches{grid-template-columns:1fr}}
1003
+ .dsh-tp-swatches{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,86px),1fr));gap:10px}
914
1004
  .dsh-tp-swatch{display:flex;flex-direction:column;gap:6px;cursor:pointer}
915
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}
916
1006
  .dsh-tp-color::-webkit-color-swatch-wrapper{padding:3px}
917
1007
  .dsh-tp-color::-webkit-color-swatch{border:none;border-radius:8px}
918
1008
  .dsh-tp-swatch-name{font-size:12px;color:var(--dsw-alias-label-secondary)}
919
1009
  .dsh-tp-hex{font:11.5px/1 ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-tertiary)}
920
- .dsh-tp-custom-hint{margin:0;font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
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)}
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)}
1014
+ .dsh-tp-custom-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary)}
921
1015
  .dsh-tp-note{font-size:11.5px;color:var(--dsw-alias-label-tertiary)}
922
1016
  .dsh-tp-status{margin:0;font-size:11.5px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
923
1017
  .dsh-tp-status.is-bad{color:var(--dsw-alias-state-error-primary)}
1018
+ .dsh-tp-field{display:flex;flex-direction:column;gap:8px;padding:11px 0;border-top:.5px solid var(--dsw-alias-border-l2)}
1019
+ .dsh-tp-field-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1020
+ .dsh-tp-field-title{font-size:13.5px;font-weight:500}
1021
+ .dsh-tp-input{text-overflow:ellipsis}
1022
+ @media (prefers-reduced-motion:reduce){.dsh-tp-card,.dsh-tp-seg button,.dsh-tp-more,.dsh-tp-check,.dsh-tp-mini{transition:none}}
924
1023
  `
925
1024
 
926
1025
  function installRightbarStyle() {
@@ -962,24 +1061,57 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
962
1061
  */
963
1062
  const TOKEN_PROBE = [
964
1063
  '--dsw-static-neutral-bluish-00',
1064
+ '--dsw-static-neutral-bluish-600',
965
1065
  '--dsw-static-neutral-bluish-1000',
1066
+ '--dsw-static-deepseek-500',
966
1067
  '--dsw-alias-link',
967
1068
  '--dsw-specific-sidebar-fill',
968
1069
  '--dsw-alias-label-caption',
969
1070
  ]
970
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
+ */
971
1099
  function probeApplied(tokens) {
972
1100
  try {
973
1101
  if (typeof getComputedStyle !== 'function') return null
974
1102
  const style = getComputedStyle(document.body)
975
1103
  let checked = 0
976
- let ok = true
1104
+ const mismatched = []
977
1105
  for (const name of TOKEN_PROBE) {
978
- if (!(name in tokens)) continue
1106
+ const pair = tokens[name]
1107
+ if (!pair) continue
979
1108
  checked++
980
- 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)
981
1113
  }
982
- return checked === 0 ? null : { ok, checked }
1114
+ return checked === 0 ? null : { ok: mismatched.length === 0, checked, mismatched }
983
1115
  } catch (err) {
984
1116
  return null
985
1117
  }
@@ -994,6 +1126,11 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
994
1126
  return 'mono'
995
1127
  }
996
1128
 
1129
+ /** 是否已经有用户配置:新装用户不需要看“行为变更”提示。 */
1130
+ function hasStoredConfig() {
1131
+ try { return localStorage.getItem(STORE_KEY) !== null } catch (err) { return false }
1132
+ }
1133
+
997
1134
  function writeStored(id) {
998
1135
  try { localStorage.setItem(STORE_KEY, id) } catch (err) { /* 记不住不影响本次生效 */ }
999
1136
  }
@@ -1082,14 +1219,30 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1082
1219
  try { localStorage.setItem(HC_KEY, id) } catch (err) { /* 同理 */ }
1083
1220
  }
1084
1221
 
1085
- /* ---------- 配置导入/导出(反哺 A-2)----------
1086
- * 短码格式:`dshs1.` + base64url(JSON),字段全部用单字母,
1087
- * 导入时逐项对照模式表校验,任何一项不认识就整体拒绝(不猜、不部分应用)。
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`(自定义三色),解码时兼容:旧码仍可导入,只是不带自定义色。
1088
1236
  */
1089
1237
  const CONFIG_PREFIX = 'dshs1.'
1090
1238
 
1091
1239
  function encodeConfig(state) {
1092
- 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
+ }
1093
1246
  try {
1094
1247
  const b64 = btoa(unescape(encodeURIComponent(JSON.stringify(raw))))
1095
1248
  return CONFIG_PREFIX + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
@@ -1118,7 +1271,12 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1118
1271
  hc: hasId(HC_MODES, raw.h) ? raw.h : null,
1119
1272
  lang: raw.l === 'auto' || LOCALES.indexOf(raw.l) !== -1 ? raw.l : null,
1120
1273
  }
1121
- 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
1122
1280
  } catch (err) { return null }
1123
1281
  }
1124
1282
 
@@ -1172,14 +1330,10 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1172
1330
  nav: '主题色',
1173
1331
  hint: '配色与明暗模式独立;切换后立即生效并保存在 localStorage。',
1174
1332
  md: 'Markdown 强调',
1175
- mdHint: '标题与加粗用「正文色混强调色」,比正文更有色;关掉则与正文同色。',
1176
1333
  control: '开关 / 勾选',
1177
- controlHint: '宿主的开关、复选框、单选都压在近黑/近白上;开启后改用强调色。',
1178
1334
  scroll: '滚动条',
1179
- scrollHint: '默认中性灰;「跟随」上配色色相,「强调」用强调色。明暗关系不变。',
1180
1335
  scrollHintMono: '当前是黑白配色,没有色相可跟随,滚动条保持中性灰。',
1181
1336
  rightbar: '右侧栏底色',
1182
- rightbarHint: '右栏本来没有自己的背景,露的是会话区画布色。',
1183
1337
  advanced: '更多选项',
1184
1338
  textSub: '正文与强调的着色程度',
1185
1339
  sidebarSub: '相对会话区的层次',
@@ -1188,42 +1342,43 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1188
1342
  scrollSub: '滚动条与滑轨的色相',
1189
1343
  controlSub: '开关与复选框是否跟强调色',
1190
1344
  langSub: '自动跟随宿主界面语言',
1191
- surface: '面板表面',
1192
- surfaceHint: '决定侧栏与右栏相对会话区是「浮起」还是「凹陷」。',
1193
- ok: '✓ 配色已生效',
1194
1345
  bad: '⚠ 部分 token 未生效(宿主可能改名),请查看控制台',
1195
1346
  healed: '配色层被宿主丢过一次,已自动恢复 ',
1196
1347
  healedSuffix: ' 次',
1197
1348
  text: '强度',
1198
- textHint: '让正文/次级文字带配色色味;三档都满足 AA。',
1199
1349
  sidebar: '左侧栏底色',
1200
- sidebarHint: '让侧栏比会话区更浅(或更深),做出区块层次。',
1201
1350
  lang: '语言',
1202
1351
  hc: '高对比',
1203
1352
  custom: '自定义配色',
1204
- 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: '将覆盖:',
1205
1361
  config: '配置',
1206
- configHint: '导出这台机器的配色选择,或粘贴短码导入;导入会覆盖全部 8 项。',
1362
+ configHint: '导出这台机器的配色选择(含自定义三色),或粘贴短码导入;导入会覆盖全部 9 项。',
1207
1363
  reset: '恢复默认',
1208
1364
  copy: '复制',
1209
1365
  applyImport: '导入',
1210
1366
  copied: '已复制',
1367
+ copyFail: '复制失败,请手选后 Ctrl+C',
1211
1368
  importOk: '已导入',
1369
+ importLegacy: '已导入(旧短码不含自定义三色)',
1212
1370
  importBad: '短码无效,已忽略',
1213
- hcSub: '把文字下限抬到 AAA(正文 ≥15、次文 ≥9、三级 ≥7)',
1371
+ badHex: 'HEX 需为 #RRGGBB',
1372
+ hcSub: '尽量把文字下限抬到 AAA(正文 ≥15、次文 ≥9、三级 ≥7);深色模块面的上限受表面亮度限制。',
1214
1373
  },
1215
1374
  'zh-TW': {
1216
1375
  nav: '主題色',
1217
1376
  hint: '配色與明暗模式獨立;切換後立即生效並存在 localStorage。',
1218
1377
  md: 'Markdown 強調',
1219
- mdHint: '標題與粗體用「本文色混強調色」,比本文更有色;關掉則與本文同色。',
1220
1378
  control: '開關 / 勾選',
1221
- controlHint: '宿主開關、核取方塊、單選都用近黑/近白;開啟後改用強調色。',
1222
1379
  scroll: '捲軸',
1223
- scrollHint: '預設中性灰;「跟隨」上配色色相,「強調」用強調色。明暗關係不變。',
1224
1380
  scrollHintMono: '目前是黑白配色,沒有色相可跟隨,捲軸保持中性灰。',
1225
1381
  rightbar: '右側欄底色',
1226
- rightbarHint: '右欄本來沒有自己的背景,露的是會話區畫布色。',
1227
1382
  advanced: '更多選項',
1228
1383
  textSub: '本文與強調的著色程度',
1229
1384
  sidebarSub: '相對會話區的層次',
@@ -1232,42 +1387,43 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1232
1387
  scrollSub: '捲軸與軌道的色相',
1233
1388
  controlSub: '開關與核取方塊是否跟強調色',
1234
1389
  langSub: '自動跟隨宿主介面語言',
1235
- surface: '面板表面',
1236
- surfaceHint: '決定側欄與右欄相對會話區是「浮起」還是「凹陷」。',
1237
- ok: '✓ 配色已生效',
1238
1390
  bad: '⚠ 部分 token 未生效(宿主可能改名),請看主控台',
1239
1391
  healed: '配色層被宿主丟棄過一次,已自動恢復 ',
1240
1392
  healedSuffix: ' 次',
1241
1393
  text: '強度',
1242
- textHint: '讓本文/次級文字帶配色色味;三檔都符合 AA。',
1243
1394
  sidebar: '左側欄底色',
1244
- sidebarHint: '讓側欄比會話區更淺(或更深),做出區塊層次。',
1245
1395
  lang: '語言',
1246
1396
  hc: '高對比',
1247
1397
  custom: '自訂配色',
1248
- 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: '將覆蓋:',
1249
1406
  config: '設定',
1250
- configHint: '匯出這台機器的配色選擇,或貼上短碼匯入;匯入會覆蓋全部 8 項。',
1407
+ configHint: '匯出這台機器的配色選擇(含自訂三色),或貼上短碼匯入;匯入會覆蓋全部 9 項。',
1251
1408
  reset: '恢復預設',
1252
1409
  copy: '複製',
1253
1410
  applyImport: '匯入',
1254
1411
  copied: '已複製',
1412
+ copyFail: '複製失敗,請手選後 Ctrl+C',
1255
1413
  importOk: '已匯入',
1414
+ importLegacy: '已匯入(舊短碼不含自訂三色)',
1256
1415
  importBad: '短碼無效,已忽略',
1257
- hcSub: '把文字下限抬到 AAA(正文 ≥15、次文 ≥9、三級 ≥7)',
1416
+ badHex: 'HEX 需為 #RRGGBB',
1417
+ hcSub: '盡量把文字下限抬到 AAA(正文 ≥15、次文 ≥9、三級 ≥7);深色模組面的上限受表面亮度限制。',
1258
1418
  },
1259
1419
  en: {
1260
1420
  nav: 'Color theme',
1261
1421
  hint: 'Independent of light/dark mode. Applies immediately and is saved to localStorage.',
1262
1422
  md: 'Markdown emphasis',
1263
- mdHint: 'Headings and bold mix the accent into body colour; off keeps them identical to body text.',
1264
1423
  control: 'Switches / checks',
1265
- controlHint: 'Switches, checkboxes and radios use near-black/white in the host. Turn this on to use the accent colour.',
1266
1424
  scroll: 'Scrollbars',
1267
- scrollHint: 'Neutral grey by default. Tint follows the palette hue; Accent uses the accent colour. Lightness is unchanged.',
1268
1425
  scrollHintMono: 'Monochrome has no hue to follow; scrollbars stay neutral.',
1269
1426
  rightbar: 'Right panel surface',
1270
- rightbarHint: 'The right panel has no fill of its own; it shows the canvas.',
1271
1427
  advanced: 'More options',
1272
1428
  textSub: 'How strongly text follows the palette',
1273
1429
  sidebarSub: 'Layer relative to the conversation',
@@ -1276,42 +1432,43 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1276
1432
  scrollSub: 'Hue of the scrollbar',
1277
1433
  controlSub: 'Switches and checkboxes',
1278
1434
  langSub: 'Follow the host interface language',
1279
- surface: 'Panel surfaces',
1280
- surfaceHint: 'Whether the sidebars sit above or below the conversation area.',
1281
- ok: '✓ Palette applied',
1282
1435
  bad: '⚠ Some tokens did not apply (host may have renamed them); check the console',
1283
1436
  healed: 'Palette layer was dropped by the host; restored ',
1284
1437
  healedSuffix: 'x',
1285
1438
  text: 'Strength',
1286
- textHint: 'Tints body and secondary text toward the palette; all three levels meet AA.',
1287
1439
  sidebar: 'Sidebar surface',
1288
- sidebarHint: 'Makes the sidebar lighter (or deeper) than the conversation area.',
1289
1440
  lang: 'Language',
1290
1441
  hc: 'High contrast',
1291
1442
  custom: 'Custom palette',
1292
- 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:',
1293
1451
  config: 'Config',
1294
- 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.',
1295
1453
  reset: 'Reset',
1296
1454
  copy: 'Copy',
1297
1455
  applyImport: 'Import',
1298
1456
  copied: 'Copied',
1457
+ copyFail: 'Copy failed — select the code and press Ctrl+C',
1299
1458
  importOk: 'Imported',
1459
+ importLegacy: 'Imported (old code has no custom colours)',
1300
1460
  importBad: 'Invalid code, ignored',
1301
- 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.',
1302
1463
  },
1303
1464
  ja: {
1304
1465
  nav: 'カラーテーマ',
1305
1466
  hint: 'ライト/ダークとは独立です。変更は即時反映され、localStorage に保存されます。',
1306
1467
  md: 'Markdown の強調',
1307
- mdHint: '見出しと太字にアクセント色を混ぜます。オフにすると本文と同じ色になります。',
1308
1468
  control: 'スイッチ / チェック',
1309
- controlHint: 'ホストのスイッチ・チェックボックスはほぼ白黒です。オンにするとアクセント色になります。',
1310
1469
  scroll: 'スクロールバー',
1311
- scrollHint: '既定はニュートラルグレー。「追従」でテーマの色相、「アクセント」でアクセント色になります。',
1312
1470
  scrollHintMono: 'モノクロには色相がないため、スクロールバーはグレーのままです。',
1313
1471
  rightbar: '右パネルの背景',
1314
- rightbarHint: '右パネルは独自の背景を持たず、キャンバス色が見えています。',
1315
1472
  advanced: 'その他のオプション',
1316
1473
  textSub: '本文と強調の色づき具合',
1317
1474
  sidebarSub: '会話エリアとの段差',
@@ -1320,29 +1477,34 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1320
1477
  scrollSub: 'スクロールバーの色相',
1321
1478
  controlSub: 'スイッチとチェックの色',
1322
1479
  langSub: 'ホストの表示言語に合わせます',
1323
- surface: 'パネル表面',
1324
- surfaceHint: 'サイドバーが会話エリアより浮くか沈むかを決めます。',
1325
- ok: '✓ 配色を適用しました',
1326
1480
  bad: '⚠ 一部の token が適用されていません(ホスト側で改名された可能性)。コンソールを確認してください',
1327
1481
  healed: '配色レイヤーがホストに破棄されたため、自動で復元しました(',
1328
1482
  healedSuffix: ' 回)',
1329
1483
  text: '強さ',
1330
- textHint: '本文と副次テキストをテーマ色に寄せます。3 段階とも AA を満たします。',
1331
1484
  sidebar: '左サイドバーの背景',
1332
- sidebarHint: 'サイドバーを会話エリアより明るく(または暗く)して段差を作ります。',
1333
1485
  lang: '言語',
1334
1486
  hc: 'ハイコントラスト',
1335
1487
  custom: 'カスタム配色',
1336
- 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: '上書きする内容:',
1337
1496
  config: '設定の共有',
1338
- configHint: 'この端末の配色設定を書き出す/短いコードを貼り付けて読み込みます(8 項目すべて上書き)。',
1497
+ configHint: 'この端末の配色設定(カスタム 3 色を含む)を書き出す/短いコードを貼り付けて読み込みます(9 項目すべて上書き)。',
1339
1498
  reset: 'リセット',
1340
1499
  copy: 'コピー',
1341
1500
  applyImport: '読み込み',
1342
1501
  copied: 'コピーしました',
1502
+ copyFail: 'コピーできません。選択して Ctrl+C してください',
1343
1503
  importOk: '読み込みました',
1504
+ importLegacy: '読み込みました(旧コードにカスタム 3 色は含まれません)',
1344
1505
  importBad: '無効なコードです',
1345
- hcSub: '本文の下限を AAA に引き上げます(本文 ≥15、副次 ≥9、三次 ≥7)',
1506
+ badHex: 'HEX は #RRGGBB 形式で入力してください',
1507
+ hcSub: '本文の下限を AAA に近づけます(本文 ≥15、副次 ≥9、三次 ≥7)。暗いモジュール面は表面の明るさで上限が決まります。',
1346
1508
  },
1347
1509
  }
1348
1510
 
@@ -1392,8 +1554,8 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1392
1554
  desc: { mono: '色なし · 既定', warm: 'クリーム + ブラウン', indigo: 'クールホワイト + ブルー', moss: 'グレーグリーン', custom: '3 色から派生' },
1393
1555
  text: { neutral: '控えめ', tinted: '標準', deep: '強め' },
1394
1556
  sidebar: { light: '明るく', match: '同じ', deep: '暗く' },
1395
- rightbar: { sidebar: 'サイドバーに合わせる', canvas: 'キャンバスに従う' },
1396
- scroll: { neutral: 'ニュートラル', tinted: '追従', accent: 'アクセント' },
1557
+ rightbar: { sidebar: 'サイドバー', canvas: 'キャンバス' },
1558
+ scroll: { neutral: '無彩', tinted: '追従', accent: '強調' },
1397
1559
  control: { accent: 'アクセント', mono: 'モノクロ' },
1398
1560
  md: { on: '色づけ', off: '本文と同じ' },
1399
1561
  lang: { auto: '自動' },
@@ -1403,6 +1565,11 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1403
1565
  }
1404
1566
 
1405
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
+
1406
1573
  function PaletteSection(props) {
1407
1574
  const current = React.useSyncExternalStore(props.subscribe, props.getCurrent)
1408
1575
  const sidebarMode = React.useSyncExternalStore(props.subscribeSidebar, props.getSidebarMode)
@@ -1415,36 +1582,98 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1415
1582
  const health = React.useSyncExternalStore(props.subscribeHealth, props.getHealth)
1416
1583
  const [importText, setImportText] = React.useState('')
1417
1584
  const [importState, setImportState] = React.useState(null)
1585
+ const [importPreview, setImportPreview] = React.useState(null)
1586
+ const [copyState, setCopyState] = React.useState(null)
1418
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)
1419
1595
  const locale = React.useSyncExternalStore(props.subscribeLocale, props.getLocale)
1420
1596
  const langPref = React.useSyncExternalStore(props.subscribeLocale, props.getLangPref)
1597
+ const langAttr = langPref !== 'auto'
1598
+ ? langPref
1599
+ : ((typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) || 'zh-CN')
1421
1600
  const txt = TEXT[locale] || TEXT['zh-CN']
1422
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
+ }
1423
1607
 
1424
- const seg = (modes, value, onPick, labels) => h(
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
+ }
1627
+
1628
+ // A6:读屏需要知道"这是哪个设置的选项组";单选用 radio 语义更准
1629
+ const seg = (modes, value, onPick, labels, labelId, disabled) => h(
1425
1630
  'div',
1426
- { className: 'dsh-tp-seg', role: 'group' },
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
+ },
1427
1638
  modes.map((mode) => h(
1428
1639
  'button',
1429
1640
  {
1430
1641
  key: mode.id,
1431
1642
  type: 'button',
1643
+ role: 'radio',
1432
1644
  className: mode.id === value ? 'is-on' : undefined,
1433
- 'aria-pressed': mode.id === value,
1434
- onClick: () => onPick(mode.id),
1645
+ 'aria-checked': mode.id === value,
1646
+ 'aria-disabled': disabled ? 'true' : undefined,
1647
+ // roving tabindex:只有选中项在 Tab 序列里,其余靠方向键进入
1648
+ tabIndex: mode.id === value ? 0 : -1,
1649
+ disabled: disabled === true,
1650
+ onClick: () => { if (!disabled) onPick(mode.id) },
1435
1651
  },
1436
1652
  (labels && labels[mode.id]) || mode.label,
1437
1653
  )),
1438
1654
  )
1439
1655
 
1440
- const row = (titleText, subText, control) => h(
1656
+ const rowId = (key) => 'dsh-tp-row-' + key
1657
+ const row = (key, titleText, subText, control) => h(
1441
1658
  'div',
1442
1659
  { className: 'dsh-tp-row' },
1443
- h('div', { className: 'dsh-tp-row-label' }, h('b', null, titleText), h('span', null, subText)),
1660
+ h('div', { className: 'dsh-tp-row-label', id: rowId(key) + '-desc' },
1661
+ h('b', { id: rowId(key) }, titleText), h('span', null, subText)),
1444
1662
  control,
1445
1663
  )
1446
1664
 
1447
- 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
+ }
1448
1677
  const customPanel = current === 'custom' && h(
1449
1678
  'div',
1450
1679
  { className: 'dsh-tp-custom' },
@@ -1456,25 +1685,68 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1456
1685
  onClick: () => props.resetCustom(),
1457
1686
  }, txt.reset)),
1458
1687
  h('div', { className: 'dsh-tp-swatches' },
1459
- ['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
+ },
1460
1700
  h('input', {
1461
1701
  type: 'color',
1702
+ id: colorId,
1462
1703
  className: 'dsh-tp-color',
1463
1704
  value: customColors[field],
1464
1705
  // input 是"拖动过程中"就触发,change 是"确认后"触发;两个都接,
1465
1706
  // 拖动时实时看到整页变化,松手/确认后不会再跳一次
1466
1707
  onInput: (e) => props.setCustomColor(field, e.target.value),
1467
1708
  onChange: (e) => props.setCustomColor(field, e.target.value),
1468
- 'aria-label': (lb.custom && lb.custom[field]) || field,
1709
+ 'aria-label': name,
1710
+ }),
1711
+ h('label', { htmlFor: hexId, className: 'dsh-tp-swatch-name' }, name),
1712
+ h('input', {
1713
+ id: hexId,
1714
+ className: 'dsh-tp-hexinput',
1715
+ value: hexDraft[field] !== undefined ? hexDraft[field] : customColors[field],
1716
+ spellCheck: false,
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
+ },
1469
1733
  }),
1470
- h('span', { className: 'dsh-tp-swatch-name' }, (lb.custom && lb.custom[field]) || field),
1471
- h('code', { className: 'dsh-tp-hex' }, customColors[field])))),
1734
+ invalid && h('span', { id: errId, className: 'dsh-tp-hexerror', role: 'alert' }, txt.badHex))
1735
+ })),
1472
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
+ ),
1473
1745
  )
1474
1746
 
1475
1747
  const card = (scheme) => {
1476
1748
  const picked = scheme.id === current
1477
- const c = cardColors(scheme, textMode)
1749
+ const c = cardColors(scheme, textMode, sidebarMode)
1478
1750
  return h(
1479
1751
  'button',
1480
1752
  {
@@ -1502,16 +1774,20 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1502
1774
 
1503
1775
  return h(
1504
1776
  'section',
1505
- { className: 'dsh-tp' },
1777
+ { className: 'dsh-tp', lang: langAttr },
1506
1778
  h('div', { className: 'dsh-tp-head' },
1507
1779
  h('h2', null, txt.nav),
1508
1780
  h('span', { className: 'dsh-tp-badge' }, 'v' + PLUGIN_VERSION)),
1509
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)),
1510
1785
  h('div', { className: 'dsh-tp-grid' }, SCHEMES.map(card)),
1511
1786
  customPanel,
1512
- row(txt.text, txt.textSub, seg(TEXT_MODES, textMode, props.setTextMode, lb.text)),
1513
- row(txt.sidebar, txt.sidebarSub, seg(SIDEBAR_MODES, sidebarMode, props.setSidebarMode, lb.sidebar)),
1514
- row(txt.rightbar, txt.rightbarSub, seg(RIGHTBAR_MODES, rightbarMode, props.setRightbarMode, lb.rightbar)),
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'))),
1789
+ row('sidebar', txt.sidebar, txt.sidebarSub, seg(SIDEBAR_MODES, sidebarMode, props.setSidebarMode, lb.sidebar, rowId('sidebar'))),
1790
+ row('rightbar', txt.rightbar, txt.rightbarSub, seg(RIGHTBAR_MODES, rightbarMode, props.setRightbarMode, lb.rightbar, rowId('rightbar'))),
1515
1791
  health && !health.ok && h('p', { className: 'dsh-tp-status is-bad' }, txt.bad),
1516
1792
  health && health.ok && health.reapplies > 0 && h(
1517
1793
  'p',
@@ -1527,50 +1803,96 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1527
1803
  advanced && h(
1528
1804
  'div',
1529
1805
  { className: 'dsh-tp-adv' },
1530
- row(txt.lang, txt.langSub, seg(LANG_OPTIONS, langPref, props.setLangPref, lb.lang)),
1806
+ row('lang', txt.lang, txt.langSub, seg(LANG_OPTIONS, langPref, props.setLangPref, lb.lang, rowId('lang'))),
1531
1807
  h(
1532
1808
  'div',
1533
- { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
1534
- h('div', { style: { fontSize: 14, lineHeight: '22px' } }, txt.config),
1535
- h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
1809
+ { className: 'dsh-tp-field' },
1810
+ h('div', { className: 'dsh-tp-field-title', id: rowId('config') }, txt.config),
1811
+ h('div', { className: 'dsh-tp-field-row' },
1536
1812
  h('input', {
1537
1813
  className: 'dsh-tp-input',
1538
1814
  readOnly: true,
1539
1815
  value: props.getConfigCode(),
1816
+ title: props.getConfigCode(),
1540
1817
  onFocus: (e) => { try { e.target.select() } catch (err) { /* 忽略 */ } },
1541
- 'aria-label': txt.config,
1818
+ 'aria-label': txt.config + ' — ' + txt.copy,
1542
1819
  }),
1543
1820
  h('button', {
1544
1821
  type: 'button',
1545
1822
  className: 'dsh-tp-mini',
1546
- onClick: () => {
1823
+ onClick: async () => {
1547
1824
  const code = props.getConfigCode()
1548
1825
  try {
1549
- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code)
1550
- } catch (err) { /* 剪贴板不可用时靠手选 */ }
1551
- setImportState('copied')
1826
+ if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') throw new Error('no clipboard api')
1827
+ await navigator.clipboard.writeText(code)
1828
+ setCopyState('copied')
1829
+ } catch (err) {
1830
+ // 剪贴板可能被拒(无安全上下文/权限);不能谎报成功
1831
+ setCopyState('fail')
1832
+ }
1552
1833
  },
1553
- }, txt.copy)),
1554
- h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
1834
+ }, txt.copy),
1835
+ copyState && h('span', { className: 'dsh-tp-note', role: 'status', 'aria-live': 'polite' },
1836
+ copyState === 'copied' ? txt.copied : txt.copyFail)),
1837
+ h('div', { className: 'dsh-tp-field-row' },
1555
1838
  h('input', {
1556
1839
  className: 'dsh-tp-input',
1557
1840
  placeholder: 'dshs1.…',
1558
1841
  value: importText,
1559
- onChange: (e) => { setImportText(e.target.value); setImportState(null) },
1842
+ 'aria-label': txt.applyImport,
1843
+ onChange: (e) => {
1844
+ setImportText(e.target.value)
1845
+ setImportState(null)
1846
+ setImportPreview(null)
1847
+ },
1560
1848
  }),
1561
1849
  h('button', {
1562
1850
  type: 'button',
1563
1851
  className: 'dsh-tp-mini',
1564
- onClick: () => setImportState(props.applyConfigCode(importText) ? 'ok' : 'bad'),
1565
- }, txt.applyImport),
1566
- importState && h('span', { className: 'dsh-tp-note' },
1567
- importState === 'ok' ? txt.importOk : importState === 'copied' ? txt.copied : txt.importBad)),
1568
- h('p', { style: { margin: 0, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary)' } }, txt.configHint),
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),
1891
+ h('p', { className: 'dsh-tp-custom-hint' }, txt.configHint),
1569
1892
  ),
1570
- row(txt.md, txt.mdSub, seg(MD_MODES, mdMode, props.setMdMode, lb.md)),
1571
- row(txt.hc, txt.hcSub, seg(HC_MODES, hcMode, props.setHcMode, lb.hc)),
1572
- row(txt.scroll, current === 'mono' ? txt.scrollHintMono : txt.scrollSub, seg(SCROLL_MODES, scrollMode, props.setScrollMode, lb.scroll)),
1573
- row(txt.control, txt.controlSub, seg(CONTROL_MODES, controlMode, props.setControlMode, lb.control)),
1893
+ row('md', txt.md, txt.mdSub, seg(MD_MODES, mdMode, props.setMdMode, lb.md, rowId('md'))),
1894
+ row('scroll', txt.scroll, current === 'mono' ? txt.scrollHintMono : txt.scrollSub, seg(SCROLL_MODES, scrollMode, props.setScrollMode, lb.scroll, rowId('scroll'), current === 'mono')),
1895
+ row('control', txt.control, txt.controlSub, seg(CONTROL_MODES, controlMode, props.setControlMode, lb.control, rowId('control'), current === 'mono')),
1574
1896
  ),
1575
1897
  )
1576
1898
  }
@@ -1594,7 +1916,7 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1594
1916
 
1595
1917
  // 开发期接口:palette-dev.mjs 通过它拿到同一套引擎(避免「报表好看、上线不一样」)
1596
1918
  if (DEV_OPT_IN && typeof window !== 'undefined' && typeof window.__DSH_THEME_PALETTE_DEV__ === 'function') {
1597
- 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 })
1598
1920
  }
1599
1921
 
1600
1922
  return {
@@ -1607,6 +1929,8 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1607
1929
  }
1608
1930
 
1609
1931
  let current = readStored()
1932
+ // 只有升级前已有配置的用户才需要“行为变更”提示
1933
+ const hadConfig = hasStoredConfig()
1610
1934
  let sidebarMode = readSidebarStored()
1611
1935
  let textMode = readTextStored()
1612
1936
  let rightbarMode = readRightbarStored()
@@ -1627,6 +1951,7 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1627
1951
  const controlListeners = new Set()
1628
1952
  const mdListeners = new Set()
1629
1953
  const hcListeners = new Set()
1954
+ const customListeners = new Set()
1630
1955
 
1631
1956
  const getCurrent = () => current
1632
1957
  const getSidebarMode = () => sidebarMode
@@ -1676,6 +2001,27 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1676
2001
  return () => { hcListeners.delete(fn) }
1677
2002
  }
1678
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
+ }
1679
2025
 
1680
2026
  let disposeLayer = () => {}
1681
2027
  /** 按当前配色 + 侧栏档位重建覆盖层(换配色与换侧栏档位走同一条路)。 */
@@ -1765,7 +2111,8 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1765
2111
  const run = () => {
1766
2112
  const next = probeApplied(tokens)
1767
2113
  if (next === null) return
1768
- 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
1769
2116
  health = { ...next, reapplies: reapplyCount }
1770
2117
  if (!next.ok) console.warn('[dsh-theme-palette] 有 token 没写进 body,宿主可能改名了')
1771
2118
  emitHealth()
@@ -1822,10 +2169,10 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1822
2169
  applyAll()
1823
2170
  emitMd()
1824
2171
  }
1825
- /** 导入:整份校验通过才落地;返回 true/false 供 UI 显示结果 */
2172
+ /** 导入:整份校验通过才落地;返回结果供 UI 显示(旧码不含自定义三色时 hasCustom=false) */
1826
2173
  const applyConfigCode = (code) => {
1827
2174
  const cfg = decodeConfig(code)
1828
- if (!cfg) return false
2175
+ if (!cfg) return { ok: false, hasCustom: false }
1829
2176
  current = cfg.palette
1830
2177
  sidebarMode = cfg.sidebar
1831
2178
  textMode = cfg.text
@@ -1835,6 +2182,7 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1835
2182
  mdMode = cfg.md
1836
2183
  hcMode = cfg.hc
1837
2184
  langPref = cfg.lang
2185
+ if (cfg.custom) writeCustom(cfg.custom)
1838
2186
  writeStored(current)
1839
2187
  writeSidebarStored(sidebarMode)
1840
2188
  writeTextStored(textMode)
@@ -1846,21 +2194,51 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1846
2194
  writeLangPref(langPref)
1847
2195
  applyAll('import')
1848
2196
  emit(); emitSidebar(); emitText(); emitRightbar(); emitScroll(); emitControl(); emitMd(); emitHc(); emitLocale()
1849
- return true
2197
+ if (cfg.custom) emitCustom()
2198
+ return { ok: true, hasCustom: !!cfg.custom }
1850
2199
  }
1851
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
+ }
1852
2229
  const resetCustom = () => {
1853
2230
  writeCustom(Object.assign({}, CUSTOM_DEFAULT))
1854
2231
  applyAll('custom:reset')
1855
- emit()
2232
+ emitCustom()
1856
2233
  }
1857
2234
  const setCustomColor = (field, hex) => {
1858
- if (CUSTOM_FIELDS.indexOf(field) === -1) return
1859
- if (typeof hex !== 'string' || !HEX_RE.test(hex)) return
1860
- 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() })
1861
2238
  writeCustom(next)
1862
2239
  applyAll('custom:' + field)
1863
- emit()
2240
+ emitCustom()
2241
+ return true
1864
2242
  }
1865
2243
  const setHcMode = (id) => {
1866
2244
  if (!HC_MODES.some((m) => m.id === id)) return
@@ -1995,6 +2373,12 @@ body[data-ds-dark-theme] .dsh-tp-check{background:var(--t-accent-d);color:var(--
1995
2373
  getConfigCode,
1996
2374
  applyConfigCode,
1997
2375
  getCustomColors,
2376
+ getCustomExplain,
2377
+ previewConfigCode,
2378
+ subscribeNotice,
2379
+ getNoticePending,
2380
+ dismissNotice,
2381
+ subscribeCustom,
1998
2382
  setCustomColor,
1999
2383
  resetCustom,
2000
2384
  getLangPref,