Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

22 changed files with 167 additions and 586 deletions

42
.gitattributes vendored
View file

@ -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

14
.gitignore vendored
View file

@ -47,17 +47,3 @@ src-tauri/target/
src-tauri/Cargo.lock src-tauri/Cargo.lock
*.tsbuildinfo *.tsbuildinfo
.vite/ .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

30
CLAUDE.md Normal file
View file

@ -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$\<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,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 subscription usage — the same percentages Claude Code's `/usage` command
displays, refreshed every 5 minutes. displays, refreshed every 5 minutes.
## Themes ```
┌─────── Claude Usage ────────────╳ ─┐
Pick one in Settings; it applies live. Each is its own typographic + chromatic system. │ │
│ ╭───────╮ │
| | | 72% ╲ │
|---|---| │ │ session │ │
| **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. | │ ╲ resets
| ![Anthropic theme](docs/screenshots/anthropic.png) | ![Instrument theme](docs/screenshots/instrument.png) | │ ╰ 2:50am ╯ │
| **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) | │ Models (current block) │
│ ▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱ │
│ ● Opus 42M ● Haiku 3M │
│ │
│ Weekly limits resets May 9 │
│ All models ▆░░░░░ 8% │
│ Sonnet ▃░░░░░ 5% │
└─────────────────────────────────────┘
```
## Install ## Install
@ -96,4 +104,5 @@ pnpm tauri dev # iterate
pnpm tauri build # produces NSIS installer in src-tauri\target\release\bundle\nsis\ 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).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

66
memory.md Normal file
View file

@ -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$\<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", "name": "claude-usage-widget",
"private": true, "private": true,
"version": "0.1.2", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -11,11 +11,6 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "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/api": "^2.0.0",
"@tauri-apps/plugin-autostart": "^2.0.0", "@tauri-apps/plugin-autostart": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0",

View file

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

View file

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

View file

@ -10,23 +10,6 @@
use serde::Serialize; use serde::Serialize;
use std::path::{Path, PathBuf}; 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)] #[derive(Debug, Clone, Serialize)]
pub struct ResolvedRoots { pub struct ResolvedRoots {
pub roots: Vec<PathBuf>, pub roots: Vec<PathBuf>,
@ -41,7 +24,7 @@ pub fn list_wsl_distros() -> anyhow::Result<Vec<String>> {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let out = match quiet_command("wsl.exe") let out = match std::process::Command::new("wsl.exe")
.args(["-l", "-q"]) .args(["-l", "-q"])
.output() .output()
{ {

View file

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

View file

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

View file

@ -22,18 +22,10 @@
let showSettings = $state(false); let showSettings = $state(false);
/** True when Claude Code can't be found anywhere on this machine. */ /** True when Claude Code can't be found anywhere on this machine. */
let claudeMissing = $state(false); 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 unlisten1: (() => void) | null = null;
let unlisten2: (() => 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 () => { onMount(async () => {
try { try {
snap = await getSnapshot(); snap = await getSnapshot();
@ -52,10 +44,9 @@
console.error("listen failed", e); console.error("listen failed", e);
} }
// Probe whether claude is reachable at all + load theme preference. // Probe whether claude is reachable at all.
try { try {
const settings = await getSettings(); const settings = await getSettings();
theme = settings.theme || "anthropic";
const hasOverride = !!(settings.claude_command && settings.claude_command.trim()); const hasOverride = !!(settings.claude_command && settings.claude_command.trim());
const auto = await autodetectClaudeCommand(); const auto = await autodetectClaudeCommand();
claudeMissing = !hasOverride && !auto; claudeMissing = !hasOverride && !auto;

View file

@ -52,23 +52,6 @@
transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`} transform={`rotate(-90 ${SIZE / 2} ${SIZE / 2})`}
class:pulse 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"> <text x={SIZE / 2} y={SIZE / 2 - 6} text-anchor="middle" class="big">
{bar ? `${bar.percent}%` : fallbackText} {bar ? `${bar.percent}%` : fallbackText}
</text> </text>
@ -100,53 +83,13 @@
max-width: 220px; /* don't get absurdly large in a wide window */ max-width: 220px; /* don't get absurdly large in a wide window */
max-height: 220px; max-height: 220px;
} }
text { fill: var(--fg); } text { fill: var(--fg); font-family: inherit; }
text.big { text.big { font-size: 26px; font-weight: 600; }
font-size: 38px; text.small { font-size: 11px; fill: var(--fg-dim); }
font-weight: 600; text.resets { font-size: 10px; fill: var(--fg-dim); }
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; } .pulse { animation: pulse 1.6s ease-in-out infinite; }
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.55; } 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> </style>

View file

