diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index b94f291..0000000 --- a/.gitattributes +++ /dev/null @@ -1,42 +0,0 @@ -# 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 6fe985b..40bad81 100644 --- a/.gitignore +++ b/.gitignore @@ -47,17 +47,3 @@ 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 new file mode 100644 index 0000000..a633e3e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# 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 127eecb..058ac7c 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,24 @@ 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. -## 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) | +``` +┌─────── Claude Usage ────────────╳ ─┐ +│ │ +│ ╭───────╮ │ +│ ╱ 72% ╲ │ +│ │ session │ │ +│ ╲ resets ╱ │ +│ ╰ 2:50am ╯ │ +│ │ +│ Models (current block) │ +│ ▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱ │ +│ ● Opus 42M ● Haiku 3M │ +│ │ +│ Weekly limits resets May 9 │ +│ All models ▆░░░░░ 8% │ +│ Sonnet ▃░░░░░ 5% │ +└─────────────────────────────────────┘ +``` ## Install @@ -96,4 +104,5 @@ pnpm tauri dev # iterate pnpm tauri build # produces NSIS installer in src-tauri\target\release\bundle\nsis\ ``` -Filing issues / pull requests on the Forgejo repo is welcome. +Project layout, architecture decisions, and known follow-ups live in +[`memory.md`](./memory.md). diff --git a/docs/screenshots/anthropic.png b/docs/screenshots/anthropic.png deleted file mode 100755 index 3acfc85..0000000 Binary files a/docs/screenshots/anthropic.png and /dev/null differ diff --git a/docs/screenshots/editorial.png b/docs/screenshots/editorial.png deleted file mode 100755 index 90cdc27..0000000 Binary files a/docs/screenshots/editorial.png and /dev/null differ diff --git a/docs/screenshots/instrument.png b/docs/screenshots/instrument.png deleted file mode 100755 index 77a4629..0000000 Binary files a/docs/screenshots/instrument.png and /dev/null differ diff --git a/docs/screenshots/retro.png b/docs/screenshots/retro.png deleted file mode 100755 index bf07269..0000000 Binary files a/docs/screenshots/retro.png and /dev/null differ diff --git a/memory.md b/memory.md new file mode 100644 index 0000000..1375299 --- /dev/null +++ b/memory.md @@ -0,0 +1,66 @@ +# 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 fae27cd..c370011 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-usage-widget", "private": true, - "version": "0.1.2", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", @@ -11,11 +11,6 @@ "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 019fe63..2a3a424 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "claude-usage-widget" -version = "0.1.2" +version = "0.1.0" 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 26287e6..f0d6a7a 100644 --- a/src-tauri/src/cli_usage.rs +++ b/src-tauri/src/cli_usage.rs @@ -114,8 +114,9 @@ pub fn autodetect_command() -> Option> { } fn which_exists(name: &str) -> bool { + use std::process::Command; let probe = if cfg!(windows) { "where" } else { "which" }; - crate::paths::quiet_command(probe) + Command::new(probe) .arg(name) .output() .map(|o| o.status.success() && !o.stdout.is_empty()) @@ -123,7 +124,8 @@ fn which_exists(name: &str) -> bool { } fn list_wsl_distros() -> Vec { - let Ok(out) = crate::paths::quiet_command("wsl.exe").args(["-l", "-q"]).output() else { + use std::process::Command; + let Ok(out) = Command::new("wsl.exe").args(["-l", "-q"]).output() else { return Vec::new(); }; if !out.status.success() { @@ -143,7 +145,8 @@ fn list_wsl_distros() -> Vec { } fn probe_claude_in_wsl(distro: &str) -> bool { - crate::paths::quiet_command("wsl.exe") + use std::process::Command; + Command::new("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 dbbea8b..b8d8e6e 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -10,23 +10,6 @@ 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, @@ -41,7 +24,7 @@ pub fn list_wsl_distros() -> anyhow::Result> { return Ok(Vec::new()); } - let out = match quiet_command("wsl.exe") + let out = match std::process::Command::new("wsl.exe") .args(["-l", "-q"]) .output() { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index ed481b5..664b744 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -42,8 +42,6 @@ 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 { @@ -56,7 +54,6 @@ 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 68612ba..0d8548d 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.2", + "version": "0.1.0", "identifier": "com.megaproxy.claude-usage-widget", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/components/App.svelte b/src/components/App.svelte index 9725f03..c12f3cd 100644 --- a/src/components/App.svelte +++ b/src/components/App.svelte @@ -22,18 +22,10 @@ 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(); @@ -52,10 +44,9 @@ console.error("listen failed", e); } - // Probe whether claude is reachable at all + load theme preference. + // Probe whether claude is reachable at all. 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 7e278fa..26e02f2 100644 --- a/src/components/BlockRing.svelte +++ b/src/components/BlockRing.svelte @@ -52,23 +52,6 @@ transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`} class:pulse /> - - - {bar ? `${bar.percent}%` : fallbackText} @@ -100,53 +83,13 @@ max-width: 220px; /* don't get absurdly large in a wide window */ max-height: 220px; } - 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); - } + 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); } .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 a1700f0..9a540b6 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -24,19 +24,6 @@ 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 { @@ -44,7 +31,6 @@ 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 }; @@ -70,8 +56,6 @@ try { await setSettings(settings); - // Successful save: lock in the live-previewed theme. - originalTheme = settings.theme; onClose(); } catch (e) { err = `${e}`; @@ -80,18 +64,6 @@ } } - 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; @@ -112,7 +84,7 @@
Settings - +
{#if err}
{err}
{/if} @@ -193,25 +165,8 @@
{/if} -
-
theme
-
- {#each THEMES as t (t.id)} - - {/each} -
-
-
- +
{:else} @@ -224,7 +179,8 @@ .overlay { position: absolute; inset: 0; - background: var(--bg); + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); display: flex; align-items: stretch; z-index: 10; @@ -243,36 +199,6 @@ .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 dee3876..625367c 100644 --- a/src/components/TitleBar.svelte +++ b/src/components/TitleBar.svelte @@ -60,7 +60,6 @@ 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; } @@ -68,48 +67,4 @@ 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 94e14c5..953b0de 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,19 +1,5 @@ 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 986d712..5a6f61a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,11 +1,25 @@ -/* ============================================================ - Base structure (theme-agnostic). - Theme tokens live in the [data-theme="..."] blocks below. - ============================================================ */ +/* Glass / always-on-top widget look. */ :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; } @@ -15,16 +29,10 @@ html, body { padding: 0; width: 100%; height: 100%; - background: transparent; - overflow: hidden; + background: transparent; /* honor Tauri transparent:true */ + overflow: hidden; /* viewport never scrolls; sections handle their own */ 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 { @@ -33,29 +41,18 @@ html, body { background: var(--bg); border: 1px solid var(--border); border-radius: 12px; - overflow: hidden; + overflow: hidden; /* the window itself never scrolls */ display: flex; flex-direction: column; backdrop-filter: blur(14px); - position: relative; } -/* 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; -} +/* 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). */ +/* 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; } @@ -68,9 +65,8 @@ button { border-radius: 6px; padding: 4px 8px; cursor: pointer; - font-family: var(--font-body); } -button:hover { background: var(--hover); } +button:hover { background: rgba(255, 255, 255, 0.06); } button.icon { border: none; padding: 4px; @@ -86,12 +82,12 @@ button.icon:hover { color: var(--fg); } input[type="number"], input[type="text"], select { font: inherit; color: inherit; - background: var(--input-bg); + background: rgba(0, 0, 0, 0.25); border: 1px solid var(--border); border-radius: 6px; padding: 4px 6px; width: 100%; - font-family: var(--font-body); + box-sizing: border-box; } .section { @@ -102,9 +98,9 @@ input[type="number"], input[type="text"], select { .section:first-child { border-top: none; } .label { - font: var(--label-font); - text-transform: var(--label-transform, uppercase); - letter-spacing: var(--label-tracking, 0.6px); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.6px; color: var(--fg-dim); margin-bottom: 4px; } @@ -112,241 +108,3 @@ 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 cd1991b..28e0f47 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,17 +45,12 @@ 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 {