clauddy 1.0.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,614 @@
1
+ const SVGNS = 'http://www.w3.org/2000/svg'
2
+ const el = (id) => document.getElementById(id)
3
+
4
+ // Claude pixel-art sprite
5
+ const SPRITE = [
6
+ '.########.',
7
+ '.########.',
8
+ '##########',
9
+ '###o##o###',
10
+ '##########',
11
+ '.########.',
12
+ '.########.',
13
+ '.#.#..#.#.',
14
+ '.#.#..#.#.',
15
+ ]
16
+
17
+ ;(function buildPixel() {
18
+ const body = el('body')
19
+ const eyes = el('eyes')
20
+ const C = 10
21
+ SPRITE.forEach((row, r) => {
22
+ for (let c = 0; c < row.length; c++) {
23
+ const ch = row[c]
24
+ if (ch === '.') continue
25
+ const rect = document.createElementNS(SVGNS, 'rect')
26
+ rect.setAttribute('x', c * C)
27
+ rect.setAttribute('y', r * C)
28
+ rect.setAttribute('width', C)
29
+ rect.setAttribute('height', C)
30
+ body.appendChild(rect)
31
+ if (ch === 'o') {
32
+ const eye = document.createElementNS(SVGNS, 'rect')
33
+ eye.setAttribute('x', c * C)
34
+ eye.setAttribute('y', r * C)
35
+ eye.setAttribute('width', C)
36
+ eye.setAttribute('height', C)
37
+ eyes.appendChild(eye)
38
+ }
39
+ }
40
+ })
41
+ })()
42
+
43
+ // night scene backdrop (clouds, crescent moon, stars, dotted ground/sky)
44
+ ;(function buildScene() {
45
+ const s = el('scene')
46
+ if (!s) return
47
+ const add = (tag, attrs) => {
48
+ const e = document.createElementNS(SVGNS, tag)
49
+ for (const k in attrs) e.setAttribute(k, attrs[k])
50
+ s.appendChild(e)
51
+ }
52
+ // dotted top + bottom (sky + ground)
53
+ for (let x = 4; x <= 212; x += 9) {
54
+ add('circle', { cx: x, cy: 6, r: 1.3, fill: '#fff', 'fill-opacity': 0.85 })
55
+ add('circle', { cx: x, cy: 130, r: 1.3, fill: '#fff', 'fill-opacity': 0.85 })
56
+ }
57
+ // clouds (blocky, dark gray)
58
+ const cloud = (x, y, b, m, t) => {
59
+ add('rect', { x: x + 12, y: y, width: t, height: 9, fill: '#3a3a3a' })
60
+ add('rect', { x: x + 5, y: y + 7, width: m, height: 9, fill: '#3a3a3a' })
61
+ add('rect', { x: x, y: y + 14, width: b, height: 10, fill: '#3a3a3a' })
62
+ }
63
+ cloud(16, 10, 58, 42, 22)
64
+ cloud(120, 50, 40, 28, 15)
65
+ // stars (small plus)
66
+ const star = (x, y) => {
67
+ add('rect', { x: x - 3, y: y - 0.7, width: 6, height: 1.4, fill: '#d8d8d8' })
68
+ add('rect', { x: x - 0.7, y: y - 3, width: 1.4, height: 6, fill: '#d8d8d8' })
69
+ }
70
+ ;[
71
+ [100, 16],
72
+ [60, 22],
73
+ [150, 100],
74
+ [196, 56],
75
+ [205, 98],
76
+ [128, 26],
77
+ [182, 22],
78
+ [202, 14],
79
+ ].forEach(([x, y]) => {
80
+ star(x, y)
81
+ })
82
+ })()
83
+
84
+ // helpers
85
+ function fmtTokens(t) {
86
+ t = t || 0
87
+ if (t >= 1e9) return `${(t / 1e9).toFixed(2)}B`
88
+ if (t >= 1e6) return `${(t / 1e6).toFixed(1)}M`
89
+ if (t >= 1e3) return `${(t / 1e3).toFixed(1)}k`
90
+ return String(t)
91
+ }
92
+ function fmtReset(ms) {
93
+ if (!ms || ms <= 0) return 'now'
94
+ const h = Math.floor(ms / 3600000)
95
+ const m = Math.floor((ms % 3600000) / 60000)
96
+ return h > 0 ? `${h}h ${m}m` : `${m}m`
97
+ }
98
+ function setState(name) {
99
+ const b = document.body
100
+ ;[...b.classList].forEach((c) => {
101
+ if (c.startsWith('state-')) b.classList.remove(c)
102
+ })
103
+ b.classList.add(`state-${name}`)
104
+ }
105
+
106
+ // coins (Claude "eating" tokens) — arc in, spin, get gulped with a crumb pop
107
+ const W = 86
108
+ const H = 77
109
+ const MOUTH_X = 43
110
+ const MOUTH_Y = 46
111
+
112
+ function spawnCoin() {
113
+ const zone = el('dropzone')
114
+ const coin = document.createElement('div')
115
+ coin.className = 'coin'
116
+ const sz = 6 + Math.random() * 3
117
+ coin.style.width = coin.style.height = `${sz.toFixed(1)}px`
118
+
119
+ const side = Math.floor(Math.random() * 4)
120
+ let x, y
121
+ if (side === 0) {
122
+ x = Math.random() * W
123
+ y = -10
124
+ } else if (side === 1) {
125
+ x = W + 10
126
+ y = Math.random() * H * 0.7
127
+ } else if (side === 2) {
128
+ x = Math.random() * W
129
+ y = H + 10
130
+ } else {
131
+ x = -10
132
+ y = Math.random() * H * 0.7
133
+ }
134
+ coin.style.left = `${x}px`
135
+ coin.style.top = `${y}px`
136
+ zone.appendChild(coin)
137
+
138
+ const dx = MOUTH_X - x
139
+ const dy = MOUTH_Y - y
140
+ // perpendicular offset -> curved arc toward the mouth
141
+ const mxo = dx * 0.5 - dy * 0.18
142
+ const myo = dy * 0.5 + dx * 0.18
143
+ const rot = (Math.random() * 2 - 1) * 320
144
+ const anim = coin.animate(
145
+ [
146
+ { transform: 'translate(0,0) scale(0.7) rotate(0deg)', opacity: 0.95 },
147
+ {
148
+ transform: `translate(${mxo}px,${myo}px) scale(1) rotate(${(rot * 0.6).toFixed(0)}deg)`,
149
+ opacity: 1,
150
+ offset: 0.55,
151
+ },
152
+ {
153
+ transform: `translate(${dx}px,${dy}px) scale(0.2) rotate(${rot.toFixed(0)}deg)`,
154
+ opacity: 0,
155
+ },
156
+ ],
157
+ { duration: 780 + Math.random() * 320, easing: 'cubic-bezier(0.45,0,0.55,1)' },
158
+ )
159
+ anim.onfinish = () => {
160
+ coin.remove()
161
+ popCrumbs()
162
+ chomp() // mouth opens to eat it
163
+ nibble() // tiny gulp reaction
164
+ }
165
+ }
166
+
167
+ // the mouth opens and snaps shut on each token
168
+ function chomp() {
169
+ el('mouth').animate(
170
+ [
171
+ { transform: 'scaleY(0.1)' },
172
+ { transform: 'scaleY(1)', offset: 0.4 },
173
+ { transform: 'scaleY(0.1)' },
174
+ ],
175
+ { duration: 240, easing: 'ease-in-out' },
176
+ )
177
+ }
178
+
179
+ // quick squash of the whole pet on each gulp (doesn't fight the hop on #claude)
180
+ function nibble() {
181
+ el('pet').animate(
182
+ [
183
+ { transform: 'scale(1, 1)' },
184
+ { transform: 'scale(1.06, 0.94)', offset: 0.5 },
185
+ { transform: 'scale(1, 1)' },
186
+ ],
187
+ { duration: 200, easing: 'ease' },
188
+ )
189
+ }
190
+
191
+ // poke reaction: bouncy squish + little hearts floating up
192
+ function popHearts() {
193
+ const zone = el('dropzone')
194
+ const n = 5
195
+ for (let i = 0; i < n; i++) {
196
+ const h = document.createElement('div')
197
+ h.className = 'heart'
198
+ h.textContent = '♥'
199
+ // spread across lanes along the width + a little jitter
200
+ const baseX = 14 + i * 15 + (Math.random() * 6 - 3)
201
+ h.style.left = `${baseX.toFixed(0)}px`
202
+ h.style.top = `${(14 + Math.random() * 12).toFixed(0)}px`
203
+ zone.appendChild(h)
204
+ // fan outward from the center
205
+ const dx = (baseX - 43) * 0.55 + (Math.random() * 8 - 4)
206
+ const a = h.animate(
207
+ [
208
+ { transform: 'translate(0, 8px) scale(0.4)', opacity: 0 },
209
+ {
210
+ transform: `translate(${(dx * 0.5).toFixed(1)}px, -12px) scale(1.3)`,
211
+ opacity: 1,
212
+ offset: 0.3,
213
+ },
214
+ { transform: `translate(${dx.toFixed(1)}px, -48px) scale(0.85)`, opacity: 0 },
215
+ ],
216
+ { duration: 1100 + Math.random() * 350, easing: 'ease-out', delay: i * 120 },
217
+ )
218
+ a.onfinish = () => h.remove()
219
+ }
220
+ }
221
+ function pokePet() {
222
+ oneShot('poke', 850)
223
+ popHearts()
224
+ }
225
+
226
+ function popCrumbs() {
227
+ const zone = el('dropzone')
228
+ for (let i = 0; i < 3; i++) {
229
+ const c = document.createElement('div')
230
+ c.className = 'crumb'
231
+ c.style.left = `${MOUTH_X}px`
232
+ c.style.top = `${MOUTH_Y}px`
233
+ zone.appendChild(c)
234
+ const ang = Math.random() * Math.PI * 2
235
+ const d = 5 + Math.random() * 8
236
+ const a = c.animate(
237
+ [
238
+ { transform: 'translate(0,0) scale(1)', opacity: 0.9 },
239
+ {
240
+ transform: `translate(${(Math.cos(ang) * d).toFixed(1)}px, ${(Math.sin(ang) * d - 4).toFixed(1)}px) scale(0.2)`,
241
+ opacity: 0,
242
+ },
243
+ ],
244
+ { duration: 240 + Math.random() * 160, easing: 'ease-out' },
245
+ )
246
+ a.onfinish = () => c.remove()
247
+ }
248
+ }
249
+
250
+ // continuous stream while working; faster when burning more tokens/min
251
+ let eatTimer = null
252
+ let eating = false
253
+ let currentRate = 0
254
+ function eatInterval() {
255
+ return Math.max(750, 1700 - Math.min(850, currentRate / 2600))
256
+ }
257
+ function startEating() {
258
+ stopEating()
259
+ const loop = () => {
260
+ spawnCoin()
261
+ eatTimer = setTimeout(loop, eatInterval())
262
+ }
263
+ loop()
264
+ }
265
+ function stopEating() {
266
+ if (eatTimer) clearTimeout(eatTimer)
267
+ eatTimer = null
268
+ }
269
+
270
+ // 30-day map
271
+ function renderHeat(days) {
272
+ const row = el('heat-row')
273
+ row.innerHTML = ''
274
+ const max = Math.max(1, ...days)
275
+ days.forEach((v, i) => {
276
+ const sq = document.createElement('div')
277
+ let lv = 0
278
+ if (v > 0) {
279
+ const r = v / max
280
+ lv = r < 0.3 ? 1 : r < 0.6 ? 2 : r < 0.85 ? 3 : 4
281
+ }
282
+ sq.className = `sq${lv ? ` lv${lv}` : ''}`
283
+ const daysAgo = days.length - 1 - i
284
+ sq.title = `${daysAgo === 0 ? 'today' : `${daysAgo}d ago`} · ${fmtTokens(v)} tokens`
285
+ row.appendChild(sq)
286
+ })
287
+ }
288
+
289
+ // by model (7 days)
290
+ function renderModels(list) {
291
+ const box = el('bymodel-list')
292
+ box.innerHTML = ''
293
+ const top = list.slice(0, 4)
294
+ const max = Math.max(1, ...top.map((m) => m.tokens))
295
+ for (const m of top) {
296
+ const row = document.createElement('div')
297
+ row.className = 'mrow'
298
+ row.innerHTML =
299
+ `<span class="mname">${m.label}</span>` +
300
+ `<span class="mbar"><i style="width:${(m.tokens / max) * 100}%"></i></span>` +
301
+ `<span class="mval">${fmtTokens(m.tokens)}</span>`
302
+ box.appendChild(row)
303
+ }
304
+ if (!top.length) {
305
+ box.innerHTML = '<div class="mrow" style="opacity:.5">no activity</div>'
306
+ }
307
+ }
308
+
309
+ // one-shot reaction (adds a class, removes after ms)
310
+ function oneShot(cls, ms) {
311
+ document.body.classList.add(cls)
312
+ setTimeout(() => document.body.classList.remove(cls), ms)
313
+ }
314
+
315
+ // session-reset celebration: jump + a colorful confetti burst
316
+ const CONFETTI_COLORS = ['#ffd23f', '#ff5d86', '#7ec77d', '#6db3f2', '#e0805a', '#c89bff']
317
+ function spawnConfetti(i) {
318
+ const zone = el('dropzone')
319
+ const p = document.createElement('div')
320
+ p.className = 'confetti'
321
+ p.style.background = CONFETTI_COLORS[i % CONFETTI_COLORS.length]
322
+ const w = 4 + Math.random() * 4
323
+ p.style.width = `${w.toFixed(1)}px`
324
+ p.style.height = `${(w + 2 + Math.random() * 4).toFixed(1)}px`
325
+ p.style.left = '43px'
326
+ p.style.top = '38px'
327
+ zone.appendChild(p)
328
+ // launch up + outward, then fall back down with a tumble
329
+ const ang = -Math.PI / 2 + (Math.random() * 2 - 1) * 1.15
330
+ const speed = 34 + Math.random() * 36
331
+ const ux = Math.cos(ang) * speed
332
+ const uy = Math.sin(ang) * speed // negative = upward
333
+ const fallY = 50 + Math.random() * 40
334
+ const rot = (Math.random() * 2 - 1) * 600
335
+ const a = p.animate(
336
+ [
337
+ { transform: 'translate(0,0) rotate(0) scale(0.5)', opacity: 1 },
338
+ {
339
+ transform: `translate(${ux.toFixed(0)}px, ${uy.toFixed(0)}px) rotate(${(rot * 0.4).toFixed(0)}deg) scale(1)`,
340
+ opacity: 1,
341
+ offset: 0.4,
342
+ },
343
+ {
344
+ transform: `translate(${(ux * 1.4).toFixed(0)}px, ${fallY.toFixed(0)}px) rotate(${rot.toFixed(0)}deg) scale(0.9)`,
345
+ opacity: 0,
346
+ },
347
+ ],
348
+ { duration: 1150 + Math.random() * 550, easing: 'cubic-bezier(0.25, 0.7, 0.4, 1)' },
349
+ )
350
+ a.onfinish = () => p.remove()
351
+ }
352
+ function celebrate() {
353
+ oneShot('celebrate', 1300)
354
+ for (let i = 0; i < 24; i++) spawnConfetti(i)
355
+ }
356
+
357
+ // main render
358
+ let prevState = null
359
+ let prevPct = null
360
+ let lastData = null
361
+ function render(d) {
362
+ lastData = d
363
+ // % comes only from the connected account — no estimates
364
+ const liveOn = !!realUsage
365
+ document.body.classList.toggle('live', liveOn)
366
+ const sessPct = liveOn ? realUsage.session.pct : 0
367
+ const sessReset = liveOn ? realUsage.session.resetMs : null
368
+ const sessActive = liveOn && sessReset != null
369
+ const wkPct = liveOn ? realUsage.week.pct : 0
370
+ const wkReset = liveOn ? realUsage.week.resetMs : null
371
+
372
+ let st =
373
+ liveOn && sessPct >= 100
374
+ ? 'tired'
375
+ : d.active
376
+ ? 'working'
377
+ : liveOn && sessPct >= 90
378
+ ? 'stressed'
379
+ : d.sleeping
380
+ ? 'sleeping'
381
+ : 'idle'
382
+ // dev override from `./pet <state>`
383
+ if (debugState) {
384
+ const map = {
385
+ working: 'working',
386
+ sleeping: 'sleeping',
387
+ fire: 'stressed',
388
+ tired: 'tired',
389
+ idle: 'idle',
390
+ }
391
+ if (map[debugState]) st = map[debugState]
392
+ }
393
+ setState(st)
394
+ if (prevState && prevState !== st) {
395
+ if (st === 'sleeping') oneShot('drowse', 700)
396
+ else if (prevState === 'sleeping') oneShot('wake', 700)
397
+ }
398
+ prevState = st
399
+
400
+ const word =
401
+ st === 'working'
402
+ ? 'working'
403
+ : st === 'sleeping'
404
+ ? 'sleeping'
405
+ : st === 'stressed'
406
+ ? 'on fire'
407
+ : st === 'tired'
408
+ ? 'maxed out'
409
+ : 'idle'
410
+ el('status-text').textContent = word
411
+ el('mini-text').textContent = word
412
+ el('rate').textContent =
413
+ d.active && d.tokensPerMin > 0
414
+ ? `${fmtTokens(d.tokensPerMin)} tok/min`
415
+ : `${fmtTokens(d.today.tokens)} tokens today`
416
+
417
+ if (liveOn && prevPct != null && sessActive && prevPct - sessPct > 25) celebrate()
418
+ prevPct = sessPct
419
+ el('session-pct').textContent = `${Math.round(sessPct)}%`
420
+ const mini = el('mini-pct')
421
+ mini.textContent = liveOn ? `${Math.round(sessPct)}%` : '—'
422
+ mini.classList.toggle('high', liveOn && sessPct >= 80)
423
+ const sf = el('session-fill')
424
+ sf.style.width = `${sessPct}%`
425
+ sf.classList.toggle('high', sessPct >= 80)
426
+ el('session-sub').textContent = sessActive
427
+ ? `resets in ${fmtReset(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
428
+ : 'no active session'
429
+
430
+ el('week-pct').textContent = `${Math.round(wkPct)}%`
431
+ const wf = el('week-fill')
432
+ wf.style.width = `${wkPct}%`
433
+ wf.classList.toggle('high', wkPct >= 80)
434
+ el('week-sub').textContent =
435
+ wkReset != null
436
+ ? `resets in ${fmtReset(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
437
+ : `${fmtTokens(d.week.tokens)} tokens · last 7 days`
438
+
439
+ renderModels(d.byModel || [])
440
+ renderHeat(d.days30 || [])
441
+ el('month-total').textContent = `${fmtTokens(d.monthTokens)} tokens`
442
+
443
+ currentRate = d.tokensPerMin || 0
444
+ if (st === 'working') {
445
+ if (!eating) {
446
+ eating = true
447
+ startEating()
448
+ }
449
+ } else if (eating) {
450
+ eating = false
451
+ stopEating()
452
+ }
453
+
454
+ fitSize()
455
+ }
456
+
457
+ // fit the window to the content (no leftover border)
458
+ let lastH = 0
459
+ let lastW = 0
460
+ function fitSize() {
461
+ requestAnimationFrame(() => {
462
+ const collapsed = document.body.classList.contains('collapsed')
463
+ const w = collapsed ? 140 : 276
464
+ const h = el('card').offsetHeight + 24 // 12px margin top + bottom
465
+ if (Math.abs(h - lastH) > 2 || w !== lastW) {
466
+ lastH = h
467
+ lastW = w
468
+ window.api.resize(w, h)
469
+ }
470
+ })
471
+ }
472
+
473
+ let currentConfig = {}
474
+ let realUsage = null
475
+ let debugState = null
476
+ window.api.onDebugState((o) => {
477
+ const s = o?.state
478
+ if (s === 'poke') return pokePet()
479
+ if (s === 'celebrate') return celebrate()
480
+ debugState = s === 'auto' || s === 'clear' || !s ? null : s
481
+ if (lastData) render(lastData)
482
+ })
483
+ window.api.onUsage(render)
484
+ window.api.onError((msg) => {
485
+ el('status-text').textContent = 'error'
486
+ console.error(msg)
487
+ })
488
+ window.api.onConfig((cfg) => {
489
+ currentConfig = cfg || {}
490
+ })
491
+ window.api.onRealUsage((u) => {
492
+ realUsage = u || null
493
+ if (lastData) render(lastData)
494
+ })
495
+ window.api.onAuthState((s) => {
496
+ const on = !!s?.connected
497
+ document.body.classList.toggle('auth-on', on)
498
+ if (!on) {
499
+ realUsage = null
500
+ document.body.classList.remove('live')
501
+ el('acc-paste').classList.remove('show')
502
+ }
503
+ if (document.body.classList.contains('settings-open')) fitSize()
504
+ })
505
+ window.api.onAuthResult((r) => {
506
+ if (r?.ok) {
507
+ el('acc-msg').textContent = ''
508
+ el('acc-code').value = ''
509
+ el('acc-paste').classList.remove('show')
510
+ } else {
511
+ const e = r?.error || ''
512
+ el('acc-msg').textContent = /429|rate_limit/i.test(e)
513
+ ? 'Rate limited by Anthropic — wait a few minutes, then try once with a fresh code.'
514
+ : `Failed: ${e || 'check the code and try again'}`
515
+ }
516
+ fitSize()
517
+ })
518
+
519
+ el('close').addEventListener('click', () => window.api.quit())
520
+ el('usage').addEventListener('click', () => window.api.openUsage())
521
+
522
+ // account login (browser flow)
523
+ el('acc-connect').addEventListener('click', () => {
524
+ window.api.authStart() // opens the browser to log in
525
+ el('acc-paste').classList.add('show') // reveal the code field
526
+ fitSize()
527
+ })
528
+ el('acc-confirm').addEventListener('click', () => {
529
+ const code = el('acc-code').value.trim()
530
+ if (!code) return
531
+ el('acc-msg').textContent = 'Checking…'
532
+ window.api.authCode(code)
533
+ const b = el('acc-confirm')
534
+ b.disabled = true
535
+ setTimeout(() => (b.disabled = false), 5000) // avoid hammering the rate-limited endpoint
536
+ })
537
+ el('acc-logout').addEventListener('click', () => window.api.authLogout())
538
+
539
+ // settings panel
540
+ function populateSettings() {
541
+ const c = currentConfig || {}
542
+ el('set-alerts').checked = c.alerts !== false
543
+ const th = c.alertThresholds || [80, 95]
544
+ el('set-t1').value = th[0] != null ? th[0] : 80
545
+ el('set-t2').value = th[1] != null ? th[1] : 95
546
+ }
547
+ function openSettings() {
548
+ document.body.classList.remove('collapsed')
549
+ populateSettings()
550
+ document.body.classList.add('settings-open')
551
+ fitSize()
552
+ }
553
+ el('gear').addEventListener('click', openSettings)
554
+ // the "connect" placeholder jumps straight to settings
555
+ el('limits-connect').addEventListener('click', openSettings)
556
+ // custom number steppers (▲ / ▼)
557
+ for (const b of document.querySelectorAll('.num-btn')) {
558
+ b.addEventListener('click', () => {
559
+ const input = el(b.dataset.for)
560
+ const min = Number(input.min) || 1
561
+ const max = Number(input.max) || 100
562
+ const next = (Number.parseInt(input.value, 10) || 0) + Number(b.dataset.step)
563
+ input.value = Math.min(max, Math.max(min, next))
564
+ })
565
+ }
566
+ el('set-cancel').addEventListener('click', () => {
567
+ document.body.classList.remove('settings-open')
568
+ fitSize()
569
+ })
570
+ el('set-save').addEventListener('click', () => {
571
+ const num = (id) => parseFloat(el(id).value)
572
+ window.api.saveConfig({
573
+ alerts: el('set-alerts').checked,
574
+ alertThresholds: [num('set-t1'), num('set-t2')]
575
+ .filter((n) => n >= 1 && n <= 100)
576
+ .sort((a, b) => a - b),
577
+ })
578
+ document.body.classList.remove('settings-open')
579
+ fitSize()
580
+ })
581
+ el('min').addEventListener('click', () => {
582
+ document.body.classList.toggle('collapsed')
583
+ fitSize()
584
+ })
585
+ // double-click the pet to collapse / expand
586
+ el('pet').addEventListener('dblclick', () => {
587
+ document.body.classList.toggle('collapsed')
588
+ fitSize()
589
+ })
590
+
591
+ // poke the pet -> bouncy squish + hearts
592
+ el('pet').addEventListener('click', () => pokePet())
593
+
594
+ // eyes follow the cursor
595
+ const eyesG = el('eyes')
596
+ window.addEventListener('mousemove', (e) => {
597
+ const b = document.body.classList
598
+ if (b.contains('state-sleeping') || b.contains('state-tired')) return
599
+ const r = el('pet').getBoundingClientRect()
600
+ const dx = e.clientX - (r.left + r.width / 2)
601
+ const dy = e.clientY - (r.top + r.height / 2)
602
+ const len = Math.hypot(dx, dy) || 1
603
+ eyesG.setAttribute(
604
+ 'transform',
605
+ `translate(${((dx / len) * 3).toFixed(2)} ${((dy / len) * 2).toFixed(2)})`,
606
+ )
607
+ })
608
+ window.addEventListener('mouseout', (e) => {
609
+ if (!e.relatedTarget) eyesG.setAttribute('transform', 'translate(0 0)')
610
+ })
611
+
612
+ // welcome wave
613
+ document.body.classList.add('greet')
614
+ setTimeout(() => document.body.classList.remove('greet'), 1200)