clauddy 1.28.2 → 1.29.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/renderer/pet.js CHANGED
@@ -51,6 +51,50 @@ const SPRITE = [
51
51
  })
52
52
  })()
53
53
 
54
+ // ---- selectable pets (pets.js) ----------------------------------------------
55
+ // The classic sprite stays in #claude; another pet is drawn inside it as #skin,
56
+ // so every mood animation on #claude (sleep bob, slump, poke…) still applies.
57
+ // Working swaps the shared hop for the pet's own move (pets.css).
58
+ const SKIN_ID = 'skin'
59
+ let shownPet = null
60
+ let pickedPet = null // the Settings selection, previewed live until Save/Cancel
61
+ function showPet(id) {
62
+ if (id === shownPet) return
63
+ shownPet = id
64
+ el(SKIN_ID)?.remove()
65
+ const b = document.body.classList
66
+ for (const c of [...b]) if (c.startsWith('pet-move-')) b.remove(c)
67
+ const pet = Pets.PETS.find((p) => p.id === id)
68
+ el('claude').classList.toggle('skinned', !!pet)
69
+ if (!pet) return
70
+ el('claude').insertAdjacentHTML(
71
+ 'beforeend',
72
+ Pets.petMarkup(
73
+ id,
74
+ 'sk',
75
+ `id="${SKIN_ID}" x="-8" y="-26" width="116" height="116" preserveAspectRatio="xMidYMax meet"`,
76
+ ),
77
+ )
78
+ b.add(`pet-move-${pet.move}`)
79
+ }
80
+ const chosenPet = () => currentConfig.pet || Pets.DEFAULT_PET
81
+ function refreshPet() {
82
+ showPet(pickedPet ?? Pets.activePet(chosenPet()))
83
+ }
84
+ // holidays start and end at midnight, with the widget open
85
+ setInterval(refreshPet, 60_000)
86
+ // What the eyes follow: the sprite's eye group, or the pet's (its blink groups,
87
+ // moved with `translate` so it composes with the blink's own transform). The
88
+ // pet's viewBox is about twice the sprite's, so its units are half as big.
89
+ function lookTargets() {
90
+ const skin = el(SKIN_ID)
91
+ return skin ? [...skin.querySelectorAll('.blink')] : [el('eyes')]
92
+ }
93
+ function lookAt(x, y) {
94
+ const skin = el(SKIN_ID)
95
+ if (!skin) return el('eyes').setAttribute('transform', `translate(${x} ${y})`)
96
+ for (const g of lookTargets()) g.style.translate = `${x * 2}px ${y * 2}px`
97
+ }
54
98
  // night scene backdrop (clouds, crescent moon, stars, dotted ground/sky)
