diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b94f291 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 40bad81..6fe985b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a633e3e..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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$\\…` 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 `/subagents/.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). diff --git a/README.md b/README.md index 058ac7c..127eecb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/screenshots/anthropic.png b/docs/screenshots/anthropic.png new file mode 100755 index 0000000..3acfc85 Binary files /dev/null and b/docs/screenshots/anthropic.png differ diff --git a/docs/screenshots/editorial.png b/docs/screenshots/editorial.png new file mode 100755 index 0000000..90cdc27 Binary files /dev/null and b/docs/screenshots/editorial.png differ diff --git a/docs/screenshots/instrument.png b/docs/screenshots/instrument.png new file mode 100755 index 0000000..77a4629 Binary files /dev/null and b/docs/screenshots/instrument.png differ diff --git a/docs/screenshots/retro.png b/docs/screenshots/retro.png new file mode 100755 index 0000000..bf07269 Binary files /dev/null and b/docs/screenshots/retro.png differ diff --git a/memory.md b/memory.md deleted file mode 100644 index 1375299..0000000 --- a/memory.md +++ /dev/null @@ -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$\\home\\.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 diff --git a/package.json b/package.json index c370011..fae27cd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2a3a424..019fe63 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/cli_usage.rs b/src-tauri/src/cli_usage.rs index f0d6a7a..26287e6 100644 --- a/src-tauri/src/cli_usage.rs +++ b/src-tauri/src/cli_usage.rs @@ -114,9 +114,8 @@ pub fn autodetect_command() -> Option> { } 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 { - 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 { } 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()) diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index b8d8e6e..dbbea8b 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -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, @@ -24,7 +41,7 @@ pub fn list_wsl_distros() -> anyhow::Result> { return Ok(Vec::new()); } - let out = match std::process::Command::new("wsl.exe") + let out = match quiet_command("wsl.exe") .args(["-l", "-q"]) .output() { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 664b744..ed481b5 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -42,6 +42,8 @@ pub struct Settings { pub claude_command: Option, /// 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(), } } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0d8548d..68612ba 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/App.svelte b/src/components/App.svelte index c12f3cd..9725f03 100644 --- a/src/components/App.svelte +++ b/src/components/App.svelte @@ -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("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; diff --git a/src/components/BlockRing.svelte b/src/components/BlockRing.svelte index 26e02f2..7e278fa 100644 --- a/src/components/BlockRing.svelte +++ b/src/components/BlockRing.svelte @@ -52,6 +52,23 @@ transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`} class:pulse /> + + + {bar ? `${bar.percent}%` : fallbackText} @@ -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; } diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index 9a540b6..a1700f0 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -24,6 +24,19 @@ let busy = $state(false); let testingCli = $state(false); let err = $state(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 @@
Settings - +
{#if err}
{err}
{/if} @@ -165,8 +193,25 @@
{/if} +
+
theme
+
+ {#each THEMES as t (t.id)} + + {/each} +
+
+
- +
{: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; diff --git a/src/components/TitleBar.svelte b/src/components/TitleBar.svelte index 625367c..dee3876 100644 --- a/src/components/TitleBar.svelte +++ b/src/components/TitleBar.svelte @@ -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; } diff --git a/src/main.ts b/src/main.ts index 953b0de..94e14c5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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")! }); diff --git a/src/styles.css b/src/styles.css index 5a6f61a..986d712 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; + } +} diff --git a/src/types.ts b/src/types.ts index 28e0f47..cd1991b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 {