dsh-wisp 1.36.0 → 1.42.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/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.42.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -80,6 +80,12 @@ const WHATS_NEW = {
80
80
  '1.34.1': '菜单改成紧凑刻度,占屏幕小了三成。',
81
81
  '1.35.0': '她开始替你值班:你走开时看着,回来报账;还能说今日小结。',
82
82
  '1.36.0': '她会说英文了(跟随界面语言/系统语言,也能强制);音效与"汇报"姿势已就位。',
83
+ '1.37.0': '菜单里能直接选台词语言(跟随界面 / 中文 / English),选一次就记住。',
84
+ '1.38.0': '她有身体了:按下去会压一下弹回来、拖着会侧身、落地会顿一下,指针靠近会朝你倾过来。',
85
+ '1.39.0': '她会看你的余额了(菜单里点一下),未登录或查不到时如实说。',
86
+ '1.40.0': '她的透明处真的点不到了:按轮廓命中,站在按钮上也不挡你点它。',
87
+ '1.41.0': '检查更新更耐得住:一个源连不上不再把另一个拖死,失败时 doctor() 会说清是哪个源、为什么。',
88
+ '1.42.0': '查更新失败时她会直接说清是哪一种:没通道 / 连不上 / 宿主没应答。',
83
89
  }
84
90
 
85
91
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -218,11 +224,63 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
218
224
  const WAKE_THROTTLE_MS = 1000
219
225
  const WORRIED_MS = 3500 // 出错表情持续多久
220
226
 
227
+ /* ---------------------------------------------------------------------
228
+ 动作(motion)。
229
+
230
+ 在这之前,"她的动作"只有两件事:换一张精灵图,以及一条常驻的呼吸
231
+ 循环动画。按下、拖动、松手这些**手势**在画面上没有任何回应 —— 她像
232
+ 一张贴在屏幕上的图,而不是一个被碰到会有反应的东西。
233
+
234
+ 这里补的是"身体感",而且刻意只用 CSS 关键帧 + 一个样式变量:插件
235
+ 的稳态开销承诺是**零动画帧**(见 README 性能表),加一条 rAF 循环
236
+ 就等于把那条承诺撤掉。所有动作都是"事件写一次属性,浏览器自己算"。
237
+
238
+ 三个层各管一件事。CSS 的 transform 在同一个元素上是**覆盖**而不是
239
+ 叠加,所以"分层"不是设计洁癖,是唯一做法:
240
+
241
+ .wisp-body 情绪动画(呼吸 / 跳 / 推 / 担心)—— 既有,不动
242
+ .wisp-motion 一次性动作(按下压一下 / 落地顿一下 / 换表情弹一下)
243
+ 由 data-accent 驱动,放外层 → 落地时的压扁是**屏幕坐标**
244
+ 里的竖直方向(重力方向),不会跟着侧倾一起歪
245
+ .wisp-lean 持续姿势(拖动速度 / 指针靠近 / 溜达方向)
246
+ 由 --wisp-tilt 驱动,旋转原点在脚底 → 像钟摆,不像转盘
247
+
248
+ data-accent 的取值里,pop-a / pop-b 是**同一件事的两个名字**:改
249
+ animation-name 是让同一条动画立刻重播的唯一可靠办法(同一个值再写
250
+ 一次不会重播),而连戳两下必须看到两次弹。
251
+ --------------------------------------------------------------------- */
252
+ const MOTION_AMP = { full: 1, subtle: 0.5, off: 0 }
253
+ const TILT_DRAG_MAX = 8 // 拖动时最大侧倾(度)
254
+ const TILT_DRAG_PER_PX = 0.55 // 每一次指针事件横向走 1px 折算多少度(≈速度)
255
+ const TILT_NEAR_MAX = 3.5 // 指针靠近时最大侧倾(度)
256
+ const TILT_NEAR_PX = 170 // 多远算"她注意到你了"
257
+ const TILT_GLIDE_MAX = 2.5 // 溜达时朝行进方向前倾(度)
258
+ const TILT_GLIDE_PER_PX = 0.02 // 溜达距离折算成前倾角的斜率
259
+ /* 变化小于这个数就不写样式 —— pointermove 一秒能来上百次,而 0.05° 在
260
+ 屏幕上是看不见的。写一次 = 一次样式重算,阈值就是它的闸门。 */
261
+ const TILT_STEP = 0.05
262
+
263
+ /* 动作时长必须是**代码**里的一份:CSS 只负责怎么动,什么时候收手由这里
264
+ 决定(和 glide 的 1700ms 一个路子)。两边对不上就会出现"动画早放完了,
265
+ 属性还挂着"或者反过来的空档。
266
+ 没有 release:松手之后紧接着就是**点击反应**(换表情 → 弹一下),两者
267
+ 抢同一个通道,而反应赢 —— "手指抬起来了"不值得盖过"她换了个表情"。
268
+ 通道只留最后发生的那件事,不留一个永远演不到的关键帧。 */
269
+ const ACCENT_MS = { press: 200, land: 560, pop: 380 }
270
+
271
+ /* 换表情时"弹一下"的表情集合。idle / alert / sleep 不在里面:它们要么是
272
+ 长时间稳态,要么由轮询反复重算,弹一下就变成抖。 */
273
+ const POPPY_MOODS = ['happy', 'poked', 'proud', 'attn', 'care', 'eat', 'worried', 'report']
274
+
221
275
  const DEFAULTS = {
222
276
  bottom: GROUND,
223
277
  right: 26,
224
278
  sleepAfterMs: 90000,
225
279
  reactions: true,
280
+ /* 交互动效档位:full 灵动 / subtle 克制(半幅)/ off 静止。
281
+ 默认全开 —— 但必须有一个**不用改系统设置**就能让她安静下来的开关,
282
+ prefers-reduced-motion 是给"所有动画"的,不是给"我不想让她动"的。 */
283
+ motion: 'full',
226
284
  size: 4, // 560x840 CSS px, drawn from the 2048x3072 tier
227
285
  persist: true, // remember where she was dragged to
228
286
  celebrate: true, // cheer when a run finishes
@@ -239,6 +297,14 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
239
297
  backAfterMs: 120000, // 离开超过这么久,回来时她会说一句(2 分钟;0 = 关)
240
298
  milestones: true, // 今日轮次到达里程碑时得意一下
241
299
  memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
300
+ /* lang 与 soundVolume 曾经**只在 CONFIG_SPEC 里声明、DEFAULTS 里没有**:
301
+ 前者靠 isZhNow() 把非字符串兜成 'auto' 侥幸没事,后者直接是 undefined →
302
+ `Number(undefined)` = NaN → `clip.volume = NaN` 在浏览器里抛,被
303
+ playSound 的 try/catch 吞掉 —— 于是"打开音效"整条路静默失效。
304
+ 配置键的两个"端"(声明 / 默认值)现在由预检钉住。 */
305
+ lang: 'auto', // 'auto' 跟随界面语言;'zh' / 'en' 强制
306
+ sound: false, // 音效默认关(无故出声是最招人烦的交互之一),且要有素材
307
+ soundVolume: 0.7, // 音效音量(音效本身默认关,且要有 assets/audio 才存在)
242
308
  happyMs: 1100,
243
309
  wander: true, // stroll around on her own while idle
244
310
  wanderMs: 45000, // how often she considers moving
@@ -397,6 +463,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
397
463
  updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
398
464
  updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
399
465
  updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
466
+ /* 失败不是一个笼统的状态:「这个壳里没有联网的通道」「两个源都没连上」
467
+ 「宿主那边没应答」是三件事,含糊地说成"没查到"就没人能追下去 ——
468
+ 而用户看到的恰恰只有那一句。 */
469
+ updateNoChannel: ['这个壳里我没有联网的通道,查不了更新。', '这里没有能出去的网络通道。'],
470
+ updateNoHost: ['宿主那边没应答,更新没查成。', '问不到宿主,更新没查成。'],
400
471
  milestone: [
401
472
  '今天第 {n} 轮了。', '{n} 轮了,还不错。', '已经 {n} 轮了,你挺能撑。',
402
473
  '第 {n} 轮 —— 我数着呢。', '{n} 轮,进度可观。', '今天 {n} 轮,记上了。',
@@ -408,6 +479,17 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
408
479
  '去窗边看看。', '我在这儿等你回来。', '坐太久了,动一下。', '你不动,我就一直看着你。',
409
480
  ],
410
481
  hint: ['右键我一下试试。', '右键我有菜单。', '拖我,或者右键我。'],
482
+
483
+ /* 余额。数字由客户端拼好({n}),台词只管口气 —— 她报的是账户,
484
+ 不是她自己的钱,所以既不邀功也不夸张。 */
485
+ balanceChecking: ['看一下……', '等等,我查查。', '让我看看。'],
486
+ balance: ['余额 {n}。', '还有 {n}。', '{n},暂时够用。', '账上是 {n}。'],
487
+ balanceBonus: ['其中赠送 {n}。', '还有 {n} 是赠送的。'],
488
+ balanceEmpty: ['没查到余额记录。', '账上没有额度记录。'],
489
+ balanceSignedOut: ['没登录,看不到余额。', '这个得先登录才行。', '还没登录,余额在平台那边。'],
490
+ balanceUnavailable: ['刚才没读到,一会儿再看。', '这一下没读到,稍后再试。'],
491
+ balanceFailed: ['余额没查成。', '这个没读出来,稍后再试。'],
492
+ balanceUnsupported: ['这个壳里我查不了余额。', '这里没有查余额的通道。'],
411
493
  }
