Compare commits

...

9 commits
v0.1.0 ... main

Author SHA1 Message Date
a1bbc6f8ba Enforce LF line endings via .gitattributes + ignore tauri-icon's extra outputs
Fixes the recurring 'Your local changes to Cargo.toml would be overwritten'
error on Windows pulls. Git-for-Windows defaults to core.autocrlf=true,
which made every text file appear modified vs the LF-stored versions.
'* text=auto eol=lf' in .gitattributes overrides autocrlf and forces a
stable LF checkout.

Also ignores Square*Logo.png, android/, ios/, 64x64.png, icon.png — these
are produced by 'pnpm tauri icon' but we ship only the canonical 5 files
(32, 128, 128@2x, .ico, .icns).
2026-05-09 16:05:55 +01:00
160e08d4a8 Suppress console-window flash on subprocess spawn (CREATE_NO_WINDOW); bump 0.1.2
paths::quiet_command sets CREATE_NO_WINDOW (0x08000000) on Windows so
short-lived child processes (wsl.exe -l -q, where claude, wsl.exe -d X
bash -lc 'command -v claude') don't briefly allocate a real console
window and flash on click. Used by both paths.rs and cli_usage.rs.
2026-05-09 16:03:40 +01:00
6dd8200802 README: replace ASCII mock with 4-theme screenshot grid (anthropic / instrument / editorial / retro) 2026-05-09 16:00:49 +01:00
4d5bce244a Bump version to 0.1.1 (themes release) 2026-05-09 15:53:03 +01:00
14d9ca0b41 Settings overlay: cover the widget cleanly (was rendering below content because of a stacking-context fix from theme atmosphere layer) 2026-05-09 15:51:38 +01:00
3a4b1a4d7a Settings: collapse theme picker into a segmented control + move below command settings near Save 2026-05-09 15:48:49 +01:00
8a7ebd60b1 Add 4 switchable themes (Anthropic / Instrument / Editorial / Retro CRT)
- @fontsource-variable/{fraunces,jetbrains-mono,newsreader} + @fontsource/{dm-sans,ibm-plex-mono} bundled offline.
- styles.css restructured: theme-agnostic base + 4 [data-theme="..."] overrides driving CSS custom props (--bg, --fg, --accent, --font-display, --font-body, --atmosphere).
- Each theme has its own typographic + chromatic personality:
  * Anthropic: warm cream-on-charcoal, Newsreader display + DM Sans body, sunset orange + claude purple.
  * Instrument: synth panel, JetBrains Mono throughout, chartreuse on slate, ring tick marks, faint scanlines, bracket corners on title bar.
  * Editorial: magazine artifact, Fraunces variable serif (opsz axis), saffron on warm charcoal, hairline rules.
  * Retro CRT: phosphor green on near-black, IBM Plex Mono, scanlines + vignette, blink-cursor in corner, [bracketed] header label.
