clauddy 1.10.1 → 1.11.1
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 +2 -0
- package/main.js +148 -6
- package/package.json +1 -1
- package/preload.js +4 -0
- package/renderer/index.html +51 -34
- package/renderer/pet.js +45 -0
- package/renderer/style.css +150 -15
package/README.md
CHANGED
|
@@ -87,6 +87,8 @@ curl -fsSL https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main
|
|
|
87
87
|
|
|
88
88
|
**Clauddy is free and open source** — the command above just downloads the latest release from this repo and drops it in `/Applications`, nothing else (you can read [`install.sh`](install.sh) first if you'd like).
|
|
89
89
|
|
|
90
|
+
Once installed, you can update in-app: **⚙ Settings → Check for updates → Update now** runs the same installer and relaunches the new build.
|
|
91
|
+
|
|
90
92
|
Why not a normal download? macOS blocks **unsigned** apps downloaded through a browser with a scary *"damaged, move to Trash"* warning — even when they're perfectly safe. It's a false alarm: the only way to silence it is to pay Apple **$99/year** to sign + notarize, which a free hobby app skips. Files fetched with `curl` aren't flagged, so this method simply **lets your Mac open the app** without the block. It then registers in **Login Items** and starts with your Mac — set it and forget it.
|
|
91
93
|
|
|
92
94
|
#### 2. Run it via `bunx` (no install)
|
package/main.js
CHANGED
|
@@ -12,9 +12,12 @@ const {
|
|
|
12
12
|
const path = require('node:path')
|
|
13
13
|
const fs = require('node:fs')
|
|
14
14
|
const os = require('node:os')
|
|
15
|
+
const { spawn } = require('node:child_process')
|
|
15
16
|
const { getUsage } = require('./usage')
|
|
16
17
|
const auth = require('./auth')
|
|
17
18
|
|
|
19
|
+
const REPO = 'renatoaug/claude-usage-monitor'
|
|
20
|
+
|
|
18
21
|
// data dir: kept outside the project folder so moving the repo doesn't break it.
|
|
19
22
|
// when CLAUDE_CONFIG_DIR is set (e.g. via direnv for multi-account setups), nest
|
|
20
23
|
// the widget's data under that account's Claude config dir so each account gets
|
|
@@ -33,6 +36,9 @@ if (process.env.CLAUDE_CONFIG_DIR) {
|
|
|
33
36
|
const EXTERNAL_CONFIG = path.join(DATA_DIR, 'config.json')
|
|
34
37
|
// debug channel: `./pet <state>` writes here to force a state (dev only)
|
|
35
38
|
const DEBUG_FILE = path.join(DATA_DIR, 'debug.json')
|
|
39
|
+
// remembered floating-widget position: survives restarts and lets the widget
|
|
40
|
+
// return to the monitor the user parked it on after a display is unplugged/replugged
|
|
41
|
+
const WINDOW_STATE = path.join(DATA_DIR, 'window.json')
|
|
36
42
|
|
|
37
43
|
let win
|
|
38
44
|
let pollTimer
|
|
@@ -46,6 +52,8 @@ let currentMode = 'floating' // 'floating' widget | 'menubar' popover
|
|
|
46
52
|
let trayBounds = null // last known tray icon rect, to anchor the popover
|
|
47
53
|
let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
|
|
48
54
|
let sessionPct = null // authoritative session % shown in the tray title
|
|
55
|
+
let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
|
|
56
|
+
let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
|
|
49
57
|
|
|
50
58
|
function publicConfig(c) {
|
|
51
59
|
return {
|
|
@@ -148,6 +156,27 @@ function createWindow() {
|
|
|
148
156
|
}
|
|
149
157
|
})
|
|
150
158
|
|
|
159
|
+
// remember where the user parks the widget — but not the moves we make
|
|
160
|
+
// ourselves (resize re-anchoring) nor the ones the OS forces when a display
|
|
161
|
+
// (dis)connects, so a monitor going dark never overwrites the saved spot
|
|
162
|
+
win.on('moved', () => {
|
|
163
|
+
if (Date.now() - lastProgrammaticMove < 500) return
|
|
164
|
+
if (Date.now() - displayChanging < 2000) return
|
|
165
|
+
saveWindowState()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// when a monitor is unplugged/replugged (or its layout changes), put the
|
|
169
|
+
// floating widget back on the display the user parked it on — the OS dumps
|
|
170
|
+
// it on the primary display otherwise, and never moves it back on its own
|
|
171
|
+
const onDisplayChange = () => {
|
|
172
|
+
displayChanging = Date.now()
|
|
173
|
+
if (currentMode === 'floating' && win && !win.isDestroyed() && win.isVisible())
|
|
174
|
+
positionFloating()
|
|
175
|
+
}
|
|
176
|
+
screen.on('display-added', onDisplayChange)
|
|
177
|
+
screen.on('display-removed', onDisplayChange)
|
|
178
|
+
screen.on('display-metrics-changed', onDisplayChange)
|
|
179
|
+
|
|
151
180
|
const tick = () => {
|
|
152
181
|
if (!win || win.isDestroyed()) return
|
|
153
182
|
try {
|
|
@@ -163,12 +192,15 @@ function createWindow() {
|
|
|
163
192
|
win.webContents.once('did-finish-load', () => {
|
|
164
193
|
tick()
|
|
165
194
|
win.webContents.send('config', publicConfig(config))
|
|
195
|
+
win.webContents.send('version', app.getVersion())
|
|
166
196
|
win.webContents.send('auth-state', { connected: auth.isConnected() })
|
|
167
197
|
pollTimer = setInterval(tick, config.pollIntervalMs)
|
|
168
198
|
startUsagePoll()
|
|
169
199
|
sendProfile()
|
|
170
200
|
watchDebug()
|
|
171
201
|
applyMode(config.mode) // reveal the widget, or set up the tray + popover
|
|
202
|
+
setTimeout(autoUpdateCheck, 8000) // check once shortly after launch…
|
|
203
|
+
setInterval(autoUpdateCheck, 6 * 60 * 60 * 1000) // …then every 6h
|
|
172
204
|
})
|
|
173
205
|
}
|
|
174
206
|
|
|
@@ -244,6 +276,43 @@ function showPopover() {
|
|
|
244
276
|
win.focus()
|
|
245
277
|
}
|
|
246
278
|
|
|
279
|
+
// route every programmatic move through here so the 'moved' handler can tell
|
|
280
|
+
// our own repositioning apart from a genuine user drag (and skip saving it)
|
|
281
|
+
function moveWindow(x, y) {
|
|
282
|
+
lastProgrammaticMove = Date.now()
|
|
283
|
+
win.setPosition(Math.round(x), Math.round(y))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// persist the widget's anchor — its bottom-right corner, since the window's
|
|
287
|
+
// size changes as the pet animates — so it can be restored later
|
|
288
|
+
function saveWindowState() {
|
|
289
|
+
if (currentMode !== 'floating' || !win || win.isDestroyed()) return
|
|
290
|
+
const b = win.getBounds()
|
|
291
|
+
try {
|
|
292
|
+
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
293
|
+
fs.writeFileSync(WINDOW_STATE, JSON.stringify({ right: b.x + b.width, bottom: b.y + b.height }))
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function loadWindowState() {
|
|
298
|
+
try {
|
|
299
|
+
const s = JSON.parse(fs.readFileSync(WINDOW_STATE, 'utf8'))
|
|
300
|
+
return Number.isFinite(s?.right) && Number.isFinite(s?.bottom) ? s : null
|
|
301
|
+
} catch {
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// is the saved bottom-right corner on a display that's currently connected?
|
|
307
|
+
function cornerVisible(right, bottom) {
|
|
308
|
+
const x = Math.round(right - 1)
|
|
309
|
+
const y = Math.round(bottom - 1)
|
|
310
|
+
return screen.getAllDisplays().some((d) => {
|
|
311
|
+
const b = d.bounds
|
|
312
|
+
return x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
247
316
|
// center the popover under the tray icon, kept on-screen
|
|
248
317
|
function positionUnderTray() {
|
|
249
318
|
const b = win.getBounds()
|
|
@@ -255,13 +324,20 @@ function positionUnderTray() {
|
|
|
255
324
|
y = Math.round(trayBounds.y + trayBounds.height)
|
|
256
325
|
}
|
|
257
326
|
x = Math.max(8, Math.min(x, workAreaSize.width - b.width - 8))
|
|
258
|
-
|
|
327
|
+
moveWindow(x, y)
|
|
259
328
|
}
|
|
260
329
|
|
|
261
330
|
function positionFloating() {
|
|
262
|
-
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
263
331
|
const b = win.getBounds()
|
|
264
|
-
|
|
332
|
+
// reuse the saved spot when that monitor is still connected; otherwise fall
|
|
333
|
+
// back to the primary display's bottom-right corner
|
|
334
|
+
const saved = loadWindowState()
|
|
335
|
+
if (saved && cornerVisible(saved.right, saved.bottom)) {
|
|
336
|
+
moveWindow(saved.right - b.width, saved.bottom - b.height)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
340
|
+
moveWindow(workAreaSize.width - b.width - 24, workAreaSize.height - b.height - 24)
|
|
265
341
|
}
|
|
266
342
|
|
|
267
343
|
// the tray shows the live session % (macOS title), turning 🔥 near the limit
|
|
@@ -290,12 +366,20 @@ ipcMain.on('resize', (_e, w, h) => {
|
|
|
290
366
|
if (!win || win.isDestroyed()) return
|
|
291
367
|
const width = Math.max(100, Math.round(w))
|
|
292
368
|
const height = Math.max(110, Math.round(h))
|
|
293
|
-
win.setContentSize(width, height)
|
|
294
369
|
if (currentMode === 'menubar') {
|
|
370
|
+
win.setContentSize(width, height)
|
|
295
371
|
if (win.isVisible()) positionUnderTray() // keep it anchored under the tray
|
|
296
372
|
} else {
|
|
297
|
-
|
|
298
|
-
|
|
373
|
+
// keep the widget pinned to its current bottom-right corner on whatever
|
|
374
|
+
// display the user dragged it to. setContentSize grows from the top-left
|
|
375
|
+
// origin, so re-anchor by the old corner instead of snapping to the primary
|
|
376
|
+
// display's bottom-right (which yanked the widget back on every update).
|
|
377
|
+
const before = win.getBounds()
|
|
378
|
+
const right = before.x + before.width
|
|
379
|
+
const bottom = before.y + before.height
|
|
380
|
+
win.setContentSize(width, height)
|
|
381
|
+
const after = win.getBounds()
|
|
382
|
+
moveWindow(right - after.width, bottom - after.height)
|
|
299
383
|
}
|
|
300
384
|
})
|
|
301
385
|
|
|
@@ -412,6 +496,64 @@ ipcMain.on('save-config', (_e, patch) => {
|
|
|
412
496
|
applyMode(config.mode) // switch between floating widget and menu-bar popover live
|
|
413
497
|
})
|
|
414
498
|
|
|
499
|
+
// ---- self-update (checks the latest GitHub release, runs install.sh) ---------
|
|
500
|
+
async function fetchLatestTag() {
|
|
501
|
+
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
|
|
502
|
+
headers: { 'User-Agent': 'Clauddy', Accept: 'application/vnd.github+json' },
|
|
503
|
+
})
|
|
504
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
505
|
+
const j = await res.json()
|
|
506
|
+
return j.tag_name // e.g. "v1.9.1"
|
|
507
|
+
}
|
|
508
|
+
// compare two "1.2.3" versions; >0 if a is newer than b
|
|
509
|
+
function cmpVer(a, b) {
|
|
510
|
+
const pa = String(a).replace(/^v/, '').split('.').map(Number)
|
|
511
|
+
const pb = String(b).replace(/^v/, '').split('.').map(Number)
|
|
512
|
+
for (let i = 0; i < 3; i++) {
|
|
513
|
+
const d = (pa[i] || 0) - (pb[i] || 0)
|
|
514
|
+
if (d) return d > 0 ? 1 : -1
|
|
515
|
+
}
|
|
516
|
+
return 0
|
|
517
|
+
}
|
|
518
|
+
async function computeUpdate() {
|
|
519
|
+
const latest = await fetchLatestTag()
|
|
520
|
+
return { latest, available: cmpVer(latest, app.getVersion()) > 0 }
|
|
521
|
+
}
|
|
522
|
+
// manual check (from the Settings button): shows checking / result / error
|
|
523
|
+
ipcMain.on('check-updates', async () => {
|
|
524
|
+
if (!win || win.isDestroyed()) return
|
|
525
|
+
const send = (s) => !win.isDestroyed() && win.webContents.send('update-status', s)
|
|
526
|
+
send({ state: 'checking' })
|
|
527
|
+
try {
|
|
528
|
+
const u = await computeUpdate()
|
|
529
|
+
send(u.available ? { state: 'available', latest: u.latest } : { state: 'uptodate' })
|
|
530
|
+
} catch (e) {
|
|
531
|
+
send({ state: 'error', message: String(e?.message || e) })
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
// silent background check: only speaks up when there's actually an update, so
|
|
535
|
+
// the renderer can badge the gear without any UI churn on the common case
|
|
536
|
+
async function autoUpdateCheck() {
|
|
537
|
+
if (!win || win.isDestroyed()) return
|
|
538
|
+
try {
|
|
539
|
+
const u = await computeUpdate()
|
|
540
|
+
if (u.available) win.webContents.send('update-status', { state: 'available', latest: u.latest })
|
|
541
|
+
} catch {} // offline / rate-limited: stay quiet, try again on the next tick
|
|
542
|
+
}
|
|
543
|
+
ipcMain.on('do-update', () => {
|
|
544
|
+
// Windows/Linux have no install.sh: send them to the releases page instead.
|
|
545
|
+
if (process.platform !== 'darwin') {
|
|
546
|
+
shell.openExternal(`https://github.com/${REPO}/releases/latest`)
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
if (win && !win.isDestroyed()) win.webContents.send('update-status', { state: 'updating' })
|
|
550
|
+
const cmd = `curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`
|
|
551
|
+
const child = spawn('/bin/bash', ['-lc', cmd], { detached: true, stdio: 'ignore' })
|
|
552
|
+
child.unref()
|
|
553
|
+
// quit so the installer can replace the running .app and relaunch the new build
|
|
554
|
+
setTimeout(() => app.quit(), 1500)
|
|
555
|
+
})
|
|
556
|
+
|
|
415
557
|
ipcMain.on('quit', () => app.quit())
|
|
416
558
|
|
|
417
559
|
// Electron's login-item API only covers macOS (SMAppService) and Windows (the
|
package/package.json
CHANGED
package/preload.js
CHANGED
|
@@ -15,5 +15,9 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
15
15
|
authCode: (code) => ipcRenderer.send('auth-code', code),
|
|
16
16
|
authLogout: () => ipcRenderer.send('auth-logout'),
|
|
17
17
|
onDebugState: (cb) => ipcRenderer.on('debug-state', (_e, s) => cb(s)),
|
|
18
|
+
onVersion: (cb) => ipcRenderer.on('version', (_e, v) => cb(v)),
|
|
19
|
+
onUpdateStatus: (cb) => ipcRenderer.on('update-status', (_e, s) => cb(s)),
|
|
20
|
+
checkUpdates: () => ipcRenderer.send('check-updates'),
|
|
21
|
+
doUpdate: () => ipcRenderer.send('do-update'),
|
|
18
22
|
quit: () => ipcRenderer.send('quit'),
|
|
19
23
|
})
|
package/renderer/index.html
CHANGED
|
@@ -305,55 +305,72 @@
|
|
|
305
305
|
</div>
|
|
306
306
|
</div>
|
|
307
307
|
|
|
308
|
-
<div class="set-
|
|
309
|
-
|
|
308
|
+
<div class="set-section">
|
|
309
|
+
<div class="set-sec-title">Display</div>
|
|
310
310
|
<span class="seg" id="set-mode">
|
|
311
311
|
<button type="button" class="seg-btn" data-mode="floating">Floating pet</button>
|
|
312
312
|
<button type="button" class="seg-btn" data-mode="menubar">Menu bar</button>
|
|
313
313
|
</span>
|
|
314
|
-
</
|
|
314
|
+
</div>
|
|
315
315
|
|
|
316
|
-
<
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
316
|
+
<div class="set-section">
|
|
317
|
+
<div class="set-sec-title">
|
|
318
|
+
Alerts
|
|
319
|
+
<input id="set-alerts" class="set-toggle" type="checkbox" aria-label="Enable alerts" />
|
|
320
|
+
</div>
|
|
321
|
+
<div class="set-thresholds">
|
|
322
|
+
<span class="thr">
|
|
323
|
+
<span class="thr-cap">heads-up</span>
|
|
324
|
+
<span class="num">
|
|
325
|
+
<input id="set-t1" type="number" min="1" max="100" />
|
|
326
|
+
<span class="num-spin">
|
|
327
|
+
<button type="button" class="num-btn" data-for="set-t1" data-step="1">▲</button>
|
|
328
|
+
<button type="button" class="num-btn" data-for="set-t1" data-step="-1">▼</button>
|
|
329
|
+
</span>
|
|
325
330
|
</span>
|
|
326
331
|
</span>
|
|
327
|
-
<span class="
|
|
328
|
-
<
|
|
329
|
-
<span class="num
|
|
330
|
-
<
|
|
331
|
-
<
|
|
332
|
+
<span class="thr">
|
|
333
|
+
<span class="thr-cap">near limit</span>
|
|
334
|
+
<span class="num">
|
|
335
|
+
<input id="set-t2" type="number" min="1" max="100" />
|
|
336
|
+
<span class="num-spin">
|
|
337
|
+
<button type="button" class="num-btn" data-for="set-t2" data-step="1">▲</button>
|
|
338
|
+
<button type="button" class="num-btn" data-for="set-t2" data-step="-1">▼</button>
|
|
339
|
+
</span>
|
|
332
340
|
</span>
|
|
333
341
|
</span>
|
|
334
|
-
</
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
</
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<div class="set-section">
|
|
346
|
+
<div class="set-sec-title">Catch fire</div>
|
|
347
|
+
<div class="set-thresholds">
|
|
348
|
+
<span class="thr">
|
|
349
|
+
<span class="thr-cap">session %</span>
|
|
350
|
+
<span class="num">
|
|
351
|
+
<input id="set-fire" type="number" min="1" max="99" />
|
|
352
|
+
<span class="num-spin">
|
|
353
|
+
<button type="button" class="num-btn" data-for="set-fire" data-step="1">▲</button>
|
|
354
|
+
<button type="button" class="num-btn" data-for="set-fire" data-step="-1">▼</button>
|
|
355
|
+
</span>
|
|
348
356
|
</span>
|
|
349
357
|
</span>
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
353
361
|
<div class="set-actions">
|
|
354
362
|
<button id="set-cancel">Cancel</button>
|
|
355
363
|
<button id="set-save">Save</button>
|
|
356
364
|
</div>
|
|
365
|
+
|
|
366
|
+
<div class="set-updates">
|
|
367
|
+
<span class="upd-ver">Clauddy v<span id="app-version">—</span></span>
|
|
368
|
+
<span class="upd-right">
|
|
369
|
+
<span id="upd-status"></span>
|
|
370
|
+
<button type="button" id="upd-check" class="acc-link">Check for updates</button>
|
|
371
|
+
<button type="button" id="upd-now" class="upd-btn" hidden>Update now</button>
|
|
372
|
+
</span>
|
|
373
|
+
</div>
|
|
357
374
|
</div>
|
|
358
375
|
</div>
|
|
359
376
|
<script src="pet.js"></script>
|
package/renderer/pet.js
CHANGED
|
@@ -640,6 +640,51 @@ el('acc-confirm').addEventListener('click', () => {
|
|
|
640
640
|
})
|
|
641
641
|
el('acc-logout').addEventListener('click', () => window.api.authLogout())
|
|
642
642
|
|
|
643
|
+
// self-update: show the version, check the latest release, one-click update
|
|
644
|
+
window.api.onVersion((v) => {
|
|
645
|
+
if (v) el('app-version').textContent = v
|
|
646
|
+
})
|
|
647
|
+
window.api.onUpdateStatus((s) => {
|
|
648
|
+
const status = el('upd-status')
|
|
649
|
+
const now = el('upd-now')
|
|
650
|
+
const check = el('upd-check')
|
|
651
|
+
// reset to the idle look, then layer each state on top
|
|
652
|
+
now.hidden = true
|
|
653
|
+
check.hidden = false
|
|
654
|
+
status.textContent = ''
|
|
655
|
+
status.className = ''
|
|
656
|
+
const state = s?.state
|
|
657
|
+
if (state === 'checking') {
|
|
658
|
+
status.textContent = 'Checking…'
|
|
659
|
+
check.hidden = true
|
|
660
|
+
} else if (state === 'uptodate') {
|
|
661
|
+
status.textContent = "You're up to date"
|
|
662
|
+
status.className = 'ok'
|
|
663
|
+
} else if (state === 'available') {
|
|
664
|
+
// the button carries the version, so no separate status text is needed
|
|
665
|
+
now.textContent = `Update to ${s.latest}`
|
|
666
|
+
now.hidden = false
|
|
667
|
+
check.hidden = true
|
|
668
|
+
} else if (state === 'updating') {
|
|
669
|
+
status.textContent = 'Updating… the app will restart'
|
|
670
|
+
check.hidden = true
|
|
671
|
+
} else if (state === 'error') {
|
|
672
|
+
status.textContent = 'Check failed'
|
|
673
|
+
status.className = 'err'
|
|
674
|
+
}
|
|
675
|
+
// pulse a dot on the gear whenever an update is waiting (cleared once it's
|
|
676
|
+
// up to date or actively updating) — so it's noticeable without opening Settings
|
|
677
|
+
if (state === 'available') document.body.classList.add('has-update')
|
|
678
|
+
else if (state === 'uptodate' || state === 'updating')
|
|
679
|
+
document.body.classList.remove('has-update')
|
|
680
|
+
if (document.body.classList.contains('settings-open')) fitSize()
|
|
681
|
+
})
|
|
682
|
+
el('upd-check').addEventListener('click', () => window.api.checkUpdates())
|
|
683
|
+
el('upd-now').addEventListener('click', () => {
|
|
684
|
+
el('upd-now').disabled = true
|
|
685
|
+
window.api.doUpdate()
|
|
686
|
+
})
|
|
687
|
+
|
|
643
688
|
// settings panel
|
|
644
689
|
// snapshot of the editable fields, to tell whether there are unsaved changes
|
|
645
690
|
let settingsBaseline = null
|
package/renderer/style.css
CHANGED
|
@@ -104,6 +104,38 @@ body {
|
|
|
104
104
|
#gear {
|
|
105
105
|
font-size: 10px;
|
|
106
106
|
}
|
|
107
|
+
/* "update available" cue: a soft pulsing coral dot on the gear's corner, so a
|
|
108
|
+
new version is noticeable without opening Settings */
|
|
109
|
+
body.has-update #gear {
|
|
110
|
+
position: relative;
|
|
111
|
+
overflow: visible;
|
|
112
|
+
}
|
|
113
|
+
body.has-update #gear::after {
|
|
114
|
+
content: "";
|
|
115
|
+
position: absolute;
|
|
116
|
+
top: -2px;
|
|
117
|
+
right: -2px;
|
|
118
|
+
width: 7px;
|
|
119
|
+
height: 7px;
|
|
120
|
+
border-radius: 50%;
|
|
121
|
+
background: var(--coral);
|
|
122
|
+
box-shadow:
|
|
123
|
+
0 0 0 1.5px #2a1f19,
|
|
124
|
+
0 0 5px 1px rgba(217, 119, 87, 0.6);
|
|
125
|
+
animation: upd-badge 1.6s ease-in-out infinite;
|
|
126
|
+
pointer-events: none;
|
|
127
|
+
}
|
|
128
|
+
@keyframes upd-badge {
|
|
129
|
+
0%,
|
|
130
|
+
100% {
|
|
131
|
+
transform: scale(1);
|
|
132
|
+
opacity: 0.9;
|
|
133
|
+
}
|
|
134
|
+
50% {
|
|
135
|
+
transform: scale(1.3);
|
|
136
|
+
opacity: 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
107
139
|
/* minimize icon swaps to an "expand" icon when collapsed */
|
|
108
140
|
.ic-max {
|
|
109
141
|
display: none;
|
|
@@ -232,8 +264,8 @@ body.settings-open #settings {
|
|
|
232
264
|
border-color: var(--coral);
|
|
233
265
|
}
|
|
234
266
|
/* hide the ugly native number spinners (custom ones below) */
|
|
235
|
-
|
|
236
|
-
|
|
267
|
+
input[type="number"]::-webkit-inner-spin-button,
|
|
268
|
+
input[type="number"]::-webkit-outer-spin-button {
|
|
237
269
|
-webkit-appearance: none;
|
|
238
270
|
appearance: none;
|
|
239
271
|
margin: 0;
|
|
@@ -270,28 +302,73 @@ body.settings-open #settings {
|
|
|
270
302
|
body.is-menubar #min {
|
|
271
303
|
display: none;
|
|
272
304
|
}
|
|
273
|
-
|
|
305
|
+
/* settings sections: Display / Alerts / Catch fire — each with a standardized
|
|
306
|
+
title, separated by a hairline divider */
|
|
307
|
+
.set-section {
|
|
308
|
+
margin-bottom: 10px;
|
|
309
|
+
}
|
|
310
|
+
.set-section + .set-section {
|
|
311
|
+
border-top: 0.5px solid var(--hair);
|
|
312
|
+
padding-top: 10px;
|
|
313
|
+
}
|
|
314
|
+
.set-sec-title {
|
|
274
315
|
display: flex;
|
|
275
|
-
|
|
316
|
+
align-items: center;
|
|
317
|
+
justify-content: space-between;
|
|
318
|
+
font-size: 11px;
|
|
319
|
+
font-weight: 600;
|
|
320
|
+
color: var(--text);
|
|
321
|
+
margin-bottom: 8px;
|
|
276
322
|
}
|
|
277
|
-
/*
|
|
278
|
-
.set-
|
|
279
|
-
margin
|
|
323
|
+
/* alerts on/off toggle, sitting to the right of the section title */
|
|
324
|
+
.set-toggle {
|
|
325
|
+
margin: 0;
|
|
326
|
+
width: 13px;
|
|
327
|
+
height: 13px;
|
|
328
|
+
accent-color: var(--coral);
|
|
329
|
+
cursor: pointer;
|
|
280
330
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
331
|
+
/* threshold fields: caption on top, number input below — same shape for all
|
|
332
|
+
three (heads-up, near limit, catch fire) so they read as one set */
|
|
333
|
+
.set-thresholds {
|
|
334
|
+
display: flex;
|
|
335
|
+
gap: 12px;
|
|
336
|
+
}
|
|
337
|
+
.thr {
|
|
338
|
+
display: flex;
|
|
339
|
+
flex-direction: column;
|
|
340
|
+
gap: 4px;
|
|
341
|
+
}
|
|
342
|
+
.thr-cap {
|
|
343
|
+
font-size: 9px;
|
|
284
344
|
color: var(--muted);
|
|
285
|
-
|
|
345
|
+
height: 12px;
|
|
346
|
+
line-height: 12px;
|
|
286
347
|
}
|
|
287
|
-
/* custom
|
|
348
|
+
/* number field + custom stepper */
|
|
288
349
|
.num {
|
|
289
350
|
position: relative;
|
|
290
|
-
flex:
|
|
351
|
+
flex: none;
|
|
352
|
+
width: 66px;
|
|
291
353
|
}
|
|
292
354
|
.num input {
|
|
293
355
|
width: 100%;
|
|
294
|
-
|
|
356
|
+
background: rgba(255, 255, 255, 0.06);
|
|
357
|
+
border: 0.5px solid var(--hair);
|
|
358
|
+
border-radius: 8px;
|
|
359
|
+
color: var(--text);
|
|
360
|
+
font-size: 13px;
|
|
361
|
+
font-weight: 600;
|
|
362
|
+
font-variant-numeric: tabular-nums;
|
|
363
|
+
padding: 6px 24px 6px 10px;
|
|
364
|
+
outline: none;
|
|
365
|
+
transition:
|
|
366
|
+
border-color 0.15s,
|
|
367
|
+
background 0.15s;
|
|
368
|
+
}
|
|
369
|
+
.num input:focus {
|
|
370
|
+
border-color: var(--coral);
|
|
371
|
+
background: rgba(255, 255, 255, 0.09);
|
|
295
372
|
}
|
|
296
373
|
.num-spin {
|
|
297
374
|
position: absolute;
|
|
@@ -387,6 +464,61 @@ body.is-menubar #min {
|
|
|
387
464
|
}
|
|
388
465
|
}
|
|
389
466
|
|
|
467
|
+
/* updates footer */
|
|
468
|
+
.set-updates {
|
|
469
|
+
display: flex;
|
|
470
|
+
align-items: center;
|
|
471
|
+
justify-content: space-between;
|
|
472
|
+
gap: 8px;
|
|
473
|
+
margin-top: 11px;
|
|
474
|
+
padding-top: 9px;
|
|
475
|
+
border-top: 0.5px solid var(--hair);
|
|
476
|
+
}
|
|
477
|
+
.upd-ver {
|
|
478
|
+
font-size: 10px;
|
|
479
|
+
color: var(--muted);
|
|
480
|
+
}
|
|
481
|
+
.upd-right {
|
|
482
|
+
display: flex;
|
|
483
|
+
align-items: center;
|
|
484
|
+
gap: 8px;
|
|
485
|
+
}
|
|
486
|
+
#upd-status {
|
|
487
|
+
font-size: 10px;
|
|
488
|
+
color: var(--muted);
|
|
489
|
+
}
|
|
490
|
+
#upd-status.ok {
|
|
491
|
+
color: var(--green);
|
|
492
|
+
}
|
|
493
|
+
#upd-status.err {
|
|
494
|
+
color: var(--coral);
|
|
495
|
+
}
|
|
496
|
+
/* compact update button — lighter than the full-width .acc-btn */
|
|
497
|
+
.upd-btn {
|
|
498
|
+
border: none;
|
|
499
|
+
border-radius: 7px;
|
|
500
|
+
padding: 4px 9px;
|
|
501
|
+
font-size: 10px;
|
|
502
|
+
font-weight: 600;
|
|
503
|
+
color: #fff;
|
|
504
|
+
background: var(--coral);
|
|
505
|
+
cursor: pointer;
|
|
506
|
+
-webkit-app-region: no-drag;
|
|
507
|
+
transition:
|
|
508
|
+
background 0.18s,
|
|
509
|
+
transform 0.1s;
|
|
510
|
+
}
|
|
511
|
+
.upd-btn:hover {
|
|
512
|
+
background: #e2876a;
|
|
513
|
+
}
|
|
514
|
+
.upd-btn:active {
|
|
515
|
+
transform: scale(0.97);
|
|
516
|
+
}
|
|
517
|
+
.upd-btn:disabled {
|
|
518
|
+
opacity: 0.6;
|
|
519
|
+
cursor: default;
|
|
520
|
+
}
|
|
521
|
+
|
|
390
522
|
/* live tag */
|
|
391
523
|
.live-only {
|
|
392
524
|
display: none;
|
|
@@ -402,6 +534,8 @@ body.live .live-only {
|
|
|
402
534
|
/* account / auth */
|
|
403
535
|
#account {
|
|
404
536
|
margin-bottom: 10px;
|
|
537
|
+
padding-bottom: 10px;
|
|
538
|
+
border-bottom: 0.5px solid var(--hair); /* divider before the first section */
|
|
405
539
|
}
|
|
406
540
|
.acc-btn {
|
|
407
541
|
width: 100%;
|
|
@@ -434,12 +568,13 @@ body.live .live-only {
|
|
|
434
568
|
color: var(--muted);
|
|
435
569
|
font-size: 10px;
|
|
436
570
|
cursor: pointer;
|
|
437
|
-
text-decoration:
|
|
571
|
+
text-decoration: none;
|
|
438
572
|
-webkit-app-region: no-drag;
|
|
439
573
|
transition: color 0.15s;
|
|
440
574
|
}
|
|
441
575
|
.acc-link:hover {
|
|
442
576
|
color: var(--text);
|
|
577
|
+
text-decoration: underline;
|
|
443
578
|
}
|
|
444
579
|
.acc-ok {
|
|
445
580
|
color: var(--green);
|