412
494
 
413
495
  /* 英文台词。**只翻译她说的话**(菜单按钮文案仍由 DSH 界面语言决定,不在这一层)。
@@ -442,6 +524,17 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
442
524
  backSoon: ['Back already?', 'There you are.'],
443
525
  backLong: ['You were gone a while.', 'Long break. Welcome back.'],
444
526
  streak: ['{n} days in a row.', 'Day {n} together.'],
527
+ updateFailed: ["The update check didn't work.", "Couldn't reach the update sources."],
528
+ updateNoChannel: ["This shell gives me no network channel, so I can't check for updates.", 'No way out to the network from here.'],
529
+ updateNoHost: ["The host side didn't answer — the update check failed.", 'No answer from the host.'],
530
+ balanceChecking: ['One moment…', 'Let me check.'],
531
+ balance: ['Balance: {n}.', "You've got {n}.", '{n} on the account.'],
532
+ balanceBonus: [' {n} of that is credit.', ' ({n} is credit.)'],
533
+ balanceEmpty: ['No balance record came back.', 'Nothing on the account.'],
534
+ balanceSignedOut: ["You're signed out — I can't see the balance.", 'Sign in first and I can read it.'],
535
+ balanceUnavailable: ["Couldn't read it just now — try again in a bit.", 'That read did not go through.'],
536
+ balanceFailed: ["The balance lookup didn't work.", "Couldn't read that — try again later."],
537
+ balanceUnsupported: ["I can't reach the balance from this shell.", 'No balance channel here.'],
445
538
  }
446
539
 
447
540
  /* 语言:中文环境(或读不到语言)用中文,其余走英文覆盖层。
@@ -462,8 +555,35 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
462
555
  if (langHtml !== '') return langHtml
463
556
  try { return String(navigator.language || navigator.userLanguage || 'zh') } catch (e) { return 'zh' }
464
557
  })()
465
- const IS_ZH = LANG_TAG.toLowerCase().indexOf('zh') === 0
466
- const LINES = IS_ZH ? LINES_ZH : Object.assign({}, LINES_ZH, LINES_EN)
558
+ /* 进页面时的默认判定(用户没显式选过就用它) */
559
+ const IS_ZH_DEFAULT = LANG_TAG.toLowerCase().indexOf('zh') === 0
560
+
561
+ /* 现在用哪种语言:① 设置里显式选过就听它 ② 否则用进页面时的判定。
562
+ —— 做成**函数**而不是常量,这样菜单里切换立刻生效,不用刷新。 */
563
+ const isZhNow = () => {
564
+ /* cfg 是后面才声明的 const:取词可能发生在它初始化之前,
565
+ 而那时访问它会抛 TDZ 错误(不是 undefined)—— 所以这里必须兜住。 */
566
+ let forced = 'auto'
567
+ try { forced = (cfg && typeof cfg.lang === 'string') ? cfg.lang : 'auto' } catch (e) { forced = 'auto' }
568
+ if (forced === 'zh') return true
569
+ if (forced === 'en') return false
570
+ return IS_ZH_DEFAULT
571
+ }
572
+
573
+ /* LINES 是一个**视图**:取词的那一刻才决定用中文还是英文覆盖层。
574
+ 用 Proxy 而不是"提前合并成一个对象" —— 后者在切换语言时是死值,必须刷新页面。
575
+ 覆盖层缺哪条就退回中文,所以可以慢慢补齐,永远不会出现半句话。 */
576
+ const LINES = new Proxy({}, {
577
+ get(_target, key) {
578
+ const zh = LINES_ZH[key]
579
+ if (isZhNow()) return zh
580
+ const en = LINES_EN[key]
581
+ return en === undefined ? zh : en
582
+ },
583
+ has(_target, key) { return key in LINES_ZH },
584
+ ownKeys() { return Reflect.ownKeys(LINES_ZH) },
585
+ getOwnPropertyDescriptor() { return { enumerable: true, configurable: true } },
586
+ })
467
587
 
468
588
  /* 深夜窗口。bedtime > wake 时它跨零点(默认 23→5);不跨也能用(比如 1→5)。 */
469
589
  const isNightHour = (hour, from, to) => {
@@ -558,7 +678,10 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
558
678
  if (Ctor === null) return
559
679
  try {
560
680
  const clip = new Ctor(src)
561
- clip.volume = Math.max(0, Math.min(1, Number(cfg.soundVolume)))
681
+ /* 音量必须落到 [0,1] 内的有限数:给媒体元素设 NaN/Infinity 会**抛异常**,
682
+ 而这一整段是被 catch 包住的 —— 一个坏值会让音效静默消失,看起来像"开关没反应"。 */
683
+ const volume = Number(cfg.soundVolume)
684
+ clip.volume = isFinite(volume) ? clamp(volume, 0, 1) : 0.7
562
685
  const started = clip.play()
563
686
  if (started && typeof started.catch === 'function') started.catch(() => { /* 自动播放被拦下就作罢 */ })
564
687
  } catch (e) { /* 音效永远不该打断别的事 */ }
@@ -575,6 +698,7 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
575
698
  hungry: { kind: 'bool' },
576
699
  hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
577
700
  focusMinutes: { kind: 'num', lo: 1, hi: 600 },
701
+ lang: { kind: 'str' },
578
702
  sound: { kind: 'bool' },
579
703
  soundVolume: { kind: 'num', lo: 0, hi: 1 },
580
704
  night: { kind: 'bool' },
@@ -588,6 +712,7 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
588
712
  wanderMs: { kind: 'num', lo: 5000, hi: 3600000 },
589
713
  wanderRange: { kind: 'num', lo: 0, hi: 2000 },
590
714
  reactions: { kind: 'bool' },
715
+ motion: { kind: 'one', of: Object.keys(MOTION_AMP) },
591
716
  persist: { kind: 'bool' },
592
717
  celebrate: { kind: 'bool' },
593
718
  wander: { kind: 'bool' },
@@ -600,6 +725,9 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
600
725
  const spec = CONFIG_SPEC[key]
601
726
  if (spec.kind === 'num') return num(value, spec.lo, spec.hi, fallback)
602
727
  if (spec.kind === 'str') return typeof value === 'string' ? value : fallback
728
+ /* 枚举:认不出来的值一律退回 fallback。写错一个档位名不该让她变成
729
+ "动也不动、也没有报错"的那种坏状态。 */
730
+ if (spec.kind === 'one') return spec.of.indexOf(value) >= 0 ? value : fallback
603
731
  return bool(value, fallback)
604
732
  }
605
733
 
@@ -776,8 +904,13 @@ const SOUNDS = (typeof __WISP_SOUNDS__ === 'object' && __WISP_SOUNDS__ !== null)
776
904
  .wisp-root{position:absolute;width:${BOX_W}px;height:${BOX_H}px;pointer-events:none;transform-origin:top center;--wisp-aura:rgba(110,200,255,.42)}
777
905
  .wisp-root[data-dragging="true"]{will-change:transform}
778
906
  .wisp-root[data-gliding="true"]{transition:transform 1.6s cubic-bezier(.4,0,.2,1)}