- Settings panel: 4-up theme picker (each card renders a sample percentage in that theme's actual fonts/colors). Click = live preview; Cancel reverts; Save persists.
- BlockRing big % bumped to 38px logical with theme-specific font-variation-settings.
- TitleBar voice differs per theme without changing underlying string.
- Default theme: Anthropic (warmest first impression).
2026-05-09 15:43:57 +01:00
5200caf21f Repo cleanup: untrack CLAUDE.md / memory.md (agent-internal); fix README link 2026-05-09 15:00:16 +01:00
dbf77a0cec memory: record v0.1.0 release and public visibility flip 2026-05-09 14:59:11 +01:00
22 changed files with 586 additions and 167 deletions

42
.gitattributes vendored Normal file
View file

@ -0,0 +1,42 @@
# Always check out source files with LF endings, regardless of the user's
# core.autocrlf setting. Without this, Windows clones with core.autocrlf=true
# (the Git-for-Windows default) treat every text file as "modified" because
# Git's stored version is LF but the working copy is CRLF.
* text=auto eol=lf
# Explicit text files (belt-and-suspenders).
*.rs text eol=lf
*.toml text eol=lf
*.json text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.svelte text eol=lf
*.css text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# Shell + Windows scripts keep their native endings.
*.sh text eol=lf
*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf
# Binaries — never touch these.
*.png binary
*.ico binary
*.icns binary
*.jpg binary
*.jpeg binary
*.gif binary
*.exe binary
*.dll binary
*.so binary
*.dylib binary
*.woff binary
*.woff2 binary
*.ttf binary
*.otf binary

14
.gitignore vendored
View file

@ -47,3 +47,17 @@ src-tauri/target/
src-tauri/Cargo.lock
*.tsbuildinfo
.vite/
# Extra outputs from `pnpm tauri icon` — we ship only the canonical set
# (32x32.png, 128x128.png, 128x128@2x.png, icon.ico, icon.icns).
src-tauri/icons/64x64.png
src-tauri/icons/icon.png
src-tauri/icons/Square*.png
src-tauri/icons/StoreLogo.png
src-tauri/icons/android/
src-tauri/icons/ios/
# Agent working files — meaningful to local Claude Code sessions, noise for
# anyone else. Kept out of the public repo (they still live on disk).
CLAUDE.md
memory.md

View file

@ -1,30 +0,0 @@
# Project: claude-usage-widget
A small always-on-top Windows desktop widget that visualizes local Claude Code usage — current 5-hour session block, 7-day rolling weekly window, and per-model token breakdown. Reads `~/.claude/projects/**/*.jsonl` directly (via `\\wsl$\<distro>\…` from Windows). No Anthropic API. No auth.
## Working agreement
- This is a git repo. Commit after each logical change with a one-line imperative message.
- Read `memory.md` at session start. Update it before ending the session.
- Never commit secrets — see `.gitignore` and the rules in `~/claude/CLAUDE.md`.
## Project-specific notes
- **Stack:** Tauri 2 (Rust + Svelte 5 + Vite + TS). Inline SVG for charts, no chart library.
- **Build target:** Windows `.exe` only. Develop the Rust + frontend code in WSL; do `pnpm tauri dev` / `pnpm tauri build` on the Windows host (it needs MSVC toolchain + WebView2).
- **Data source:** `~/.claude/projects/**/*.jsonl` — assistant lines have `message.usage.{input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens}` and a `requestId` / `uuid` for dedupe (subagent transcripts in `<sessionId>/subagents/<id>.jsonl` overlap the parent).
- **Aggregation algorithm:** ccusage-equivalent. 5-hour blocks are `floor_to_hour(first_ts) → +5h`; new block on ≥5h gap or when previous block ends. Weekly is rolling 7 days.
- **Plan reference:** `~/.claude/plans/snug-mapping-milner.md` (the approved plan that drove this scaffold).
## Run
```powershell
# On the Windows host, from this directory:
pnpm install
pnpm tauri dev # iterate
pnpm tauri build # NSIS installer in src-tauri/target/release/bundle/nsis/
```
## Verify
See `memory.md` and the plan file for the 10-step verification checklist (parse correctness, block boundary, dedupe, live tail, watcher fallback, autostart, etc).

View file

@ -4,24 +4,16 @@ A small always-on-top Windows desktop widget that shows your live Claude
subscription usage — the same percentages Claude Code's `/usage` command
displays, refreshed every 5 minutes.
```
┌─────── Claude Usage ────────────╳ ─┐
│ │
│ ╭───────╮ │
72% ╲ │
│ │ session │ │
│ ╲ resets
│ ╰ 2:50am ╯ │
│ │
│ Models (current block) │
│ ▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱ │
│ ● Opus 42M ● Haiku 3M │
│ │
│ Weekly limits resets May 9 │
│ All models ▆░░░░░ 8% │
│ Sonnet ▃░░░░░ 5% │
└─────────────────────────────────────┘
```
## Themes
Pick one in Settings; it applies live. Each is its own typographic + chromatic system.
| | |
|---|---|
| **Anthropic** — warm cream-on-charcoal, Newsreader serif + DM Sans, sunset-orange ring, soft radial halo. The default. | **Instrument** — modular-synth panel, JetBrains Mono, chartreuse on slate, tick marks at 12/3/6/9, faint scanlines, bracket corners on the title bar. |
| ![Anthropic theme](docs/screenshots/anthropic.png) | ![Instrument theme](docs/screenshots/instrument.png) |
| **Editorial** — magazine artifact, Fraunces variable serif (optical-size axis), saffron on warm charcoal, italic labels, hairline rules. | **Retro CRT** — 1980s home computer, IBM Plex Mono, phosphor green on near-black, scanlines + vignette, bracketed `[ Claude Usage ]` header, blink cursor in the corner. |
| ![Editorial theme](docs/screenshots/editorial.png) | ![Retro CRT theme](docs/screenshots/retro.png) |
## Install
@ -104,5 +96,4 @@ pnpm tauri dev # iterate
pnpm tauri build # produces NSIS installer in src-tauri\target\release\bundle\nsis\
```
Project layout, architecture decisions, and known follow-ups live in
[`memory.md`](./memory.md).
Filing issues / pull requests on the Forgejo repo is welcome.

BIN
docs/screenshots/anthropic.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/screenshots/editorial.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
docs/screenshots/instrument.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
docs/screenshots/retro.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View file

@ -1,66 +0,0 @@
# memory — claude-usage-widget
Durable memory for this project. Read at session start, update before session end. Date format: `YYYY-MM-DD`.
## Decisions & rationale
- **Tauri 2 (Rust + Svelte 5 + Vite + TS)** over Electron — smaller binary (~10 MB vs ~150 MB), native Windows transparency, real always-on-top z-order. Chose Svelte over React because the widget has only three SVG primitives; React boilerplate isn't worth it.
- **Inline SVG, no chart library**`BlockRing` is one ring, `WeeklyBar` is two stacked progress bars, `ModelStack` is a single segmented bar. Adding Chart.js / ECharts / uPlot for ~80 lines of SVG would balloon the bundle for nothing.
- **Subscription %s come from PTY-driving `claude /usage`** (NOT JSONL estimates, NOT the Anthropic API). The widget spawns `claude` via `portable-pty`, sends `/usage`, parses the three rendered bars (Current session / Current week all / Current week Sonnet), and shows those exact numbers. This is the same data Anthropic shows you in the CLI — no API key, no admin scope, no reverse-engineering of their backend. The trade-off is ~3-5 s per refresh and brittleness if Anthropic changes the rendered output format. Refresh every 5 min by default; manual refresh button on the title bar.
- **Per-model breakdown still comes from local JSONL** — the CLI's `/usage` doesn't break out Opus/Sonnet/Haiku, but our token-summing does. ModelStack remains.
- **Widget runs on Windows host, not in WSLg** — needs to pin to the Windows desktop, autostart on login, and share the always-on-top z-order with native Windows apps. WSLg windows can't do that. JSONL transcripts read via `\\wsl$\<distro>\home\<user>\.claude\projects\` UNC mount; the PTY-driven `claude` is invoked via `wsl.exe -d Ubuntu bash -lc claude` (default on Windows when wsl.exe is on PATH).
- **`notify` watcher + 60s tokio poll fallback** — `ReadDirectoryChangesW` on the WSL 9P mount is unreliable; the poll backstops it.
- **All filesystem reads happen Rust-side** — the JS `capabilities/default.json` does NOT grant `tauri-plugin-fs`. Keeps the webview sandbox tight.
- **Block algorithm (still in code, used for ModelStack only)**`block_start = floor_to_hour(first_ts_of_block)`, `block_end = block_start + 5h`, new block on ≥5h gap OR when previous block ends. ccusage-equivalent.
- **DROPPED: caps + tier-detection UI.** Replaced by real CLI percentages. Caps struct still exists in code as a deprecated fallback but the Settings panel no longer exposes it.
## Open questions / TODOs
- [ ] **Watcher does not re-bind on settings change.** If user changes WSL distro override in Settings, `set_settings` calls `refresh_and_emit` and updates `state.roots`, but the `notify` watcher is still pinned to the *old* roots. v0 workaround: restart the widget after changing distro.
- [ ] **`/usage` parser is fragile to output format changes.** If Anthropic changes the rendered text (relabels sections, adds new ones, changes "X% used" pattern), the bars stop parsing silently. Settings panel exposes the raw output for debugging when this happens.
- [ ] **`/usage` spawn cost is ~3-5s on Windows.** That's per refresh; default refresh is 300s so net overhead is fine. Title-bar refresh button gives user control. Consider caching to disk so cold start has *something* before the first PTY drive completes.
- [ ] **Autostart toggle fails in dev builds** (target\debug\ exe path is unstable). Currently swallowed as a warning; needs proper testing once we ship the NSIS bundle.
- [ ] **The default WSL-on-Windows command assumes Ubuntu.** Auto-detect could iterate `wsl.exe -l -q` for any distro that has `claude` on its login PATH, instead of hardcoding `-d Ubuntu`.
- [ ] Decide whether to expose a tray icon for relaunch after `quit_app` (currently the widget can only be reopened via Start Menu / autostart).
- [ ] Window background is too transparent — files behind it bleed through visibly. Bump `--bg` opacity from 0.78 to ~0.92.
- [ ] Replace placeholder Tauri icons in `src-tauri/icons/` before release (`pnpm tauri icon source.png`).
- [ ] Caps struct + Caps::default() are dead code now — delete after a few releases of stability.
## Session log
### 2026-05-08 / 2026-05-09
- Planned the project (approved plan at `~/.claude/plans/snug-mapping-milner.md`) and built the full scaffold in one session that crossed midnight UTC.
- Authored Tauri config (frameless, transparent, alwaysOnTop, skipTaskbar, 280×360), Cargo.toml, capabilities/default.json.
- Authored Rust modules: `state.rs`, `settings.rs`, `paths.rs`, `jsonl.rs`, `usage.rs` (with unit tests for block boundaries), `watch.rs`, `commands.rs`, `lib.rs`, `main.rs`.
- Authored Svelte 5 frontend: `App`, `TitleBar`, `BlockRing`, `WeeklyBar`, `ModelStack`, `Settings` components plus `ipc.ts`, `types.ts`, `format.ts`, `styles.css`.
- Wrote `scripts/seed-fake-jsonl.ps1` verification helper and `README.md` build instructions.
- Pushed to Forgejo at `https://git.rdx4.com/megaproxy/claude-usage-widget`. (Note: had to rename local `master``main` after the fact — `git init` ran before the global `init.defaultBranch=main` was set.)
- **First successful run on Windows host (Doug's machine).** Took 4 incremental fixes to get there:
1. pnpm 11 default-deny on postinstall scripts → declared `pnpm.onlyBuiltDependencies: ["esbuild"]` in `package.json`.
2. Missing icons → user generated placeholder via PowerShell `System.Drawing` + `pnpm tauri icon`.
3. `notify::Watcher` trait not in scope (E0599) and `tokio::JoinHandle``tauri::async_runtime::JoinHandle` (E0308 ×2) in `watch.rs` — fixed in commit `ab75ca9`.
4. `tauri-plugin-autostart` doesn't accept a `{"args":[…]}` block in `tauri.conf.json` — args go through the Rust `init()` call only. Removed the JSON entry in commit `8c25b01`.
- **Pivoted from cap estimation to real `/usage` data.** First version showed 999% red ring because the placeholder caps (200k/2M) were wildly under what a Max user actually does. Tier-detection from `.claude.json` improved defaults but still wasn't right; user pointed out they'd previously had an app showing real subscription %. Investigated and found:
- `/usage` slash command output isn't reachable via `claude --print` (LLM intercepts the literal string).
- The data isn't cached anywhere on disk between invocations.
- The OAuth credentials at `~/.claude/.credentials.json` work, but reusing them to call an undocumented endpoint felt fragile.
- **Solution: PTY-drive `claude` itself.** New `src-tauri/src/cli_usage.rs` spawns claude via `portable-pty`, sends `/usage`, parses the three rendered bars (Current session / Current week all / Current week Sonnet). 5-min refresh + manual button. ~3-5s per fetch. (commit `db9a10a`)
- **Bring-up gotchas while wiring the PTY drive on Windows:**
1. Settings UI was non-interactive earlier because of two compounding bugs — a) Save was silently failing because the autostart plugin threw "OS error 2" in dev builds (target\debug exe path is unstable) and we let it abort the save; b) my PowerShell `mouse_event` clicks weren't reaching WebView2 (legacy API on a transparent borderless host), making me think the user's clicks were broken too. Real-mouse clicks worked once Save stopped getting blocked. Fix: best-effort autostart toggle (commit `9786437`).
2. `paths::resolve_roots` was canonicalizing UNC paths to `\\?\UNC\…` form, which broke `Path::parent()` and made tier detection silently fail. Stopped canonicalizing (commit `c5c38d1`).
3. Default `wsl.exe -- claude` invokes a non-interactive non-login shell with no PATH; resolved by defaulting to `wsl.exe -d Ubuntu bash -lc claude` on Windows when wsl.exe is detected (commit `7504990`). The `-d Ubuntu` matters because user's default WSL distro was `docker-desktop` (Alpine; no claude, no bash).
4. Title bar buttons were inside `data-tauri-drag-region`; needed explicit `data-tauri-drag-region="false"` per button so clicks don't get interpreted as drag-start.
- **Final UX polish (this session):** widget made resizable (220×240 min, 300×320 default), inline-SVG ring scales via viewBox, background opacity bumped to 93% so files behind don't bleed through, scrollbar bug from `border + width: 100vw` overflow killed via `box-sizing: border-box` reset + `body { overflow: hidden }` (commits `f90bb3b`, `c38d895`).
- **Status:** widget is live on Windows showing real subscription percentages (72% session at end of session). 18 commits on `main`, all pushed. User is happy.
- Toolchain (rust/node/pnpm) NOT installed in this WSL environment — that's expected; the build runs on the Windows host. `cargo check` / `pnpm install` not run from here.
## External references
- Forgejo repo: https://git.rdx4.com/megaproxy/claude-usage-widget
- Approved plan: `/home/megaproxy/.claude/plans/snug-mapping-milner.md`
- Tauri 2 prerequisites: https://v2.tauri.app/start/prerequisites/
- Tauri 2 window customization: https://v2.tauri.app/learn/window-customization/
- Tauri 2 autostart plugin: https://v2.tauri.app/plugin/autostart/
- Tauri 2 capabilities: https://v2.tauri.app/security/capabilities/
- ccusage (algorithm reference): https://github.com/ryoppippi/ccusage
- Claude Max weekly reset issues (context for "rolling 7d" choice): https://github.com/anthropics/claude-code/issues/54974 · https://github.com/anthropics/claude-code/issues/52921

