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 +3 -3
- package/accounts.js +40 -4
- package/main.js +18 -3
- package/package.json +1 -1
- package/preload.js +1 -0
- package/renderer/index.html +13 -8
- package/renderer/pet.js +123 -35
- package/renderer/style.css +219 -31
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?
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 (
|
|
322
|
-
|
|
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
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)),
|
package/renderer/index.html
CHANGED
|
@@ -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 ?
|
|
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
|
|
728
|
-
//
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
779
|
+
for (const acc of a?.accounts || []) {
|
|
740
780
|
const active = acc.id === a.active
|
|
741
|
-
const row =
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
798
|
+
renderAccountMenu()
|
|
773
799
|
}
|
|
774
800
|
})
|
|
775
801
|
row.appendChild(x)
|
|
776
802
|
}
|
|
777
803
|
|
|
778
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/renderer/style.css
CHANGED
|
@@ -169,9 +169,10 @@ body.collapsed #usage {
|
|
|
169
169
|
z-index: 10;
|
|
170
170
|
display: flex;
|
|
171
171
|
align-items: center;
|
|
172
|
-
gap:
|
|
172
|
+
gap: 3px;
|
|
173
173
|
height: 19px; /* match #controls buttons so text centers on the same line */
|
|
174
|
-
|
|
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; /*
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1087
|
-
height:
|
|
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
|
}
|