779
- .wisp-body{position:absolute;inset:0;pointer-events:auto;cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;animation:wisp-bob 5.6s ease-in-out infinite}
780
- .wisp-body:active{cursor:grabbing}
907
+ .wisp-body{position:absolute;inset:0;pointer-events:none;touch-action:none;user-select:none;-webkit-user-select:none;animation:wisp-bob 5.6s ease-in-out infinite}
908
+ /* 命中层:一个**不画任何东西**的空盒子,只负责被鼠标点到。它带一层由 alpha 蒙版
909
+ 生成的 clip-path —— 于是"透明处点不到"是**浏览器**做的,而不是 JS 事后补救:
910
+ 事件根本不会派到她身上,下面的应用照常收到 hover / 点击 / 右键 / 滚轮。
911
+ (只写 pointer-events:none 是不够的:那只是不拦她自己的事,盒子仍然盖在应用上面。) */
912
+ .wisp-hit{position:absolute;inset:0;pointer-events:auto;cursor:grab}
913
+ .wisp-hit:active{cursor:grabbing}
781
914
  .wisp-img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:bottom center;filter:drop-shadow(0 6px 14px rgba(0,0,0,.30));transition:opacity .28s ease,filter .5s ease}
782
915
  .wisp-shine{position:absolute;left:50%;bottom:-8px;width:68%;height:16px;margin-left:-34%;border-radius:50%;background:radial-gradient(ellipse at center,var(--wisp-aura) 0%,transparent 70%);filter:blur(4px);pointer-events:none;transition:background .5s ease}
783
916
  .wisp-zzz{position:absolute;right:6px;top:8px;font-size:calc(13px * var(--wisp-scale));font-weight:600;color:var(--wisp-ink);text-shadow:0 0 8px var(--wisp-aura);animation:wisp-zzz 2.6s ease-out infinite;pointer-events:none}
@@ -800,6 +933,29 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
800
933
  .wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
801
934
  @keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
802
935
  @keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
936
+ /* ---- 动作层(见文件上方 motion 常量处的说明)----
937
+ 两层都是**空盒子**:绝对定位铺满、不吃指针。姿势与一次性动作分别只碰自己
938
+ 那一层的 transform,所以它们永远不会互相覆盖。
939
+ transform-origin 放在脚底:压扁是"踩在地上压下去",侧倾是"钟摆",不是"转盘"。 */
940
+ .wisp-motion,.wisp-lean{position:absolute;inset:0;pointer-events:none;transform-origin:50% 100%}
941
+ .wisp-lean{transform:rotate(var(--wisp-tilt,0deg));transition:transform .5s cubic-bezier(.22,1.2,.36,1)}
942
+ /* 拖动中姿势必须**跟手**:一有过渡,拖起来就像拽着一根橡皮筋。 */
943
+ .wisp-root[data-dragging="true"] .wisp-lean{transition:none}
944
+ /* 幅度档位:一个变量管住所有一次性动作的幅度(关键帧里用 calc 乘它),
945
+ 指针侧倾的幅度在 JS 里读同一张表 —— 两边不会各调各的。 */
946
+ .wisp-root{--wisp-amp:1}
947
+ .wisp-root[data-motion="subtle"]{--wisp-amp:.5}
948
+ .wisp-root[data-motion="off"]{--wisp-amp:0}
949
+ /* 静止档:连呼吸和 z 都停 —— 她说"我不想让她动"时,是最彻底的静止。 */
950
+ .wisp-root[data-motion="off"] .wisp-body,.wisp-root[data-motion="off"] .wisp-zzz{animation:none}
951
+ .wisp-root[data-accent="press"] .wisp-motion{animation:wisp-press .2s ease-out}
952
+ .wisp-root[data-accent="land"] .wisp-motion{animation:wisp-land .56s cubic-bezier(.2,1.15,.35,1)}
953
+ .wisp-root[data-accent="pop-a"] .wisp-motion{animation:wisp-pop-a .38s ease-out}
954
+ .wisp-root[data-accent="pop-b"] .wisp-motion{animation:wisp-pop-b .38s ease-out}
955
+ @keyframes wisp-press{0%{transform:scale(1,1)}60%{transform:scale(calc(1 + .045*var(--wisp-amp,1)),calc(1 - .06*var(--wisp-amp,1)))}100%{transform:scale(1,1)}}
956
+ @keyframes wisp-land{0%{transform:scale(calc(1 + .06*var(--wisp-amp,1)),calc(1 - .085*var(--wisp-amp,1)))}42%{transform:scale(calc(1 - .022*var(--wisp-amp,1)),calc(1 + .028*var(--wisp-amp,1)))}74%{transform:scale(calc(1 + .01*var(--wisp-amp,1)),calc(1 - .012*var(--wisp-amp,1)))}100%{transform:scale(1,1)}}
957
+ @keyframes wisp-pop-a{0%{transform:scale(1)}46%{transform:scale(calc(1 + .035*var(--wisp-amp,1)))}100%{transform:scale(1)}}
958
+ @keyframes wisp-pop-b{0%{transform:scale(1)}46%{transform:scale(calc(1 + .03*var(--wisp-amp,1)),calc(1 - .025*var(--wisp-amp,1)))}100%{transform:scale(1,1)}}
803
959
  /* 菜单与级联子面板共用一套外观变量 —— 面板是菜单的"上一层",只改阴影与浮起量。
804
960
  尺寸走**紧凑刻度**:这是陪伴插件的浮层,不该占掉屏幕的观感重心。 */
805
961
  .wisp-menu{--wisp-surface:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));--wisp-line:var(--dsw-alias-border-l1,var(--wisp-say-line));--wisp-hover:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2));--wisp-accent:var(--dsw-alias-brand-primary,#4a7cf7);position:absolute;min-width:164px;max-width:196px;padding:4px;border-radius:9px;font-size:11px;line-height:1.2;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--wisp-surface);border:1px solid var(--wisp-line);box-shadow:0 10px 28px rgba(0,0,0,.3);-webkit-backdrop-filter:blur(16px) saturate(160%);backdrop-filter:blur(16px) saturate(160%);pointer-events:auto;animation:wisp-menu .13s ease}
@@ -825,6 +981,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
825
981
  .wisp-menu-glyph{display:inline-block;width:12px;margin-right:4px;text-align:center;font-size:10.5px;opacity:.72;font-weight:400}
826
982
  .wisp-menu-item[data-accent="true"] .wisp-menu-glyph{opacity:1}
827
983
  .wisp-menu-caret{opacity:.5;font-size:8px;flex:0 0 auto}
984
+ /* 三选一的胶囊组:选中的那颗有强调色,其余是淡底 —— 一眼看出"只能选一个、现在选的是哪个" */
985
+ .wisp-choice{display:flex;gap:2px;flex:0 0 auto;padding:1px;border-radius:999px;background:rgba(140,160,190,.18)}
986
+ .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}
987
+ .wisp-choice-opt:hover{opacity:1}
988
+ .wisp-choice-opt[data-on="true"]{background:var(--wisp-accent,#4a7cf7);color:#fff;opacity:1}
828
989
  .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
990
  .wisp-switch[data-on="true"]{background:var(--wisp-accent,#4a7cf7);box-shadow:0 0 0 2px rgba(74,124,247,.14)}
830
991
  .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}
@@ -872,7 +1033,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
872
1033
  .wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
873
1034
  .wisp-dialog-action{display:block;width:100%;margin:4px 0 2px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));border-radius:8px;background:none;color:inherit;font:inherit;cursor:pointer}
874
1035
  .wisp-dialog-action:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2))}
875
- @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}.wisp-menu,.wisp-submenu,.wisp-switch,.wisp-switch i,.wisp-slider-knob,.wisp-menu-item,.wisp-corner{animation:none!important;transition:none!important}}`
1036
+ @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}.wisp-motion{animation:none!important}.wisp-lean{transform:none!important;transition:none!important}.wisp-menu,.wisp-submenu,.wisp-switch,.wisp-switch i,.wisp-slider-knob,.wisp-menu-item,.wisp-corner{animation:none!important;transition:none!important}}`
876
1037
 
877
1038
  const installStyles = (styles) => {
878
1039
  if (styles && typeof styles.insert === 'function') {
@@ -937,8 +1098,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
937
1098
  return null
938
1099
  }
939
1100
 
940
- const buildMask = (key, url) => {
941
- if (masks[key]) return
1101
+ const buildMask = (key, url, onReady) => {
1102
+ if (masks[key]) {
1103
+ if (typeof onReady === 'function') onReady(key)
1104
+ return
1105
+ }
942
1106
  try {
943
1107
  const canvas = document.createElement('canvas')
944
1108
  if (!canvas || typeof canvas.getContext !== 'function') return
@@ -969,12 +1133,62 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
969
1133
  // the aura, so without this a click that looks like it is on her
970
1134
  // edge falls into the pass-through margin.
971
1135
  masks[key] = dilate(raw)
1136
+ if (typeof onReady === 'function') onReady(key)
972
1137
  } catch (e) { /* tainted or unsupported: the full box stays hittable */ }
973
1138
  }
974
1139
  probe.src = url
975
1140
  } catch (e) { /* no canvas: the full box stays hittable */ }
976
1141
  }
