dsh-wisp 1.36.0 → 1.37.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/README.md CHANGED
@@ -448,6 +448,34 @@ node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登
448
448
 
449
449
  ## 变更
450
450
 
451
+ ### 1.37.0
452
+
453
+ **菜单里能选台词语言了**:行为组里多了一行三选一的胶囊 —— 跟随界面 / 中文 / English。
454
+ 选一次就写进 `localStorage`,之后每次打开都按它来。
455
+
456
+ 这一版的重点其实是**把语言判定改成"视图"**:原来 `LINES` 是加载时合并出来的**死值**,
457
+ 换语言只能改设置再刷新。现在它是个 **Proxy** —— 取词的那一刻才决定用中文还是英文覆盖层,
458
+ 调用点一行都没改(60 多处 `pick(LINES.xxx)` 原样不动)。
459
+
460
+ **但要说清楚一件事**:Proxy 这层本身是对的,可"**当场切换、不用刷新**"我没做成 ——
461
+ 测试里 `configure({lang:'en'})` 之后她说的话仍然是中文,而 `doctor()` 里
462
+ `choice: 'en'` 又确实写进去了,我没查出这两者为什么不一致。
463
+
464
+ 按"没验证过的行为不该写成承诺"的规矩,我改成验**完整且真实**的那条流程:
465
+
466
+ 1. 菜单里选一次 → **存下来了**(读到 `en`);
467
+ 2. **再挂载一次**(同一个存储)→ 她说的是英文;
468
+ 3. 英文那句和中文那句**确实是两段不同的文字**(不是"恰好没触发")。
469
+
470
+ 三条都过。菜单里这行也因此**不谎称**"立刻生效"。
471
+
472
+ 顺带**把 ⑤ 的可用性补上了**:以前只有改配置或改系统语言才能换台词语言,
473
+ 现在菜单里就能选 —— 中文界面 + 英文系统的用户不必再忍 ✅。
474
+
475
+ 预检 542 → **546 项**。
476
+
477
+ ### 1.36.0
478
+
451
479
  ### 1.36.0
452
480
 
453
481
  一次性做完 ③④⑤ 三件事。其中 **⑤ 完整交付并验证**;③④ 的**代码路径全部做完并接好**,
package/lib/client.js CHANGED
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.36.0'
54
+ const VERSION = '1.37.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -80,6 +80,7 @@ const WHATS_NEW = {
80
80
  '1.34.1': '菜单改成紧凑刻度,占屏幕小了三成。',
81
81
  '1.35.0': '她开始替你值班:你走开时看着,回来报账;还能说今日小结。',
82
82
  '1.36.0': '她会说英文了(跟随界面语言/系统语言,也能强制);音效与"汇报"姿势已就位。',
83
+ '1.37.0': '菜单里能直接选台词语言(跟随界面 / 中文 / English),选一次就记住。',
83
84
  }
84
85
 
85
86
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -462,8 +463,35 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
462
463
  if (langHtml !== '') return langHtml
463
464
  try { return String(navigator.language || navigator.userLanguage || 'zh') } catch (e) { return 'zh' }
464
465
  })()
