clauddy 1.19.0 → 1.21.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/README.md CHANGED
@@ -31,11 +31,11 @@ The token is saved locally (see [Data & privacy](#data--privacy)) and refreshed
31
31
 
32
32
  ### Several subscriptions
33
33
 
34
- Got more than one Claude account — say a personal Pro and a Max from work? **⚙ Settings "+ Add another account"** opens the same browser login, and the token it brings back lands in a new slot. Once two accounts exist, a list appears in Settings (and an **Account** submenu on the tray icon) to switch between them. Give up halfway and the empty slot disappears on its own — the list only ever holds accounts you actually logged into.
34
+ Got more than one Claude account — say a personal Pro and a Max from work? The **account chip** in the top-left corner is the switcher: click it for the list of accounts, with the active one marked, plus **"+ Add another account"** — which opens the same browser login and drops the token it brings back into a new slot. The tray icon has the same list under its **Account** submenu. Give up halfway and the empty slot disappears on its own — the list only ever holds accounts you actually logged into.
35
35
 
36
36
  The widget follows **one account at a time**: the one you pick is the one whose % is shown, whose logs are counted, and the only one that can notify you. Each account keeps its own token and its own armed alerts, so switching never replays a notification you already dismissed elsewhere. Everything else — window position, display mode, zoom, thresholds — is shared.
37
37
 
38
- Removing an account deletes its token from disk. The first account can't be removed, and neither can the one you're currently on switch away first.
38
+ Removing an account (the **×** on its row) deletes its token from disk. The one you're currently on can't be removed — switch away first — and neither can the last one left. Removing the first account clears its token without touching the settings that live in the same folder.
39
39
 
40
40
  > Prefer one widget per account instead? Setting `CLAUDE_CONFIG_DIR` still isolates a whole instance — token, settings and logs — so you can run two Clauddys side by side.
41
41
 
@@ -171,7 +171,7 @@ update-desktop-database ~/.local/share/applications 2>/dev/null
171
171
  ## Controls
172
172
 
173
173
  - **Drag** the widget anywhere on screen
174
- - **–** minimizes to just the pet's face (showing the live session %); the **⤢** button or a double-click on the pet expands it back
174
+ - **–** minimizes to just the pet, ringed by the live session % (the number sits inside the ring); the **⤢** button or a double-click on the pet expands it back
175
175
  - **⚙** opens settings (log in, toggle alerts, set thresholds, pick the display mode)
176
176
  - **↗** opens the official Usage page
177
177
  - **×** quits
package/accounts.js CHANGED
@@ -28,8 +28,10 @@ function sane(j) {
28
28
  label: typeof a.label === 'string' && a.label ? a.label : null,
29
29
  claudeDir: typeof a.claudeDir === 'string' && a.claudeDir ? a.claudeDir : null,
30
30
  }))
31
- if (!list.some((a) => a.id === DEFAULT_ID)) list.unshift(defaults().accounts[0])
32
- const active = list.some((a) => a.id === j?.active) ? j.active : DEFAULT_ID
31
+ // the default account can be removed like any other, so it is only recreated
32
+ // when nothing is left — an empty list would leave the widget with no token
33
+ if (!list.length) list.push(defaults().accounts[0])
34
+ const active = list.some((a) => a.id === j?.active) ? j.active : list[0].id
33
35
  return { active, accounts: list }
34
36
  }
35
37
 