977
1142
 
1143
+ /* ---------------------------------------------------------------------
1144
+ 蒙版 → clip-path。
1145
+
1146
+ 蒙版是 96×144 的格,这里把每行连续的不透明格合并成矩形(再和上一行同列同宽的
1147
+ 矩形纵向合并),输出一条 SVG path。段数通常在几十段(实测某套皮肤 40 段),
1148
+ 一次设置、只在换皮肤或改大小时重算 —— 不是每帧的事。
1149
+
1150
+ 为什么非要交给浏览器:`pointer-events:none` 只能让她自己不拦事件,她那个
1151
+ 560×840 的盒子仍然盖在应用上面 —— 事件**先派给她**,JS 再 return 已经晚了。
1152
+ 有了 clip-path,命中判定在浏览器里就按她的轮廓做,透明处是真的穿透。
1153
+ --------------------------------------------------------------------- */
1154
+ const clipPathOf = (mask, width, height) => {
1155
+ if (!mask || !(width > 0) || !(height > 0)) return ''
1156
+ const rects = []
1157
+ let prev = new Map() // 上一行里"可能继续往下长"的矩形
1158
+ for (let y = 0; y < MASK_H; y++) {
1159
+ const next = new Map()
1160
+ let x = 0
1161
+ while (x < MASK_W) {
1162
+ if (mask[y * MASK_W + x] <= MASK_MIN_ALPHA) { x++; continue }
1163
+ const start = x
1164
+ while (x < MASK_W && mask[y * MASK_W + x] > MASK_MIN_ALPHA) x++
1165
+ const key = start + ':' + (x - start)
1166
+ const carried = prev.get(key)
1167
+ if (carried && carried.y + carried.h === y) {
1168
+ carried.h++
1169
+ next.set(key, carried)
1170
+ } else {
1171
+ const rect = { x: start, y, w: x - start, h: 1 }
1172
+ rects.push(rect)
1173
+ next.set(key, rect)
1174
+ }
1175
+ }
1176
+ prev = next
1177
+ }
1178
+ if (rects.length === 0) return ''
1179
+ const sx = width / MASK_W
1180
+ const sy = height / MASK_H
1181
+ let d = ''
1182
+ for (const rect of rects) {
1183
+ const rx = (rect.x * sx).toFixed(1)
1184
+ const ry = (rect.y * sy).toFixed(1)
1185
+ const rw = (rect.w * sx).toFixed(1)
1186
+ const rh = (rect.h * sy).toFixed(1)
1187
+ d += 'M' + rx + ' ' + ry + 'h' + rw + 'v' + rh + 'h-' + rw + 'z'
1188
+ }
1189
+ return 'path("' + d + '")'
1190
+ }
1191
+
978
1192
  /* ---------------------------------------------------------------------
979
1193
  WHAT THE APP IS DOING — read from the DOM, deliberately.
980
1194
 
@@ -1282,6 +1496,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1282
1496
  sayEl.style.top = Math.round(Math.max(coords.y, SAY_CLEARANCE)) + 'px'
1283
1497
  }
1284
1498
 
1499
+ /* ---- 姿势角 --------------------------------------------------------
1500
+ tiltScreen 是**屏幕坐标**里的角度:正数 = 顶朝右。
1501
+ 但她在左半边时整个 root 是镜像的(scaleX(-1)),镜像会把里面的旋转
1502
+ 一起翻过去 —— 所以写进样式前先乘一次 facing。少了这一步,"她朝指针
1503
+ 倾"在她跨过屏幕中线之后会变成朝反方向倾,而且只在半张屏幕上错。
1504
+ 它必须写在 paint() 里,因为 facing 只在 paint() 里算。 */
1505
+ let tiltScreen = 0
1506
+ let tiltWritten = null
1507
+ let facingNow = 1
1508
+ const applyTilt = () => {
1509
+ const text = (Math.round(tiltScreen * facingNow * 10) / 10) + 'deg'
1510
+ if (text === tiltWritten) return // 同一个值不重复写:写一次就是一次样式重算
1511
+ tiltWritten = text
1512
+ root.style.setProperty('--wisp-tilt', text)
1513
+ }
1514
+
1285
1515
  const paint = () => {
1286
1516
  const facing = coords.x + boxW() / 2 < vw() / 2 ? -1 : 1
1287
1517
  // The translate places the box's TOP-LEFT corner, so it is `coords.x`,
@@ -1289,7 +1519,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1289
1519
  // feeding it a centre offset put the whole box half a box-width to the
1290
1520
  // right, off the edge of the viewport. The mirror still pivots in place
1291
1521
  // because `transform-origin` is the top CENTRE.
1522
+ facingNow = facing
1292
1523
  root.style.transform = 'translate3d(' + coords.x + 'px,' + coords.y + 'px,0) scaleX(' + facing + ')'
1524
+ applyTilt()
1293
1525
  placeSay()
1294
1526
  }
1295
1527
  paint()
@@ -1301,7 +1533,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1301
1533
  bodyEl.style.left = '0'
1302
1534
  bodyEl.style.right = '0'
1303
1535
  bodyEl.style.bottom = '0'
1304
- bodyEl.style.pointerEvents = 'auto'
1536
+ bodyEl.style.pointerEvents = 'none'
1305
1537
  bodyEl.setAttribute('role', 'button')
1306
1538
  /* tabindex 是 0 而不是 -1:菜单已经支持键盘导航(↑↓/Home/End/Esc),但如果她本身
1307
1539
  不可聚焦,键盘用户就**没有办法打开它** —— 声明了 role="menu" 却没有入口,与
@@ -1311,6 +1543,38 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1311
1543
  bodyEl.setAttribute('aria-label', 'DeepSeek娘')
1312
1544
  bodyEl.setAttribute('aria-haspopup', 'menu')
1313
1545
 
1546
+ /* 命中层(见 clipPathOf 上面的说明):空盒子 + 由蒙版生成的 clip-path。
1547
+ 关键样式同样行内再写一遍 —— 样式表缺失时它必须仍然是"铺满、吃指针"的那个。 */
1548
+ const hitEl = document.createElement('div')
1549
+ hitEl.className = 'wisp-hit'
1550
+ hitEl.setAttribute('aria-hidden', 'true')
1551
+ hitEl.style.position = 'absolute'
1552
+ hitEl.style.top = '0'
1553
+ hitEl.style.left = '0'
1554
+ hitEl.style.right = '0'
1555
+ hitEl.style.bottom = '0'
1556
+ hitEl.style.pointerEvents = 'auto'
1557
+ hitEl.style.cursor = 'grab'
1558
+
1559
+ /* 动作层(见文件上方 motion 常量处的说明):两个空盒子,把"姿势"与
1560
+ "一次性动作"从情绪动画里分出来。关键样式同样**行内再写一遍** ——
1561
+ 理由和 layer/root 一样:样式表缺失时它们是 position:static 的普通
1562
+ div,会把精灵图挤成两个叠着的方块。 */
1563
+ const motionEl = document.createElement('div')
1564
+ motionEl.className = 'wisp-motion'
1565
+ const leanEl = document.createElement('div')
1566
+ leanEl.className = 'wisp-lean'
1567
+ for (const box of [motionEl, leanEl]) {
1568
+ box.style.position = 'absolute'
1569
+ box.style.top = '0'
1570
+ box.style.left = '0'
1571
+ box.style.right = '0'
1572
+ box.style.bottom = '0'
1573
+ box.style.pointerEvents = 'none'
1574
+ /* 旋转/压扁的原点在脚底:否则侧倾像转盘,压扁像从头顶往下缩。 */
1575
+ box.style.transformOrigin = '50% 100%'
1576
+ }
1577
+
1314
1578
  const shine = document.createElement('div')
1315
1579
  shine.className = 'wisp-shine'
1316
1580
 
@@ -1341,9 +1605,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1341
1605
  return skin.idle
1342
1606
  }
1343
1607
 
