Standalone Electron app rendering the v6 "indigo instrument" wireframes, fed by the codeburn CLI via the menubar JSON contract (spawn codeburn --json, decode, poll). Six sections: Overview, Spend, Optimize, Models, Plans, Settings/Devices. Data aggregation stays CLI-side; renderer never fakes data.
11 KiB
CodeBurn Desktop — Design Spec
Date: 2026-07-10
Branch: feat/desktop-app (cut from origin/main, v0.9.15)
Design reference: codeburn-desktop-wireframes.html (v6.1 "indigo instrument", repo root)
Status: Approved design → implementation planning
1. Summary
Build CodeBurn Desktop — a standalone, resizable native desktop application (Electron) that renders the approved v6 "indigo instrument" wireframes. It is a first-class app in the vein of Claude Desktop / the Codex app, distinct from:
- the
dash/web dashboard (React app served bycodeburn web) — untouched, unrelated; - the
mac/+gnome/menubar apps — small tray popovers.
The desktop app is local-first: it never sends data anywhere, and it does not re-implement
usage analytics. All aggregation stays in the codeburn CLI; the app is a view layer that spawns
the CLI and renders its JSON — the same contract the menubar apps use today.
2. Goals / Non-goals
Goals
- A resizable window with six sections: Overview · Spend · Optimize · Models · Plans · Settings/Devices.
- Pixel-faithful to the approved wireframe (the wireframe CSS is the design system, ported verbatim).
- Data via the menubar contract: spawn
codeburn … --json, decode, poll on a 30s timer + on demand. - Reuse existing CLI JSON where it exists; add small new
--jsonemitters where the wireframe needs data that the CLI doesn't expose yet. No faked/stubbed data in the renderer.
Non-goals (this milestone)
- Signed/notarized distributable (
.dmg/.app/installer). Milestone 1 runs locally (electron .); packaging (electron-builder + notarization) is an explicit follow-up. - Touching
dash/,mac/,gnome/, or the existing Tauri popover. - Auto-update, telemetry, cloud sync, multi-window.
3. Architecture
New top-level directory app/ in the monorepo:
app/
├── package.json # own package: electron, vite, react 19, typescript
├── electron/
│ ├── main.ts # BrowserWindow lifecycle, 30s poll timer, IPC handlers
│ ├── cli.ts # resolve codeburn binary path + spawn `codeburn … --json` (argv, no shell)
│ └── preload.ts # contextBridge → window.codeburn.{getOverview,getModels,getPlans,getDevices,…}
├── renderer/
│ ├── index.html
│ ├── main.tsx
│ ├── App.tsx # sidebar nav + window chrome (period/provider), section router (local state)
│ ├── styles/
│ │ └── indigo.css # wireframe CSS ported verbatim (tokens + component classes)
│ ├── sections/
│ │ ├── Overview.tsx Spend.tsx Optimize.tsx Models.tsx Plans.tsx Settings.tsx
│ ├── components/ # shared: Window chrome, Panel, StatCard, CapsuleChart, StackedBars,
│ │ # Sankey, ListRow, Track, SegTabs, etc. (extracted from wireframe)
│ ├── lib/
│ │ ├── ipc.ts # typed wrappers over window.codeburn.*
│ │ └── types.ts # TS types mirroring each CLI JSON payload
│ └── hooks/ # useOverview(period), useModels(), … (poll + cache)
└── vite.config.ts
Process model
- Main process is the only place that touches the CLI or the filesystem. It:
- resolves the
codeburnbinary path (persisted-path file →PATHfallback across brew/nvm/volta/asdf, mirroringmac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift); - spawns
codeburn <subcommand> --json …as plain argv (no shell), with a hard timeout (~45s) and a cap on concurrent spawns; - exposes IPC handlers per data need; re-polls every 30s and on user action (period/provider change, manual refresh);
- handles the "CLI not found / non-zero exit / bad JSON" states → surfaces the wireframe's empty / permission-denied states (§7 "States & trust").
- resolves the
- Renderer runs with
contextIsolation: true,nodeIntegration: false. It receives typed data over IPC and renders. No Node, no direct spawn. - preload exposes a minimal, typed
window.codeburnsurface viacontextBridge.
Binary resolution reuses the menubar approach; if codeburn cannot be found, the app shows a
first-run "point me at your codeburn CLI" state rather than crashing.
4. Data contract (the menubar pattern, generalized)
The app spawns the CLI per section and decodes JSON. Aggregation is 100% CLI-side.
| Section | CLI invocation | Payload | Exists today? |
|---|---|---|---|
| Overview | codeburn status --format menubar-json --period 30days [--provider X] |
MenubarPayload (src/menubar-json.ts) |
✅ |
| Spend (bars, projects) | same MenubarPayload (current.topProjects, history.daily) |
— | ✅ (⚠ per-model-per-day may need history.daily enrichment) |
| Spend (Sankey model→project) | new codeburn spend --format flow-json (or extend menubar payload) |
model×project cost matrix | ⚠ new emitter |
| Optimize (waste) | MenubarPayload.optimize |
findings | ✅ |
| Optimize (reverts/abandoned) | new codeburn yield --json |
shipped-vs-reverted, abandoned sessions | ⚠ new emitter |
| Models | codeburn models --json |
per-model table (calls/input/output/cache/cost/saved) | ✅ |
| Plans | new codeburn plan --json (list plans + usage vs cycle) |
plans + progress | ⚠ new emitter (data exists in src/config.ts; needs JSON shape) |
| Settings/Devices | codeburn devices --json / codeburn share status --json (or the origin/main /api/devices,/api/share/status handlers as reference) |
this device, discovered, paired | ✅ commands exist on main; ⚠ confirm/add --json |
Rule: every ⚠ becomes its own CLI-side task in src/ (TypeScript). The renderer never invents data;
if an emitter isn't ready, its section shows the wireframe's honest loading/empty state, not fake numbers.
renderer/lib/types.ts mirrors each payload as a hand-kept TS type (same convention dash/ uses — no
shared type package). Where a menubar payload type already exists in src/menubar-json.ts, copy it.
5. Styling
Port the wireframe's hand-written CSS verbatim into renderer/styles/indigo.css:
- token block (
--pg / --panel / --blue / --purple / --lav / --cyan / --grad / --grad-bar / …); - component classes (
.win / .sb / .ni / .bar / .panel / .phead / .stat / .plot / .bars / .sbars / .li / .track / .seg / .pop / .btn / .rail / .mini / …).
Each wireframe section (<section> blocks in the HTML) maps near-directly to a React component. Shared
repeating structures (.panel, .stat, capsule chart, stacked bars, the Sankey SVG, list rows, plan
tracks) get extracted into renderer/components/ so the six sections compose them rather than duplicate
markup. This keeps the approved look exactly while giving each section a clean, independently-testable
boundary. Not rebuilding in Tailwind — that would risk drift from the approved design and cost time.
Light theme is a follow-up (wireframe notes a light ladder using the same tokens); ship dark first.
6. Section specs (what each renders)
- Overview — 4 stat cards (Today, Month-to-date, Projected month, Waste found), the daily-spend
capsule chart (30 bars, gradient+glow on peak), "Most expensive sessions" list. Source:
MenubarPayload(current,history.daily,topSessions,optimize.savingsUSD); projected month computed from history. - Spend — lens tabs (Projects/Activity/Tools/MCP/Subagents); stacked daily-by-model bars + "By project" list; the model→project Sankey signature. Sankey needs the new flow emitter.
- Optimize — segment tabs (Waste / Reverts / Abandoned / Fixes) with dollar totals; ranked findings with
evidence (mono) + copy-fix chips. Waste from
optimize; reverts/abandoned from newyield --json. - Models — pricing table with per-model series dots (calls/input/output/cache/cost/saved), unpriced-row
"add alias" affordance, Compare mode. Source
models --json. - Plans — vendor-cycle plan tracks (gradient fill; red past 100%), pace warnings as text. Source new
plan --json. - Settings/Devices — settings rail (General/Providers/Aliases/Plans/Devices/Export/Privacy); Devices pane: this device, discovered-nearby (approve), paired list, "combine usage" toggle. Source devices/share JSON.
- States & trust (cross-cutting) — per-provider empty/permission-denied/loading/provenance states, wired from CLI exit codes + partial payloads.
Window chrome (traffic lights, app mark, sidebar nav with ⌘1–⌘5 / ⌘,, period segmented control, provider popover, ⌘K affordance, footer hints) is shared across all sections.
7. Task decomposition (for delegated implementation)
Fable orchestrates and reviews; implementers are Opus 4.8 (primary) and Codex 5.6-high (alternate).
- T0 — Scaffold (
app/Electron+Vite+React, main/preload/renderer, IPC bridge,indigo.cssport, sharedcomponents/, window chrome + sidebar nav,codeburnpath resolution + one working IPC call). → Opus 4.8. Blocks all section work. - T1 — CLI JSON emitters in
src/(TypeScript):spend … flow-json(Sankey matrix),yield --json,plan --json, confirm/adddevices/share … --json; unit tests intests/. → Codex 5.6-high. Parallel with T0. - T2 Overview, T3 Spend, T4 Optimize, T5 Models, T6 Plans, T7 Settings/Devices — one self-contained component + typed hook each. Split across Opus 4.8 + Codex 5.6-high. Each depends on T0; T3/T4/T6/T7 also depend on their T1 emitter (mock the typed payload until the emitter lands, then swap).
- T8 — Integration pass: 30s polling, refresh-on-action, error/empty/loading states end-to-end,
README +
npm run app:dev. → Opus 4.8.
Every returned unit comes back to Fable for review before it counts as done.
8. Milestones
- M1 (this effort): app runs via
electron .(dev), all six sections rendering real CLI data (⚠ gaps filled by T1 emitters), dark theme, honest states. No packaging. - M2 (follow-up): electron-builder packaging, code-sign + notarize,
codeburn applauncher subcommand (mirroringcodeburn menubar), auto-update, light theme.
9. Testing / verification
- CLI emitters (T1): vitest in
tests/against fixtures (the repo's established pattern). - Renderer: component render tests for each section against typed mock payloads (Vitest + RTL; add to
app/). Typecheck viatsc --noEmit. - End-to-end smoke: launch
electron .against real local session data; verify each section populates, period/provider switching re-polls, and CLI-missing → first-run state. Verified by Fable before sign-off.
10. Risks / open items
- Data-shape drift: hand-kept types between
src/andapp/can diverge (same riskdash/carries). Mitigation: copy menubar types verbatim; T1 emitters ship with typed fixtures the renderer reuses. - Sankey matrix cost: model×project aggregation may be non-trivial in
usage-aggregator.ts; if it slips, Spend ships bars+projects first and the Sankey lands in a fast follow. - Provider-name / project-path privacy: local app shows real project names (unlike shared/sanitized data); fine for a local-only window, but keep the sanitize boundary in mind for any future device-pull views.
- Electron version / security defaults: pin Electron, enable
contextIsolation, disablenodeIntegration, restrictpreloadsurface; no remote content loaded.