@ -24,19 +24,6 @@
let busy = $state(false); let busy = $state(false);
let testingCli = $state(false); let testingCli = $state(false);
let err = $state<string | null>(null); 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 () => { onMount(async () => {
try { try {
@ -44,7 +31,6 @@
distros = await listDistros(); distros = await listDistros();
roots = await getRoots(); roots = await getRoots();
cli = await getCliUsage(); cli = await getCliUsage();
originalTheme = settings.theme || "anthropic";
const enabled = await isAutostartEnabled(); const enabled = await isAutostartEnabled();
if (settings && settings.autostart !== enabled) { if (settings && settings.autostart !== enabled) {
settings = { ...settings, autostart: enabled }; settings = { ...settings, autostart: enabled };
@ -70,8 +56,6 @@
try { try {
await setSettings(settings); await setSettings(settings);
// Successful save: lock in the live-previewed theme.
originalTheme = settings.theme;
onClose(); onClose();
} catch (e) { } catch (e) {
err = `${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() { async function testCli() {
if (!settings) return; if (!settings) return;
testingCli = true; testingCli = true;
@ -112,7 +84,7 @@
<div class="panel"> <div class="panel">
<div class="row spread"> <div class="row spread">
<strong>Settings</strong> <strong>Settings</strong>
<button class="icon" onclick={cancel} aria-label="Close settings">×</button> <button class="icon" onclick={onClose} aria-label="Close settings">×</button>
</div> </div>
{#if err}<div class="error">{err}</div>{/if} {#if err}<div class="error">{err}</div>{/if}
@ -193,25 +165,8 @@
</div> </div>
{/if} {/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"> <div class="row spread actions">
<button onclick={cancel}>Cancel</button> <button onclick={onClose}>Cancel</button>
<button onclick={save} disabled={busy}>{busy ? "Saving…" : "Save"}</button> <button onclick={save} disabled={busy}>{busy ? "Saving…" : "Save"}</button>
</div> </div>
{:else} {:else}
@ -224,7 +179,8 @@
.overlay { .overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: var(--bg); background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
display: flex; display: flex;
align-items: stretch; align-items: stretch;
z-index: 10; z-index: 10;
@ -243,36 +199,6 @@
.roots { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; } .roots { margin: 0; padding-left: 14px; max-height: 60px; overflow: auto; }
.roots code { font-size: 10px; word-break: break-all; } .roots code { font-size: 10px; word-break: break-all; }
.hint { font-size: 10px; line-height: 1.3; } .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 { pre.raw {
margin: 4px 0 0; margin: 4px 0 0;
padding: 6px; padding: 6px;

View file

@ -60,7 +60,6 @@
letter-spacing: 0.8px; letter-spacing: 0.8px;
color: var(--fg-dim); color: var(--fg-dim);
cursor: grab; cursor: grab;
font-family: var(--font-body);
} }
.actions { display: inline-flex; gap: 2px; } .actions { display: inline-flex; gap: 2px; }
.icon.spin { animation: spin 1.2s linear infinite; } .icon.spin { animation: spin 1.2s linear infinite; }
@ -68,48 +67,4 @@
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } 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> </style>

View file

@ -1,19 +1,5 @@
import { mount } from "svelte"; import { mount } from "svelte";
import App from "./components/App.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"; import "./styles.css";
const app = mount(App, { target: document.getElementById("app")! }); const app = mount(App, { target: document.getElementById("app")! });

View file

@ -1,11 +1,25 @@
/* ============================================================ /* Glass / always-on-top widget look. */
Base structure (theme-agnostic).
Theme tokens live in the [data-theme="..."] blocks below.
============================================================ */
:root { :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; --radius: 10px;
--pad: 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; } *, *::before, *::after { box-sizing: border-box; }
@ -15,16 +29,10 @@ html, body {
padding: 0; padding: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: transparent; background: transparent; /* honor Tauri transparent:true */
overflow: hidden; overflow: hidden; /* viewport never scrolls; sections handle their own */
user-select: none; user-select: none;
-webkit-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 { #app {
@ -33,29 +41,18 @@ html, body {
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden; /* the window itself never scrolls */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
position: relative;
} }
/* Optional decorative atmosphere layer per theme set --atmosphere /* Each child of #app gets a sensible flex behavior so resizing reflows
to a `background` value (e.g. a gradient + noise) to enable. instead of overflowing. TitleBar and section panels are flex:0 (size
z-index:-1 keeps it below all in-flow content WITHOUT having to to content); the BlockRing wrap is flex:1 (claims remaining space). */
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 { width: 6px; height: 6px; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
@ -68,9 +65,8 @@ button {
border-radius: 6px; border-radius: 6px;
padding: 4px 8px; padding: 4px 8px;
cursor: pointer; cursor: pointer;
font-family: var(--font-body);
} }
button:hover { background: var(--hover); } button:hover { background: rgba(255, 255, 255, 0.06); }
button.icon { button.icon {
border: none; border: none;
padding: 4px; padding: 4px;
@ -86,12 +82,12 @@ button.icon:hover { color: var(--fg); }
input[type="number"], input[type="text"], select { input[type="number"], input[type="text"], select {
font: inherit; font: inherit;
color: inherit; color: inherit;
background: var(--input-bg); background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
padding: 4px 6px; padding: 4px 6px;
width: 100%; width: 100%;
font-family: var(--font-body); box-sizing: border-box;
} }
.section { .section {
@ -102,9 +98,9 @@ input[type="number"], input[type="text"], select {
.section:first-child { border-top: none; } .section:first-child { border-top: none; }
.label { .label {
font: var(--label-font); font-size: 10px;
text-transform: var(--label-transform, uppercase); text-transform: uppercase;
letter-spacing: var(--label-tracking, 0.6px); letter-spacing: 0.6px;
color: var(--fg-dim); color: var(--fg-dim);
margin-bottom: 4px; margin-bottom: 4px;
} }
@ -112,241 +108,3 @@ input[type="number"], input[type="text"], select {
.row { display: flex; align-items: center; gap: 8px; } .row { display: flex; align-items: center; gap: 8px; }
.row.spread { justify-content: space-between; } .row.spread { justify-content: space-between; }
.muted { color: var(--fg-dim); font-size: 11px; } .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,17 +45,12 @@ export interface UsageSnapshot {
generated_at: string; generated_at: string;
} }
export type Theme = "anthropic" | "instrument" | "editorial" | "retro";
export interface Settings { export interface Settings {
caps: Caps; caps: Caps;
wsl_distro_override: string | null; wsl_distro_override: string | null;
include_native: boolean; include_native: boolean;
window_pos: [number, number] | null; window_pos: [number, number] | null;
autostart: boolean; autostart: boolean;
claude_command: string | null;
cli_refresh_secs: number;
theme: Theme;
} }
export interface ResolvedRoots { export interface ResolvedRoots {