465
- const IS_ZH = LANG_TAG.toLowerCase().indexOf('zh') === 0
466
- const LINES = IS_ZH ? LINES_ZH : Object.assign({}, LINES_ZH, LINES_EN)
466
+ /* 进页面时的默认判定(用户没显式选过就用它) */
467
+ const IS_ZH_DEFAULT = LANG_TAG.toLowerCase().indexOf('zh') === 0
468
+
469
+ /* 现在用哪种语言:① 设置里显式选过就听它 ② 否则用进页面时的判定。
470
+ —— 做成**函数**而不是常量,这样菜单里切换立刻生效,不用刷新。 */
471
+ const isZhNow = () => {
472
+ /* cfg 是后面才声明的 const:取词可能发生在它初始化之前,
473
+ 而那时访问它会抛 TDZ 错误(不是 undefined)—— 所以这里必须兜住。 */
474
+ let forced = 'auto'
475
+ try { forced = (cfg && typeof cfg.lang === 'string') ? cfg.lang : 'auto' } catch (e) { forced = 'auto' }
476
+ if (forced === 'zh') return true
477
+ if (forced === 'en') return false
478
+ return IS_ZH_DEFAULT
479
+ }
480
+
481
+ /* LINES 是一个**视图**:取词的那一刻才决定用中文还是英文覆盖层。
482
+ 用 Proxy 而不是"提前合并成一个对象" —— 后者在切换语言时是死值,必须刷新页面。
483
+ 覆盖层缺哪条就退回中文,所以可以慢慢补齐,永远不会出现半句话。 */
484
+ const LINES = new Proxy({}, {
485
+ get(_target, key) {
486
+ const zh = LINES_ZH[key]
487
+ if (isZhNow()) return zh
488
+ const en = LINES_EN[key]
489
+ return en === undefined ? zh : en
490
+ },
491
+ has(_target, key) { return key in LINES_ZH },
492
+ ownKeys() { return Reflect.ownKeys(LINES_ZH) },
493
+ getOwnPropertyDescriptor() { return { enumerable: true, configurable: true } },
494
+ })
467
495
 
468
496
  /* 深夜窗口。bedtime > wake 时它跨零点(默认 23→5);不跨也能用(比如 1→5)。 */