View file

@ -1,7 +1,7 @@
{
"name": "claude-usage-widget",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",
@ -11,6 +11,11 @@
"tauri": "tauri"
},
"dependencies": {
"@fontsource-variable/fraunces": "^5.0.0",
"@fontsource-variable/jetbrains-mono": "^5.0.0",
"@fontsource-variable/newsreader": "^5.0.0",
"@fontsource/dm-sans": "^5.0.0",
"@fontsource/ibm-plex-mono": "^5.0.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-autostart": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",

View file

@ -1,6 +1,6 @@
[package]
name = "claude-usage-widget"
version = "0.1.0"
version = "0.1.2"
description = "Always-on-top Windows widget visualizing local Claude Code usage"
authors = ["megaproxy"]
edition = "2021"

View file

@ -114,9 +114,8 @@ pub fn autodetect_command() -> Option<Vec<String>> {
}
fn which_exists(name: &str) -> bool {
use std::process::Command;
let probe = if cfg!(windows) { "where" } else { "which" };
Command::new(probe)
crate::paths::quiet_command(probe)
.arg(name)
.output()
.map(|o| o.status.success() && !o.stdout.is_empty())
@ -124,8 +123,7 @@ fn which_exists(name: &str) -> bool {
}
fn list_wsl_distros() -> Vec<String> {
use std::process::Command;
let Ok(out) = Command::new("wsl.exe").args(["-l", "-q"]).output() else {
let Ok(out) = crate::paths::quiet_command("wsl.exe").args(["-l", "-q"]).output() else {
return Vec::new();
};
if !out.status.success() {
@ -145,8 +143,7 @@ fn list_wsl_distros() -> Vec<String> {
}
fn probe_claude_in_wsl(distro: &str) -> bool {
use std::process::Command;
Command::new("wsl.exe")
crate::paths::quiet_command("wsl.exe")
.args(["-d", distro, "bash", "-lc", "command -v claude"])
.output()
.map(|o| o.status.success() && !o.stdout.is_empty())

View file

@ -10,6 +10,23 @@
use serde::Serialize;
use std::path::{Path, PathBuf};
/// Construct a `Command` that won't flash a console window on Windows.
///
/// `Command::new(...).output()` allocates a console for the child process
/// before it exits, which appears as a black flash for short-lived processes
/// like `wsl.exe -l -q` or `where claude`. The CREATE_NO_WINDOW flag (Win32
/// process-creation flag 0x08000000) suppresses that.
pub fn quiet_command(program: &str) -> std::process::Command {
let mut c = std::process::Command::new(program);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
c.creation_flags(CREATE_NO_WINDOW);
}
c
}
#[derive(Debug, Clone, Serialize)]
pub struct ResolvedRoots {
pub roots: Vec<PathBuf>,
@ -24,7 +41,7 @@ pub fn list_wsl_distros() -> anyhow::Result<Vec<String>> {
return Ok(Vec::new());
}
let out = match std::process::Command::new("wsl.exe")
let out = match quiet_command("wsl.exe")
.args(["-l", "-q"])
.output()
{

View file

@ -42,6 +42,8 @@ pub struct Settings {
pub claude_command: Option<String>,
/// How often (seconds) to refetch `/usage`. Defaults to 300 (5 min).
pub cli_refresh_secs: u64,
/// One of: "anthropic" (default), "instrument", "editorial", "retro".
pub theme: String,
}
impl Default for Settings {
@ -54,6 +56,7 @@ impl Default for Settings {
autostart: false,
claude_command: None,
cli_refresh_secs: 300,
theme: "anthropic".to_string(),
}
}
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Claude Usage Widget",
"version": "0.1.0",
"version": "0.1.2",
"identifier": "com.megaproxy.claude-usage-widget",
"build": {
"beforeDevCommand": "pnpm dev",

View file

@ -22,10 +22,18 @@
let showSettings = $state(false);
/** True when Claude Code can't be found anywhere on this machine. */
let claudeMissing = $state(false);
/** Active theme; applied as data-theme on the document root. */
let theme = $state<string>("anthropic");
let unlisten1: (() => void) | null = null;
let unlisten2: (() => void) | null = null;
// Whenever theme changes, propagate it to the document root so CSS
// [data-theme="..."] selectors take effect.
$effect(() => {
document.documentElement.setAttribute("data-theme", theme);
});
onMount(async () => {
try {
snap = await getSnapshot();
@ -44,9 +52,10 @@
console.error("listen failed", e);
}
// Probe whether claude is reachable at all.
// Probe whether claude is reachable at all + load theme preference.
try {
const settings = await getSettings();
theme = settings.theme || "anthropic";
const hasOverride = !!(settings.claude_command && settings.claude_command.trim());
const auto = await autodetectClaudeCommand();
claudeMissing = !hasOverride && !auto;

View file

@ -52,6 +52,23 @@
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
class:pulse
/>
<!-- Quarter-position tick marks; only the instrument theme shows them. -->
<g class="ticks" aria-hidden="true">
{#each [0, 90, 180, 270] as deg (deg)}
{@const a = (deg - 90) * Math.PI / 180}
{@const r1 = R - STROKE / 2 - 2}
{@const r2 = R + STROKE / 2 + 2}
<line
x1={SIZE / 2 + r1 * Math.cos(a)}
y1={SIZE / 2 + r1 * Math.sin(a)}
x2={SIZE / 2 + r2 * Math.cos(a)}
y2={SIZE / 2 + r2 * Math.sin(a)}
stroke="currentColor"
stroke-width="1"
/>
{/each}
</g>
<text x={SIZE / 2} y={SIZE / 2 - 6} text-anchor="middle" class="big">
{bar ? `${bar.percent}%` : fallbackText}
</text>
@ -83,13 +100,53 @@
max-width: 220px; /* don't get absurdly large in a wide window */
max-height: 220px;
}
text { fill: var(--fg); font-family: inherit; }
text.big { font-size: 26px; font-weight: 600; }
text.small { font-size: 11px; fill: var(--fg-dim); }
text.resets { font-size: 10px; fill: var(--fg-dim); }
text { fill: var(--fg); }
text.big {
font-size: 38px;
font-weight: 600;
font-family: var(--font-display);
font-variant-numeric: tabular-nums;
}
text.small {
font-size: 11px;
fill: var(--fg-dim);
font-family: var(--font-body);
text-transform: lowercase;
letter-spacing: 0.5px;
}
text.resets {
font-size: 10px;
fill: var(--fg-dim);
font-family: var(--font-body);
}
.pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* Per-theme touches on the ring text */
:global([data-theme="editorial"]) text.big {
font-variation-settings: "opsz" 144, "SOFT" 50;
font-weight: 400;
}
:global([data-theme="editorial"]) text.small {
font-style: italic;
}
:global([data-theme="retro"]) text.big {
font-weight: 700;
letter-spacing: -1px;
}
:global([data-theme="instrument"]) text.big {
font-weight: 700;
letter-spacing: -1px;
}
:global([data-theme="anthropic"]) text.big {
font-variation-settings: "opsz" 36;
font-weight: 500;
}
/* Tick marks: hidden everywhere except instrument theme */
.ticks { color: var(--fg-dim); display: none; }
:global([data-theme="instrument"]) .ticks { display: block; }
</style>

View file

@ -24,6 +24,19 @@
let busy = $state(false);
let testingCli = $state(false);
let err = $state<string | null>(null);
/** Theme captured on mount so Cancel can revert a live preview. */
let originalTheme = "anthropic";
const THEMES = [
{ id: "anthropic", label: "Anthropic", blurb: "Warm cream-on-charcoal · serif" },
{ id: "instrument", label: "Instrument", blurb: "Synth panel · mono · chartreuse" },
{ id: "editorial", label: "Editorial", blurb: "Magazine artifact · saffron" },
{ id: "retro", label: "Retro CRT", blurb: "Phosphor green · scanlines" },
] as const;
function applyThemeLive(id: string) {
document.documentElement.setAttribute("data-theme", id);
}
onMount(async () => {
try {
@ -31,6 +44,7 @@
distros = await listDistros();
roots = await getRoots();
cli = await getCliUsage();
originalTheme = settings.theme || "anthropic";
const enabled = await isAutostartEnabled();
if (settings && settings.autostart !== enabled) {
settings = { ...settings, autostart: enabled };
@ -56,6 +70,8 @@
try {
await setSettings(settings);
// Successful save: lock in the live-previewed theme.
originalTheme = settings.theme;
onClose();
} catch (e) {
err = `${e}`;
@ -64,6 +80,18 @@
}
}
function cancel() {
// Revert any live theme preview the user was sampling.
applyThemeLive(originalTheme);
onClose();
}
function pickTheme(id: string) {
if (!settings) return;
settings = { ...settings, theme: id as any };
applyThemeLive(id);
}
async function testCli() {
if (!settings) return;
testingCli = true;
@ -84,7 +112,7 @@
<div class="panel">
<div class="row spread">
<strong>Settings</strong>
<button class="icon" onclick={onClose} aria-label="Close settings">×</button>
<button class="icon" onclick={cancel} aria-label="Close settings">×</button>
</div>
{#if err}<div class="error">{err}</div>{/if}
@ -165,8 +193,25 @@
</div>
{/if}
<div class="field">
<div class="label">theme</div>
<div class="seg" role="radiogroup" aria-label="Theme">
{#each THEMES as t (t.id)}
<button
type="button"
class="seg-item"
class:selected={settings.theme === t.id}
role="radio"
aria-checked={settings.theme === t.id}
onclick={() => pickTheme(t.id)}
title={t.blurb}
>{t.label}</button>
{/each}
</div>
</div>
<div class="row spread actions">
<button onclick={onClose}>Cancel</button>
<button onclick={cancel}>Cancel</button>
<button onclick={save} disabled={busy}>{busy ? "Saving…" : "Save"}</button>
</div>
{:else}
@ -179,8 +224,7 @@
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
background: var(--bg);
display: flex;
align-items: stretch;
z-index: 10;
@ -199,6 +243,36 @@
.roots { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; }
.roots code { font-size: 10px; word-break: break-all; }
.hint { font-size: 10px; line-height: 1.3; }
/* ---- Theme picker (segmented control) ---- */
.seg {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: var(--input-bg);
}
.seg-item {
flex: 1 1 0;
border: 0;
border-right: 1px solid var(--border);
border-radius: 0;
padding: 6px 4px;
background: transparent;
color: var(--fg-dim);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.seg-item:last-child { border-right: 0; }
.seg-item:hover { background: var(--hover); color: var(--fg); }
.seg-item.selected {
background: var(--accent);
color: var(--bg);
font-weight: 600;
}
pre.raw {
margin: 4px 0 0;
padding: 6px;

View file

@ -60,6 +60,7 @@
letter-spacing: 0.8px;
color: var(--fg-dim);
cursor: grab;
font-family: var(--font-body);
}
.actions { display: inline-flex; gap: 2px; }
.icon.spin { animation: spin 1.2s linear infinite; }
@ -67,4 +68,48 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Per-theme title voice ------------------------------------------------ */
/* Anthropic: serif italic, soft and warm */
:global([data-theme="anthropic"]) .title {
font-family: var(--font-display);
font-style: italic;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
font-size: 13px;
color: var(--fg);
opacity: 0.85;
}
/* Instrument: tracked monospace caps, accent colored */
:global([data-theme="instrument"]) .title {
color: var(--accent);
font-weight: 700;
letter-spacing: 1.6px;
font-size: 10px;
}
/* Editorial: italic display serif */
:global([data-theme="editorial"]) .title {
font-family: var(--font-display);
font-style: italic;
text-transform: none;
letter-spacing: 0.2px;
font-size: 14px;
font-variation-settings: "opsz" 18, "SOFT" 50;
color: var(--fg);
opacity: 0.85;
}
/* Retro: bracketed CLI-style label */
:global([data-theme="retro"]) .title {
font-weight: 700;
color: var(--accent);
letter-spacing: 1px;
font-size: 11px;
}
:global([data-theme="retro"]) .title::before { content: "[ "; opacity: 0.6; }
:global([data-theme="retro"]) .title::after { content: " ]"; opacity: 0.6; }
</style>

View file

@ -1,5 +1,19 @@
import { mount } from "svelte";
import App from "./components/App.svelte";
// Theme fonts — bundled offline so the widget renders identically without a
// network connection. Variable-axis fonts where available so we can tune
// weight/optical size from CSS.
import "@fontsource-variable/jetbrains-mono";
import "@fontsource-variable/fraunces";
import "@fontsource-variable/newsreader";
import "@fontsource/dm-sans/400.css";
import "@fontsource/dm-sans/500.css";
import "@fontsource/dm-sans/600.css";
import "@fontsource/ibm-plex-mono/400.css";
import "@fontsource/ibm-plex-mono/500.css";
import "@fontsource/ibm-plex-mono/700.css";
import "./styles.css";
const app = mount(App, { target: document.getElementById("app")! });

View file

@ -1,25 +1,11 @@
/* Glass / always-on-top widget look. */
/* ============================================================
Base structure (theme-agnostic).
Theme tokens live in the [data-theme="..."] blocks below.
============================================================ */
:root {
--bg: rgba(18, 20, 26, 0.93);
--bg-card: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.08);
--fg: #e8eaf0;
--fg-dim: #9aa0aa;
--accent: #b08bff; /* Anthropic-ish purple */
--opus: #b08bff;
--sonnet: #6ec1ff;
--haiku: #7ee0a3;
--other: #d8d8d8;
--warn: #ffb454;
--danger: #ff6b6b;
--radius: 10px;
--pad: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: var(--fg);
}
*, *::before, *::after { box-sizing: border-box; }
@ -29,10 +15,16 @@ html, body {
padding: 0;
width: 100%;
height: 100%;
background: transparent; /* honor Tauri transparent:true */
overflow: hidden; /* viewport never scrolls; sections handle their own */
background: transparent;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
font-family: var(--font-body);
font-size: 13px;
color: var(--fg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
#app {
@ -41,18 +33,29 @@ html, body {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden; /* the window itself never scrolls */
overflow: hidden;
display: flex;
flex-direction: column;
backdrop-filter: blur(14px);
position: relative;
}
/* Each child of #app gets a sensible flex behavior so resizing reflows
instead of overflowing. TitleBar and section panels are flex:0 (size
to content); the BlockRing wrap is flex:1 (claims remaining space). */
/* Optional decorative atmosphere layer per theme set --atmosphere
to a `background` value (e.g. a gradient + noise) to enable.
z-index:-1 keeps it below all in-flow content WITHOUT having to
promote children to a stacking context (which would clobber the
Settings overlay's position: absolute). */
#app::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: var(--atmosphere, none);
opacity: var(--atmosphere-opacity, 0);
mix-blend-mode: var(--atmosphere-blend, normal);
z-index: -1;
}
/* Hide scrollbars unless content really overflows; when they do appear,
make them subtle. */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; }
::-webkit-scrollbar-track { background: transparent; }
@ -65,8 +68,9 @@ button {
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
font-family: var(--font-body);
}
button:hover { background: rgba(255, 255, 255, 0.06); }
button:hover { background: var(--hover); }
button.icon {
border: none;
padding: 4px;
@ -82,12 +86,12 @@ button.icon:hover { color: var(--fg); }
input[type="number"], input[type="text"], select {
font: inherit;
color: inherit;
background: rgba(0, 0, 0, 0.25);
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 6px;
width: 100%;
box-sizing: border-box;
font-family: var(--font-body);
}
.section {
@ -98,9 +102,9 @@ input[type="number"], input[type="text"], select {
.section:first-child { border-top: none; }
.label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.6px;
font: var(--label-font);
text-transform: var(--label-transform, uppercase);
letter-spacing: var(--label-tracking, 0.6px);
color: var(--fg-dim);
margin-bottom: 4px;
}
@ -108,3 +112,241 @@ input[type="number"], input[type="text"], select {
.row { display: flex; align-items: center; gap: 8px; }
.row.spread { justify-content: space-between; }
.muted { color: var(--fg-dim); font-size: 11px; }
/* Tabular numerals everywhere a number can change */
.num, .big, text.big, .muted, .label, .bar-row .num {
font-variant-numeric: tabular-nums;
}
/* ============================================================
Theme: ANTHROPIC (default)
Warm charcoal + cream + sunset orange + claude purple.
Newsreader serif for display, DM Sans for body.
============================================================ */
[data-theme="anthropic"] {
--font-display: "Newsreader Variable", "Newsreader", Georgia, serif;
--font-body: "DM Sans", -apple-system, "Segoe UI", sans-serif;
--font-mono: "DM Sans", monospace;
--bg: #1a1815;
--bg-card: rgba(250, 249, 245, 0.04);
--border: rgba(250, 249, 245, 0.10);
--hover: rgba(250, 249, 245, 0.06);
--input-bg: rgba(0, 0, 0, 0.20);
--fg: #faf9f5;
--fg-dim: #b8ad9e;
--accent: #cc785c; /* sunset orange */
--accent-2: #c084fc; /* claude purple */
--opus: #c084fc;
--sonnet: #6ec1ff;
--haiku: #a3d9a5;
--other: #d8d8d8;
--warn: #e8a02f;
--danger: #d97559;
--label-font: 500 10px/1 var(--font-body);
--atmosphere: radial-gradient(60% 50% at 50% 0%, rgba(204, 120, 92, 0.18), transparent 60%);
--atmosphere-opacity: 1;
}
/* ============================================================
Theme: INSTRUMENT
Modular synth panel. JetBrains Mono. Chartreuse on slate.
Faint scanlines, tick marks on the ring.
============================================================ */
[data-theme="instrument"] {
--font-display: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
--font-body: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
--font-mono: "JetBrains Mono Variable", monospace;
--bg: #0e1014;
--bg-card: rgba(255, 255, 255, 0.03);
--border: rgba(212, 255, 61, 0.18);
--hover: rgba(212, 255, 61, 0.06);
--input-bg: rgba(0, 0, 0, 0.45);
--fg: #d6e2d8;
--fg-dim: #6f8478;
--accent: #d4ff3d; /* chartreuse */
--accent-2: #5dd8ff; /* cyan secondary */
--opus: #d4ff3d;
--sonnet: #5dd8ff;
--haiku: #ff6fb5;
--other: #9aa0aa;
--warn: #ffb454;
--danger: #ff3b88;
--label-font: 600 9px/1 var(--font-mono);
--label-tracking: 1.4px;
/* Faint horizontal scanlines + a subtle grid */
--atmosphere:
repeating-linear-gradient(
180deg,
transparent 0px,
transparent 2px,
rgba(212, 255, 61, 0.025) 2px,
rgba(212, 255, 61, 0.025) 3px
),
radial-gradient(80% 60% at 50% 0%, rgba(212, 255, 61, 0.05), transparent 70%);
--atmosphere-opacity: 1;
}
[data-theme="instrument"] #app {
border-color: rgba(212, 255, 61, 0.22);
}
/* Bracket-corners on the title bar in instrument mode */
[data-theme="instrument"] header::before,
[data-theme="instrument"] header::after {
content: "";
position: absolute;
width: 6px; height: 6px;
border: 1px solid var(--accent);
opacity: 0.5;
}
[data-theme="instrument"] header { position: relative; }
[data-theme="instrument"] header::before {
top: 4px; left: 4px;
border-right: 0; border-bottom: 0;
}
[data-theme="instrument"] header::after {
top: 4px; right: 4px;
border-left: 0; border-bottom: 0;
}
/* ============================================================
Theme: EDITORIAL
Magazine artifact. Fraunces serif. Saffron on warm charcoal.
Hairline rules, generous spacing.
============================================================ */
[data-theme="editorial"] {
--font-display: "Fraunces Variable", "Fraunces", Georgia, serif;
--font-body: "Fraunces Variable", "Fraunces", Georgia, serif;
--font-mono: "Fraunces Variable", monospace;
--bg: #1a1813;
--bg-card: rgba(245, 241, 232, 0.03);
--border: rgba(245, 241, 232, 0.12);
--hover: rgba(245, 241, 232, 0.05);
--input-bg: rgba(0, 0, 0, 0.25);
--fg: #f5f1e8;
--fg-dim: #9b9183;
--accent: #e8a02f; /* saffron */
--accent-2: #d97559;
--opus: #e8a02f;
--sonnet: #c8987a;
--haiku: #a3a87a;
--other: #9b9183;
--warn: #d97559;
--danger: #b54a3c;
--label-font: italic 400 11px/1 var(--font-display);
--label-transform: none;
--label-tracking: 0.2px;
}
/* Editorial uses small-caps italic for labels — let the variable axis breathe */
[data-theme="editorial"] .label {
font-variation-settings: "opsz" 14, "SOFT" 50;
}
[data-theme="editorial"] #app {
border-radius: 6px;
}
[data-theme="editorial"] .section {
border-top-style: solid;
border-top-width: 0.5px;
}
/* ============================================================
Theme: RETRO CRT
1980s home computer. IBM Plex Mono. Phosphor green.
Scanlines, bracket header, blink cursor.
============================================================ */
[data-theme="retro"] {
--font-display: "IBM Plex Mono", ui-monospace, monospace;
--font-body: "IBM Plex Mono", ui-monospace, monospace;
--font-mono: "IBM Plex Mono", monospace;
--bg: #0a0e0a;
--bg-card: rgba(122, 240, 122, 0.03);
--border: rgba(122, 240, 122, 0.20);
--hover: rgba(122, 240, 122, 0.08);
--input-bg: rgba(0, 0, 0, 0.55);
--fg: #b8f0b8;
--fg-dim: #5a8a5a;
--accent: #7af07a; /* phosphor green */
--accent-2: #ffb454; /* amber for warnings */
--opus: #7af07a;
--sonnet: #5dd8ff;
--haiku: #ffe066;
--other: #b8f0b8;
--warn: #ffb454;
--danger: #ff5a5a;
--label-font: 700 9px/1 var(--font-mono);
--label-tracking: 1.5px;
/* Strong scanlines + vignette + faint phosphor glow */
--atmosphere:
repeating-linear-gradient(
180deg,
transparent 0px,
transparent 1px,
rgba(0, 0, 0, 0.35) 1px,
rgba(0, 0, 0, 0.35) 2px
),
radial-gradient(120% 90% at 50% 50%, transparent 50%, rgba(0, 0, 0, 0.55) 100%),
radial-gradient(60% 40% at 50% 30%, rgba(122, 240, 122, 0.10), transparent 70%);
--atmosphere-opacity: 1;
}
[data-theme="retro"] #app {
border-radius: 4px;
border-color: rgba(122, 240, 122, 0.30);
text-shadow: 0 0 6px rgba(122, 240, 122, 0.35);
}
[data-theme="retro"] header { border-bottom-style: dashed; }
/* Blink cursor in the bottom-right of the widget — pure decoration */
[data-theme="retro"] #app::after {
content: "█";
position: absolute;
bottom: 6px;
right: 10px;
color: var(--accent);
opacity: 0.8;
animation: retro-blink 1.1s steps(2, end) infinite;
font-family: var(--font-mono);
font-size: 12px;
pointer-events: none;
z-index: 2;
}
@keyframes retro-blink {
50% { opacity: 0; }
}
/* ============================================================
Reduced-motion accommodation: kill all the breathing effects.
============================================================ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}

View file

@ -45,12 +45,17 @@ export interface UsageSnapshot {
generated_at: string;
}
export type Theme = "anthropic" | "instrument" | "editorial" | "retro";
export interface Settings {
caps: Caps;
wsl_distro_override: string | null;
include_native: boolean;
window_pos: [number, number] | null;
autostart: boolean;
claude_command: string | null;
cli_refresh_secs: number;
theme: Theme;
}
export interface ResolvedRoots {