diff --git a/.specs/features/web-usage-redesign/spec.md b/.specs/features/web-usage-redesign/spec.md new file mode 100644 index 0000000..1359014 --- /dev/null +++ b/.specs/features/web-usage-redesign/spec.md @@ -0,0 +1,122 @@ +# Web usage redesign + +## Goal + +Replace the current `/usage` page of `codedeck ui` with the approved "mock D" design: a black + blue analytics +dashboard in the Cloudflare/Vercel language, with masked numbers, charts and working controls. Introduce the +CodeDeck brand (edge-on deck logo, palette) as a shared web module so later pages can reuse it. + +The approved visual reference was a static mock ("mock D") built from real local usage data. It is kept out of +the repository because that data names private repositories. Where the mock and this spec disagree, this spec +wins. The known mock defects are listed under R9 and must not ship. + +## Out of scope + +- Restyling Home, Setup and Review pages (a follow-up feature reuses the brand module). +- A sessions endpoint and the "Recent sessions" widget from the mock (dropped: the usage API has no sessions). +- Fixing the repository grouping that reports worktree/cwd basenames (`e174`, `.claude`, `dev`) as repositories. +- Changes to the daemon, the usage query, the store, or `/api/usage` response shape. +- New runtime dependencies. The page stays a self-contained server-rendered string with inline CSS, SVG and script. + +## Brand (source of truth for tokens) + +- Colors: `--bg #000000`, `--surface #0a0a0a`, `--surface-hover #111111`, `--surface-raised #161616`, + `--border #1f1f1f`, `--border-strong #2e2e2e`, `--grid #1a1a1a`, `--text #ededed`, `--text-muted #a1a1a1`, + `--text-faint #6b6b6b`, `--blue #0070f3`, `--blue-chart #3b82f6`, categorical ramp + `#3b82f6 #93c5fd #1d4ed8 #60a5fa #bfdbfe #1e3a8a`, other `#737373 #404040`, success `#10b981`, error `#e5484d`. +- Type: sans `"Geist", "Inter", ui-sans-serif, system-ui, sans-serif`; mono `"Geist Mono", ui-monospace, "JetBrains Mono", monospace`. +- Logo "edge-on deck" (viewBox 0 0 64 64): + cards `m13 25 34-8 6 4-34 8zM13 32l34-8 6 4-34 8zM13 39l34-8 6 4-34 8zM13 46l34-8 6 4-34 8z` fill `#000`, stroke `#ededed`; + top card `m13 25 34-8 6 4-34 8z` fill and stroke `#0070f3`. Stroke width scales up as size drops + (about 2.2 at 64px, 5 at 20px, 6 at 16px). Wordmark: lowercase `codedeck`, sans 600. + +## Requirements + +### R1. Page loads data in a real browser + +- R1.1 WHEN the usage page script runs in a browser THEN it SHALL call `fetch`, `setInterval` and `clearInterval` + with the global object as receiver, so a native `fetch` never throws "Illegal invocation". +- R1.2 WHEN `/usage` is opened in headless Chromium against a running `codedeck ui` THEN the console SHALL log no + uncaught error and the page SHALL show the totals of the default range. + +### R2. Brand and chrome + +- R2.1 The page SHALL render a 52px top bar with the edge-on deck logo, the `codedeck` wordmark, the page name and + quiet nav links to every page registered in the route table, with Usage marked active. +- R2.2 The page SHALL declare the edge-on deck logo as its favicon (inline data URI), replacing `data:,`. +- R2.3 The palette and type tokens above SHALL live in one shared web module that exports the CSS custom + properties, the logo SVG (sized) and the top bar renderer, and the usage page SHALL consume them from there. +- R2.4 The page SHALL render exactly one range picker. + +### R3. Number masks + +- R3.1 Token and count values ≥ 1,000,000,000 SHALL render as `1.19B`, ≥ 1,000,000 as `16.8M`, ≥ 10,000 as `842K`; + smaller counts SHALL render with thousands separators (`2,427`). +- R3.2 Currency SHALL render as `$1,865.42` (two decimals, thousands separators); axis ticks SHALL use at most two decimals. +- R3.3 Percentages SHALL render with one decimal (`47.8%`) and name the base they are a share of ("of spend", "of sessions"). +- R3.4 Every masked value SHALL carry its unmasked value in a `title` attribute. +- R3.5 WHEN `costComplete` is false THEN the cost SHALL render as `≥ $X` next to a neutral pill "`N` unpriced" using + `sessionsWithoutCost`; a bare trailing `?` SHALL NOT appear anywhere on the page. + +### R4. Range, filters and refresh + +- R4.1 The range picker SHALL offer Today, Last 3 days, Last 7 days, Last 30 days, All time and Custom; Custom SHALL + reveal since/until date inputs. Changing it SHALL re-query `/api/usage` with the same parameters the page accepts today. +- R4.2 "Add filter" SHALL open a menu to add a repository, model or agent filter; an applied filter SHALL render as a + removable chip, and adding or removing a chip SHALL re-query with that filter. +- R4.3 WHILE Live refresh is on the page SHALL poll at the configured interval; WHEN it is toggled off THEN polling SHALL stop. +- R4.4 A background poll SHALL NOT replace rendered content with a loading state; a loading indicator SHALL appear only + for the first load and for user-initiated changes. +- R4.5 IF a query fails THEN the page SHALL keep the last good data visible and show the error in a dismissible banner. +- R4.6 WHEN `/usage` is opened with `period`, `repo`, `model`, `agent`, `since`, `until` or `by` in its query string THEN + the page SHALL start with those values. +- R4.7 WHEN the range, a filter or the breakdown tab changes THEN the page SHALL update its URL query with + `history.replaceState` so "Copy link" copies a URL that reopens the same view. + +### R5. KPI row + +- R5.1 The page SHALL show four KPI cards: Spend, Sessions, Total tokens, Cache hit rate (cached / (input + cached)), + each with a sparkline from `byDay` bleeding to the card's bottom edge. +- R5.2 WHEN the range is 3d, 7d or 30d THEN each KPI SHALL show a delta arrow and percentage versus the immediately + preceding window of equal length, labeled "vs previous 7d" (or 3d/30d), from a second `/api/usage` query with + explicit since/until. +- R5.3 WHEN the range is Today, All time or Custom, or the previous window has zero for that metric, THEN the delta SHALL be hidden. + +### R6. Charts + +- R6.1 "Spend over time" SHALL plot `byDay` as a line with area fill and dashed grid, with a legend switch between + Spend, Tokens and Sessions, and a crosshair tooltip showing date and masked value. +- R6.2 "Spend by model" SHALL be a donut of `byModel.costUsd` with the top 6 models plus one "Other" slice, its center + showing the masked total spend, and a legend with model id (mono), value and share of spend. +- R6.3 "Spend by repository" SHALL list `byRepository` by cost descending with a thin blue bar scaled to the top row, + scrolling inside its card. +- R6.4 "Sessions by outcome" SHALL show completed, failed, active and other (sessions minus the three) as one + segmented bar with counts and share of sessions; failed SHALL use the error color. +- R6.5 "Harness" SHALL show, per `byAgent` key, share of spend and share of sessions, both labeled, each on one line at 1440px. +- R6.6 "Origin" SHALL show worker vs orchestrator share of sessions and of spend, both labeled. +- R6.7 Every chart segment or point SHALL have a hover tooltip; SVG text SHALL never be stretched + (no `preserveAspectRatio="none"` on any SVG containing text) and charts SHALL re-render on resize. +- R6.8 WHEN a breakdown or series is empty THEN its card SHALL show "No usage in this range" instead of an empty chart. + +### R7. Breakdown table + +- R7.1 Tabs Day, Repository, Model, Agent, Run, Origin SHALL switch the table rows to the selected breakdown, and only + the selected breakdown SHALL be visible. +- R7.2 Clicking a column header SHALL sort by that column, toggling ascending/descending. +- R7.3 The table SHALL show the top 10 rows and a "Show all N" toggle when there are more. +- R7.4 A search input SHALL filter the visible rows by label; pressing `/` outside an input SHALL focus it. +- R7.5 Columns SHALL be Name, Sessions, Spend, Tokens, Share of spend (thin bar left-aligned in its own column). + +### R8. Layout + +- R8.1 At 1440px wide the layout SHALL follow the mock order: header, filter bar, KPI row (4), spend over time, + model + repository row, outcome + harness + origin row, breakdown. +- R8.2 At 390px wide the page SHALL have no page-level horizontal scroll (`document.documentElement.scrollWidth <= 390`), + cards SHALL stack in one column, and axis labels SHALL render at 10px or larger. +- R8.3 Cards in the same row SHALL size to content without leaving a region taller than 120px empty. + +### R9. Mock defects that must not ship + +- R9.1 No native control SHALL render with a light/white background (profile or range selects included). +- R9.2 The donut center SHALL show total spend as currency, never a token/count abbreviation. +- R9.3 No duplicated control (range picker, refresh) SHALL appear. diff --git a/.specs/features/web-usage-redesign/tasks.md b/.specs/features/web-usage-redesign/tasks.md new file mode 100644 index 0000000..67e0571 --- /dev/null +++ b/.specs/features/web-usage-redesign/tasks.md @@ -0,0 +1,76 @@ +# Web usage redesign: tasks + +Source of truth: `spec.md` in this folder. The visual reference mock is local only (see `spec.md`). + +## Design notes + +- Keep the current architecture: `renderUsagePage` returns a self-contained HTML string; browser behavior is shipped + by serializing named functions with `Function.prototype.toString` into the inline script. Every function the + browser calls must be in that list and must not reference module imports or closures outside itself. +- Chart and formatting code are pure functions that return strings or plain data (for example `formatCompact`, + `formatUsd`, `formatShare`, `previousWindow`, `computeDelta`, `donutSlices`, `renderDonutSvg`, + `renderLineChartSvg`, `renderSparklineSvg`). DOM code only inserts their output. This keeps them unit-testable + under vitest's node environment without a DOM library. +- `src/web/brand.ts` owns tokens, logo and top bar. It is server-side only (it renders strings into the page). +- The nav in the top bar comes from the route table: `createUiRoutes` already computes `pages`; pass them to the + usage page through `UsageRoutesOptions.page`. +- Default range stays `today` (current behavior and existing tests). + +## Coverage matrix + +| Layer | Test type | Location | Command | +| --- | --- | --- | --- | +| Brand module `src/web/brand.ts` | unit | `tests/web-brand.test.ts` | `npx vitest run tests/web-brand.test.ts` | +| Usage pure logic (formatters, deltas, previous window, donut slices, SVG builders, controller) | unit | `tests/usage-page.test.ts` | `npx vitest run tests/usage-page.test.ts` | +| Usage inline script (serialized functions run, receiver binding) | unit via `node:vm` | `tests/usage-page.test.ts` | same as above | +| Route wiring (nav passed to usage page) | unit | `tests/usage-web.test.ts`, `tests/web-cli.test.ts` | `npx vitest run tests/usage-web.test.ts tests/web-cli.test.ts` | +| Rendered page in a real browser (R1.2, R8, R9) | manual e2e gate | none committed; evidence is screenshots + console log | T3 commands | + +## T1. Brand module + +- Requirement: R2.2, R2.3. +- Files: new `src/web/brand.ts`, new `tests/web-brand.test.ts`. +- Produces: `BRAND_CSS` (custom properties + base resets for black surfaces, native controls styled dark per R9.1), + `renderLogoSvg(size: 16 | 20 | 64)`, `LOGO_FAVICON_HREF` (data URI), `renderTopBar({ pages, activePath, title })` + with escaped labels. +- Tests: tokens present with exact hex values; logo contains both path data strings and the blue top card; stroke + width grows as size drops; favicon is a `data:image/svg+xml` URI; top bar lists every page, marks the active one + with `aria-current="page"`, escapes HTML in labels. +- Gate: `npx vitest run tests/web-brand.test.ts` passes; `npx tsc --noEmit` clean. + +## T2. Usage page redesign + +- Requirement: R1.1, R2.1, R2.4, R3.*, R4.*, R5.*, R6.*, R7.*, R8.1, R9.*. +- Files: `src/web/usage-page.ts`, `src/web/usage-routes.ts`, `src/cli/commands/ui.ts`, `tests/usage-page.test.ts`, + `tests/usage-web.test.ts`, `tests/web-cli.test.ts` (only if the wiring assertion belongs there). +- Tests (ship with the code): + - R1.1: in `node:vm`, run the page script with a `fetch`/`setInterval`/`clearInterval` that throw + `TypeError("Illegal invocation")` when `this` is not the context global; start the page with a minimal fake DOM + or call the environment wiring the script uses, and assert no throw and one fetch. + - R3.*: table-driven cases for `formatCompact` (999, 10_000, 842_310, 16_756_717, 1_193_095_059), `formatUsd` + (1865.421706 → `$1,865.42`), share formatting, incomplete-cost rendering (`≥` + unpriced pill, no bare `?`). + - R5.2/R5.3: `previousWindow` for 3d/7d/30d returns an equal-length window ending where the current begins; + `computeDelta` hides on zero base and for today/all/custom. + - R6.2/R9.2: `donutSlices` returns top 6 + Other summing to the total; donut center renders currency. + - R6.7: no SVG builder output that contains ``). + 2. `chromium --headless=new --disable-gpu --hide-scrollbars --window-size=1440,2600 --virtual-time-budget=5000 --screenshot=/usage-1440.png "/usage?t=&period=7d"`. + 3. Same at `--window-size=390,4200` → `usage-390.png`. + 4. `chromium --headless=new --disable-gpu --enable-logging=stderr --v=0 --virtual-time-budget=5000 --dump-dom "" 2>&1 | grep -i -e uncaught -e "illegal invocation"` → empty. + 5. Stop the server. +- Tests: none (manual gate per the matrix). +- Gate: screenshots show real data with masked numbers and no white controls; no console errors; no page-level horizontal scroll at 390px. diff --git a/src/cli/commands/ui.ts b/src/cli/commands/ui.ts index c01c285..c59ca95 100644 --- a/src/cli/commands/ui.ts +++ b/src/cli/commands/ui.ts @@ -20,13 +20,17 @@ export interface UiCommandDependencies { export function createUiRoutes(dependencies: Pick = {}): WebRoute[] { const reviewRoutes = createReviewRoutes().filter((route) => route.path !== "/"); const setupRoutes = createSetupRoutes(dependencies.setup); - const usageRoutes = createUsageRoutes(dependencies.usage ?? { fetchUsageQuery }).map((route) => + const usageOptions = dependencies.usage ?? { fetchUsageQuery }; + const labeledUsageRoutes = createUsageRoutes(usageOptions).map((route) => route.path === "/usage" ? { ...route, label: "Usage" } : route, ); - const routes = [...reviewRoutes, ...setupRoutes, ...usageRoutes]; - const pages = routes.flatMap((route) => + const routes = [...reviewRoutes, ...setupRoutes]; + const pages = [...routes, ...labeledUsageRoutes].flatMap((route) => route.kind === "page" && route.label ? [{ label: route.label, path: route.path }] : [], ); + const usageRoutes = createUsageRoutes({ ...usageOptions, pages }).map((route) => + route.path === "/usage" ? { ...route, label: "Usage" } : route, + ); const home: WebRoute = { path: "/", kind: "page", @@ -35,7 +39,7 @@ export function createUiRoutes(dependencies: Pick