1608
+ /* 把当前精灵图的蒙版变成命中层的形状。蒙版是异步来的(图片解码完才有),
1609
+ 所以它由 buildMask 的回调触发;换皮肤、改大小的时候各再调一次。 */
1610
+ let hitClip = null
1611
+ const applyHitClip = (key) => {
1612
+ if (key !== currentSprite) return // 过期的回调:那张图已经不是现在这张
1613
+ const mask = masks[activeSkin + '/' + key]
1614
+ const path = mask ? clipPathOf(mask, boxW(), boxH()) : ''
1615
+ if (path === hitClip) return // 同一个形状不重复写
1616
+ hitClip = path
1617
+ hitEl.style.clipPath = path
1618
+ hitEl.style.webkitClipPath = path
1619
+ }
1620
+
1344
1621
  const setSprite = (key) => {
1622
+ currentSprite = key
1345
1623
  const url = resolveSprite(spriteOf(key))
1346
- buildMask(activeSkin + '/' + key, url)
1624
+ buildMask(activeSkin + '/' + key, url, () => applyHitClip(key))
1347
1625
  currentSprite = key
1348
1626
  /* Cross-fade rather than swapping `src` outright: a hard swap makes every
1349
1627
  mood change read as a glitch. The outgoing sprite keeps its own layer
@@ -1367,7 +1645,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1367
1645
  outgoing.draggable = false
1368
1646
  outgoing.setAttribute('aria-hidden', 'true')
1369
1647
  outgoing.src = previous
1370
- bodyEl.insertBefore(outgoing, img)
1648
+ leanEl.insertBefore(outgoing, img)
1371
1649
  img.src = url
1372
1650
  img.style.opacity = '0'
1373
1651
  clock.after(16, () => {
@@ -1378,8 +1656,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1378
1656
  clock.after(340, () => { if (outgoing.parentNode) outgoing.parentNode.removeChild(outgoing) })
1379
1657
  }
1380
1658
 
1381
- bodyEl.appendChild(shine)
1382
- bodyEl.appendChild(img)
1659
+ /* 结构:body(情绪动画)> motion(一次性动作)> lean(姿势)> shine/img,
1660
+ 外加一个兄弟节点 hit(命中层)。 */
1661
+ leanEl.appendChild(shine)
1662
+ leanEl.appendChild(img)
1663
+ motionEl.appendChild(leanEl)
1664
+ bodyEl.appendChild(motionEl)
1665
+ bodyEl.appendChild(hitEl)
1383
1666
  root.appendChild(bodyEl)
1384
1667
  layer.appendChild(root)
1385
1668
  setSprite('idle')
@@ -1436,10 +1719,86 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1436
1719
  return true
1437
1720
  }
1438
1721
 
1722
+ /* ---- 动作层的三个入口 -------------------------------------------------
1723
+ amp() :幅度档位(full 1 / subtle .5 / off 0),CSS 那边读的是同一个
1724
+ 区间 —— 关键帧里用 calc 乘 --wisp-amp,指针侧倾在这里乘。
1725
+ reduced():prefers-reduced-motion。CSS 已经把动画关掉了,但**姿势是 JS
1726
+ 写进去的**,样式表关不掉它,所以这里必须再问一次系统。
1727
+ matchMedia 不存在的壳里返回 false:拿不到就不假装系统说了什么。
1728
+ accent() :放一次"一次性动作"。它只写一个属性,什么时候收手由这里的
1729
+ 时钟决定(CSS 只管怎么动)—— 所以不会出现"动画早放完了、
1730
+ 属性还挂着"那种半截状态。 */
1731
+ let reducedMql = null
1732
+ const reduced = () => {
1733
+ try {
1734
+ if (reducedMql === null && typeof window.matchMedia === 'function') {
1735
+ reducedMql = window.matchMedia('(prefers-reduced-motion: reduce)')
1736
+ }
1737
+ return !!(reducedMql && reducedMql.matches)
1738
+ } catch (e) { return false }
1739
+ }
1740
+ const amp = () => {
1741
+ const value = MOTION_AMP[cfg.motion]
1742
+ return typeof value === 'number' ? value : 1
1743
+ }
1744
+
1745
+ let accentHandle = null
1746
+ let accentFlip = false
1747
+ const accent = (name) => {
1748
+ if (destroyed) return
1749
+ /* 静止档与"系统要求别动"都不放动作。CSS 那边也关着(媒体查询 +
1750
+ --wisp-amp:0),但**属性本身也不该被写上** —— 否则 doctor() 会报一个
1751
+ 根本没发生的动作,读代码的人也会以为这条路是活的。 */
1752
+ if (amp() === 0 || reduced()) return
1753
+ /* pop-a / pop-b 是同一件事的两个名字。**同一个值再写一次不会重播动画**,
1754
+ 而连戳两下必须看到两次弹 —— 换名字是这里最可靠的强制重播办法
1755
+ (靠读一次 offsetWidth 强制重排也能做到,但那要在替身里也装一个
1756
+ 布局引擎,而替身没有布局)。 */
1757
+ const value = name === 'pop'
1758
+ ? ((accentFlip = !accentFlip) ? 'pop-b' : 'pop-a')
1759
+ : name
1760
+ root.dataset.accent = value
1761
+ if (accentHandle) clock.cancel(accentHandle)
1762
+ accentHandle = clock.after(ACCENT_MS[name] || 400, () => {
1763
+ accentHandle = null
1764
+ if (!destroyed) delete root.dataset.accent
1765
+ })
1766
+ }
1767
+
1768
+ /* 姿势:° 是**屏幕坐标**里的角度(正 = 顶朝右),乘镜像由 applyTilt 做。
1769
+ 这里是所有姿势的唯一闸门 —— 静止档与"系统要求别动"都在这一个地方归零,
1770
+ 调用点不必各自记得判断。 */
1771
+ const setTilt = (deg) => {
1772
+ const next = (amp() === 0 || reduced()) ? 0 : clamp(deg, -TILT_DRAG_MAX, TILT_DRAG_MAX)
1773
+ if (Math.abs(next - tiltScreen) < TILT_STEP) return
1774
+ tiltScreen = next
1775
+ applyTilt()
1776
+ }
1777
+
1778
+ /* 档位改动要**当场**生效,不用刷新:属性一改,CSS 那边的全部幅度规则
1779
+ (关键帧里的 --wisp-amp、静止档关掉呼吸与 z)立刻跟上。
1780
+ 另外把姿势归零 —— 从"灵动"切到"静止"时她还歪着,就成了半截状态。 */
1781
+ const applyMotion = () => {
1782
+ root.dataset.motion = cfg.motion
1783
+ if (amp() === 0 || reduced()) setTilt(0)
1784
+ }
1785
+ applyMotion()
1786
+
1439
1787
  const setMood = (next) => {
1440
- if (destroyed || mood === next) return
1788
+ if (destroyed) return
1789
+ if (mood === next) return
1441
1790
  mood = next
1442
1791
  root.dataset.mood = next
1792
+ /* 换表情 = 弹一下(只给有表情的那几个状态配,理由见 POPPY_MOODS)。
1793
+ 它必须在"真的换了表情"**之后**:`attn` 也在 POPPY_MOODS 里,而
1794
+ poll 只要看到待处理面板就每一轮重算 setMood('attn') —— 放在去重之前,
1795
+ 她会在你盯着审批卡片等的时候每 1.2 秒抽一下(真机上 6 秒 5 次)。
1796
+ "连戳两下第二下也要弹"是**手势**的要求,由 flashHappy/flashPoked
1797
+ 自己补(见那两处),不该让稳态重算也走这条路。 */
1798
+ if (POPPY_MOODS.indexOf(next) >= 0) accent('pop')
1799
+ /* 睡着时不该还朝着你的指针倾 —— 那和一个"睡着了还跟着你转头"的人一样怪。
1800
+ 在**进入** sleep 时归零,而不是在指针处理器里每帧都判一次。 */
1801
+ if (next === 'sleep') setTilt(0)
1443
1802
  const want = SPRITE_OF[next] || 'idle'
1444
1803
  if (want !== currentSprite) setSprite(want)
1445
1804
  const zs = root.querySelectorAll('.wisp-zzz')
@@ -1467,6 +1826,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1467
1826
  const el = document.createElement('div')
1468
1827
  el.className = 'wisp-say'
1469
1828
  el.style.position = 'absolute'
1829
+ /* 气泡的淡出时长跟着**它的寿命**走,而不是跟着样式表里那个数:
1830
+ 两边各写一份就会漂(样式表里写 4.2s、代码里活 4.4s —— 气泡会提前
1831
+ 200ms 淡完再干等)。和 ACCENT_MS 一个道理:CSS 管怎么动,代码管多久。 */
1832
+ el.style.animationDuration = (SAY_MS / 1000) + 's'
1470
1833
  el.textContent = text
1471
1834
  // Into the LAYER, not the root: a child of the root would be mirrored
1472
1835
  // with her and scaled by `size`.
@@ -1700,6 +2063,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1700
2063
  }
1701
2064
 