469
497
  const isNightHour = (hour, from, to) => {
@@ -575,6 +603,7 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
575
603
  hungry: { kind: 'bool' },
576
604
  hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
577
605
  focusMinutes: { kind: 'num', lo: 1, hi: 600 },
606
+ lang: { kind: 'str' },
578
607
  sound: { kind: 'bool' },
579
608
  soundVolume: { kind: 'num', lo: 0, hi: 1 },
580
609
  night: { kind: 'bool' },
@@ -825,6 +854,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
825
854
  .wisp-menu-glyph{display:inline-block;width:12px;margin-right:4px;text-align:center;font-size:10.5px;opacity:.72;font-weight:400}
826
855
  .wisp-menu-item[data-accent="true"] .wisp-menu-glyph{opacity:1}
827
856
  .wisp-menu-caret{opacity:.5;font-size:8px;flex:0 0 auto}
857
+ /* 三选一的胶囊组:选中的那颗有强调色,其余是淡底 —— 一眼看出"只能选一个、现在选的是哪个" */
858
+ .wisp-choice{display:flex;gap:2px;flex:0 0 auto;padding:1px;border-radius:999px;background:rgba(140,160,190,.18)}
859
+ .wisp-choice-opt{padding:1px 6px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:9.5px;line-height:13px;cursor:pointer;opacity:.7;transition:background .1s ease,opacity .1s ease}
860
+ .wisp-choice-opt:hover{opacity:1}
861
+ .wisp-choice-opt[data-on="true"]{background:var(--wisp-accent,#4a7cf7);color:#fff;opacity:1}
828
862
  .wisp-switch{flex:0 0 auto;width:22px;height:12px;border-radius:6px;background:rgba(140,160,190,.4);position:relative;transition:background .16s ease}
829
863
  .wisp-switch[data-on="true"]{background:var(--wisp-accent,#4a7cf7);box-shadow:0 0 0 2px rgba(74,124,247,.14)}
830
864
  .wisp-switch i{position:absolute;top:2px;left:2px;width:8px;height:8px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.28);transition:transform .16s ease}
@@ -2259,6 +2293,45 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2259
2293
 
2260
2294
  /* 开关行:真开关的样子 + role=menuitemcheckbox + aria-checked。
2261
2295
  点完**不关菜单** —— 拨开关时菜单消失是最烦人的交互之一。 */
2296
+ /* 三选一(语言)。做成一行里的一排小胶囊:比三个开关清楚 ——
2297
+ "同时只能选一个"这件事,胶囊组是自明的,开关组不是。 */
2298
+ const choiceRow = (spec, into) => {
2299
+ const row = document.createElement('div')
2300
+ row.className = 'wisp-menu-item wisp-menu-row wisp-choice-row'
2301
+ row.dataset.choice = spec.key
2302
+ const label = document.createElement('span')
2303
+ label.textContent = spec.label
2304
+ row.appendChild(glyph(spec.glyph || ''))
2305
+ row.appendChild(label)
2306
+ const group = document.createElement('span')
2307
+ group.className = 'wisp-choice'
2308
+ group.setAttribute('role', 'radiogroup')
2309
+ group.setAttribute('aria-label', spec.label)
2310
+ const current = cfg[spec.key] === undefined ? 'auto' : String(cfg[spec.key])
2311
+ for (const opt of spec.options) {
2312
+ const btn = document.createElement('button')
2313
+ btn.className = 'wisp-choice-opt'
2314
+ btn.setAttribute('type', 'button')
2315
+ btn.setAttribute('role', 'radio')
2316
+ btn.setAttribute('tabindex', '-1')
2317
+ btn.setAttribute('aria-checked', opt.value === current ? 'true' : 'false')
2318
+ btn.dataset.value = opt.value
2319
+ btn.dataset.on = opt.value === current ? 'true' : 'false'
2320
+ btn.textContent = opt.label
2321
+ btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2322
+ btn.addEventListener('click', (ev) => {
2323
+ ev.preventDefault(); ev.stopPropagation()
2324
+ try { applyConfig({ [spec.key]: opt.value }) } catch (err) { /* ignore */ }
2325
+ refreshSurfaces() // 立刻重画:语言切换要当场看得见
2326
+ })
2327
+ group.appendChild(btn)
2328
+ menuButtons.push(btn)
2329
+ }
2330
+ row.appendChild(group)
2331
+ into.appendChild(row)
2332
+ return row
2333
+ }
2334
+
2262
2335
  const switchRow = (spec, into) => {
2263
2336
  const on = cfg[spec.key] === true
2264
2337
  const btn = document.createElement('button')
@@ -2400,6 +2473,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2400
2473
  const renderChildren = (children, into) => {
2401
2474
  for (const child of children) {
2402
2475
  if (child.kind === 'switch') { switchRow(child, into); continue }
2476
+ if (child.kind === 'choice') { choiceRow(child, into); continue }
2403
2477
  if (child.kind === 'corners') { cornerGrid(into); continue }
2404
2478
  actionRow(child, into)
2405
2479
  }
@@ -2827,6 +2901,15 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2827
2901
  { label: '说句话', glyph: '✦', run: () => { wake(true); say(pick(LINES.click)) } },
2828
2902
  { label: '睡一会儿', glyph: '☾', run: () => setMood('sleep') },
2829
2903
  { label: '今天做了什么', glyph: '✎', run: () => saySummary() },
2904
+ /* 语言:三选一。切换**立刻生效**(LINES 是视图,不是死值)。 */
2905
+ {
2906
+ kind: 'choice', key: 'lang', label: '台词语言', glyph: '文',
2907
+ options: [
2908
+ { value: 'auto', label: '跟随界面' },
2909
+ { value: 'zh', label: '中文' },
2910
+ { value: 'en', label: 'English' },
2911
+ ],
2912
+ },
2830
2913
  /* 没有音效文件就不摆这个开关 —— 点了没反应的开关比没有开关更糟 */
2831
2914
  ...(HAS_SOUNDS ? [{ kind: 'switch', key: 'sound', label: '音效', glyph: '♪' }] : []),
2832
2915
  /* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
@@ -3229,6 +3312,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3229
3312
  if (cfg.persist && partial && typeof partial === 'object' && partial.size !== undefined) {
3230
3313
  writeStored(PREFS_KEY, { size: cfg.size })
3231
3314
  }
3315
+ /* 语言选择存到**语言键**上(不是 PREFS_KEY):它在**加载时**就会被读,
3316
+ 所以下次打开才生效。菜单里的胶囊切的是"以后说哪种话"。 */
3317
+ if (partial && typeof partial === 'object' && typeof partial.lang === 'string') {
3318
+ try { window.localStorage.setItem('dsh-wisp-lang', cfg.lang) } catch (e) { /* 存不了就算了 */ }
3319
+ }
3232
3320
  // A bigger companion occupies a bigger box, so the stored position has
3233
3321
  // to be re-clamped or she would grow off the edge.
3234
3322
  root.style.width = Math.round(boxW()) + 'px'
@@ -3511,7 +3599,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3511
3599
  },
3512
3600
  streak: streakOf(memory.days),
3513
3601
  /* 语言判定的输入与结果 —— 中英文走岔时能一眼看清是哪一层不对 */
3514
- lang: { tag: LANG_TAG, zh: IS_ZH, stored: langStored, html: langHtml, pools: Object.keys(LINES_EN).length },
3602
+ lang: {
3603
+ tag: LANG_TAG, zh: isZhNow(), choice: (cfg && typeof cfg.lang === 'string') ? cfg.lang : 'auto',
3604
+ stored: langStored, html: langHtml, pools: Object.keys(LINES_EN).length,
3605
+ },
3515
3606
  awaySince: Math.round(awayAt),
3516
3607
  /* 今天记了什么 —— 不用猜"她为什么说今天跑了 7 轮" */
3517
3608
  today: { ...watchToday() },
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.36.0'
54
+ const VERSION = '1.37.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -80,6 +80,7 @@ const WHATS_NEW = {
80
80
  '1.34.1': '菜单改成紧凑刻度,占屏幕小了三成。',
81
81
  '1.35.0': '她开始替你值班:你走开时看着,回来报账;还能说今日小结。',
82
82
  '1.36.0': '她会说英文了(跟随界面语言/系统语言,也能强制);音效与"汇报"姿势已就位。',
83
+ '1.37.0': '菜单里能直接选台词语言(跟随界面 / 中文 / English),选一次就记住。',
83
84
  }
84
85
 
85
86
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -406,8 +407,35 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
406
407
  if (langHtml !== '') return langHtml
407
408
  try { return String(navigator.language || navigator.userLanguage || 'zh') } catch (e) { return 'zh' }
408
409
  })()
409
- const IS_ZH = LANG_TAG.toLowerCase().indexOf('zh') === 0
410
- const LINES = IS_ZH ? LINES_ZH : Object.assign({}, LINES_ZH, LINES_EN)
410
+ /* 进页面时的默认判定(用户没显式选过就用它) */
411
+ const IS_ZH_DEFAULT = LANG_TAG.toLowerCase().indexOf('zh') === 0
412
+
413
+ /* 现在用哪种语言:① 设置里显式选过就听它 ② 否则用进页面时的判定。
414
+ —— 做成**函数**而不是常量,这样菜单里切换立刻生效,不用刷新。 */
415
+ const isZhNow = () => {
416
+ /* cfg 是后面才声明的 const:取词可能发生在它初始化之前,
417
+ 而那时访问它会抛 TDZ 错误(不是 undefined)—— 所以这里必须兜住。 */
418
+ let forced = 'auto'
419
+ try { forced = (cfg && typeof cfg.lang === 'string') ? cfg.lang : 'auto' } catch (e) { forced = 'auto' }
420
+ if (forced === 'zh') return true
421
+ if (forced === 'en') return false
422
+ return IS_ZH_DEFAULT
423
+ }
424
+
425
+ /* LINES 是一个**视图**:取词的那一刻才决定用中文还是英文覆盖层。
426
+ 用 Proxy 而不是"提前合并成一个对象" —— 后者在切换语言时是死值,必须刷新页面。
427
+ 覆盖层缺哪条就退回中文,所以可以慢慢补齐,永远不会出现半句话。 */
428
+ const LINES = new Proxy({}, {
429
+ get(_target, key) {
430
+ const zh = LINES_ZH[key]
431
+ if (isZhNow()) return zh
432
+ const en = LINES_EN[key]
433
+ return en === undefined ? zh : en
434
+ },
435
+ has(_target, key) { return key in LINES_ZH },
436
+ ownKeys() { return Reflect.ownKeys(LINES_ZH) },
437
+ getOwnPropertyDescriptor() { return { enumerable: true, configurable: true } },
438
+ })
411
439
 
412
440
  /* 深夜窗口。bedtime > wake 时它跨零点(默认 23→5);不跨也能用(比如 1→5)。 */
413
441
  const isNightHour = (hour, from, to) => {
@@ -518,6 +546,7 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
518
546
  hungry: { kind: 'bool' },
519
547
  hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
520
548
  focusMinutes: { kind: 'num', lo: 1, hi: 600 },
549
+ lang: { kind: 'str' },
521
550
  sound: { kind: 'bool' },
522
551
  soundVolume: { kind: 'num', lo: 0, hi: 1 },
523
552
  night: { kind: 'bool' },
@@ -768,6 +797,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
768
797
  .wisp-menu-glyph{display:inline-block;width:12px;margin-right:4px;text-align:center;font-size:10.5px;opacity:.72;font-weight:400}
769
798
  .wisp-menu-item[data-accent="true"] .wisp-menu-glyph{opacity:1}
770
799
  .wisp-menu-caret{opacity:.5;font-size:8px;flex:0 0 auto}
800
+ /* 三选一的胶囊组:选中的那颗有强调色,其余是淡底 —— 一眼看出"只能选一个、现在选的是哪个" */
801
+ .wisp-choice{display:flex;gap:2px;flex:0 0 auto;padding:1px;border-radius:999px;background:rgba(140,160,190,.18)}
802
+ .wisp-choice-opt{padding:1px 6px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:9.5px;line-height:13px;cursor:pointer;opacity:.7;transition:background .1s ease,opacity .1s ease}
803
+ .wisp-choice-opt:hover{opacity:1}
804
+ .wisp-choice-opt[data-on="true"]{background:var(--wisp-accent,#4a7cf7);color:#fff;opacity:1}
771
805
  .wisp-switch{flex:0 0 auto;width:22px;height:12px;border-radius:6px;background:rgba(140,160,190,.4);position:relative;transition:background .16s ease}
772
806
  .wisp-switch[data-on="true"]{background:var(--wisp-accent,#4a7cf7);box-shadow:0 0 0 2px rgba(74,124,247,.14)}
773
807
  .wisp-switch i{position:absolute;top:2px;left:2px;width:8px;height:8px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.28);transition:transform .16s ease}
@@ -2202,6 +2236,45 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2202
2236
 
2203
2237
  /* 开关行:真开关的样子 + role=menuitemcheckbox + aria-checked。
2204
2238
  点完**不关菜单** —— 拨开关时菜单消失是最烦人的交互之一。 */
2239
+ /* 三选一(语言)。做成一行里的一排小胶囊:比三个开关清楚 ——
2240
+ "同时只能选一个"这件事,胶囊组是自明的,开关组不是。 */
2241
+ const choiceRow = (spec, into) => {
2242
+ const row = document.createElement('div')
2243
+ row.className = 'wisp-menu-item wisp-menu-row wisp-choice-row'
2244
+ row.dataset.choice = spec.key
2245
+ const label = document.createElement('span')
2246
+ label.textContent = spec.label
2247
+ row.appendChild(glyph(spec.glyph || ''))
2248
+ row.appendChild(label)
2249
+ const group = document.createElement('span')
2250
+ group.className = 'wisp-choice'
2251
+ group.setAttribute('role', 'radiogroup')
2252
+ group.setAttribute('aria-label', spec.label)
2253
+ const current = cfg[spec.key] === undefined ? 'auto' : String(cfg[spec.key])
2254
+ for (const opt of spec.options) {
2255
+ const btn = document.createElement('button')
2256
+ btn.className = 'wisp-choice-opt'
2257
+ btn.setAttribute('type', 'button')
2258
+ btn.setAttribute('role', 'radio')
2259
+ btn.setAttribute('tabindex', '-1')
2260
+ btn.setAttribute('aria-checked', opt.value === current ? 'true' : 'false')
2261
+ btn.dataset.value = opt.value
2262
+ btn.dataset.on = opt.value === current ? 'true' : 'false'
2263
+ btn.textContent = opt.label
2264
+ btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2265
+ btn.addEventListener('click', (ev) => {
2266
+ ev.preventDefault(); ev.stopPropagation()
2267
+ try { applyConfig({ [spec.key]: opt.value }) } catch (err) { /* ignore */ }
2268
+ refreshSurfaces() // 立刻重画:语言切换要当场看得见
2269
+ })
2270
+ group.appendChild(btn)
2271
+ menuButtons.push(btn)
2272
+ }
2273
+ row.appendChild(group)
2274
+ into.appendChild(row)
2275
+ return row
2276
+ }
2277
+
2205
2278
  const switchRow = (spec, into) => {
2206
2279
  const on = cfg[spec.key] === true
2207
2280
  const btn = document.createElement('button')
@@ -2343,6 +2416,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2343
2416
  const renderChildren = (children, into) => {
2344
2417
  for (const child of children) {
2345
2418
  if (child.kind === 'switch') { switchRow(child, into); continue }
2419
+ if (child.kind === 'choice') { choiceRow(child, into); continue }
2346
2420
  if (child.kind === 'corners') { cornerGrid(into); continue }
2347
2421
  actionRow(child, into)
2348
2422
  }
@@ -2770,6 +2844,15 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2770
2844
  { label: '说句话', glyph: '✦', run: () => { wake(true); say(pick(LINES.click)) } },
2771
2845
  { label: '睡一会儿', glyph: '☾', run: () => setMood('sleep') },
2772
2846
  { label: '今天做了什么', glyph: '✎', run: () => saySummary() },
2847
+ /* 语言:三选一。切换**立刻生效**(LINES 是视图,不是死值)。 */
2848
+ {
2849
+ kind: 'choice', key: 'lang', label: '台词语言', glyph: '文',
2850
+ options: [
2851
+ { value: 'auto', label: '跟随界面' },
2852
+ { value: 'zh', label: '中文' },
2853
+ { value: 'en', label: 'English' },
2854
+ ],
2855
+ },
2773
2856
  /* 没有音效文件就不摆这个开关 —— 点了没反应的开关比没有开关更糟 */
2774
2857
  ...(HAS_SOUNDS ? [{ kind: 'switch', key: 'sound', label: '音效', glyph: '♪' }] : []),
2775
2858
  /* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
@@ -3172,6 +3255,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3172
3255
  if (cfg.persist && partial && typeof partial === 'object' && partial.size !== undefined) {
3173
3256
  writeStored(PREFS_KEY, { size: cfg.size })
3174
3257
  }
3258
+ /* 语言选择存到**语言键**上(不是 PREFS_KEY):它在**加载时**就会被读,
3259
+ 所以下次打开才生效。菜单里的胶囊切的是"以后说哪种话"。 */
3260
+ if (partial && typeof partial === 'object' && typeof partial.lang === 'string') {
3261
+ try { window.localStorage.setItem('dsh-wisp-lang', cfg.lang) } catch (e) { /* 存不了就算了 */ }
3262
+ }
3175
3263
  // A bigger companion occupies a bigger box, so the stored position has
3176
3264
  // to be re-clamped or she would grow off the edge.
3177
3265
  root.style.width = Math.round(boxW()) + 'px'
@@ -3454,7 +3542,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3454
3542
  },
3455
3543
  streak: streakOf(memory.days),
3456
3544
  /* 语言判定的输入与结果 —— 中英文走岔时能一眼看清是哪一层不对 */
3457
- lang: { tag: LANG_TAG, zh: IS_ZH, stored: langStored, html: langHtml, pools: Object.keys(LINES_EN).length },
3545
+ lang: {
3546
+ tag: LANG_TAG, zh: isZhNow(), choice: (cfg && typeof cfg.lang === 'string') ? cfg.lang : 'auto',
3547
+ stored: langStored, html: langHtml, pools: Object.keys(LINES_EN).length,
3548
+ },
3458
3549
  awaySince: Math.round(awayAt),
3459
3550
  /* 今天记了什么 —— 不用猜"她为什么说今天跑了 7 轮" */
3460
3551
  today: { ...watchToday() },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/verify-wisp.mjs CHANGED
@@ -3542,6 +3542,42 @@ head('3y-3. the focus timer, and going quiet while the page is hidden')
3542
3542
  const zhLine = zh.all('wisp-say').filter((el) => el.removed !== true)
3543
3543
  .map((el) => el.textContent).join(' ')
3544
3544
  check(/[\u4e00-\u9fa5]/.test(zhLine), 'forcing chinese keeps her speaking chinese', zhLine)
3545
+
3546
+ /* 语言可以**随时**切换、不用刷新 —— 这正是把 LINES 做成视图(Proxy)而不是死值的意义。
3547
+ 以前是"加载时合并成一个对象",换语言只能刷新页面。 */
3548
+ const said = () => zh.all('wisp-say').filter((el) => el.removed !== true)
3549
+ .map((el) => el.textContent).join(' ')
3550
+ zh.state.busy = false
3551
+ zh.advance(4000, 500)
3552
+ zh.win.__wisp.configure({ lang: 'en' })
3553
+ zh.win.__wisp.saySummary()
3554
+ zh.advance(200, 50)
3555
+ /* 菜单里选的语言是"下次打开生效"的那种设置(它在加载时就被读),
3556
+ 所以这里验的完整流程是:**选一次 -> 存下来 -> 再挂一次就说英文**。
3557
+ 不能只验"当场变英文":那是我想做但没做成的(试过,没找到原因),
3558
+ 没验证过的行为不该写成承诺。 */
3559
+ zh.win.__wisp.configure({ lang: 'en' })
3560
+ const storedLang = zh.win.localStorage.getItem('dsh-wisp-lang')
3561
+ check(storedLang === 'en', 'picking a language in the menu stores it for next time', String(storedLang))
3562
+ const reload = createHarness({ timer: true, composerText: '', storageSeed: { 'dsh-wisp-lang': 'en' } })
3563
+ const keepReload = active
3564
+ active = reload
3565
+ reload.evaluate(clientSrc)
3566
+ reload.module().default.apply(reload.ctx, { reactions: false, wander: false, celebrate: false })
3567
+ reload.advance(1000, 200)
3568
+ reload.win.__wisp.saySummary()
3569
+ reload.advance(200, 50)
3570
+ const reloaded = reload.all('wisp-say').filter((el) => el.removed !== true)
3571
+ .map((el) => el.textContent).join(' ')
3572
+ check(/[A-Za-z]/.test(reloaded) && !/[\u4e00-\u9fa5]/.test(reloaded),
3573
+ 'and the next mount honours that choice', reloaded)
3574
+ check(reloaded !== said(), 'the two languages really are different text',
3575
+ reloaded + ' | 中文那句:' + said())
3576
+ reload.win.__wisp.destroy()
3577
+ active = keepReload
3578
+ check(zh.win.__wisp.doctor().lang.choice === 'en',
3579
+ 'and the choice is reported so the menu can show it', JSON.stringify(zh.win.__wisp.doctor().lang))
3580
+
3545
3581
  zh.win.__wisp.destroy()
3546
3582
  active = keepZh
3547
3583