clauddy 1.22.0 → 1.23.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
@@ -18,6 +18,17 @@ const SPRITE = [
18
18
  const body = el('body')
19
19
  const eyes = el('eyes')
20
20
  const C = 10
21
+ // Each pixel is its own <rect>, so neighbours share an edge. Under the mood
22
+ // animations the sprite is scaled by fractions, that edge lands between device
23
+ // pixels, and the card shows through as a hairline grid — crispEdges cannot
24
+ // help, it rounds in local space, before the transform. So a cell is grown to
25
+ // overlap the neighbour it actually has: same fill, invisible seam, and the
26
+ // silhouette stays exact because edge cells are left alone.
27
+ const SEAM = 0.5
28
+ const filled = (r, c) => {
29
+ const ch = SPRITE[r]?.[c]
30
+ return ch !== undefined && ch !== '.'
31
+ }
21
32
  SPRITE.forEach((row, r) => {
22
33
  for (let c = 0; c < row.length; c++) {
23
34
  const ch = row[c]
@@ -25,8 +36,8 @@ const SPRITE = [
25
36
  const rect = document.createElementNS(SVGNS, 'rect')
26
37
  rect.setAttribute('x', c * C)
27
38
  rect.setAttribute('y', r * C)
28
- rect.setAttribute('width', C)
29
- rect.setAttribute('height', C)
39
+ rect.setAttribute('width', C + (filled(r, c + 1) ? SEAM : 0))
40
+ rect.setAttribute('height', C + (filled(r + 1, c) ? SEAM : 0))
30
41
  body.appendChild(rect)
31
42
  if (ch === 'o') {
32
43
  const eye = document.createElementNS(SVGNS, 'rect')
@@ -40,39 +51,6 @@ const SPRITE = [
40
51
  })
41
52
  })()
42
53
 
43
- // hard hat (mechanic mode): pixel-art bitmap so it matches the pet's blocky look
44
- ;(function buildHat() {
45
- const g = el('hardhat')
46
- if (!g) return
47
- // L = highlight, Y = main, D = shadow rim, B = brim
48
- const HAT = [
49
- '.....YLLY.....',
50
- '...YYYLLYYY...',
51
- '..YYYYLLYYYY..',
52
- '.YYYYYLLYYYYY.',
53
- '.DDDDDDDDDDDD.',
54
- 'BBBBBBBBBBBBBB',
55
- ]
56
- const COL = { L: '#ffe27a', Y: '#f5c518', D: '#d99a00', B: '#c98f14' }
57
- const C = 8 // cell size in svg units
58
- const cols = HAT[0].length
59
- const ox = 50 - (cols * C) / 2 // centered on the head (center x = 50)
60
- const oy = -24 // rises above the head, brim ends just over the eyes
61
- HAT.forEach((row, r) => {
62
- for (let c = 0; c < row.length; c++) {
63
- const ch = row[c]
64
- if (ch === '.') continue
65
- const rect = document.createElementNS(SVGNS, 'rect')
66
- rect.setAttribute('x', ox + c * C)
67
- rect.setAttribute('y', oy + r * C)
68
- rect.setAttribute('width', C)
69
- rect.setAttribute('height', C)
70
- rect.setAttribute('fill', COL[ch])
71
- g.appendChild(rect)
72
- }
73
- })
74
- })()
75
-
76
54
  // night scene backdrop (clouds, crescent moon, stars, dotted ground/sky)
77
55
  ;(function buildScene() {
78
56
  const s = el('scene')
@@ -375,8 +353,6 @@ function renderHeat(days) {
375
353
  // only comparable if every track starts and ends at the same x. So it is sized
376
354
  // to the widest label actually present, clamped so a long path cannot squeeze
377
355
  // the bars into stubs, and anything past the clamp is clipped with an ellipsis.
378
- const NAME_MIN = 64
379
- const BAR_MIN = 96 // room left for the track and the token count
380
356
 
381
357
  function renderBars(boxId, list, limit) {
382
358
  const box = el(boxId)
@@ -398,24 +374,9 @@ function renderBars(boxId, list, limit) {
398
374
  box.innerHTML = '<div class="mrow" style="opacity:.5">no activity</div>'
399
375
  return
400
376
  }
401
- fitNames(box)
402
377
  }
403
378
 
404
379
  // measure the labels unconstrained, then lock the column to the widest one
405
- function fitNames(box) {
406
- const names = [...box.querySelectorAll('.mname')]
407
- // a collapsed section measures zero — it re-fits when it opens
408
- if (!names.length || !box.offsetWidth) return
409
- box.style.setProperty('--name-w', 'auto')
410
- // offsetWidth, not getBoundingClientRect: the width we hand back is a layout px,
411
- // so it has to be measured in layout px too. A CSS transform on an ancestor
412
- // (the video stage scales the card) inflates the rect and squeezes the bars away.
413
- let widest = 0
414
- for (const n of names) widest = Math.max(widest, n.offsetWidth)
415
- const room = box.offsetWidth - BAR_MIN
416
- const w = Math.max(NAME_MIN, Math.min(widest + 2, room))
417
- box.style.setProperty('--name-w', `${w}px`)
418
- }
419
380
 
420
381
  // per-model weekly limits (e.g. "Fable" on Max) — one meter each, in the same
421
382
  // shape as the all-models one. The token count pairs the limit with the local
@@ -431,13 +392,21 @@ function renderScoped(list, byModel) {
431
392
  const m = document.createElement('div')
432
393
  m.className = 'meter'
433
394
  m.innerHTML =
434
- `<div class="meter-top"><span>weekly · ${esc(key)}</span><span>${Math.round(s.pct)}%</span></div>` +
435
- `<div class="track"><div class="fill week${s.pct >= 80 ? ' high' : ''}" style="width:${s.pct}%"></div></div>` +
395
+ `<div class="meter-top"><span>weekly · ${esc(key)}</span><span class="${levelOf(s.pct)}">${Math.round(s.pct)}%</span></div>` +
396
+ `<div class="track"><div class="fill week${s.pct >= 80 ? ' high' : ''} ${levelOf(s.pct)}" style="width:${s.pct}%"></div></div>` +
436
397
  `<div class="sub">${s.resetMs != null ? `resets in ${fmtResetIn(s.resetMs)} · ` : ''}${fmtTokens(tokens)} tokens</div>`
437
398
  box.appendChild(m)
438
399
  }
439
400
  }
440
401
 
402
+ // One ramp for every meter: plain below 60, warm to 85, hot past it. The number
403
+ // and its bar always read the same level.
404
+ const levelOf = (pct) => (pct >= 85 ? 'hot' : pct >= 60 ? 'mid' : '')
405
+ function setLevel(node, pct) {
406
+ node.classList.toggle('mid', levelOf(pct) === 'mid')
407
+ node.classList.toggle('hot', levelOf(pct) === 'hot')
408
+ }
409
+
441
410
  // by model (7 days)
442
411
  function renderModels(list) {
443
412
  renderBars('bymodel-list', list, 4)
@@ -576,6 +545,7 @@ function render(d) {
576
545
  if (liveOn && prevPct != null && sessActive && prevPct - sessPct > 25) celebrate()
577
546
  prevPct = sessPct
578
547
  el('session-pct').textContent = `${Math.round(sessPct)}%`
548
+ setLevel(el('session-pct'), liveOn ? sessPct : 0)
579
549
  const mini = el('mini-pct')
580
550
  mini.textContent = liveOn ? `${Math.round(sessPct)}%` : '—'
581
551
  mini.classList.toggle('high', liveOn && sessPct >= 80)
@@ -589,6 +559,7 @@ function render(d) {
589
559
  const sf = el('session-fill')
590
560
  sf.style.width = `${sessPct}%`
591
561
  sf.classList.toggle('high', sessPct >= 80)
562
+ setLevel(sf, sessPct)
592
563
  el('session-sub').textContent = sessActive
593
564
  ? `resets in ${fmtResetIn(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
594
565
  : 'no active session'
@@ -606,9 +577,11 @@ function render(d) {
606
577
  }
607
578
 
608
579
  el('week-pct').textContent = `${Math.round(wkPct)}%`
580
+ setLevel(el('week-pct'), wkPct)
609
581
  const wf = el('week-fill')
610
582
  wf.style.width = `${wkPct}%`
611
583
  wf.classList.toggle('high', wkPct >= 80)
584
+ setLevel(wf, wkPct)
612
585
  el('week-sub').textContent =
613
586
  wkReset != null
614
587
  ? `resets in ${fmtResetIn(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
@@ -655,7 +628,7 @@ function fitSize() {
655
628
  }
656
629
 
657
630
  // widget scale, applied as a CSS zoom (not transform) so offsetWidth/Height
658
- // keep reflecting it — see fitNames()'s comment on why transform can't be used here
631
+ // keep reflecting it — offsetHeight is a layout px, so it must be measured as one
659
632
  function applyZoom(z) {
660
633
  document.body.style.zoom = (z || 100) / 100
661
634
  }
@@ -715,8 +688,11 @@ function paintChip() {
715
688
  const a = lastAccounts
716
689
  const active = (a?.accounts || []).find((x) => x.id === a?.active)
717
690
  const email = p?.email || (active?.connected ? active.label : null)
718
- // the settings line says who is connected, not just that someone is
719
- el('acc-ok').textContent = email ? `● ${email}${p?.plan ? ` · ${p.plan}` : ''}` : '● Connected'
691
+ // the settings block says who is connected, not just that someone is: the
692
+ // avatar carries the initial, the name line the email, the sub line the plan
693
+ el('acc-ok').textContent = email || 'Connected'
694
+ el('acc-sub').textContent = p?.plan || 'your real usage is live'
695
+ el('acc-avatar').textContent = email ? email[0].toUpperCase() : '●'
720
696
  const chip = el('account-chip')
721
697
  const mini = el('mini-acct')
722
698
  if (!email) {
@@ -944,11 +920,21 @@ function toggleSection(id, force) {
944
920
  sec.classList.toggle('folded', folded)
945
921
  const head = sec.querySelector('.sec-head')
946
922
  if (head) head.setAttribute('aria-expanded', String(!folded))
947
- // the column width could not be measured while hidden
948
- if (!folded) fitNames(sec.querySelector('.sec-body'))
949
923
  // the card just changed height, and the next usage poll is seconds away —
950
- // without this the window keeps its old size and clips the content
924
+ // without this the window keeps its old size and clips the content. The body
925
+ // slides now rather than snapping, so size it again once that settles.
951
926
  fitSize()
927
+ // ...and again when the slide lands. The listener has to check what finished:
928
+ // the bar widths inside the section transition too, and those events bubble up
929
+ // here — resizing on one of them catches the card mid-slide.
930
+ const body = sec.querySelector('.sec-body')
931
+ if (!body) return
932
+ const settle = (e) => {
933
+ if (e.target !== body || e.propertyName !== 'grid-template-rows') return
934
+ body.removeEventListener('transitionend', settle)
935
+ fitSize()
936
+ }
937
+ body.addEventListener('transitionend', settle)
952
938
  }
953
939
 
954
940
  for (const head of document.querySelectorAll('.sec-head')) {
@@ -1111,7 +1097,26 @@ function openSettings() {
1111
1097
  document.body.classList.add('settings-open')
1112
1098
  fitSize()
1113
1099
  }
1114
- el('gear').addEventListener('click', openSettings)
1100
+
1101
+ // Leaving settings plays the panel out to the right while home slides back in.
1102
+ // The panel is lifted out of the flow for those few frames (see .settings-closing
1103
+ // in the stylesheet), so the card can already be measured at its home height.
1104
+ let closingTimer = null
1105
+ function closeSettings() {
1106
+ if (!document.body.classList.contains('settings-open')) return
1107
+ abandonLogin()
1108
+ document.body.classList.remove('settings-open')
1109
+ document.body.classList.add('settings-closing')
1110
+ clearTimeout(closingTimer)
1111
+ closingTimer = setTimeout(() => document.body.classList.remove('settings-closing'), 600)
1112
+ clearSaveDirty()
1113
+ applyZoom(currentConfig?.zoom != null ? currentConfig.zoom : 100) // undo the preview
1114
+ fitSize()
1115
+ }
1116
+ el('gear').addEventListener('click', () => {
1117
+ if (document.body.classList.contains('settings-open')) closeSettings()
1118
+ else openSettings()
1119
+ })
1115
1120
  // the "connect" placeholder jumps straight to settings
1116
1121
  el('limits-connect').addEventListener('click', openSettings)
1117
1122
  // custom number steppers (▲ / ▼)
@@ -1140,13 +1145,7 @@ for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
1140
1145
  refreshSaveDirty()
1141
1146
  })
1142
1147
  }
1143
- el('set-cancel').addEventListener('click', () => {
1144
- abandonLogin()
1145
- document.body.classList.remove('settings-open')
1146
- clearSaveDirty()
1147
- applyZoom(currentConfig?.zoom != null ? currentConfig.zoom : 100) // undo the preview
1148
- fitSize()
1149
- })
1148
+ el('set-cancel').addEventListener('click', closeSettings)
1150
1149
  el('set-save').addEventListener('click', () => {
1151
1150
  abandonLogin() // leaving the panel gives up on a login waiting for its code
1152
1151
  const num = (id) => parseFloat(el(id).value)