@@ -118,18 +120,51 @@ function setClaudeDir(id, dir) {
118
120
  // behind would be a credential nobody can see or revoke from the UI
119
121
  function remove(id) {
120
122
  const s = load()
121
- if (id === DEFAULT_ID) return false // the original install's data: never dropped
122
123
  if (id === s.active) return false // switch away first: the widget is showing it
123
124
  const i = s.accounts.findIndex((a) => a.id === id)
124
125
  if (i < 0) return false
126
+ if (s.accounts.length < 2) return false // never leave the widget with no account
125
127
  s.accounts.splice(i, 1)
126
128
  save(s)
127
129
  try {
128
- fs.rmSync(dataDirOf(id), { recursive: true, force: true })
130
+ if (id === DEFAULT_ID) {
131
+ // its folder is the app's own data dir: drop the credentials, not the
132
+ // settings, the account list or the simulator file that live beside them
133
+ for (const f of ['auth.json', 'alerts.json'])
134
+ fs.rmSync(path.join(dataDirOf(id), f), { force: true })
135
+ } else {
136
+ fs.rmSync(dataDirOf(id), { recursive: true, force: true })
137
+ }
129
138
  } catch {}
130
139
  return true
131
140
  }
132
141
 
142
+ // Folders left behind under `accounts/` by slots that are no longer listed —
143
+ // an add abandoned mid-login, mostly, since saving the alert state recreates
144
+ // the folder on its way out. Only ever drops a folder with no auth.json in it,
145
+ // so a token is never destroyed by a bad read of accounts.json.
146
+ function pruneOrphans() {
147
+ const root = path.join(BASE_DIR, 'accounts')
148
+ let dirs = []
149
+ try {
150
+ dirs = fs.readdirSync(root, { withFileTypes: true })
151
+ } catch {
152
+ return 0 // no extra accounts were ever added
153
+ }
154
+ const known = new Set(list().map((a) => a.id))
155
+ let n = 0
156
+ for (const d of dirs) {
157
+ if (!d.isDirectory() || known.has(d.name)) continue
158
+ const dir = path.join(root, d.name)
159
+ if (fs.existsSync(path.join(dir, 'auth.json'))) continue
160
+ try {
161
+ fs.rmSync(dir, { recursive: true, force: true })
162
+ n++
163
+ } catch {}
164
+ }
165
+ return n
166
+ }
167
+
133
168
  module.exports = {
134
169
  BASE_DIR,
135
170
  DEFAULT_ID,
@@ -142,4 +177,5 @@ module.exports = {
142
177
  label,
143
178
  setClaudeDir,
144
179
  remove,
180
+ pruneOrphans,
145
181
  }
package/main.js CHANGED
@@ -296,7 +296,7 @@ function hasToken(id) {
296
296
  }
297
297
 
298
298
  function pruneEmpty(id) {
299
- if (id === accounts.DEFAULT_ID || id === accounts.activeId()) return
299
+ if (id === accounts.DEFAULT_ID || id === accounts.activeId()) return // never auto-drop the original
300
300
  const acc = accounts.list().find((a) => a.id === id)
301
301
  if (!acc || acc.label || hasToken(id)) return
302
302
  accounts.remove(id)
@@ -305,10 +305,20 @@ function pruneEmpty(id) {
305
305
  ipcMain.on('accounts-switch', (_e, id) => switchAccount(String(id)))
306
306
  // "add an account" *is* "log in": the browser opens on the new, empty slot, so
307
307
  // the token lands in it and not in the account we just left
308
+ // the account an "add" left behind: cancelling the login has to land back on
309
+ // it, or the widget is stuck on an empty slot with no chip to switch from
310
+ let addedFrom = null
308
311
  ipcMain.on('accounts-add', () => {
312
+ addedFrom = accounts.activeId()
309
313
  switchAccount(accounts.add())
310
314
  startLogin()
311
315
  })
316
+ ipcMain.on('accounts-cancel-add', () => {
317
+ const from = addedFrom
318
+ addedFrom = null
319
+ if (!from || from === accounts.activeId() || hasToken(accounts.activeId())) return
320
+ switchAccount(from) // the empty slot is pruned on the way out
321
+ })
312
322
  ipcMain.on('accounts-remove', (_e, id) => {
313
323
  if (accounts.remove(String(id))) sendAccounts()
314
324
  })
@@ -318,9 +328,13 @@ function createWindow() {
318
328
  // a login abandoned in an earlier run: nothing stays pending across a restart,
319
329
  // so fall back to the first account and let the empty slots go
320
330
  const boot = accounts.active()
321
- if (boot.id !== accounts.DEFAULT_ID && !boot.label && !hasToken(boot.id))
322
- accounts.setActive(accounts.DEFAULT_ID)
331
+ if (!boot.label && !hasToken(boot.id)) {
332
+ // the default account can be gone by now, so land on whatever comes first
333
+ const fallback = accounts.list().find((a) => a.id !== boot.id)
334
+ if (fallback) accounts.setActive(fallback.id)
335
+ }
323
336
  for (const a of accounts.list()) pruneEmpty(a.id)
337
+ accounts.pruneOrphans() // folders those slots left behind
324
338
  applyAccount(accounts.active())
325
339
  const { workAreaSize } = screen.getPrimaryDisplay()
326
340
  const H = 480
@@ -685,6 +699,7 @@ function startLogin() {
685
699
  ipcMain.on('auth-start', startLogin)
686
700
  ipcMain.on('auth-code', async (_e, code) => {
687
701
  const ok = () => {
702
+ addedFrom = null // the new account is real now: nothing to roll back to
688
703
  if (win && !win.isDestroyed()) {
689
704
  win.webContents.send('auth-state', { connected: true })
690
705
  win.webContents.send('auth-result', { ok: true })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.19.0",
4
+ "version": "1.21.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
package/preload.js CHANGED
@@ -18,6 +18,7 @@ contextBridge.exposeInMainWorld('api', {
18
18
  onAccounts: (cb) => ipcRenderer.on('accounts', (_e, a) => cb(a)),
19
19
  accountSwitch: (id) => ipcRenderer.send('accounts-switch', id),
20
20
  accountAdd: () => ipcRenderer.send('accounts-add'),
21
+ accountCancelAdd: () => ipcRenderer.send('accounts-cancel-add'),
21
22
  accountRemove: (id) => ipcRenderer.send('accounts-remove', id),
22
23
  onDebugState: (cb) => ipcRenderer.on('debug-state', (_e, s) => cb(s)),
23
24
  onVersion: (cb) => ipcRenderer.on('version', (_e, v) => cb(v)),
@@ -6,13 +6,18 @@
6
6
  <title>Clauddy</title>
7
7
  <link rel="stylesheet" href="style.css" />
8
8
  </head>
9
- <body class="state-idle">
9
+ <body class="state-idle one-account">
10
10
  <div id="card">
11
- <div id="account-chip" hidden>
11
+ <div id="account-chip" hidden title="Switch account">
12
12
  <span class="ac-dot"></span>
13
13
  <span id="ac-email"></span>
14
+ <svg class="ac-caret" viewBox="0 0 12 12" aria-hidden="true">
15
+ <path d="M3 5l3 3 3-3" />
16
+ </svg>
14
17
  <span id="ac-plan" class="plan-badge" hidden></span>
15
18
  </div>
19
+ <div id="acc-backdrop" hidden></div>
20
+ <div id="acc-menu" hidden></div>
16
21
  <div id="controls">
17
22
  <button id="gear" title="Settings">⚙</button>
18
23
  <button id="usage" title="Open Usage page">
@@ -107,6 +112,12 @@
107
112
  <path class="dk-leaf" d="M198 103 q5 -12 3 -25 q-7 9 -3 25" />
108
113
  <path class="dk-leaf-c" d="M198 103 q0 -14 0 -27 q3 13 0 27" />
109
114
  </svg>
115
+ <!-- collapsed: a ring around the pet with the session % inside -->
116
+ <svg id="ring" viewBox="0 0 100 100" aria-hidden="true">
117
+ <circle class="ring-track" cx="50" cy="50" r="45" />
118
+ <circle id="ring-fill" cx="50" cy="50" r="45" />
119
+ </svg>
120
+ <span id="ring-pct">0%</span>
110
121
  <div id="shadow"></div>
111
122
  <div id="pet">
112
123
  <svg id="claude" viewBox="0 0 100 90" width="86" height="77">
@@ -311,12 +322,6 @@
311
322
  <span class="acc-ok" id="acc-ok">● Connected</span>
312
323
  <button id="acc-logout" class="acc-link">Disconnect</button>
313
324
  </div>
314
- <!-- one row per subscription; only the active one is polled -->
315
- <div id="acc-switch" hidden>
316
- <div class="set-hint set-hint-left">Accounts</div>
317
- <div id="acc-list"></div>
318
- </div>
319
- <button id="acc-add" class="acc-link">+ Add another account</button>
320
325
  <div id="acc-disconnected">
321
326
  <div class="set-hint set-hint-left" id="acc-intro">
322
327
  Connect your Claude account to show your real usage.
package/renderer/pet.js CHANGED
@@ -455,6 +455,8 @@ function oneShot(cls, ms) {
455
455
  setTimeout(() => document.body.classList.remove(cls), ms)
456
456
  }
457
457
 
458
+ const RING_LEN = 2 * Math.PI * 45 // the collapsed ring's circumference (r=45)
459
+
458
460
  // session-reset celebration: jump + a colorful confetti burst
459
461
  const CONFETTI_COLORS = ['#ffd23f', '#ff5d86', '#7ec77d', '#6db3f2', '#e0805a', '#c89bff']
460
462
  function spawnConfetti(i) {
@@ -577,6 +579,13 @@ function render(d) {
577
579
  const mini = el('mini-pct')
578
580
  mini.textContent = liveOn ? `${Math.round(sessPct)}%` : '—'
579
581
  mini.classList.toggle('high', liveOn && sessPct >= 80)
582
+ // the collapsed ring: how much of the session is already spent
583
+ const rf = el('ring-fill')
584
+ rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(sessPct, 100) / 100)}`
585
+ rf.classList.toggle('high', sessPct >= 80)
586
+ const rp = el('ring-pct')
587
+ rp.textContent = mini.textContent
588
+ rp.classList.toggle('high', liveOn && sessPct >= 80)
580
589
  const sf = el('session-fill')
581
590
  sf.style.width = `${sessPct}%`
582
591
  sf.classList.toggle('high', sessPct >= 80)
@@ -632,7 +641,7 @@ function fitSize() {
632
641
  requestAnimationFrame(() => {
633
642
  const collapsed = document.body.classList.contains('collapsed')
634
643
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
635
- const w = (collapsed ? 140 : 276) * zoom
644
+ const w = (collapsed ? 168 : 276) * zoom
636
645
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
637
646
  // empirically against this Electron build) — so measure the unzoomed content
638
647
  // height, then scale the whole thing (content + margin) ourselves
@@ -724,39 +733,56 @@ function setPlan(node, plan) {
724
733
  window.api.onProfile(showProfile)
725
734
 
726
735
  // ---- accounts ---------------------------------------------------------------
727
- // The list is only worth showing once there are two; with a single account the
728
- // panel keeps the shape it has always had, plus the "add" link.
736
+ // The account chip doubles as the switcher: click it for the list, with the
737
+ // active one marked. Settings only keeps the login flow itself.
729
738
  let pendingRemove = null // id whose × is armed, so removal takes two clicks
730
739
  let lastAccounts = null
740
+ let switching = false // a switch is in flight, so the menu waits for its answer
741
+
742
+ // one row of the chip dropdown
743
+ function accountRow(acc, a) {
744
+ const active = acc.id === a.active
745
+ const row = document.createElement('div')
746
+ row.className = 'acc-item'
747
+ if (active) row.classList.add('active')
748
+ if (acc.connected) row.classList.add('connected')
749
+ // the account being switched to owns the spinner: everything on screen
750
+ // still belongs to the one we're leaving
751
+ if (a.busy === acc.id) row.classList.add('loading')
752
+
753
+ const dot = document.createElement('span')
754
+ dot.className = 'dot'
755
+ const who = document.createElement('span')
756
+ who.className = 'who'
757
+ who.textContent =
758
+ acc.label || (acc.connected ? 'Connected' : active ? 'Waiting for login…' : 'Not connected')
759
+ row.append(dot, who)
760
+ return row
761
+ }
731
762
 
763
+ // Everything about accounts lives in the chip dropdown: the chip already says
764
+ // which one you are on, so switching, adding and removing all happen there.
732
765
  function renderAccounts(a) {
733
766
  lastAccounts = a
734
- const list = a?.accounts || []
735
- const box = el('acc-list')
736
- el('acc-switch').hidden = list.length < 2
767
+ document.body.classList.toggle('one-account', (a?.accounts || []).length < 2)
768
+ if (el('acc-menu').hidden) return
769
+ // the switch is done: the chip now names the account the menu was pointing at
770
+ if (switching && !a?.busy) closeAccountMenu()
771
+ else renderAccountMenu()
772
+ }
773
+
774
+ function renderAccountMenu() {
775
+ const a = lastAccounts
776
+ const box = el('acc-menu')
737
777
  box.textContent = ''
738
778
  box.classList.toggle('busy', !!a?.busy)
739
- for (const acc of list) {
779
+ for (const acc of a?.accounts || []) {
740
780
  const active = acc.id === a.active
741
- const row = document.createElement('div')
742
- row.className = 'acc-item'
743
- if (active) row.classList.add('active')
744
- if (acc.connected) row.classList.add('connected')
745
- // the account being switched to owns the spinner: everything on screen
746
- // still belongs to the one we're leaving
747
- if (a.busy === acc.id) row.classList.add('loading')
748
-
749
- const dot = document.createElement('span')
750
- dot.className = 'dot'
751
- const who = document.createElement('span')
752
- who.className = 'who'
753
- who.textContent =
754
- acc.label || (acc.connected ? 'Connected' : active ? 'Waiting for login…' : 'Not connected')
755
- row.append(dot, who)
756
-
757
- // the first account holds the original install's data, and the active one
758
- // is what the widget is showing — neither can be removed from under you
759
- if (acc.id !== 'default' && !active) {
781
+ const row = accountRow(acc, a)
782
+
783
+ // the active account is what the widget is showing: it can't be removed
784
+ // from under you, and the last one standing can't be removed at all
785
+ if (!active && (a.accounts || []).length > 1) {
760
786
  const x = document.createElement('button')
761
787
  const armed = pendingRemove === acc.id
762
788
  x.className = armed ? 'drop armed' : 'drop'
@@ -769,24 +795,72 @@ function renderAccounts(a) {
769
795
  window.api.accountRemove(acc.id)
770
796
  } else {
771
797
  pendingRemove = acc.id // a click deletes a token: ask once
772
- renderAccounts(a)
798
+ renderAccountMenu()
773
799
  }
774
800
  })
775
801
  row.appendChild(x)
776
802
  }
777
803
 
778
- if (!active) row.addEventListener('click', () => switchAccount(acc.id))
804
+ // stopPropagation: re-rendering detaches this row, and the document-level
805
+ // "clicked outside" handler would then read that as a click off the menu
806
+ if (!active)
807
+ row.addEventListener('click', (e) => {
808
+ e.stopPropagation()
809
+ switchAccount(acc.id)
810
+ })
779
811
  box.appendChild(row)
780
812
  }
781
- fitSize()
813
+
814
+ const add = document.createElement('div')
815
+ add.className = 'acc-item acc-add'
816
+ add.textContent = '+ Add another account'
817
+ add.addEventListener('click', () => {
818
+ closeAccountMenu()
819
+ el('acc-msg').textContent = ''
820
+ window.api.accountAdd() // switches to a fresh slot and opens the browser
821
+ })
822
+ box.appendChild(add)
823
+ }
824
+
825
+ function closeAccountMenu() {
826
+ el('acc-menu').hidden = true
827
+ el('acc-backdrop').hidden = true
828
+ pendingRemove = null
829
+ switching = false
782
830
  }
783
831
 
832
+ function toggleAccountMenu() {
833
+ const box = el('acc-menu')
834
+ if (!box.hidden) {
835
+ closeAccountMenu()
836
+ return
837
+ }
838
+ renderAccountMenu()
839
+ box.hidden = false
840
+ el('acc-backdrop').hidden = false
841
+ }
842
+
843
+ el('account-chip').addEventListener('click', (e) => {
844
+ e.stopPropagation()
845
+ toggleAccountMenu()
846
+ })
847
+ // clicking anywhere else — the pet, the gear, another app — puts it away
848
+ el('acc-backdrop').addEventListener('mousedown', closeAccountMenu)
849
+ document.addEventListener('click', (e) => {
850
+ if (!el('acc-menu').hidden && !el('acc-menu').contains(e.target)) closeAccountMenu()
851
+ })
852
+ window.addEventListener('blur', closeAccountMenu)
853
+ document.addEventListener('keydown', (e) => {
854
+ if (e.key === 'Escape') closeAccountMenu()
855
+ })
856
+
784
857
  // paint the pending state from the click itself rather than waiting for main to
785
858
  // answer — the answer is exactly what takes a moment
786
859
  function switchAccount(id) {
787
860
  if (lastAccounts?.busy) return
788
861
  endLogin() // the code from the account we're leaving is no good here
789
862
  pendingRemove = null
863
+ switching = true // the row stays on screen, pending, until main answers
790
864
  renderAccounts({ ...lastAccounts, busy: id })
791
865
  window.api.accountSwitch(id)
792
866
  }
@@ -796,16 +870,20 @@ window.api.onAccounts((a) => {
796
870
  renderAccounts(a)
797
871
  })
798
872
 
873
+ // closing the panel while a login is pending gives up on it: main puts us back
874
+ // on the account we were using, and the half-made one goes away
875
+ function abandonLogin() {
876
+ if (!document.body.classList.contains('awaiting')) return
877
+ endLogin()
878
+ window.api.accountCancelAdd()
879
+ }
880
+
799
881
  function endLogin() {
800
882
  document.body.classList.remove('awaiting')
801
883
  el('acc-paste').classList.remove('show')
802
884
  el('acc-code').value = ''
803
885
  el('acc-msg').textContent = ''
804
886
  }
805
- el('acc-add').addEventListener('click', () => {
806
- el('acc-msg').textContent = ''
807
- window.api.accountAdd() // switches to a fresh slot and opens the browser
808
- })
809
887
  let successTimer = null
810
888
  window.api.onAuthResult((r) => {
811
889
  if (r?.ok) {
@@ -884,14 +962,21 @@ el('acc-connect').addEventListener('click', () => window.api.authStart())
884
962
  // main opened the browser — the only thing left to do is paste the code back,
885
963
  // so the panel narrows to exactly that
886
964
  window.api.onAuthPending(() => {
965
+ // the login can start from the account menu, with the panel closed: the code
966
+ // field is where the flow continues, so bring it up
967
+ openSettings()
887
968
  document.body.classList.add('awaiting')
888
969
  el('acc-paste').classList.add('show')
970
+ el('acc-code').classList.remove('filled')
889
971
  el('acc-code').focus()
890
972
  fitSize()
891
973
  })
892
- // the Connect button only lights up once there's a code to submit
974
+ // the Connect button only lights up once there's a code to submit, and the
975
+ // field stops asking for attention once it has one
893
976
  el('acc-code').addEventListener('input', () => {
894
- el('acc-confirm').classList.toggle('ready', !!el('acc-code').value.trim())
977
+ const code = el('acc-code').value.trim()
978
+ el('acc-confirm').classList.toggle('ready', !!code)
979
+ el('acc-code').classList.toggle('filled', !!code)
895
980
  })
896
981
  el('acc-confirm').addEventListener('click', () => {
897
982
  const code = el('acc-code').value.trim()
@@ -1042,12 +1127,14 @@ for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
1042
1127
  })
1043
1128
  }
1044
1129
  el('set-cancel').addEventListener('click', () => {
1130
+ abandonLogin()
1045
1131
  document.body.classList.remove('settings-open')
1046
1132
  clearSaveDirty()
1047
1133
  applyZoom(currentConfig?.zoom != null ? currentConfig.zoom : 100) // undo the preview
1048
1134
  fitSize()
1049
1135
  })
1050
1136
  el('set-save').addEventListener('click', () => {
1137
+ abandonLogin() // leaving the panel gives up on a login waiting for its code
1051
1138
  const num = (id) => parseFloat(el(id).value)
1052
1139
  const fire = num('set-fire')
1053
1140
  const zoomRaw = num('set-zoom')
@@ -1116,6 +1203,7 @@ if (typeof module === 'object' && module.exports) {
1116
1203
  renderHeat,
1117
1204
  showProfile,
1118
1205
  renderAccounts,
1206
+ renderAccountMenu,
1119
1207
  burn,
1120
1208
  }
1121
1209
  }
@@ -169,9 +169,10 @@ body.collapsed #usage {
169
169
  z-index: 10;
170
170
  display: flex;
171
171
  align-items: center;
172
- gap: 5px;
172
+ gap: 3px;
173
173
  height: 19px; /* match #controls buttons so text centers on the same line */
174
- max-width: 138px;
174
+ /* #controls starts at ~154px: stop short of it, the pill has padding too */
175
+ max-width: 142px;
175
176
  font-size: 9.5px;
176
177
  line-height: 1;
177
178
  color: var(--muted);
@@ -179,6 +180,106 @@ body.collapsed #usage {
179
180
  #account-chip[hidden] {
180
181
  display: none;
181
182
  }
183
+ /* with something to switch to, the chip stops being a label and reads as a
184
+ control: a pill that holds the caret instead of leaving it floating */
185
+ #account-chip {
186
+ -webkit-app-region: no-drag;
187
+ cursor: pointer;
188
+ padding: 0 5px 0 6px;
189
+ margin-left: -6px; /* the text stays where it has always been */
190
+ border-radius: 10px;
191
+ background: rgba(255, 255, 255, 0.05);
192
+ transition:
193
+ background 0.15s,
194
+ color 0.15s;
195
+ }
196
+ #account-chip:hover {
197
+ background: rgba(255, 255, 255, 0.1);
198
+ color: var(--text);
199
+ }
200
+ /* a drawn chevron, not the ▾ glyph: the glyph's ink sits high in its em box, so
201
+ it never lines up with the dot and the text */
202
+ .ac-caret {
203
+ flex: none;
204
+ width: 9px;
205
+ height: 9px;
206
+ margin-left: -2px; /* hugs the email rather than drifting off it */
207
+ fill: none;
208
+ stroke: currentColor;
209
+ stroke-width: 1.6;
210
+ stroke-linecap: round;
211
+ stroke-linejoin: round;
212
+ }
213
+ /* a single account keeps the pill, minus the affordance: nothing to switch to.
214
+ The email then takes the width the chevron leaves behind. */
215
+ body.one-account #account-chip {
216
+ cursor: default;
217
+ }
218
+ body.one-account #account-chip:hover {
219
+ background: rgba(255, 255, 255, 0.05);
220
+ color: var(--muted);
221
+ }
222
+ body.one-account .ac-caret {
223
+ display: none;
224
+ }
225
+ /* the widget is mostly a drag region, and those swallow clicks before the page
226
+ ever sees them — so an invisible sheet under the menu catches them instead */
227
+ #acc-backdrop {
228
+ position: fixed;
229
+ inset: 0;
230
+ z-index: 19;
231
+ -webkit-app-region: no-drag;
232
+ }
233
+ #acc-backdrop[hidden] {
234
+ display: none;
235
+ }
236
+ /* dropdown under the chip; it overlays the pet rather than resizing the window */
237
+ #acc-menu {
238
+ position: absolute;
239
+ top: 24px;
240
+ left: 8px;
241
+ z-index: 20;
242
+ min-width: 118px;
243
+ max-width: 176px;
244
+ max-height: 120px;
245
+ overflow-y: auto;
246
+ padding: 3px;
247
+ border: 0.5px solid rgba(255, 210, 180, 0.1);
248
+ border-radius: 7px;
249
+ background: #221a15;
250
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
251
+ -webkit-app-region: no-drag;
252
+ }
253
+ #acc-menu[hidden] {
254
+ display: none;
255
+ }
256
+ #acc-menu.busy {
257
+ pointer-events: none; /* one switch at a time */
258
+ }
259
+ /* the add entry closes the list: it belongs to no account */
260
+ #acc-menu .acc-add {
261
+ margin-top: 2px;
262
+ padding-top: 6px;
263
+ border-top: 0.5px solid var(--hair);
264
+ border-radius: 0;
265
+ color: var(--coral-soft);
266
+ /* no .who inside this one, so it needs the row font itself */
267
+ font-size: 9.5px;
268
+ white-space: nowrap;
269
+ }
270
+ #acc-menu .acc-add:hover {
271
+ background: none;
272
+ color: var(--coral);
273
+ }
274
+ /* tighter than the dropdown-less list it replaced: it floats over the pet */
275
+ #acc-menu .acc-item {
276
+ gap: 6px;
277
+ padding: 4px 6px;
278
+ border-radius: 5px;
279
+ }
280
+ #acc-menu .acc-item .who {
281
+ font-size: 9.5px;
282
+ }
182
283
  .ac-dot {
183
284
  flex: none;
184
285
  width: 6px;
@@ -187,6 +288,11 @@ body.collapsed #usage {
187
288
  background: var(--pixel);
188
289
  }
189
290
  #ac-email {
291
+ /* line-height:1 leaves the ascender inside the box, so the text reads a hair
292
+ lower than the dot next to it — lift it back onto the same line */
293
+ transform: translateY(-1px);
294
+ flex: 1; /* claim the leftover width so the ellipsis is not left hanging */
295
+ min-width: 0;
190
296
  overflow: hidden;
191
297
  text-overflow: ellipsis;
192
298
  white-space: nowrap;
@@ -208,6 +314,19 @@ body.collapsed #usage {
208
314
  body.collapsed #account-chip {
209
315
  display: none;
210
316
  }
317
+ /* collapsed: the account rides up on the button line, like the chip does */
318
+ body.collapsed #mini-acct {
319
+ position: absolute;
320
+ top: 6px;
321
+ left: 10px;
322
+ z-index: 10;
323
+ height: 19px;
324
+ margin-bottom: 0;
325
+ justify-content: flex-start;
326
+ }
327
+ body.collapsed #mini-acct-name {
328
+ max-width: 62px;
329
+ }
211
330
 
212
331
  /* settings panel */
213
332
  #settings {
@@ -683,20 +802,6 @@ body.live .live-only {
683
802
  gap: 10px; /* the email ellipsizes instead of touching Disconnect */
684
803
  flex-wrap: wrap; /* the success line drops to its own row */
685
804
  }
686
- #acc-switch {
687
- margin-top: 10px;
688
- }
689
- #acc-switch[hidden] {
690
- display: none;
691
- }
692
- #acc-list {
693
- display: flex;
694
- flex-direction: column;
695
- margin-top: 2px;
696
- }
697
- #acc-list.busy {
698
- pointer-events: none; /* one switch at a time */
699
- }
700
805
  /* a plain list, not a stack of cards: the panel already has enough boxes */
701
806
  .acc-item {
702
807
  display: flex;
@@ -762,11 +867,11 @@ body.live .live-only {
762
867
  line-height: 1;
763
868
  padding: 0 2px;
764
869
  cursor: pointer;
765
- opacity: 0; /* one row at a time, on hover: this deletes a token */
870
+ opacity: 0.3; /* faint until hover: this deletes a token */
766
871
  transition: opacity 0.15s;
767
872
  }
768
873
  .acc-item:hover .drop {
769
- opacity: 0.6;
874
+ opacity: 0.75;
770
875
  }
771
876
  .acc-item .drop:hover {
772
877
  opacity: 1;
@@ -779,13 +884,6 @@ body.live .live-only {
779
884
  font-size: 10px;
780
885
  color: var(--coral);
781
886
  }
782
- #acc-add {
783
- margin-top: 6px;
784
- }
785
- /* while a login is pending, "add another" would start a second one */
786
- body.awaiting #acc-add {
787
- display: none;
788
- }
789
887
  #acc-paste {
790
888
  display: none;
791
889
  margin-top: 8px;
@@ -793,6 +891,20 @@ body.awaiting #acc-add {
793
891
  #acc-paste.show {
794
892
  display: block;
795
893
  }
894
+ /* while the browser is open, this empty field is the whole flow: make it say so */
895
+ body.awaiting #acc-code:not(.filled) {
896
+ border-color: var(--coral);
897
+ animation: code-wait 1.6s ease-in-out infinite;
898
+ }
899
+ @keyframes code-wait {
900
+ 0%,
901
+ 100% {
902
+ box-shadow: 0 0 0 0 rgba(217, 119, 87, 0);
903
+ }
904
+ 50% {
905
+ box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.22);
906
+ }
907
+ }
796
908
  #acc-code {
797
909
  width: 100%;
798
910
  margin: 4px 0 6px;
@@ -977,7 +1089,7 @@ body.collapsed #mini {
977
1089
  display: block;
978
1090
  }
979
1091
  body.collapsed #card {
980
- width: 116px;
1092
+ width: 144px;
981
1093
  padding: 12px 12px 13px;
982
1094
  }
983
1095
  body.collapsed #stage {
@@ -1049,7 +1161,7 @@ body.settings-open #pet {
1049
1161
 
1050
1162
  /* collapsed: just the face, on the warm card (like before) */
1051
1163
  body.collapsed #stage {
1052
- height: 98px;
1164
+ height: 120px;
1053
1165
  margin-top: 9px;
1054
1166
  background: transparent;
1055
1167
  box-shadow: none;
@@ -1072,19 +1184,95 @@ body.collapsed #gears,
1072
1184
  body.collapsed #glasses {
1073
1185
  display: none;
1074
1186
  }
1187
+ /* collapsed: the pet sits a bit high, so the % fits under it inside the ring */
1075
1188
  body.collapsed #pet {
1076
1189
  position: static;
1077
1190
  left: auto;
1078
1191
  bottom: auto;
1079
- zoom: 1;
1192
+ transform: translateY(-8px);
1193
+ zoom: 0.86;
1194
+ }
1195
+ /* the ring is a fixed frame, so the usual bob reads as jumping in there */
1196
+ body.collapsed #claude {
1197
+ animation: bob-mini 3.6s ease-in-out infinite;
1198
+ }
1199
+ @keyframes bob-mini {
1200
+ 0%,
1201
+ 100% {
1202
+ transform: translateY(0);
1203
+ }
1204
+ 50% {
1205
+ transform: translateY(-1.5px);
1206
+ }
1207
+ }
1208
+ /* the ring: session % drawn around the pet, only when there's a real % to show */
1209
+ #ring {
1210
+ display: none;
1211
+ }
1212
+ body.collapsed #ring {
1213
+ display: block;
1214
+ position: absolute;
1215
+ left: 50%;
1216
+ top: 50%;
1217
+ width: 118px;
1218
+ height: 118px;
1219
+ transform: translate(-50%, -50%);
1220
+ pointer-events: none;
1221
+ z-index: 1;
1222
+ }
1223
+ .ring-track {
1224
+ fill: none;
1225
+ stroke: var(--track);
1226
+ stroke-width: 3.5;
1227
+ }
1228
+ #ring-fill {
1229
+ fill: none;
1230
+ stroke: var(--green);
1231
+ stroke-width: 3.5;
1232
+ stroke-linecap: round;
1233
+ transform: rotate(-90deg);
1234
+ transform-origin: 50% 50%;
1235
+ stroke-dasharray: 282.7;
1236
+ stroke-dashoffset: 282.7; /* set from JS */
1237
+ transition:
1238
+ stroke-dashoffset 0.5s ease,
1239
+ stroke 0.3s ease;
1240
+ }
1241
+ #ring-fill.high {
1242
+ stroke: #e0553f;
1243
+ }
1244
+ #ring-pct {
1245
+ display: none;
1246
+ }
1247
+ body.collapsed #ring-pct {
1248
+ display: block;
1249
+ position: absolute;
1250
+ left: 0;
1251
+ right: 0;
1252
+ bottom: 12px;
1253
+ z-index: 3;
1254
+ text-align: center;
1255
+ font-size: 13px;
1256
+ font-weight: 700;
1257
+ color: var(--text);
1258
+ font-variant-numeric: tabular-nums;
1259
+ pointer-events: none;
1260
+ }
1261
+ #ring-pct.high {
1262
+ color: #e0553f;
1263
+ }
1264
+ /* the ring already says it — no need to repeat it under the pet */
1265
+ body.collapsed #mini-pct-row {
1266
+ display: none;
1080
1267
  }
1081
1268
  body.collapsed #shadow {
1082
1269
  display: block;
1083
1270
  position: absolute;
1084
- bottom: 2px;
1271
+ /* pinned under the pet's feet, which the ring lifted off the stage floor */
1272
+ top: calc(50% + 23px);
1085
1273
  left: 50%;
1086
- width: 60px;
1087
- height: 9px;
1274
+ width: 48px;
1275
+ height: 8px;
1088
1276
  background: radial-gradient(ellipse, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0));
1089
1277
  transform: translateX(-50%);
1090
1278
  }