1702
2065
  const flashHappy = (text) => {
2066
+ /* 手势驱动的反应:**已经是这个表情时也要弹**。setMood 的弹一下挂在
2067
+ "真的换了表情"后面(否则轮询重算的 attn 会变成 1.2 秒一次的抽搐),
2068
+ 所以"连戳两下"这条要求由这里补上 —— 它只由用户的手势触发。 */
2069
+ if (mood === 'happy') accent('pop')
1703
2070
  setMood('happy')
1704
2071
  say(text)
1705
2072
  if (happyHandle) clock.cancel(happyHandle)
@@ -1711,6 +2078,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1711
2078
  const POKED_MS = 1800
1712
2079
  let pokedHandle = null
1713
2080
  const flashPoked = (text) => {
2081
+ if (mood === 'poked') accent('pop') // 同上:连戳到底也要每次都弹
1714
2082
  setMood('poked')
1715
2083
  say(text)
1716
2084
  if (pokedHandle) clock.cancel(pokedHandle)
@@ -1983,18 +2351,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1983
2351
  `data-gliding`,由 CSS 过渡接管 transform;一旦按下就立刻摘掉,
1984
2352
  否则拖动会被过渡拖后腿。 */
1985
2353
  let glideHandle = null
1986
- const glideTo = () => {
2354
+ const glideTo = (dx) => {
1987
2355
  root.dataset.gliding = 'true'
1988
2356
  paint()
2357
+ /* 朝行进方向微微前倾。整段 1.6 秒的滑行里,这一点倾斜就是"她往那边去"
2358
+ 与"她被平移到那边"的区别 —— 归零那一半由 .wisp-lean 的过渡做。 */
2359
+ setTilt(clamp((Number(dx) || 0) * TILT_GLIDE_PER_PX, -TILT_GLIDE_MAX, TILT_GLIDE_MAX) * amp())
1989
2360
  if (glideHandle) clock.cancel(glideHandle)
1990
2361
  glideHandle = clock.after(1700, () => {
1991
2362
  glideHandle = null
1992
2363
  delete root.dataset.gliding
2364
+ setTilt(0)
1993
2365
  })
1994
2366
  }
1995
2367
  const stopGlide = () => {
1996
2368
  if (glideHandle) { clock.cancel(glideHandle); glideHandle = null }
1997
2369
  delete root.dataset.gliding
2370
+ setTilt(0)
1998
2371
  }
1999
2372
 
2000
2373
  let wanderHandle = null
@@ -2008,9 +2381,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2008
2381
  // a real stroll rather than a rounding error in a random direction.
2009
2382
  const angle = Math.random() * Math.PI * 2
2010
2383
  const dist = cfg.wanderRange * (0.45 + Math.random() * 0.55)
2384
+ const fromX = coords.x
2011
2385
  coords.x = clamp(coords.x + Math.cos(angle) * dist, 0, maxX())
2012
2386
  coords.y = clamp(coords.y + Math.sin(angle) * dist * 0.6, 0, maxY())
2013
- glideTo()
2387
+ glideTo(coords.x - fromX)
2014
2388
  // 故意不 rememberPosition():记忆的是"用户把她放在哪",她自己的溜达
2015
2389
  // 不该覆盖那个意图,否则刷新后她会出现在一个用户从没选过的位置。
2016
2390
  })
@@ -2066,13 +2440,15 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2066
2440
  if (destroyed) return
2067
2441
  hidden = next === true
2068
2442
  root.dataset.hidden = hidden ? 'true' : 'false'
2069
- if (hidden) { closeMenu(); hideTab(); clearSay(); showTab() }
2443
+ if (hidden) { closeMenu(); hideTab(); clearSay(); showTab(); setTilt(0) }
2070
2444
  else { hideTab(); paint() }
2071
2445
  if (cfg.persist) writeStored(HIDDEN_KEY, { hidden })
2072
2446
  }
2073
2447
 
2074
2448
  /* ---- drag & click ---- */
2075
2449
  let start = null, origin = null, moved = false, lastClick = 0
2450
+ /* 上一次指针的横坐标:拖动中的侧倾是"这一段的位移",不是"从按下算起的总位移" */
2451
+ let lastDragX = 0
2076
2452
  /* 连戳计数:POKE_WINDOW_MS 内累计到 POKE_ANNOYED_AT 次就抗议一次 */
2077
2453
  const POKE_WINDOW_MS = 3000
2078
2454
  const POKE_ANNOYED_AT = 3
@@ -2107,12 +2483,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2107
2483
  if (!moved) return
2108
2484
  coords.x = clamp(origin.x + dx, 0, maxX())
2109
2485
  coords.y = clamp(origin.y + dy, 0, maxY())
2486
+ /* 拖动时她朝**运动的反方向**倾:被拎着走的东西,顶是往后倒的。
2487
+ 只看"这一次事件走了多远"就等价于速度,不用存历史、不用积分 ——
2488
+ 而且它天然跟着指针事件的频率走,慢拖就是小角度。 */
2489
+ setTilt(-(e.clientX - lastDragX) * TILT_DRAG_PER_PX * amp())
2490
+ lastDragX = e.clientX
2110
2491
  paint()
2111
2492
  }