55
99
  ;(function buildScene() {
56
100
  const s = el('scene')
@@ -950,8 +994,34 @@ const activityTracker = Companion.createActivityTracker()
950
994
  function setPetPeek(on) {
951
995
  if (!document.body.classList.contains('pet-only')) return
952
996
  clearTimeout(peekTimer)
997
+ // placed once as it appears: re-placing on every click or focus made it jump
998
+ if (on && !document.body.classList.contains('pet-peek')) placeGlance()
953
999
  document.body.classList.toggle('pet-peek', on)
954
1000
  }
1001
+ // The glance bubble sits just above the pet's head, and every pet's head is at
1002
+ // its own height (the bat's ears, the wizard's hat), so measure the drawing's
1003
+ // top instead of a fixed spot. Parts that only show while working (the owl,
1004
+ // sparks, snow) don't count: they come and go. The pet is mid-animation (a
1005
+ // bob, a hop, levitating), so the head is measured against #claude itself and
1006
+ // laid on #claude's resting spot, at the foot of the still #pet box.
1007
+ function placeGlance() {
1008
+ const skin = el(SKIN_ID)
1009
+ const parts = skin
1010
+ ? [...skin.children].filter((c) => !c.matches('defs, .only-work'))
1011
+ : [el('body')]
1012
+ const tops = parts
1013
+ .map((part) => part.getBoundingClientRect())
1014
+ .filter((r) => r.width > 0)
1015
+ .map((r) => r.top)
1016
+ if (!tops.length) return
1017
+ const zoom = Number.parseFloat(document.body.style.zoom) || 1
1018
+ const sprite = el('claude').getBoundingClientRect()
1019
+ const resting = el('pet').getBoundingClientRect().bottom - sprite.height
1020
+ const head =
1021
+ (Math.min(...tops) - sprite.top + resting - el('card').getBoundingClientRect().top) / zoom
1022
+ const glance = el('pet-glance')
1023
+ glance.style.top = `${Math.max(2, Math.round(head - glance.offsetHeight - 10))}px`
1024
+ }
955
1025
  function setDisplaySize(size, remember = true) {
956
1026
  if (!['expanded', 'compact', 'pet'].includes(size)) size = 'expanded'
957
1027
  if (currentConfig.mode === 'menubar' && size === 'pet') size = 'compact'
@@ -1026,6 +1096,9 @@ function paintCompanion(view) {
1026
1096
  button.setAttribute('aria-pressed', String(provider === view))
1027
1097
  button.classList.toggle('urgent', session?.pct >= warnAt())
1028
1098
  el(`glance-${provider}-value`).textContent = pctText(session?.pct)
1099
+ // the bar under each reading fills with the session %; none when unknown
1100
+ const pct = session?.pct
1101
+ button.style.setProperty('--pct', pct == null ? '0' : String(Math.max(0, Math.min(100, pct))))
1029
1102
  const stale = provider !== 'claude' && readingStale(dataOf(provider))
1030
1103
  button.title = stale
1031
1104
  ? 'Last recorded usage · waiting for a fresh reading'
@@ -1100,15 +1173,18 @@ function reactToProvider(provider, data) {
1100
1173
  if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
1101
1174
  // in tab order: Claude to the left, Codex to the right, Cursor down-right
1102
1175
  const [x, y] = { claude: [-3, 0], codex: [3, 0], cursor: [3, 1] }[provider] || [0, 0]
1103
- el('eyes').animate(
1104
- [
1105
- { transform: 'translate(0, 0)' },
1106
- { transform: `translate(${x}px, ${y}px)`, offset: 0.3 },
1107
- { transform: `translate(${x}px, ${y}px)`, offset: 0.7 },
1108
- { transform: 'translate(0, 0)' },
1109
- ],
1110
- { duration: 1100, easing: 'ease-in-out' },
1111
- )
1176
+ const k = el(SKIN_ID) ? 2 : 1
1177
+ for (const target of lookTargets()) {
1178
+ target.animate(
1179
+ [
1180
+ { translate: '0 0' },
1181
+ { translate: `${x * k}px ${y * k}px`, offset: 0.3 },
1182
+ { translate: `${x * k}px ${y * k}px`, offset: 0.7 },
1183
+ { translate: '0 0' },
1184
+ ],
1185
+ { duration: 1100, easing: 'ease-in-out' },
1186
+ )
1187
+ }
1112
1188
  }
1113
1189
  }
1114
1190
 
@@ -1363,7 +1439,9 @@ function paint() {
1363
1439
  // fit the window to the content (no leftover border)
1364
1440
  let lastH = 0
1365
1441
  let lastW = 0
1366
- function fitSize() {
1442
+ // `animate` eases the window to its new size (opening and closing Settings);
1443
+ // every other resize is immediate, so the widget tracks its content exactly
1444
+ function fitSize(animate = false) {
1367
1445
  requestAnimationFrame(() => {
1368
1446
  const collapsed = document.body.classList.contains('collapsed')
1369
1447
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
@@ -1372,7 +1450,7 @@ function fitSize() {
1372
1450
  const dual = document.body.classList.contains('dual')
1373
1451
  const trio = document.body.classList.contains('trio')
1374
1452
  const petOnly = document.body.classList.contains('pet-only')
1375
- const w = (petOnly ? (trio ? 212 : 168) : collapsed ? (dual ? 240 : 192) : 304) * zoom
1453
+ const w = (petOnly ? (trio ? 212 : 168) : collapsed ? (dual ? 240 : 170) : 304) * zoom
1376
1454
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
1377
1455
  // empirically against this Electron build) — so measure the unzoomed content
1378
1456
  // height, then scale the whole thing (content + margin) ourselves
@@ -1380,7 +1458,7 @@ function fitSize() {
1380
1458
  if (Math.abs(h - lastH) > 2 || w !== lastW) {
1381
1459
  lastH = h
1382
1460
  lastW = w
1383
- window.api.resize(w, h)
1461
+ window.api.resize(w, h, animate)
1384
1462
  }
1385
1463
  })
1386
1464
  }
@@ -1404,6 +1482,8 @@ window.api.onDebugState((o) => {
1404
1482
  if (lastData) render(lastData)
1405
1483
  })
1406
1484
  window.api.onUsage(render)
1485
+ // menu-bar popover: where its nib sits, so it points at the tray icon
1486
+ window.api.onPopoverAnchor((x) => document.body.style.setProperty('--nib-x', `${x}px`))
1407
1487
  window.api.onError((msg) => {
1408
1488
  el('status-text').textContent = 'error'
1409
1489
  console.error(msg)
@@ -1428,6 +1508,7 @@ window.api.onConfig((cfg) => {
1428
1508
  setDisplaySize('compact', false)
1429
1509
  }
1430
1510
  heardConfig = true
1511
+ refreshPet()
1431
1512
  paintMute()
1432
1513
  paint()
1433
1514
  applyZoom(currentConfig.zoom)
@@ -1946,6 +2027,7 @@ function snapshotSettings() {
1946
2027
  zoom: el('set-zoom').value,
1947
2028
  talk: el('set-talk').checked,
1948
2029
  sound: el('set-sound').checked,
2030
+ pet: pickedPet,
1949
2031
  })
1950
2032
  }
1951
2033
  function refreshSaveDirty() {
@@ -1954,12 +2036,19 @@ function refreshSaveDirty() {
1954
2036
  const b = el('set-save')
1955
2037
  b.classList.toggle('dirty', dirty)
1956
2038
  b.disabled = !dirty // nothing to save, nothing to click
2039
+ paintSettingsFooter(dirty)
2040
+ }
2041
+ // nothing changed: a single Done; once something has, Cancel and Save
2042
+ function paintSettingsFooter(dirty) {
2043
+ el('settings').classList.toggle('dirty', dirty)
2044
+ el('set-cancel').textContent = dirty ? 'Cancel' : 'Done'
1957
2045
  }
1958
2046
  function clearSaveDirty() {
1959
2047
  settingsBaseline = snapshotSettings()
1960
2048
  const b = el('set-save')
1961
2049
  b.classList.remove('dirty')
1962
2050
  b.disabled = true
2051
+ paintSettingsFooter(false)
1963
2052
  }
1964
2053
  function reflectAlertsOn() {
1965
2054
  el('set-rows').classList.toggle('alerts-off', !el('set-alerts').checked)
@@ -1989,6 +2078,14 @@ function populateSettings() {
1989
2078
  el('set-talk').checked = c.talk !== false
1990
2079
  el('set-sound').checked = !!c.sound
1991
2080
  reflectTalkOn()
2081
+ pickedPet = Pets.findPet(c.pet) ? c.pet : Pets.DEFAULT_PET
2082
+ paintPetPicker()
2083
+ const visiting = Pets.findPet(Pets.seasonalPet())
2084
+ el('pet-season').hidden = !visiting
2085
+ if (visiting) {
2086
+ el('pet-season').textContent =
2087
+ `${visiting.name} takes over for the holiday, then your pick comes back.`
2088
+ }
1992
2089
  clearSaveDirty() // fields now match the saved config
1993
2090
  }
1994
2091
  // `provider` opens that service's connection card straight away
@@ -1996,10 +2093,42 @@ function openSettings(provider = null) {
1996
2093
  hideBubble()
1997
2094
  setDisplaySize('expanded', false)
1998
2095
  populateSettings()
2096
+ showSettingsTab(typeof provider === 'string' ? 'accounts' : settingsTab)
1999
2097
  if (typeof provider === 'string') openConnection(provider)
2000
2098
  document.body.classList.add('settings-open')
2001
- fitSize()
2002
- }
2099
+ fitSize(true)
2100
+ }
2101
+
2102
+ // Settings tabs: one pane at a time, each at its own height, and the window
2103
+ // eases between them rather than keeping the tallest one's empty space
2104
+ let settingsTab = 'accounts'
2105
+ function showSettingsTab(tab) {
2106
+ const changed = tab !== settingsTab
2107
+ settingsTab = tab
2108
+ for (const b of document.querySelectorAll('.set-tab')) {
2109
+ const on = b.dataset.tab === tab
2110
+ b.classList.toggle('on', on)
2111
+ b.setAttribute('aria-selected', String(on))
2112
+ b.tabIndex = on ? 0 : -1
2113
+ el(`pane-${b.dataset.tab}`).classList.toggle('on', on)
2114
+ }
2115
+ if (changed && document.body.classList.contains('settings-open')) fitSize(true)
2116
+ }
2117
+ for (const b of document.querySelectorAll('.set-tab')) {
2118
+ b.addEventListener('click', () => showSettingsTab(b.dataset.tab))
2119
+ }
2120
+ // arrow keys move between tabs, as a tablist should
2121
+ el('set-tabs').addEventListener('keydown', (e) => {
2122
+ const tabs = [...document.querySelectorAll('.set-tab')]
2123
+ const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key]
2124
+ if (!step) return
2125
+ const next =
2126
+ tabs[(tabs.findIndex((t) => t.dataset.tab === settingsTab) + step + tabs.length) % tabs.length]
2127
+ showSettingsTab(next.dataset.tab)
2128
+ next.focus()
2129
+ e.preventDefault()
2130
+ })
2131
+ showSettingsTab(settingsTab)
2003
2132
 
2004
2133
  // Settings → Connections: a tile per service says whether it's on (a check and
2005
2134
  // its plan); a click opens its card underneath, one at a time
@@ -2049,10 +2178,13 @@ function closeSettings() {
2049
2178
  document.body.classList.remove('settings-open')
2050
2179
  document.body.classList.add('settings-closing')
2051
2180
  clearTimeout(closingTimer)
2052
- closingTimer = setTimeout(() => document.body.classList.remove('settings-closing'), 600)
2181
+ closingTimer = setTimeout(() => document.body.classList.remove('settings-closing'), 420)
2053
2182
  clearSaveDirty()
2054
2183
  applyZoom(currentConfig?.zoom != null ? currentConfig.zoom : 100) // undo the preview
2055
- fitSize()
2184
+ pickedPet = null
2185
+ el('pet-modal').hidden = true
2186
+ refreshPet()
2187
+ fitSize(true)
2056
2188
  }
2057
2189
  el('gear').addEventListener('click', () => {
2058
2190
  if (document.body.classList.contains('settings-open')) closeSettings()
@@ -2073,6 +2205,74 @@ for (const b of document.querySelectorAll('.num-btn')) {
2073
2205
  if (input.id === 'set-zoom') previewZoom()
2074
2206
  })
2075
2207
  }
2208
+ // the pet picker: one button per pet, grouped by pack; a click previews it
2209
+ function clauddyThumb() {
2210
+ // one path per colour: separate squares leave hairline seams when scaled
2211
+ const cells = (pick) =>
2212
+ SPRITE.flatMap((row, r) =>
2213
+ [...row].map((ch, c) => (pick(ch) ? `M${c} ${r}h1v1h-1z` : '')),
2214
+ ).join('')
2215
+ return `<svg viewBox="-1 -1 12 11" aria-hidden="true"><path d="${cells((ch) => ch !== '.')}" fill="var(--pixel)"/><path d="${cells((ch) => ch === 'o')}" fill="var(--eye)"/></svg>`
2216
+ }
2217
+ function buildPetPicker() {
2218
+ for (const pack of Pets.PACKS) {
2219
+ const pets = Pets.PETS.filter((p) => p.pack === pack.id)
2220
+ if (pack.id === 'everyday') pets.unshift(Pets.findPet(Pets.DEFAULT_PET))
2221
+ const group = document.createElement('div')
2222
+ group.innerHTML = `<div class="pet-pack-name">${pack.name}</div><div class="pet-grid"></div>`
2223
+ for (const pet of pets) {
2224
+ const b = document.createElement('button')
2225
+ b.type = 'button'
2226
+ b.className = 'pet-opt'
2227
+ b.dataset.pet = pet.id
2228
+ b.setAttribute('role', 'radio')
2229
+ b.setAttribute('aria-label', pet.name)
2230
+ b.innerHTML =
2231
+ pet.id === Pets.DEFAULT_PET ? clauddyThumb() : Pets.petMarkup(pet.id, `th-${pet.id}-`)
2232
+ b.addEventListener('click', () => {
2233
+ pickedPet = pet.id
2234
+ paintPetPicker()
2235
+ showPet(pet.id)
2236
+ refreshSaveDirty()
2237
+ closePetModal()
2238
+ })
2239
+ group.querySelector('.pet-grid').appendChild(b)
2240
+ }
2241
+ el('set-pet').appendChild(group)
2242
+ }
2243
+ }
2244
+ function paintPetPicker() {
2245
+ for (const b of el('set-pet').querySelectorAll('.pet-opt')) {
2246
+ const on = b.dataset.pet === pickedPet
2247
+ b.classList.toggle('on', on)
2248
+ b.setAttribute('aria-checked', String(on))
2249
+ }
2250
+ // the row shows the pick; the full catalog waits behind the pencil
2251
+ const pet = Pets.findPet(pickedPet)
2252
+ el('pet-current-name').textContent = pet?.name || ''
2253
+ el('pet-current-thumb').innerHTML =
2254
+ pickedPet === Pets.DEFAULT_PET ? clauddyThumb() : Pets.petMarkup(pickedPet, 'cur-')
2255
+ }
2256
+ function openPetModal() {
2257
+ el('pet-modal').hidden = false
2258
+ ;(el('set-pet').querySelector('.pet-opt.on') || el('set-pet').querySelector('.pet-opt'))?.focus()
2259
+ }
2260
+ function closePetModal() {
2261
+ if (el('pet-modal').hidden) return
2262
+ el('pet-modal').hidden = true
2263
+ el('pet-edit').focus()
2264
+ }
2265
+ buildPetPicker()
2266
+ el('pet-edit').addEventListener('click', openPetModal)
2267
+ el('pet-modal-close').addEventListener('click', closePetModal)
2268
+ // a click on the dimmed backdrop, outside the card, puts it away too
2269
+ el('pet-modal').addEventListener('click', (e) => {
2270
+ if (e.target === el('pet-modal')) closePetModal()
2271
+ })
2272
+ document.addEventListener('keydown', (e) => {
2273
+ if (e.key === 'Escape') closePetModal()
2274
+ })
2275
+
2076
2276
  // light up Save whenever an editable field changes
2077
2277
  for (const id of [
2078
2278
  'set-alerts',
@@ -2118,7 +2318,11 @@ el('set-save').addEventListener('click', () => {
2118
2318
  zoom,
2119
2319
  talk: el('set-talk').checked,
2120
2320
  sound: el('set-sound').checked,
2321
+ pet: pickedPet,
2121
2322
  })
2323
+ const saved = pickedPet
2324
+ pickedPet = null
2325
+ showPet(Pets.activePet(saved))
2122
2326
  clearSaveDirty()
2123
2327
  document.body.classList.remove('settings-open')
2124
2328
  fitSize()
@@ -2140,7 +2344,6 @@ el('pet').addEventListener('click', () => {
2140
2344
  })
2141
2345
 
2142
2346
  // eyes follow the cursor
2143
- const eyesG = el('eyes')
2144
2347
  window.addEventListener('mousemove', (e) => {
2145
2348
  const b = document.body.classList
2146
2349
  if (b.contains('state-sleeping') || b.contains('state-tired')) return
@@ -2148,13 +2351,10 @@ window.addEventListener('mousemove', (e) => {
2148
2351
  const dx = e.clientX - (r.left + r.width / 2)
2149
2352
  const dy = e.clientY - (r.top + r.height / 2)
2150
2353
  const len = Math.hypot(dx, dy) || 1
2151
- eyesG.setAttribute(
2152
- 'transform',
2153
- `translate(${((dx / len) * 3).toFixed(2)} ${((dy / len) * 2).toFixed(2)})`,
2154
- )
2354
+ lookAt(((dx / len) * 3).toFixed(2), ((dy / len) * 2).toFixed(2))
2155
2355
  })
2156
2356
  window.addEventListener('mouseout', (e) => {
2157
- if (!e.relatedTarget) eyesG.setAttribute('transform', 'translate(0 0)')
2357
+ if (!e.relatedTarget) lookAt(0, 0)
2158
2358
  })
2159
2359
 
2160
2360
  // welcome wave
@@ -2188,5 +2388,7 @@ if (typeof module === 'object' && module.exports) {
2188
2388
  paintCompanion,
2189
2389
  activityTracker,
2190
2390
  blipper,
2391
+ showPet,
2392
+ refreshPet,
2191
2393
  }
2192
2394
  }