dsh-wisp 0.6.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.
@@ -0,0 +1,1086 @@
1
+ /* ============================================================================
2
+ dsh-wisp — browser half. GENERATED FILE — edit lib/client.template.js then
3
+ run `node build.mjs`. The build injects one object of data URIs (see the SPRITES const).
4
+
5
+ A CLASSIC browser script that registers itself with the web shell's module
6
+ loader. Not an ES module: no import, no export.
7
+
8
+ window.__ModuleLoader__.load({ id, factory: (require) => { … } })
9
+
10
+ The companion is DeepSeek娘 — the community's personification of DeepSeek.
11
+ Four AI-generated sprites are embedded as data URIs so the plugin stays a
12
+ single self-contained file with no asset paths to resolve. See NOTICE.md for
13
+ the character and image provenance.
14
+
15
+ Moods map to sprites:
16
+ idle -> idle (default)
17
+ alert -> work (agent is busy, or the composer has text)
18
+ happy -> happy (you clicked her, or a run just finished)
19
+ sleep -> sleepy (left alone for a while)
20
+
21
+ FOUR PLATFORM FACTS THIS FILE IS BUILT AROUND (each one cost a debugging
22
+ session — the traps are documented in NOTICE.md):
23
+
24
+ 1. setTimeout / setInterval / clearTimeout / clearInterval / fetch / require
25
+ are THROWING TRAPS inside a client half evaluated by the dynamic runner.
26
+ All timing here comes from the Client `timer` service (ctx.get('timer')),
27
+ with a requestAnimationFrame deadline table as the fallback for a shell
28
+ that does not provide it. Neither path ever touches a trapped global.
29
+
30
+ 2. The rAF fallback is armed ONLY while jobs are pending. An always-on frame
31
+ loop would keep a desktop GUI process waking sixty times a second to do
32
+ nothing — and pointermove fires often enough that re-arming a job per
33
+ event is its own small leak.
34
+
35
+ 3. Sprite bytes reach <img> through a blob: object URL, never as the raw
36
+ `data:` URI: this shell serves its UI from a custom origin and renders a
37
+ data: image source as a broken-image icon.
38
+
39
+ 4. The bubble and text colors come from the app's own theme tokens
40
+ (--dsw-alias-*) with hard-coded fallbacks, and the light/dark fallback is
41
+ keyed off `body[data-ds-dark-theme]` — the attribute the shell's theme
42
+ presenter actually writes.
43
+ ========================================================================== */
44
+ window.__ModuleLoader__.load({
45
+ id: 'dsh-wisp/client',
46
+ factory: (require) => {
47
+ var module = { exports: {} }
48
+ var exports = module.exports
49
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
50
+
51
+ const VERSION = '0.6.0'
52
+
53
+ /* ---- sprites: 256x384 WebP, transparent, inlined by build.mjs ---- */
54
+ const SPRITES = __SPRITES_LITERAL__
55
+
56
+ /* ---------------------------------------------------------------------
57
+ SPRITE URL RESOLUTION — why this is not simply `img.src = dataUri`.
58
+
59
+ The first shipped build set `img.src` to the base64 data URI directly.
60
+ The plugin loaded (console showed "[wisp] mounted"), the DOM was correct
61
+ and the ground glow rendered — but the sprite box showed a broken-image
62
+ icon. The embedded bytes were verified good (RIFF/WEBP magic, 256x384
63
+ RGBA), so the failure was the page refusing a `data:` URI as an image
64
+ source. This shell serves its UI over a custom `dsh-app://` protocol,
65
+ which appears to intercept resource loads.
66
+
67
+ So the data URI is decoded into a Blob and an object URL is used instead,
68
+ which is what the page accepts. Blob URLs are revoked on teardown.
69
+ --------------------------------------------------------------------- */
70
+ const blobUrls = Object.create(null)
71
+
72
+ const resolveSprite = (dataUri) => {
73
+ if (blobUrls[dataUri]) return blobUrls[dataUri]
74
+ try {
75
+ const comma = dataUri.indexOf(',')
76
+ const meta = dataUri.slice(0, comma)
77
+ const body = dataUri.slice(comma + 1)
78
+ const mime = (meta.match(/data:([^;]+)/) || [])[1] || 'image/webp'
79
+ const bin = atob(body)
80
+ const bytes = new Uint8Array(bin.length)
81
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
82
+ const url = URL.createObjectURL(new Blob([bytes], { type: mime }))
83
+ blobUrls[dataUri] = url
84
+ return url
85
+ } catch (error) {
86
+ // Blob unavailable for some reason — fall back to the raw data URI.
87
+ blobUrls[dataUri] = dataUri
88
+ return dataUri
89
+ }
90
+ }
91
+
92
+ /* Revoking is not enough: this cache is per MODULE, while a mount is per
93
+ apply, so a second apply in the same module (HMR, or the singleton
94
+ replacement below) would otherwise hand the new <img> a revoked blob URL
95
+ and render the sprite as a broken image. Dropping the entries makes the
96
+ next mount rebuild them. */
97
+ const revokeSprites = () => {
98
+ for (const key in blobUrls) {
99
+ const url = blobUrls[key]
100
+ if (typeof url === 'string' && url.indexOf('blob:') === 0) {
101
+ try { URL.revokeObjectURL(url) } catch (e) { /* ignore */ }
102
+ }
103
+ delete blobUrls[key]
104
+ }
105
+ }
106
+
107
+ /* ---- geometry: one box per mood so she never jumps ---- */
108
+ const BOX_W = 140
109
+ const BOX_H = 210
110
+ const GROUND = 46 // px from the bottom edge to her feet
111
+ const SAY_MS = 4400 // bubble lifetime
112
+ const SAY_MAX_W = 210 // must match .wisp-say max-width in the CSS
113
+ const SAY_CLEARANCE = 84 // room the bubble needs above its anchor
114
+ const POLL_MS = 1200 // reaction poll
115
+ const WAKE_THROTTLE_MS = 1000
116
+
117
+ const DEFAULTS = {
118
+ bottom: GROUND,
119
+ right: 26,
120
+ sleepAfterMs: 90000,
121
+ reactions: true,
122
+ size: 4, // 560x840 CSS px, drawn from the 2048x3072 tier
123
+ persist: true, // remember where she was dragged to
124
+ celebrate: true, // cheer when a run finishes
125
+ celebrateAfterMs: 2500, // ...but only for runs that lasted this long
126
+ chatterMs: 0, // 0 = she stays quiet when idle
127
+ happyMs: 1100,
128
+ wander: true, // stroll around on her own while idle
129
+ wanderMs: 45000, // how often she considers moving
130
+ wanderRange: 260, // furthest a single stroll may travel
131
+ }
132
+
133
+ const LINES = {
134
+ hello: ['我在这儿。', '灯亮着,你忙你的。', '需要我就戳一下。'],
135
+ click: ['嗯?', '在的。', '别戳啦。', '……痒。', '有事说,没事也成。', '我一直看着屏幕呢。'],
136
+ happy: ['叮——', '这个我喜欢。', '再来一次?'],
137
+ sleep: ['呼……', '我眯一会儿。'],
138
+ wake: ['醒了醒了。', '嗯,我在。'],
139
+ done: ['跑完了。', '收了。', '这一轮结束。', '去看看?'],
140
+ chatter: ['……', '有点安静。', '我在数窗外的光。'],
141
+ move: ['这儿也行。', '我挪个位置。', '让让,我站这儿。'],
142
+ home: ['回来了。', '还是老地方。', '嗯,归位。'],
143
+ }
144
+ const pick = (a) => a[Math.floor(Math.random() * a.length)]
145
+ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
146
+
147
+ /* Read the clock through `window` only. The obvious `window.performance &&
148
+ performance.now()` mixes two references: in a page they are the same
149
+ object, but under a shell that supplies its own `window` the bare
150
+ `performance` global can be a different — or absent — one, and then every
151
+ deadline is computed on one time base and compared against another, so
152
+ nothing ever comes due. */
153
+ const nowMs = () => {
154
+ const perf = window.performance
155
+ return perf && typeof perf.now === 'function' ? perf.now() : Date.now()
156
+ }
157
+
158
+ /* ---------------------------------------------------------------------
159
+ CONFIG — normalized and clamped on the way in.
160
+
161
+ The config arrives from a profile YAML patch, so nothing about it is
162
+ guaranteed: a typo yields a string where a number belongs, and an
163
+ extreme value yields a companion nobody can find or click. Clamping
164
+ here means a bad patch degrades into a usable wisp instead of a bug
165
+ report.
166
+ --------------------------------------------------------------------- */
167
+ const num = (value, lo, hi, fallback) => {
168
+ const n = typeof value === 'number' && isFinite(value) ? value : fallback
169
+ return clamp(n, lo, hi)
170
+ }
171
+ const bool = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
172
+
173
+ /* One table drives both the initial normalization and the partial merge a
174
+ runtime `configure({…})` performs, so a key can never be clamped one way
175
+ at mount and another way later. */
176
+ const CONFIG_SPEC = {
177
+ bottom: { kind: 'num', lo: 0, hi: 4000 },
178
+ right: { kind: 'num', lo: 0, hi: 4000 },
179
+ size: { kind: 'num', lo: 0.4, hi: 8 },
180
+ sleepAfterMs: { kind: 'num', lo: 5000, hi: 3600000 },
181
+ celebrateAfterMs: { kind: 'num', lo: 0, hi: 600000 },
182
+ chatterMs: { kind: 'num', lo: 0, hi: 3600000 },
183
+ happyMs: { kind: 'num', lo: 200, hi: 10000 },
184
+ wanderMs: { kind: 'num', lo: 5000, hi: 3600000 },
185
+ wanderRange: { kind: 'num', lo: 0, hi: 2000 },
186
+ reactions: { kind: 'bool' },
187
+ persist: { kind: 'bool' },
188
+ celebrate: { kind: 'bool' },
189
+ wander: { kind: 'bool' },
190
+ }
191
+
192
+ const coerce = (key, value, fallback) => {
193
+ const spec = CONFIG_SPEC[key]
194
+ return spec.kind === 'num' ? num(value, spec.lo, spec.hi, fallback) : bool(value, fallback)
195
+ }
196
+
197
+ const normalizeConfig = (config) => {
198
+ const src = config && typeof config === 'object' ? config : {}
199
+ const cfg = {}
200
+ for (const key in CONFIG_SPEC) cfg[key] = coerce(key, src[key], DEFAULTS[key])
201
+ return cfg
202
+ }
203
+
204
+ /* Merge only the keys the caller actually sent: `configure({size: 2})` must
205
+ not silently reset every other preference to its default. */
206
+ const mergeConfig = (cfg, partial) => {
207
+ if (!partial || typeof partial !== 'object') return cfg
208
+ for (const key in CONFIG_SPEC) {
209
+ if (!Object.prototype.hasOwnProperty.call(partial, key)) continue
210
+ cfg[key] = coerce(key, partial[key], cfg[key])
211
+ }
212
+ return cfg
213
+ }
214
+
215
+ /* ---------------------------------------------------------------------
216
+ CLOCK — one scheduling surface, two implementations.
217
+
218
+ `mode` is reported on the handle and in the mount log so a bug report can
219
+ say which path this page took without a debugger.
220
+ --------------------------------------------------------------------- */
221
+ const createServiceClock = (timer) => {
222
+ const live = []
223
+ const track = (handle) => { live.push(handle); return handle }
224
+ const release = (handle) => {
225
+ const at = live.indexOf(handle)
226
+ if (at >= 0) live.splice(at, 1)
227
+ }
228
+ return {
229
+ mode: 'timer-service',
230
+ after(ms, fn) {
231
+ const handle = { cancelled: false, release: null }
232
+ handle.release = timer.timeout(() => {
233
+ handle.cancelled = true
234
+ release(handle)
235
+ fn()
236
+ }, ms)
237
+ return track(handle)
238
+ },
239
+ every(ms, fn) {
240
+ const handle = { cancelled: false, release: null }
241
+ handle.release = timer.interval(() => {
242
+ if (!handle.cancelled) fn()
243
+ }, ms)
244
+ return track(handle)
245
+ },
246
+ cancel(handle) {
247
+ if (!handle || handle.cancelled) return
248
+ handle.cancelled = true
249
+ release(handle)
250
+ try { handle.release() } catch (e) { /* already fired */ }
251
+ },
252
+ dispose() {
253
+ const pending = live.splice(0, live.length)
254
+ for (const handle of pending) {
255
+ handle.cancelled = true
256
+ try { handle.release() } catch (e) { /* already fired */ }
257
+ }
258
+ },
259
+ }
260
+ }
261
+
262
+ const createFrameClock = (win) => {
263
+ const jobs = []
264
+ let frame = 0
265
+
266
+ const arm = () => {
267
+ if (frame === 0 && jobs.length > 0) frame = win.requestAnimationFrame(tick)
268
+ }
269
+ const tick = () => {
270
+ frame = 0
271
+ const now = nowMs()
272
+ for (let i = jobs.length - 1; i >= 0; i--) {
273
+ const job = jobs[i]
274
+ if (now < job.at) continue
275
+ if (job.every > 0) job.at = now + job.every
276
+ else { job.cancelled = true; jobs.splice(i, 1) }
277
+ try { job.fn() } catch (e) { /* one failing job must not stop the clock */ }
278
+ }
279
+ arm() // stop the loop entirely once nothing is pending
280
+ }
281
+ const push = (ms, fn, every) => {
282
+ const handle = { cancelled: false, at: nowMs() + ms, fn, every: every ? ms : 0 }
283
+ jobs.push(handle)
284
+ arm()
285
+ return handle
286
+ }
287
+
288
+ return {
289
+ mode: 'animation-frame',
290
+ after: (ms, fn) => push(ms, fn, false),
291
+ every: (ms, fn) => push(ms, fn, true),
292
+ cancel(handle) {
293
+ if (!handle || handle.cancelled) return
294
+ handle.cancelled = true
295
+ const at = jobs.indexOf(handle)
296
+ if (at >= 0) jobs.splice(at, 1)
297
+ },
298
+ dispose() {
299
+ for (const job of jobs) job.cancelled = true
300
+ jobs.length = 0
301
+ if (frame !== 0) {
302
+ try { win.cancelAnimationFrame(frame) } catch (e) { /* ignore */ }
303
+ frame = 0
304
+ }
305
+ },
306
+ }
307
+ }
308
+
309
+ const createClock = (ctx) => {
310
+ const timer = ctx && typeof ctx.get === 'function' ? ctx.get('timer') : undefined
311
+ if (timer && typeof timer.timeout === 'function' && typeof timer.interval === 'function') {
312
+ return createServiceClock(timer)
313
+ }
314
+ return createFrameClock(window)
315
+ }
316
+
317
+ /* ---------------------------------------------------------------------
318
+ STORAGE — best effort, never fatal.
319
+
320
+ Reading `window.localStorage` itself throws on an opaque origin, which is
321
+ exactly what the desktop shell can be: the same plugin has to survive a
322
+ page that has no storage at all, so every access is guarded and a miss
323
+ just means "no remembered position".
324
+ --------------------------------------------------------------------- */
325
+ const POSITION_KEY = 'dsh-wisp:position:v1'
326
+
327
+ const readStored = (key) => {
328
+ try {
329
+ const raw = window.localStorage.getItem(key)
330
+ return raw === null ? null : JSON.parse(raw)
331
+ } catch (e) {
332
+ return null
333
+ }
334
+ }
335
+ const writeStored = (key, value) => {
336
+ try {
337
+ window.localStorage.setItem(key, JSON.stringify(value))
338
+ return true
339
+ } catch (e) {
340
+ return false
341
+ }
342
+ }
343
+
344
+ /* ---------------------------------------------------------------------
345
+ THE CSS. Three things are deliberate:
346
+
347
+ * Colors prefer the app's own alias tokens, so the companion inherits
348
+ whatever theme is active (including a third-party one) instead of
349
+ hard-coding a white bubble that looks wrong in one palette. The
350
+ token-free fallbacks are the hand-tuned pair, and
351
+ `body[data-ds-dark-theme]` is the shell's real dark switch.
352
+
353
+ * The bubble lives in `.wisp-layer`, NOT inside `.wisp-root`. It used to
354
+ be a child of the root, which mirrored its text whenever she faced left
355
+ (the root carries `scaleX(-1)`) and scaled its font with `size`. In
356
+ screen space it stays readable and unmirrored at any size.
357
+
358
+ * `--wisp-scale` carries the render scale for the few decorations that
359
+ should grow with her even though the root is laid out at its final
360
+ size rather than transform-scaled.
361
+ --------------------------------------------------------------------- */
362
+ const CSS = `.wisp-layer{position:fixed;inset:0;z-index:2147482000;pointer-events:none;overflow:hidden;font-family:inherit;--wisp-scale:1;--wisp-say-fg:#0d1a22;--wisp-say-bg:rgba(255,255,255,.93);--wisp-say-line:rgba(15,30,40,.10);--wisp-ink:#3b5a70}
363
+ .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)}
364
+ .wisp-root[data-dragging="true"]{will-change:transform}
365
+ .wisp-root[data-gliding="true"]{transition:transform 1.6s cubic-bezier(.4,0,.2,1)}
366
+ .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}
367
+ .wisp-body:active{cursor:grabbing}
368
+ .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}
369
+ .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}
370
+ .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}
371
+ .wisp-say{position:absolute;max-width:210px;width:max-content;padding:7px 11px;border-radius:12px;font-size:12px;line-height:1.45;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));box-shadow:0 6px 24px rgba(0,0,0,.22);-webkit-backdrop-filter:blur(14px) saturate(160%);backdrop-filter:blur(14px) saturate(160%);animation:wisp-say 4.2s ease forwards;pointer-events:none;white-space:pre-wrap}
372
+ .wisp-root[data-mood="alert"]{--wisp-aura:rgba(255,196,92,.50)}
373
+ .wisp-root[data-mood="happy"]{--wisp-aura:rgba(255,132,206,.50)}
374
+ .wisp-root[data-mood="sleep"]{--wisp-aura:rgba(140,152,215,.30)}
375
+ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20,24,32,.88);--wisp-say-line:rgba(255,255,255,.14);--wisp-ink:#cfe6ff}
376
+ .wisp-root[data-mood="sleep"] .wisp-body{animation-duration:9s}
377
+ .wisp-root[data-mood="alert"] .wisp-body{animation-duration:4.6s}
378
+ .wisp-root[data-mood="happy"] .wisp-body{animation-name:wisp-hop;animation-duration:1.15s;animation-timing-function:ease-in-out}
379
+ .wisp-root[data-mood="sleep"] .wisp-img{filter:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.82) brightness(.96)}
380
+ @keyframes wisp-bob{0%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-9px,0)}100%{transform:translate3d(0,0,0)}}
381
+ @keyframes wisp-zzz{0%{opacity:0;transform:translate(0,0) scale(.6)}30%{opacity:.9}100%{opacity:0;transform:translate(14px,-30px) scale(1.15)}}
382
+ @keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
383
+ @keyframes wisp-say{0%{opacity:0;transform:translate(-50%,-100%) translateY(6px) scale(.94)}12%{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}82%{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}100%{opacity:0;transform:translate(-50%,-100%) translateY(-6px) scale(.98)}}
384
+ @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
385
+
386
+ const installStyles = (styles) => {
387
+ if (styles && typeof styles.insert === 'function') {
388
+ try {
389
+ const dispose = styles.insert(CSS)
390
+ if (typeof dispose === 'function') return dispose
391
+ return null
392
+ } catch (e) { /* fall through to the owned <style> tag */ }
393
+ }
394
+ const head = document.head || document.documentElement
395
+ if (!head) return null
396
+ if (!document.getElementById('dsh-wisp-css')) {
397
+ const tag = document.createElement('style')
398
+ tag.id = 'dsh-wisp-css'
399
+ tag.textContent = CSS
400
+ head.appendChild(tag)
401
+ return () => { if (tag.parentNode) tag.remove() }
402
+ }
403
+ return null
404
+ }
405
+
406
+ /* ---- hit testing: only her own pixels may take the pointer ----
407
+ `.wisp-body` has to cover a whole box to be draggable, and that box is
408
+ mostly empty: a standing figure in a 2:3 frame only fills 55-87% of the
409
+ width (measured), and the rest is transparent margin. At the shipped size
410
+ that margin sits over the composer, so a click aimed at Send would be
411
+ swallowed by nothing. Sampling the sprite's alpha channel is what makes a
412
+ large companion harmless. */
413
+ const MASK_W = 96
414
+ const MASK_H = 144
415
+ const MASK_MIN_ALPHA = 16
416
+ const masks = Object.create(null)
417
+
418
+ /** 3x3 grow, so the hit area is never tighter than the eye expects. */
419
+ const dilate = (src) => {
420
+ const out = new Uint8Array(src.length)
421
+ for (let y = 0; y < MASK_H; y++) {
422
+ for (let x = 0; x < MASK_W; x++) {
423
+ if (src[y * MASK_W + x] <= MASK_MIN_ALPHA) continue
424
+ for (let dy = -1; dy <= 1; dy++) {
425
+ const ny = y + dy
426
+ if (ny < 0 || ny >= MASK_H) continue
427
+ for (let dx = -1; dx <= 1; dx++) {
428
+ const nx = x + dx
429
+ if (nx < 0 || nx >= MASK_W) continue
430
+ out[ny * MASK_W + nx] = 255
431
+ }
432
+ }
433
+ }
434
+ }
435
+ return out
436
+ }
437
+
438
+ const buildMask = (key, url) => {
439
+ if (masks[key]) return
440
+ try {
441
+ const canvas = document.createElement('canvas')
442
+ if (!canvas || typeof canvas.getContext !== 'function') return
443
+ const ctx = canvas.getContext('2d')
444
+ if (!ctx || typeof ctx.drawImage !== 'function' || typeof ctx.getImageData !== 'function') return
445
+ canvas.width = MASK_W
446
+ canvas.height = MASK_H
447
+ const probe = new Image()
448
+ probe.onload = () => {
449
+ try {
450
+ ctx.drawImage(probe, 0, 0, MASK_W, MASK_H)
451
+ const data = ctx.getImageData(0, 0, MASK_W, MASK_H).data
452
+ const raw = new Uint8Array(MASK_W * MASK_H)
453
+ let anyOpaque = false
454
+ for (let i = 0; i < raw.length; i++) {
455
+ raw[i] = data[i * 4 + 3]
456
+ if (raw[i] > MASK_MIN_ALPHA) anyOpaque = true
457
+ }
458
+ // A mask with no opaque pixel at all means the draw or the read did
459
+ // not do what we think (a cleared canvas, an unexpected format).
460
+ // Falling back to the whole box keeps her grabbable; the alternative
461
+ // is a companion that ignores every click.
462
+ if (!anyOpaque) return
463
+ // One cell of dilation: the mask is coarser than the sprite (each
464
+ // cell is ~3.6 CSS px at the shipped size) and her widest pixels are
465
+ // the aura, so without this a click that looks like it is on her
466
+ // edge falls into the pass-through margin.
467
+ masks[key] = dilate(raw)
468
+ } catch (e) { /* tainted or unsupported: the full box stays hittable */ }
469
+ }
470
+ probe.src = url
471
+ } catch (e) { /* no canvas: the full box stays hittable */ }
472
+ }
473
+
474
+ /* ---------------------------------------------------------------------
475
+ WHAT THE APP IS DOING — read from the DOM, deliberately.
476
+
477
+ These are facts the shell renders for its own users, so they survive DSH
478
+ version upgrades the way an internal service contract does not.
479
+
480
+ Cost matters here because this runs on a timer for as long as the app is
481
+ open, and both arms were measured in Chromium on a 10k-node transcript
482
+ (40k in brackets):
483
+
484
+ querySelectorAll('[data-composer-input],[contenteditable="true"],textarea')
485
+ 0.33 ms (1.42 ms) full-document COLLECTION, every tick
486
+ querySelector('button[aria-label="…"]') x4 exact
487
+ 0.45 ms (1.86 ms) four full traversals to find nothing, which is
488
+ the usual case: the button is absent when idle
489
+ querySelector('button[aria-label*="停止"],…*="Stop"]')
490
+ 0.27 ms (1.10 ms) two traversals, and it also matches a label we
491
+ have not seen yet — the label is localized
492
+
493
+ So: the composer is resolved ONCE and cached, re-resolved only when the
494
+ element leaves the document, and busy detection uses the two substring
495
+ arms. Same reaction, roughly a third of the cost.
496
+ --------------------------------------------------------------------- */
497
+ const STOP_SELECTOR = 'button[aria-label*="停止"],button[aria-label*="Stop"]'
498
+
499
+ /* Most specific hook first; the older shapes are only tried when it is
500
+ missing, and only on the cold path. */
501
+ const COMPOSER_HOOKS = ['[data-composer-input]', 'textarea', '[contenteditable="true"]']
502
+ const MISS_RETRY_POLLS = 4
503
+
504
+ const agentIsBusy = () => !!document.querySelector(STOP_SELECTOR)
505
+
506
+ let composerEl = null
507
+ let composerMisses = 0
508
+
509
+ const composerHasText = () => {
510
+ if (composerEl === null || composerEl.isConnected === false) {
511
+ // The precise hook is probed every tick while unresolved — it is one
512
+ // early-exit query, and a composer that has just appeared (a session was
513
+ // opened) must not wait seconds to be noticed.
514
+ composerEl = document.querySelector(COMPOSER_HOOKS[0])
515
+ if (composerEl === null && composerMisses++ % MISS_RETRY_POLLS === 0) {
516
+ // The pre-`data-composer-input` shapes match any rich-text editor on
517
+ // the page, so they are only worth probing occasionally.
518
+ for (let i = 1; i < COMPOSER_HOOKS.length && composerEl === null; i++) {
519
+ composerEl = document.querySelector(COMPOSER_HOOKS[i])
520
+ }
521
+ }
522
+ }
523
+ if (composerEl === null) return false
524
+ composerMisses = 0
525
+ const text = composerEl.tagName === 'TEXTAREA' ? composerEl.value : composerEl.textContent
526
+ return !!(text && text.trim().length > 0)
527
+ }
528
+
529
+ const mount = (config, ctx) => {
530
+ const cfg = normalizeConfig(config)
531
+ const clock = createClock(ctx)
532
+ const styleDispose = installStyles(ctx && typeof ctx.get === 'function' ? ctx.get('styles') : undefined)
533
+
534
+ const vw = () => window.innerWidth || 1400
535
+ const vh = () => window.innerHeight || 900
536
+ /* The box she OCCUPIES is the base box times the render scale, and every
537
+ clamp and placement below goes through these two. Clamping against the
538
+ unscaled box let a scaled wisp hang off the edge with her drag area in
539
+ the wrong place — invisible at size 1, obvious at any other. */
540
+ const boxW = () => BOX_W * cfg.size
541
+ const boxH = () => BOX_H * cfg.size
542
+ const maxX = () => Math.max(0, vw() - boxW())
543
+ const maxY = () => Math.max(0, vh() - boxH())
544
+ const halfSay = () => Math.min(SAY_MAX_W / 2, Math.max(0, (vw() - 20) / 2))
545
+
546
+ const layer = document.createElement('div')
547
+ layer.className = 'wisp-layer'
548
+ /* Critical layout is written INLINE as well as in the stylesheet.
549
+ A `position: static` div appended to <body> is an in-flow block, and a
550
+ non-absolute <img> is an in-flow replaced element — either one can make
551
+ the whole page scrollable if the stylesheet is not applied yet (or at
552
+ all) when this mounts, which is exactly what boot-time mounting risks.
553
+ Inline styles cannot be missing. */
554
+ layer.style.position = 'fixed'
555
+ layer.style.top = '0'
556
+ layer.style.left = '0'
557
+ layer.style.right = '0'
558
+ layer.style.bottom = '0'
559
+ layer.style.overflow = 'hidden'
560
+ layer.style.pointerEvents = 'none'
561
+ layer.style.zIndex = '2147482000'
562
+
563
+ const root = document.createElement('div')
564
+ root.className = 'wisp-root'
565
+ root.dataset.mood = 'idle'
566
+ root.style.position = 'absolute'
567
+ root.style.pointerEvents = 'none'
568
+ root.style.transformOrigin = 'top center'
569
+
570
+ /* Restore where she was left, clamped into the current viewport: the
571
+ window may well have been resized (or moved to another monitor) since
572
+ the position was written. */
573
+ const stored = cfg.persist ? readStored(POSITION_KEY) : null
574
+ const coords = { x: 0, y: 0 }
575
+ if (stored && isFinite(stored.x) && isFinite(stored.y)) {
576
+ coords.x = clamp(stored.x, 0, maxX())
577
+ coords.y = clamp(stored.y, 0, maxY())
578
+ } else {
579
+ coords.x = clamp(vw() - cfg.right - boxW(), 0, maxX())
580
+ coords.y = clamp(vh() - cfg.bottom - boxH(), 0, maxY())
581
+ }
582
+
583
+ /* The root is LAID OUT at its final size rather than transform-scaled:
584
+ a scaled layer is rasterized once and then stretched, so an enlarged
585
+ sprite goes soft, whereas a laid-out box rasterizes the 512px source at
586
+ the size it is actually drawn at. Only the left/right mirror stays a
587
+ transform. `transform-origin` is the top CENTRE so mirroring happens in
588
+ place — with the old `top left` origin she jumped a full box width to
589
+ the left the moment she crossed the midline. */
590
+ root.style.width = Math.round(boxW()) + 'px'
591
+ root.style.height = Math.round(boxH()) + 'px'
592
+ layer.style.setProperty('--wisp-scale', String(cfg.size))
593
+
594
+ /* ---- the speech bubble lives in screen space (see the CSS note) ---- */
595
+ let sayEl = null
596
+ const placeSay = () => {
597
+ if (sayEl === null) return
598
+ const half = halfSay()
599
+ const centre = coords.x + boxW() / 2
600
+ const upper = Math.max(half + 10, vw() - half - 10)
601
+ sayEl.style.left = Math.round(Math.min(Math.max(centre, half + 10), upper)) + 'px'
602
+ // The bubble hangs ABOVE this anchor, so it needs clearance: at a size
603
+ // where she is pinned to the top of the viewport the anchor would sit at
604
+ // y=0 and the bubble would render entirely off-screen.
605
+ sayEl.style.top = Math.round(Math.max(coords.y, SAY_CLEARANCE)) + 'px'
606
+ }
607
+
608
+ const paint = () => {
609
+ const facing = coords.x + boxW() / 2 < vw() / 2 ? -1 : 1
610
+ // The translate places the box's TOP-LEFT corner, so it is `coords.x`,
611
+ // NOT `coords.x + boxW()/2`: a translation is origin-independent, and
612
+ // feeding it a centre offset put the whole box half a box-width to the
613
+ // right, off the edge of the viewport. The mirror still pivots in place
614
+ // because `transform-origin` is the top CENTRE.
615
+ root.style.transform = 'translate3d(' + coords.x + 'px,' + coords.y + 'px,0) scaleX(' + facing + ')'
616
+ placeSay()
617
+ }
618
+ paint()
619
+
620
+ const bodyEl = document.createElement('div')
621
+ bodyEl.className = 'wisp-body'
622
+ bodyEl.style.position = 'absolute'
623
+ bodyEl.style.top = '0'
624
+ bodyEl.style.left = '0'
625
+ bodyEl.style.right = '0'
626
+ bodyEl.style.bottom = '0'
627
+ bodyEl.style.pointerEvents = 'auto'
628
+ bodyEl.setAttribute('role', 'button')
629
+ bodyEl.setAttribute('tabindex', '-1')
630
+ bodyEl.setAttribute('aria-label', 'DeepSeek娘')
631
+
632
+ const shine = document.createElement('div')
633
+ shine.className = 'wisp-shine'
634
+
635
+ const img = document.createElement('img')
636
+ img.className = 'wisp-img'
637
+ img.style.position = 'absolute'
638
+ img.style.top = '0'
639
+ img.style.left = '0'
640
+ img.style.width = '100%'
641
+ img.style.height = '100%'
642
+ img.style.objectFit = 'contain'
643
+ img.style.objectPosition = 'bottom center'
644
+ img.alt = ''
645
+ img.draggable = false
646
+ img.decoding = 'async'
647
+ img.onerror = () => {
648
+ if (typeof console !== 'undefined' && console.error) console.error('[wisp] sprite failed to load')
649
+ }
650
+ let currentSprite = 'idle'
651
+
652
+ const setSprite = (key) => {
653
+ const url = resolveSprite(SPRITES[key])
654
+ buildMask(key, url)
655
+ currentSprite = key
656
+ /* Cross-fade rather than swapping `src` outright: a hard swap makes every
657
+ mood change read as a glitch. The outgoing sprite keeps its own layer
658
+ for the length of the fade, then is removed. */
659
+ const previous = img.src
660
+ if (!previous || previous === url) {
661
+ img.src = url
662
+ img.style.opacity = '1'
663
+ return
664
+ }
665
+ const outgoing = document.createElement('img')
666
+ outgoing.className = 'wisp-img'
667
+ outgoing.style.position = 'absolute'
668
+ outgoing.style.top = '0'
669
+ outgoing.style.left = '0'
670
+ outgoing.style.width = '100%'
671
+ outgoing.style.height = '100%'
672
+ outgoing.style.objectFit = 'contain'
673
+ outgoing.style.objectPosition = 'bottom center'
674
+ outgoing.style.opacity = '1'
675
+ outgoing.draggable = false
676
+ outgoing.setAttribute('aria-hidden', 'true')
677
+ outgoing.src = previous
678
+ bodyEl.insertBefore(outgoing, img)
679
+ img.src = url
680
+ img.style.opacity = '0'
681
+ clock.after(16, () => {
682
+ if (destroyed) return
683
+ img.style.opacity = '1'
684
+ outgoing.style.opacity = '0'
685
+ })
686
+ clock.after(340, () => { if (outgoing.parentNode) outgoing.parentNode.removeChild(outgoing) })
687
+ }
688
+
689
+ bodyEl.appendChild(shine)
690
+ bodyEl.appendChild(img)
691
+ root.appendChild(bodyEl)
692
+ layer.appendChild(root)
693
+ setSprite('idle')
694
+
695
+ let destroyed = false
696
+ let attached = false
697
+ let attachAttempts = 0
698
+
699
+ const attach = () => {
700
+ if (destroyed || attached) return
701
+ if (!document.body) {
702
+ // The client half can in principle run before <body> exists; a bounded
703
+ // retry beats both a crash and an unbounded poll.
704
+ if (attachAttempts++ < 60) clock.after(50, attach)
705
+ else if (console && console.error) console.error('[wisp] document.body never appeared')
706
+ return
707
+ }
708
+ document.body.appendChild(layer)
709
+ attached = true
710
+ }
711
+
712
+ /* ---- mood -> sprite ---- */
713
+ const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy' }
714
+ let mood = 'idle'
715
+
716
+ const setMood = (next) => {
717
+ if (destroyed || mood === next) return
718
+ mood = next
719
+ root.dataset.mood = next
720
+ const want = SPRITE_OF[next] || 'idle'
721
+ if (want !== currentSprite) setSprite(want)
722
+ const zs = root.querySelectorAll('.wisp-zzz')
723
+ for (let i = zs.length - 1; i >= 0; i--) zs[i].remove()
724
+ if (next === 'sleep') {
725
+ const z1 = document.createElement('div'); z1.className = 'wisp-zzz'; z1.textContent = 'z'
726
+ const z2 = document.createElement('div'); z2.className = 'wisp-zzz'; z2.textContent = 'z'
727
+ z2.style.right = '24px'; z2.style.top = '-2px'; z2.style.animationDelay = '1.1s'
728
+ root.appendChild(z1); root.appendChild(z2)
729
+ }
730
+ }
731
+
732
+ let sayHandle = null
733
+ const say = (text) => {
734
+ if (destroyed) return
735
+ if (sayEl !== null && sayEl.parentNode) sayEl.remove()
736
+ const el = document.createElement('div')
737
+ el.className = 'wisp-say'
738
+ el.style.position = 'absolute'
739
+ el.textContent = text
740
+ // Into the LAYER, not the root: a child of the root would be mirrored
741
+ // with her and scaled by `size`.
742
+ layer.appendChild(el)
743
+ sayEl = el
744
+ placeSay()
745
+ if (sayHandle) clock.cancel(sayHandle)
746
+ sayHandle = clock.after(SAY_MS, () => {
747
+ if (sayEl === el) sayEl = null
748
+ if (el.parentNode) el.remove()
749
+ })
750
+ }
751
+
752
+ /* ---- idle / wake ----
753
+ The sleep deadline is re-armed at most once a second. pointermove can
754
+ fire over a hundred times a second, and cancelling plus recreating a
755
+ timer for each of those is pure overhead for a decision whose real
756
+ resolution is ninety seconds. */
757
+ let sleepHandle = null
758
+ let lastWakeAt = 0
759
+ const armSleep = () => {
760
+ if (sleepHandle) clock.cancel(sleepHandle)
761
+ sleepHandle = clock.after(cfg.sleepAfterMs, () => {
762
+ sleepHandle = null
763
+ if (destroyed) return
764
+ setMood('sleep')
765
+ if (Math.random() < 0.6) say(pick(LINES.sleep))
766
+ })
767
+ }
768
+ const wake = (force) => {
769
+ if (destroyed) return
770
+ const now = nowMs()
771
+ if (!force && now - lastWakeAt < WAKE_THROTTLE_MS) return
772
+ lastWakeAt = now
773
+ if (mood === 'sleep') {
774
+ setMood('idle')
775
+ if (Math.random() < 0.5) say(pick(LINES.wake))
776
+ }
777
+ armSleep()
778
+ }
779
+ const WAKE_EVENTS = ['pointerdown', 'keydown', 'wheel', 'pointermove']
780
+ for (const ev of WAKE_EVENTS) window.addEventListener(ev, wake, { passive: true })
781
+
782
+ /* ---- theme: follow the shell's own light/dark decision ---- */
783
+ const themeService = ctx && typeof ctx.get === 'function' ? ctx.get('theme') : undefined
784
+ let offTheme = null
785
+ const applyTheme = (snapshot) => {
786
+ const scheme = snapshot && snapshot.active && snapshot.active.colorScheme === 'dark' ? 'dark' : 'light'
787
+ root.dataset.wispTheme = scheme
788
+ }
789
+ if (themeService && typeof themeService.getTheme === 'function') {
790
+ try { applyTheme(themeService.getTheme()) } catch (e) { /* the CSS fallback still applies */ }
791
+ }
792
+ if (ctx && typeof ctx.on === 'function') {
793
+ try {
794
+ offTheme = ctx.on('theme/change', (snapshot) => {
795
+ try { applyTheme(snapshot) } catch (e) { /* ignore a malformed snapshot */ }
796
+ })
797
+ } catch (e) { offTheme = null }
798
+ }
799
+
800
+ /* ---- react to what the app is doing ---- */
801
+ let lastBusy = false
802
+ let busySince = 0
803
+ let happyHandle = null
804
+
805
+ const flashHappy = (text) => {
806
+ setMood('happy')
807
+ say(text)
808
+ if (happyHandle) clock.cancel(happyHandle)
809
+ happyHandle = clock.after(cfg.happyMs, () => { if (mood === 'happy') setMood('idle') })
810
+ }
811
+
812
+ const poll = () => {
813
+ if (destroyed) return
814
+ let busy = false
815
+ try { busy = agentIsBusy() } catch (e) { busy = false }
816
+ if (busy && !lastBusy) busySince = nowMs()
817
+ if (!busy && lastBusy && cfg.celebrate && nowMs() - busySince >= cfg.celebrateAfterMs) {
818
+ flashHappy(pick(LINES.done))
819
+ }
820
+ lastBusy = busy
821
+ if (mood === 'sleep' || mood === 'happy') return
822
+ let typing = false
823
+ try { typing = composerHasText() } catch (e) { typing = false }
824
+ setMood((busy || typing) ? 'alert' : 'idle')
825
+ }
826
+
827
+ let chatterHandle = null
828
+ const syncChatter = () => {
829
+ if (chatterHandle) { clock.cancel(chatterHandle); chatterHandle = null }
830
+ if (cfg.chatterMs <= 0) return
831
+ chatterHandle = clock.every(cfg.chatterMs, () => {
832
+ if (destroyed || mood !== 'idle') return
833
+ if (agentIsBusy()) return
834
+ say(pick(LINES.chatter))
835
+ })
836
+ }
837
+
838
+ let pollHandle = null
839
+ const syncPoll = () => {
840
+ if (pollHandle) { clock.cancel(pollHandle); pollHandle = null }
841
+ if (cfg.reactions) pollHandle = clock.every(POLL_MS, poll)
842
+ }
843
+ syncPoll()
844
+ syncChatter()
845
+
846
+ /* ---- 自己踱步 ----
847
+ 只在真正空闲时发生,并且是一段可见的滑行而不是瞬移。滑行期间挂
848
+ `data-gliding`,由 CSS 过渡接管 transform;一旦按下就立刻摘掉,
849
+ 否则拖动会被过渡拖后腿。 */
850
+ let glideHandle = null
851
+ const glideTo = () => {
852
+ root.dataset.gliding = 'true'
853
+ paint()
854
+ if (glideHandle) clock.cancel(glideHandle)
855
+ glideHandle = clock.after(1700, () => {
856
+ glideHandle = null
857
+ delete root.dataset.gliding
858
+ })
859
+ }
860
+ const stopGlide = () => {
861
+ if (glideHandle) { clock.cancel(glideHandle); glideHandle = null }
862
+ delete root.dataset.gliding
863
+ }
864
+
865
+ let wanderHandle = null
866
+ const syncWander = () => {
867
+ if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
868
+ if (!cfg.wander || cfg.wanderRange <= 0) return
869
+ wanderHandle = clock.every(cfg.wanderMs, () => {
870
+ if (destroyed || mood !== 'idle' || start !== null) return
871
+ if (agentIsBusy()) return
872
+ // A random bearing with a floor on the distance, so a stroll is always
873
+ // a real stroll rather than a rounding error in a random direction.
874
+ const angle = Math.random() * Math.PI * 2
875
+ const dist = cfg.wanderRange * (0.45 + Math.random() * 0.55)
876
+ coords.x = clamp(coords.x + Math.cos(angle) * dist, 0, maxX())
877
+ coords.y = clamp(coords.y + Math.sin(angle) * dist * 0.6, 0, maxY())
878
+ glideTo()
879
+ // 故意不 rememberPosition():记忆的是"用户把她放在哪",她自己的溜达
880
+ // 不该覆盖那个意图,否则刷新后她会出现在一个用户从没选过的位置。
881
+ })
882
+ }
883
+ syncWander()
884
+
885
+ /* ---- drag & click ---- */
886
+ let start = null, origin = null, moved = false, lastClick = 0
887
+
888
+ const rememberPosition = () => {
889
+ if (!cfg.persist) return
890
+ writeStored(POSITION_KEY, { x: Math.round(coords.x), y: Math.round(coords.y) })
891
+ }
892
+
893
+ /** Whether this pointer is over an opaque pixel of the current sprite. */
894
+ const hitsBody = (e) => {
895
+ const mask = masks[currentSprite]
896
+ if (!mask) return true // no mask yet: stay grabbable rather than vanish
897
+ const boxw = boxW()
898
+ const boxh = boxH()
899
+ let mx = (e.clientX - coords.x) / boxw
900
+ const my = (e.clientY - coords.y) / boxh
901
+ if (mx < 0 || mx >= 1 || my < 0 || my >= 1) return false
902
+ // The root mirrors about its centre, so sample the un-mirrored pixel.
903
+ if (coords.x + boxw / 2 < vw() / 2) mx = 1 - mx
904
+ const col = Math.min(MASK_W - 1, Math.floor(mx * MASK_W))
905
+ const row = Math.min(MASK_H - 1, Math.floor(my * MASK_H))
906
+ return mask[row * MASK_W + col] > MASK_MIN_ALPHA
907
+ }
908
+
909
+ const onMove = (e) => {
910
+ if (!start) return
911
+ const dx = e.clientX - start.x, dy = e.clientY - start.y
912
+ if (!moved && Math.sqrt(dx * dx + dy * dy) > 4) moved = true
913
+ if (!moved) return
914
+ coords.x = clamp(origin.x + dx, 0, maxX())
915
+ coords.y = clamp(origin.y + dy, 0, maxY())
916
+ paint()
917
+ }
918
+ const onUp = () => {
919
+ window.removeEventListener('pointermove', onMove)
920
+ window.removeEventListener('pointerup', onUp)
921
+ delete root.dataset.dragging
922
+ start = null
923
+ if (moved) {
924
+ rememberPosition()
925
+ if (Math.random() < 0.35) say(pick(LINES.move))
926
+ return
927
+ }
928
+ if (destroyed) return
929
+ const now = Date.now()
930
+ const dbl = now - lastClick < 420
931
+ lastClick = now
932
+ wake(true)
933
+ flashHappy(dbl ? pick(LINES.happy) : pick(LINES.click))
934
+ }
935
+ const onDown = (e) => {
936
+ if (e.button !== 0) return
937
+ // Only her own pixels grab the pointer. Her transparent margins pass the
938
+ // click through, but anywhere she is actually drawn she is grabbable —
939
+ // including over the composer: she can be moved, so she does not have to
940
+ // yield to whatever she is standing on.
941
+ if (!hitsBody(e)) return
942
+ stopGlide() // a stroll must not fight the drag
943
+ e.preventDefault(); e.stopPropagation()
944
+ start = { x: e.clientX, y: e.clientY }
945
+ origin = { x: coords.x, y: coords.y }
946
+ moved = false
947
+ root.dataset.dragging = 'true'
948
+ window.addEventListener('pointermove', onMove)
949
+ window.addEventListener('pointerup', onUp)
950
+ }
951
+ bodyEl.addEventListener('pointerdown', onDown)
952
+
953
+ /* ---- 右键 = 叫回原位 ----
954
+ 她大了之后很容易被拖到看不见的地方,而"恢复位置"原先只能开 Console。
955
+ 右键她本体即可归位。 */
956
+ const onContext = (e) => {
957
+ if (!hitsBody(e)) return
958
+ e.preventDefault(); e.stopPropagation()
959
+ stopGlide()
960
+ // 默认落点(右下角),并把记忆的位置一起抹掉,避免下次刷新又跑回去
961
+ try { window.localStorage.removeItem(POSITION_KEY) } catch (err) { /* 存储不可用 */ }
962
+ coords.x = clamp(vw() - cfg.right - boxW(), 0, maxX())
963
+ coords.y = clamp(vh() - cfg.bottom - boxH(), 0, maxY())
964
+ paint()
965
+ rememberPosition()
966
+ say(pick(LINES.home))
967
+ }
968
+ bodyEl.addEventListener('contextmenu', onContext)
969
+
970
+ const onResize = () => {
971
+ coords.x = clamp(coords.x, 0, maxX())
972
+ coords.y = clamp(coords.y, 0, maxY())
973
+ paint()
974
+ }
975
+ window.addEventListener('resize', onResize)
976
+
977
+ attach()
978
+ clock.after(800, () => say(pick(LINES.hello)))
979
+ wake(true)
980
+
981
+ /* warm the other sprites after the first frame so mood changes never blink */
982
+ clock.after(1500, () => {
983
+ if (destroyed) return
984
+ for (const key in SPRITES) {
985
+ if (key === currentSprite) continue
986
+ const pre = new Image()
987
+ pre.src = resolveSprite(SPRITES[key])
988
+ }
989
+ })
990
+
991
+ const applyConfig = (partial) => {
992
+ mergeConfig(cfg, partial)
993
+ // A bigger companion occupies a bigger box, so the stored position has
994
+ // to be re-clamped or she would grow off the edge.
995
+ root.style.width = Math.round(boxW()) + 'px'
996
+ root.style.height = Math.round(boxH()) + 'px'
997
+ layer.style.setProperty('--wisp-scale', String(cfg.size))
998
+ coords.x = clamp(coords.x, 0, maxX())
999
+ coords.y = clamp(coords.y, 0, maxY())
1000
+ paint()
1001
+ armSleep()
1002
+ syncPoll()
1003
+ syncChatter()
1004
+ syncWander()
1005
+ return api
1006
+ }
1007
+
1008
+ const api = {
1009
+ version: VERSION,
1010
+ clock: clock.mode,
1011
+ say,
1012
+ mood: setMood,
1013
+ move(x, y) { coords.x = x; coords.y = y; paint() },
1014
+ configure: applyConfig,
1015
+ resetPosition() {
1016
+ try { window.localStorage.removeItem(POSITION_KEY) } catch (e) { /* ignore */ }
1017
+ coords.x = clamp(vw() - cfg.right - boxW(), 0, maxX())
1018
+ coords.y = clamp(vh() - cfg.bottom - boxH(), 0, maxY())
1019
+ paint()
1020
+ },
1021
+ get element() { return root },
1022
+ get sprites() { return Object.keys(SPRITES) },
1023
+ get position() { return { x: Math.round(coords.x), y: Math.round(coords.y) } },
1024
+ get currentMood() { return mood },
1025
+ destroy() {
1026
+ if (destroyed) return
1027
+ destroyed = true
1028
+ rememberPosition()
1029
+ clock.dispose()
1030
+ for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake)
1031
+ window.removeEventListener('resize', onResize)
1032
+ window.removeEventListener('pointermove', onMove)
1033
+ window.removeEventListener('pointerup', onUp)
1034
+ bodyEl.removeEventListener('pointerdown', onDown)
1035
+ bodyEl.removeEventListener('contextmenu', onContext)
1036
+ if (typeof offTheme === 'function') {
1037
+ try { offTheme() } catch (e) { /* ignore */ }
1038
+ offTheme = null
1039
+ }
1040
+ if (typeof styleDispose === 'function') {
1041
+ try { styleDispose() } catch (e) { /* ignore */ }
1042
+ }
1043
+ if (layer.parentNode) layer.remove()
1044
+ revokeSprites()
1045
+ if (window.__wisp === api) delete window.__wisp
1046
+ },
1047
+ }
1048
+ window.__wisp = api
1049
+ return api
1050
+ }
1051
+
1052
+ const plugin = {
1053
+ name: 'wisp',
1054
+ apply(ctx, config) {
1055
+ try {
1056
+ /* Single instance, always. A re-apply (HMR, a duplicate row, or the
1057
+ console-injection build) must replace the companion rather than
1058
+ stack a second one on top of it. */
1059
+ const previous = window.__wisp
1060
+ if (previous && typeof previous.destroy === 'function') {
1061
+ try { previous.destroy() } catch (e) { /* never let a dead instance block a live one */ }
1062
+ }
1063
+ const instance = mount(config, ctx)
1064
+ if (ctx && typeof ctx.effect === 'function') {
1065
+ ctx.effect(() => () => instance.destroy(), 'dsh-wisp: teardown')
1066
+ }
1067
+ if (typeof console !== 'undefined' && console.log) {
1068
+ console.log('[wisp] mounted — DeepSeek娘 v' + VERSION + ' (timers: ' + instance.clock + ')')
1069
+ }
1070
+ } catch (error) {
1071
+ if (typeof console !== 'undefined' && console.error) {
1072
+ console.error('[wisp] mount failed', error && error.message)
1073
+ }
1074
+ }
1075
+ },
1076
+ }
1077
+
1078
+ exports.default = plugin
1079
+ exports.plugin = plugin
1080
+ exports.apply = plugin.apply
1081
+ exports.name = plugin.name
1082
+ exports.version = VERSION
1083
+
1084
+ return module.exports
1085
+ },
1086
+ })