2112
2493
  const onUp = () => {
2113
2494
  window.removeEventListener('pointermove', onMove)
2114
2495
  window.removeEventListener('pointerup', onUp)
2115
2496
  delete root.dataset.dragging
2497
+ /* 松手分两种:真拖过 = 落地(顿一下);只按了一下 = 什么都不放 ——
2498
+ 紧接着的点击反应会自己弹一下(见 ACCENT_MS 的说明:通道只留
2499
+ 最后发生的那件事)。姿势则一律由 CSS 过渡归零(拖动中它被
2500
+ data-dragging 关掉了,现在恢复了)。 */
2501
+ if (moved) accent('land')
2502
+ setTilt(0)
2116
2503
  start = null
2117
2504
  if (moved) {
2118
2505
  rememberPosition()
@@ -2152,15 +2539,50 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2152
2539
  if (!hitsBody(e)) return
2153
2540
  stopGlide() // a stroll must not fight the drag
2154
2541
  e.preventDefault(); e.stopPropagation()
2542
+ /* 点她 = 把键盘焦点给她。以前这是浏览器的默认行为(她自己就是那个被点的
2543
+ tabindex=0 元素);命中层接管点击之后默认焦点会落到 <body>,于是
2544
+ "点一下她、再按 Enter 开菜单"就断了 —— 显式补回来。 */
2545
+ focusHer()
2155
2546
  start = { x: e.clientX, y: e.clientY }
2156
2547
  origin = { x: coords.x, y: coords.y }
2157
2548
  moved = false
2549
+ lastDragX = e.clientX
2158
2550
  root.dataset.dragging = 'true'
2551
+ /* 按下去就压一下 —— 触摸反馈要在**按下**那一刻发生,等到松手才动
2552
+ 就已经晚了(那正是"拖动"与"点击"共同的起点)。 */
2553
+ accent('press')
2159
2554
  window.addEventListener('pointermove', onMove)
2160
2555
  window.addEventListener('pointerup', onUp)
2161
2556
  }
2162
2557
  bodyEl.addEventListener('pointerdown', onDown)
2163
2558
 
2559
+ /* ---- 姿势:她注意到你的指针 -------------------------------------------
2560
+ 指针进到 170px 的圈里,她朝指针方向侧身;贴得越近倾得越多,出圈归零。
2561
+
2562
+ 这是全插件唯一一处"持续读指针"的逻辑,所以它必须便宜:
2563
+ · 纯算术,不查 DOM、不读布局(读一次 getBoundingClientRect 就是一次强制重排)
2564
+ · 出圈就走 setTilt(0),而 setTilt 自己挡掉"变化不到 0.05°"的写入
2565
+ · 拖动中直接返回 —— 那时候姿势由速度接管
2566
+ window 上本来就挂着 pointermove(wake:她得能被碰醒),所以这里不是
2567
+ 新开一条全局热路径,只是同一条路上的第二个判断。 */
2568
+ const leanToPointer = (e) => {
2569
+ if (destroyed || paused || hidden || start !== null) return
2570
+ if (mood === 'sleep') return
2571
+ if (cfg.motion === 'off' || reduced()) return
2572
+ if (typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return
2573
+ const dx = e.clientX - (coords.x + boxW() / 2)
2574
+ const dy = e.clientY - (coords.y + boxH() / 2)
2575
+ if (Math.abs(dx) > TILT_NEAR_PX || Math.abs(dy) > TILT_NEAR_PX) { setTilt(0); return }
2576
+ const near = 1 - Math.abs(dx) / TILT_NEAR_PX
2577
+ const level = 1 - Math.abs(dy) / TILT_NEAR_PX // 从她正上方路过不该让她倾
2578
+ setTilt((dx < 0 ? -1 : 1) * TILT_NEAR_MAX * near * level * amp())
2579
+ }
2580
+ window.addEventListener('pointermove', leanToPointer, { passive: true })
2581
+ /* 指针移出窗口、或者你切走应用之后,不会再有点事件来把它归零 ——
2582
+ 没有这一下,她会一直朝着最后那个位置歪着。 */
2583
+ const resetTilt = () => setTilt(0)
2584
+ window.addEventListener('blur', resetTilt)
2585
+
2164
2586
  /* 键盘入口:Enter / Space / 菜单键 / Shift+F10 打开菜单。
2165
2587
  Space 默认会滚动页面,Enter 在表单里会提交 —— 都要拦。 */
2166
2588
  /* 方向键微调位置。
@@ -2259,6 +2681,45 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2259
2681
 
2260
2682
  /* 开关行:真开关的样子 + role=menuitemcheckbox + aria-checked。
2261
2683
  点完**不关菜单** —— 拨开关时菜单消失是最烦人的交互之一。 */
2684
+ /* 三选一(语言)。做成一行里的一排小胶囊:比三个开关清楚 ——
2685
+ "同时只能选一个"这件事,胶囊组是自明的,开关组不是。 */
2686
+ const choiceRow = (spec, into) => {
2687
+ const row = document.createElement('div')
2688
+ row.className = 'wisp-menu-item wisp-menu-row wisp-choice-row'
2689
+ row.dataset.choice = spec.key
2690
+ const label = document.createElement('span')
2691
+ label.textContent = spec.label
2692
+ row.appendChild(glyph(spec.glyph || ''))
2693
+ row.appendChild(label)
2694
+ const group = document.createElement('span')
2695
+ group.className = 'wisp-choice'
2696
+ group.setAttribute('role', 'radiogroup')
2697
+ group.setAttribute('aria-label', spec.label)
2698
+ const current = cfg[spec.key] === undefined ? 'auto' : String(cfg[spec.key])
2699
+ for (const opt of spec.options) {
2700
+ const btn = document.createElement('button')
2701
+ btn.className = 'wisp-choice-opt'
2702
+ btn.setAttribute('type', 'button')
2703
+ btn.setAttribute('role', 'radio')
2704
+ btn.setAttribute('tabindex', '-1')
2705
+ btn.setAttribute('aria-checked', opt.value === current ? 'true' : 'false')
2706
+ btn.dataset.value = opt.value
2707
+ btn.dataset.on = opt.value === current ? 'true' : 'false'
2708
+ btn.textContent = opt.label
2709
+ btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
2710
+ btn.addEventListener('click', (ev) => {
2711
+ ev.preventDefault(); ev.stopPropagation()
2712
+ try { applyConfig({ [spec.key]: opt.value }) } catch (err) { /* ignore */ }
2713
+ refreshSurfaces() // 立刻重画:语言切换要当场看得见
2714
+ })
2715
+ group.appendChild(btn)
2716
+ menuButtons.push(btn)
2717
+ }
2718
+ row.appendChild(group)
2719
+ into.appendChild(row)
2720
+ return row
2721
+ }
2722
+
2262
2723
  const switchRow = (spec, into) => {
2263
2724
  const on = cfg[spec.key] === true
2264
2725
  const btn = document.createElement('button')
@@ -2400,6 +2861,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2400
2861
  const renderChildren = (children, into) => {
2401
2862
  for (const child of children) {
2402
2863
  if (child.kind === 'switch') { switchRow(child, into); continue }
2864
+ if (child.kind === 'choice') { choiceRow(child, into); continue }
2403
2865
  if (child.kind === 'corners') { cornerGrid(into); continue }
2404
2866
  actionRow(child, into)
2405
2867
  }
@@ -2827,6 +3289,28 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2827
3289
  { label: '说句话', glyph: '✦', run: () => { wake(true); say(pick(LINES.click)) } },
2828
3290
  { label: '睡一会儿', glyph: '☾', run: () => setMood('sleep') },
2829
3291
  { label: '今天做了什么', glyph: '✎', run: () => saySummary() },
3292
+ /* 余额:按需查,查完她自己说 —— 不做轮询(稳态零开销那条承诺不破)。 */
3293
+ { label: '看看余额', glyph: '¥', run: () => { checkBalance() } },
3294
+ /* 语言:三选一。切换**立刻生效**(LINES 是视图,不是死值)。 */
3295
+ {
3296
+ kind: 'choice', key: 'lang', label: '台词语言', glyph: '文',
3297
+ options: [
3298
+ { value: 'auto', label: '跟随界面' },
3299
+ { value: 'zh', label: '中文' },
3300
+ { value: 'en', label: 'English' },
3301
+ ],
3302
+ },
3303
+ /* 动作幅度:三选一。同样立刻生效 —— 它只改一个属性,CSS 全挂在上面。
3304
+ 给这一档的理由:prefers-reduced-motion 是**系统级**的"所有动画都别放",
3305
+ 而"我不想让这只桌宠动来动去"是另一件事,不该逼人去改系统设置。 */
3306
+ {
3307
+ kind: 'choice', key: 'motion', label: '动作幅度', glyph: '≈',
3308
+ options: [
3309
+ { value: 'full', label: '灵动' },
3310
+ { value: 'subtle', label: '克制' },
3311
+ { value: 'off', label: '静止' },
3312
+ ],
3313
+ },
2830
3314
  /* 没有音效文件就不摆这个开关 —— 点了没反应的开关比没有开关更糟 */
2831
3315
  ...(HAS_SOUNDS ? [{ kind: 'switch', key: 'sound', label: '音效', glyph: '♪' }] : []),
2832
3316
  /* 开关是**真正的开关行**:带 aria-checked,点完菜单不关(就在原地更新) */
@@ -3229,11 +3713,21 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3229
3713
  if (cfg.persist && partial && typeof partial === 'object' && partial.size !== undefined) {
3230
3714
  writeStored(PREFS_KEY, { size: cfg.size })
3231
3715
  }
3716
+ /* 语言选择存到**语言键**上(不是 PREFS_KEY):它在**加载时**就会被读,
3717
+ 所以下次打开才生效。菜单里的胶囊切的是"以后说哪种话"。 */
3718
+ if (partial && typeof partial === 'object' && typeof partial.lang === 'string') {
3719
+ try { window.localStorage.setItem('dsh-wisp-lang', cfg.lang) } catch (e) { /* 存不了就算了 */ }
3720
+ }
3232
3721
  // A bigger companion occupies a bigger box, so the stored position has
3233
3722
  // to be re-clamped or she would grow off the edge.
3234
3723
  root.style.width = Math.round(boxW()) + 'px'
3235
3724
  root.style.height = Math.round(boxH()) + 'px'
3236
3725
  layer.style.setProperty('--wisp-scale', String(cfg.size))
3726
+ /* 尺寸变了,命中层的形状也要跟着缩放(clip-path 里的坐标是 px,不是百分比)。 */
3727
+ applyHitClip(currentSprite)
3728
+ /* 动作档位也要当场生效(菜单里的胶囊就靠这一行):它只改一个属性,
3729
+ CSS 那边所有幅度规则都挂在它上面。 */
3730
+ if (partial && typeof partial === 'object' && partial.motion !== undefined) applyMotion()
3237
3731
  coords.x = clamp(coords.x, 0, maxX())
3238
3732
  coords.y = clamp(coords.y, 0, maxY())
3239
3733
  paint()
@@ -3291,7 +3785,15 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3291
3785
  .then(() => seat.call('checkUpdate', { current: VERSION }))
3292
3786
  .then((res) => {
3293
3787
  if (!res || res.ok !== true || typeof res.latest !== 'string') {
3294
- updateCheck = { state: 'failed', reason: (res && res.reason) || 'no-result', at: nowMs() }
3788
+ /* 失败时也要把**每个源各自的原因**留下来。用户看到的是"没查成",
3789
+ 而 doctor() 必须能说清是"这个壳里没有网络服务"还是"两个源都没连上" ——
3790
+ 以前这里把 sources 丢了,失败就变成一句无法追问的话。 */
3791
+ updateCheck = {
3792
+ state: 'failed',
3793
+ reason: (res && res.reason) || 'no-result',
3794
+ sources: (res && res.sources) || null,
3795
+ at: nowMs(),
3796
+ }
3295
3797
  } else if (compareVersions(VERSION, res.latest) < 0) {
3296
3798
  /* from / sources 一起留着:两个源不一致时(npm 暂存、GitHub 已更新),
3297
3799
  "这个数字是哪儿来的"比数字本身更需要能查。 */
@@ -3312,9 +3814,95 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3312
3814
  })
3313
3815
  }
3314
3816
 
3817
+ /* ---- 看余额 -------------------------------------------------------------
3818
+ 和"检查更新"走同一条路,但服务不同:宿主半包 inject 'deepseekAccount',
3819
+ 用平台自己的账户服务取余额。**插件从头到尾不碰 API key** —— 凭据在平台
3820
+ 手里,浏览器半包连读都读不到(它没有网络,fetch 是陷阱)。
3821
+
3822
+ 未登录、读不到、这个壳里没有那个服务,是三句不同的话:
3823
+ 说是"查不到"但其实是"没登录",就是在骗人。 */
3824
+ let balanceCheck = { state: 'idle', at: 0 }
3825
+
3826
+ /* 金额由客户端拼,台词只管口气。货币符号认不出来就原样写代码。 */
3827
+ const balanceAmount = (wallets) => {
3828
+ const bits = []
3829
+ for (const w of (Array.isArray(wallets) ? wallets : [])) {
3830
+ if (!w || typeof w.balance !== 'string') continue
3831
+ const sign = w.currency === 'CNY' ? '¥' : (w.currency === 'USD' ? '$' : '')
3832
+ bits.push(sign === '' ? (w.balance + (w.currency ? ' ' + w.currency : '')) : sign + w.balance)
3833
+ }
3834
+ return bits.join(' + ')
3835
+ }
3836
+
3837
+ const balanceLine = (res) => {
3838
+ if (res.state === 'ready') {
3839
+ const main = balanceAmount(res.wallets)
3840
+ if (main === '') return pick(LINES.balanceEmpty)
3841
+ const text = pick(LINES.balance).split('{n}').join(main)
3842
+ const bonus = balanceAmount(res.bonusWallets)
3843
+ return bonus === '' ? text : text + pick(LINES.balanceBonus).split('{n}').join(bonus)
3844
+ }
3845
+ if (res.state === 'signed-out') return pick(LINES.balanceSignedOut)
3846
+ if (res.state === 'unavailable') return pick(LINES.balanceUnavailable)
3847
+ if (res.state === 'unsupported') return pick(LINES.balanceUnsupported)
3848
+ return pick(LINES.balanceFailed)
3849
+ }
3850
+
3851
+ /* 按需查:菜单里那一行和 `__wisp.checkBalance()` 都走这里。
3852
+ 它**自己说话**(先"看一下……"再报结果)—— 菜单点完就关了,留一个
3853
+ 只返回状态、不出声的函数,用户会以为没反应。 */
3854
+ const checkBalance = () => {
3855
+ const seat = hostSeat()
3856
+ if (seat === null) {
3857
+ balanceCheck = { state: 'unsupported', at: nowMs() }
3858
+ wake(true)
3859
+ say(balanceLine(balanceCheck))
3860
+ return Promise.resolve(balanceCheck)
3861
+ }
3862
+ balanceCheck = { state: 'checking', at: nowMs() }
3863
+ wake(true)
3864
+ say(pick(LINES.balanceChecking))
3865
+ return Promise.resolve()
3866
+ .then(() => seat.call('checkBalance', { locale: LANG_TAG }))
3867
+ .then((res) => {
3868
+ if (!res || res.ok !== true) {
3869
+ balanceCheck = { state: 'failed', reason: (res && res.reason) || 'no-result', at: nowMs() }
3870
+ } else if (res.status === 'ready') {
3871
+ balanceCheck = {
3872
+ state: 'ready',
3873
+ wallets: Array.isArray(res.wallets) ? res.wallets : [],
3874
+ bonusWallets: Array.isArray(res.bonusWallets) ? res.bonusWallets : [],
3875
+ at: nowMs(),
3876
+ }
3877
+ } else if (res.status === 'signed-out' || res.status === 'unavailable') {
3878
+ balanceCheck = { state: res.status, at: nowMs() }
3879
+ } else {
3880
+ /* 平台答了,答案是 failed —— 是结果,不是异常。 */
3881
+ balanceCheck = { state: 'failed', reason: 'service-failed', at: nowMs() }
3882
+ }
3883
+ say(balanceLine(balanceCheck))
3884
+ return balanceCheck
3885
+ })
3886
+ .catch((err) => {
3887
+ balanceCheck = {
3888
+ state: 'failed',
3889
+ reason: 'call-failed',
3890
+ detail: String(err && err.message ? err.message : err),
3891
+ at: nowMs(),
3892
+ }
3893
+ say(balanceLine(balanceCheck))
3894
+ return balanceCheck
3895
+ })
3896
+ }
3897
+
3315
3898
  const updateLine = (res) => {
3316
3899
  if (res.state === 'available') return pick(LINES.updateAvailable) + res.latest
3317
3900
  if (res.state === 'current') return pick(LINES.updateCurrent)
3901
+ /* 失败要说清是哪一种:没有通道 / 连不上 / 宿主没应答。
3902
+ `no-web-service` 是"这个壳根本没有联网入口",不是"网络不好"——
3903
+ 以前两者共用一句"没查到更新",于是诊断只能靠猜。 */
3904
+ if (res.state === 'unsupported' || res.reason === 'no-web-service') return pick(LINES.updateNoChannel)
3905
+ if (res.reason === 'call-failed') return pick(LINES.updateNoHost)
3318
3906
  return pick(LINES.updateFailed)
3319
3907
  }
3320
3908
 
@@ -3380,6 +3968,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3380
3968
  startFocus,
3381
3969
  stopFocus,
3382
3970
  saySummary,
3971
+ checkBalance,
3383
3972
  get today() { return { ...watchToday() } },
3384
3973
  get focus() { return focusState() },
3385
3974
  openAbout: () => aboutDialog(),
@@ -3474,6 +4063,21 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3474
4063
  error: { selector: ERROR_SELECTOR, matches: probe(ERROR_SELECTOR) },
3475
4064
  },
3476
4065
  dark: !!(body && body.hasAttribute('data-ds-dark-theme')),
4066
+ /* 余额:最后一次查的结果 + 有没有 host 座位。**凭据从不经过这里** ——
4067
+ 插件拿不到 key,也就不可能被谁从 doctor() 里读走。 */
4068
+ balance: Object.assign({ hostSeat: hostSeat() !== null }, balanceCheck),
4069
+ /* 动作层的实时状态。姿势角是**屏幕坐标**里的角度(未乘镜像),
4070
+ accent 是当下挂着的一次性动作 —— "她为什么歪着/弹了一下"
4071
+ 在 Console 里一眼能看出来,不用去读样式。 */
4072
+ motion: {
4073
+ level: cfg.motion,
4074
+ amp: amp(),
4075
+ reduced: reduced(),
4076
+ tilt: Math.round(tiltScreen * 100) / 100,
4077
+ tiltLocal: tiltWritten,
4078
+ accent: root.dataset.accent || null,
4079
+ pressed: root.dataset.dragging === 'true',
4080
+ },
3477
4081
  tokens,
3478
4082
  /* 窗口背景:Mica 能不能露出来,取决于页面有没有在背景处画不透明像素。
3479
4083
  blockers 是"每个采样点上最上面那个铺满视口的画底色的元素",
@@ -3511,7 +4115,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3511
4115
  },
3512
4116
  streak: streakOf(memory.days),
3513
4117
  /* 语言判定的输入与结果 —— 中英文走岔时能一眼看清是哪一层不对 */
3514
- lang: { tag: LANG_TAG, zh: IS_ZH, stored: langStored, html: langHtml, pools: Object.keys(LINES_EN).length },
4118
+ lang: {
4119
+ tag: LANG_TAG, zh: isZhNow(), choice: (cfg && typeof cfg.lang === 'string') ? cfg.lang : 'auto',
4120
+ stored: langStored, html: langHtml, pools: Object.keys(LINES_EN).length,
4121
+ },
3515
4122
  awaySince: Math.round(awayAt),
3516
4123
  /* 今天记了什么 —— 不用猜"她为什么说今天跑了 7 轮" */
3517
4124
  today: { ...watchToday() },
@@ -3573,6 +4180,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3573
4180
  }
3574
4181
  window.removeEventListener('pointermove', onMove)
3575
4182
  window.removeEventListener('pointerup', onUp)
4183
+ window.removeEventListener('pointermove', leanToPointer)
4184
+ window.removeEventListener('blur', resetTilt)
3576
4185
  bodyEl.removeEventListener('pointerdown', onDown)
3577
4186
  bodyEl.removeEventListener('keydown', onBodyKey)
3578
4187
  bodyEl.removeEventListener('contextmenu', onContext)