# Handoff: assistant-log A self-hosted, single-user log aggregator for CLI AI assistants (Claude Code, opencode, crush, pi, kimi). Polls per-tool collectors on each host, normalizes turns/sessions/tools into a single timeline, exposes search + per-project + per-host views. This bundle ships **Direction 4 — "dense data UI"**: a GitHub/Linear-style information-dense interface, designed for power scanning. Tight rows, monospace data, sparklines, ⌘K palette, keyboard nav. --- ## About the Design Files The files in this bundle are **design references created in HTML** — a clickable prototype showing the intended look, behavior, and information architecture. They are **not production code to copy directly**. The task is to **recreate these HTML designs in the target codebase's existing environment** — using its established patterns, component primitives, routing, and data layer. If no environment exists yet, the recommended stack is **React + TypeScript + Vite**, with state via Zustand or React Query, and CSS-modules or vanilla-CSS-with-tokens (the prototype uses raw CSS custom properties and that approach maps cleanly). The prototype uses inline JSX via Babel-standalone for portability — that is **not** how it should ship. --- ## Fidelity **High-fidelity.** Colors, typography, spacing, layout, density, copy, keyboard map, and interactions are all final. Pixel-match the prototype. The prototype's mock data (`proto-data.jsx`) is illustrative — replace with real data from the backend, but **preserve the shapes** described in the [Data Shapes](#data-shapes) section below. --- ## Files | Path | Purpose | |---|---| | `Prototype.html` | Entry point — sets up React, defines `App`, wires routing, keyboard nav, theme/density tweaks | | `prototype.css` | All design tokens (`:root` and `[data-theme="dark"]`), every UI primitive's styles | | `proto-shell.jsx` | `TopBar` (brand, search trigger, tab nav) and `RouterCtx` | | `proto-atoms.jsx` | Shared primitives: `Tag`, `ToolDot`, `Spark` (inline-SVG sparkline), `StatusDot`, `Sub` (filter chip bar), `EmptyState` | | `proto-data.jsx` | Mock data — sessions, turns, projects, hosts, collectors, tool colors | | `proto-home.jsx` | Recent sessions screen (route `home`) | | `proto-search.jsx` | Search results with FTS `` highlighting (route `search`) | | `proto-session.jsx` | Single-session view: turn-list aside + linear transcript (route `session`) | | `proto-pages.jsx` | Stats, Health, Projects index, Project view, Settings — all in one file | | `proto-palette.jsx` | ⌘K command palette | | `tweaks-panel.jsx` | In-design tweak panel (developer/preview tool — **do not ship**) | | `original-spec.md` | The original direction spec we built from | --- ## Top-level structure ``` ┌──────────────────────────────────────────────────────────────────┐ │ TopBar ← dark, monospace, breadcrumb + search + tabs ├──────────────────────────────────────────────────────────────────┤ │ Sub-bar (per route) ← filter chips, range, "since" controls ├──────────────────────────────────────────────────────────────────┤ │ Body (flex: 1) ← the route's screen, scrolls ├──────────────────────────────────────────────────────────────────┤ │ Footer strip (some routes only) ← e.g. Health backfill progress └──────────────────────────────────────────────────────────────────┘ Fixed-position elements: ⌘K palette overlay, Tweaks panel, hint chip ``` The shell never scrolls — only `.body` does. Top bar, sub-bar, and (where present) footer strip are `flex: none`. --- ## Routes | Route name | Description | URL pattern (recommended) | |---|---|---| | `home` | Recent sessions across all projects/hosts | `/` | | `search` | Search results with FTS highlights | `/search?q=…` | | `session` | One session: turn list + transcript | `/session/:id` | | `projects` | All projects, ranked by recent activity | `/projects` | | `project` | One project (cwd) view | `/project/:cwd` | | `stats` | Aggregate stats (per-tool, daily, hour-of-day, top-cwd, host split) | `/stats` | | `health` | Collector ingestion health table | `/health` | | `settings` | Settings (Sources / Display / Auth / Backup / Export / Tags / Saved searches) | `/settings/:section?` | The prototype implements routing with a single React state object `{name, ...args}` plus a `RouterCtx`. **Replace with the codebase's existing router** (React Router, TanStack Router, etc.). --- ## Design Tokens All colors flow through CSS custom properties. Light mode is the `:root` block in `prototype.css`; dark mode is the `[data-theme="dark"]` block on ``. ### Surfaces | Token | Light | Dark | Use | |---|---|---|---| | `--paper` | `#fdfcf8` | `#15140f` | Main body background | | `--paper-2` | `#f3f0e7` | `#211f17` | Hover row, active tweak nav item | | `--paper-3` | `#fbfaf2` | `#1a1813` | Sub-bar bg, table head bg, footer strip bg | | `--paper-4` | `#ffffff` | `#1d1b14` | Card body, alternating-row "white" stripe | ### Ink (text) | Token | Light | Dark | Use | |---|---|---|---| | `--ink` | `#1c1a17` | `#e8e3d4` | Primary text | | `--ink-2` | `#4a453d` | `#b8b2a0` | Secondary text, USER role label | | `--ink-3` | `#7a7468` | `#807a6b` | Muted text (column heads, meta) | | `--ink-4` | `#b3ad9f` | `#4a4538` | Dim/placeholder, USER turn left-bar | ### Rules | Token | Light | Dark | |---|---|---| | `--rule` | `#d8d3c4` | `#2c2920` | | `--rule-2` | `#e6e1d2` | `#24221a` | ### Top bar (always dark, even in light mode) | Token | Light | Dark | |---|---|---| | `--topbar-bg` | `#1c1a17` | `#0d0c08` | | `--topbar-fg` | `#fdfcf8` | `#e8e3d4` | | `--topbar-input-bg` | `rgba(255,255,255,0.06)` | `rgba(255,255,255,0.04)` | | `--topbar-input-bd` | `rgba(255,255,255,0.15)` | `rgba(255,255,255,0.10)` | ### Accent — locked to OKLCH hue 120 (lime-green) | Token | Light | Dark | Use | |---|---|---|---| | `--accent` | `oklch(0.78 0.18 120)` | `oklch(0.78 0.16 120)` | Active tab underline, accent chip bg, selected row left-border, FTS mark bg, sparkline highlight, ASSISTANT turn left-bar | | `--accent-soft` | `oklch(0.94 0.05 120)` | `oklch(0.30 0.08 120)` | Selected row bg, palette active item bg, settings nav active bg | | `--accent-ink` | `oklch(0.30 0.10 120)` | `oklch(0.86 0.16 120)` | Text **on** `--accent-soft`; ASSISTANT role label | | `--accent-on` | `oklch(0.20 0.08 120)` | `oklch(0.18 0.06 120)` | Text **on** `--accent` (chip text, FTS mark text) | The four accent vars are also written **inline on ``** by the App effect — this is so the "Accent off" tweak can mute them at runtime. In production you can drop the inline writes if you don't need a runtime accent toggle. ### Status | Token | Light | Dark | Use | |---|---|---|---| | `--ok` / `--ok-bg` | `#3b6e3b` / `#d6efd6` | `#6fb46f` / `#1f3327` | Healthy collector, "ok" tag | | `--warn` / `--warn-bg` | `#c89a3a` / `#fdebcc` | `#d4a648` / `#3a2e15` | Warning state, schema drift | | `--err` / `--err-bg` | `#b04a2a` / `#f5dccf` | `#d97052` / `#3a1f15` | Error, stale collector, last-error log | ### Tag-specific | Token | Light | Dark | Use | |---|---|---|---| | `--tag-bg` | `#ebe7d8` | `#2a2820` | Default neutral tag bg | | `--tag-host-bg` / `--tag-host-fg` | `#d9e6dd` / `#2f5a3f` | `#1f3327` / `#8fc09e` | Host tag (`laptop`, `workpc`) | ### Turn surfaces (Session view) | Token | Light | Dark | |---|---|---| | `--turn-user` | `#f5f0e0` | `#1f1d15` | | `--turn-asst` | `#ffffff` | `#15140f` | | `--turn-tool` | `#f8f5e9` | `#211f17` | ### Tool identity colors (constant across themes — these are brand-like per-tool) | Tool | Color | |---|---| | `claude-code` | `#c96442` (terracotta) | | `opencode` | `#3b6e3b` (forest) | | `crush` | `#7a4ea8` (purple) | | `pi` | `#b8902a` (ochre) | | `kimi` | `#2a6e9c` (steel-blue) | ### Typography ```css --mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace; --sans: 'Inter', system-ui, sans-serif; ``` **Type scale** — body text 12 px (`body`), data rows 11.5 px, table heads 10 px UPPERCASE 0.05 em tracking, transcript body 13 px, palette input 13 px. Line-height 1.4 throughout, 1.55 in transcript prose. **Mono is used for all data**: timestamps, paths, token counts, IDs, breadcrumbs, host names. Sans is for prose only (transcript bodies, settings copy, empty-state messages). ### Sizing - Compact row height: 22–24 px (3 px vertical padding) - Comfortable row height: 28–32 px (4 px vertical padding) - **Compact is the default density** — set on the root via the class `density-compact` - Card border-radius `4px`, palette `6px`, tags `3px`, status dots `99px` - Sparkline: 60–140 × 12–18 px inline SVG ``, `stroke-width: 1.2` (1.4 when `.accent`) --- ## Screens ### 01 · Home (Recent) - **Sub-bar**: filter chips (`since: 30d`, `tool: any`, `host: any`, `+ filter`) - **Table head**: `STARTED · TOOL · HOST · SUMMARY · TURNS · TOK · CWD` - **Rows**: one per session. Cursor row gets `--accent-soft` bg + 2 px `--accent` left border (the row's `padding-left` shifts from 14 to 12 to compensate). - **Empty filter result**: `EmptyState` with glyph `∅` and copy "no sessions match these filters" ### 02 · Search results - **Sub-bar**: shows the active query as `""` in 12 px mono-bold + result count + "save as ★ saved search" action - **Rows**: hit type (`turn` | `session` | `path`), session ref, snippet with `` wrapping query terms (lime bg, dark accent-on text) - **Empty state**: glyph `⌕` and copy "no results — try a broader query" ### 03 · Session view ``` ┌────────────────────────────────────────────────────────┐ │ Sub-bar: breadcrumb (~/cwd short-summary… tool/host/branch tags) ├────────────┬───────────────────────────────────────────┤ │ Aside 240px│ Transcript (flex: 1, scrolls) │ │ (turn list)│ │ │ scrolls │ USER ← left-bar = --ink-4 │ │ │ ASSISTANT← left-bar = --accent (lime) │ │ │ TOOL ← no bar │ │ │ │ │ │ Each turn: 3 px left-border, role label │ │ │ in mono-bold uppercase, meta line in │ │ │ 10.5 px muted, body in 13 px prose │ │ │ (or 11.5 px mono for tool turns) │ └────────────┴───────────────────────────────────────────┘ ``` Aside row layout: `24px # · 14px role-glyph · 1fr preview · 36px tok` mono. Selected row gets `--paper-2` bg + 2 px accent left border. Tool turns are visually compressed — the "Show tool calls" tweak hides them entirely (`.hide-tool-calls .turn.tool { display: none }`). ### 04 · Projects index Table head: `PROJECT (cwd) · SESSIONS · TURNS · LAST ACTIVE · HOSTS · TOOLS USED`. Hosts column shows `Tag.host` chips, tools column shows colored `ToolDot`s. ### 05 · Project view Per-cwd view. If the cwd has no sessions, render the `EmptyState` with copy "no sessions in this project yet". Otherwise, sessions table identical to Home but scoped. ### 06 · Stats A 2-column grid (`.stats-grid`) of cards, with one full-width card (`.full`): 1. **Per-tool · last 30d** (full): table — tool dot, name, sessions, turns, tokens, sparkline, % share 2. **Daily turns · last 60d** (full): stacked-bar SVG chart, claude-code (accent) on bottom, opencode (forest) middle, crush (purple) top 3. **Activity heatmap · 12 weeks**: GitHub-style heatmap (84 cells, accent fill, opacity scaled by activity) 4. **Top cwd · 30d**: horizontal bar list — cwd in mono, accent bar, count right-aligned 5. **Hour-of-day distribution**: 24-bin bar chart 6. **Host split**: laptop vs workpc — mono % bars, host-specific colors ### 07 · Health (ingestion) - **Sub-bar**: `collectors` label + status counters as tags (e.g., "● 7 ok", "● 1 warn") - **Table head**: ` · HOST · TOOL · SOURCE PATH · LAG · OUT · LAST POLL · EVENTS` - **Rows**: status dot (ok/warn/stale), host, tool tag with `ToolDot`, path mono-muted truncate, lag value (red `--err` if stale, yellow `--warn` if warn, muted otherwise), out-of-band events count (yellow if > 0), last-poll ago, lifetime events count - **Footer strip**: backfill progress (`~3m`, mono path, % bar, % text) + last-error log line in `--err` color ### 08 · Settings Two-column grid (`180px sidebar / 1fr content`). Sidebar items: `sources · display · auth · backup · export · tags · saved searches`. Active item: `--accent-soft` bg + `--accent-ink` color + 600 weight. The prototype only ships **Sources** (per-host-per-tool config table with status, path, poll interval, events count) and **Display** (mirrors the in-design tweaks) at full fidelity. Auth/Backup/Export/Tags/Saved searches are placeholder cards — flesh out per real backend. --- ## Interactions & Behavior ### Keyboard map | Key | Where | Action | |---|---|---| | `⌘K` / `Ctrl+K` | global | open command palette | | `Esc` | palette open | close palette | | `↑` / `↓` | palette open | move palette cursor | | `↵` | palette open | activate palette item | | `j` | list routes (home, projects, project, search, health) | move row cursor down | | `k` | list routes | move row cursor up | | `↵` | home | open session at cursor | | `g h` | global | go to Home | | `g p` | global | go to Projects | | `g s` | global | go to Stats | | `g i` | global | go to Health (i = ingestion) | The `g` leader has an 800 ms timeout. Implement it as a ref + setTimeout pair (see `App.onKeyDown` in `Prototype.html`). ### Filter chips Each chip is `` showing `key: value`. Click → popover opens at chip's position with possible values. `+ filter` at the end opens a popover listing dimensions to add. `×` on a chip removes it. The popover is absolute-positioned, `z-index: 40`. Closes on outside click / Esc. **Dimensions to support**: `tool`, `host`, `since` (1d/7d/30d/90d/all), `cwd`, `model`, `has-tool-calls`. (The prototype only wires the first three; add the rest to match the spec.) ### ⌘K command palette - Modal scrim (`var(--scrim)`), 80 px from top, 620 × 480 max - Single input at top (mono, 13 px); list below with cursor highlighting - Items have a `kind` prefix (uppercase mono, 70 px wide column): `JUMP`, `PROJECT`, `SESSION`, `SEARCH` - When the query is non-empty and doesn't match any item, append a synthetic `SEARCH for ""` row that goes to `/search?q=…` - Keyboard: `↑↓` cursor, `↵` activate, `Esc` close - Footer: keyboard hints in mono (`↑↓ navigate · ↵ open · esc close`) ### Active row cursor In list routes, `j`/`k` move a cursor index; the cursor row gets `cursor` class → `--accent-soft` bg + `--accent` left border. On `↵` (home only in the prototype — extend to other list routes in production), navigate to the cursored item. ### Tweaks panel The `` from `tweaks-panel.jsx` is a **design-time tool only**. It is the in-design controls for theme, density, accent, etc. **Strip it from production**. The actual settings live under the Settings → Display route. The runtime knobs that should remain user-visible in production: - **Theme** (light/dark) — persist to localStorage, default to `prefers-color-scheme` - **Density** (compact/comfortable) — persist - **Show tool calls** (per-session toggle, also persisted globally) The "Accent on/off" and per-component tweaks were exploration controls; do not surface them. --- ## State Management ### Top-level App state (in `Prototype.html`) ```js { route: { name, ...args }, // home | search | session | … paletteOpen: boolean, cursor: number, // active row index in list routes query: string, // search query (mirrored in URL) tweaks: { density: 'compact' | 'comfortable', showToolCalls: boolean, accent: boolean, theme: 'light' | 'dark', }, } ``` Replace with the codebase's normal store (Zustand, Redux, signals, etc.). ### Server-state queries (recommended React Query keys) ``` ['sessions', { since, tool, host, cwd, search? }] → home / project / search ['session', id] → session view ['projects', { since }] → projects index ['stats', { range }] → stats screen ['health'] → collectors + last-poll ['settings', 'sources'] → per-host/tool config ``` All queries should support reactive invalidation when the user changes filters in the sub-bar. --- ## Data Shapes These are the shapes the prototype consumes (see `proto-data.jsx`). The real backend should serve compatible JSON. ```ts type Tool = 'claude-code' | 'opencode' | 'crush' | 'pi' | 'kimi'; type Host = 'laptop' | 'workpc'; type Role = 'user' | 'assistant' | 'tool'; interface Session { id: string; // e.g. 'inflection-3' tool: Tool; host: Host; cwd: string; // e.g. '~/code/tt-bundle' branch?: string; // git branch when session started model: string; // e.g. 'claude-opus-4-7' started: string; // ISO timestamp ended: string | null; summary: string; // first user turn, truncated turns: number; tokensIn: number; tokensOut:number; hasError: boolean; } interface Turn { i: number; // 1-indexed within session role: Role; body: string; // markdown for user/assistant; mono content for tool tokensIn?: number; // assistant turns only tokensOut?: number; toolName?: string; // tool turns: 'read', 'grep', 'bash', 'edit', 'write', … toolKind?: 'call' | 'result'; } interface Project { cwd: string; sessions: number; turns: number; lastActive: string; hosts: Host[]; tools: Tool[]; } interface Collector { host: Host; tool: Tool; sourcePath: string; // path the collector polls status: 'ok' | 'warn' | 'stale'; lagSeconds: number; // how far behind real-time outOfBand: number; // events received that didn't parse lastPoll: string; // relative, e.g. '7s ago' events: number; // lifetime lastError?: { ts: string; msg: string }; } ``` --- ## Theming The prototype supports light + dark. `` switches the entire token set. Implement: 1. Read `prefers-color-scheme` on first load 2. Persist user choice to `localStorage['theme']` 3. Apply `data-theme` attribute on `` (NOT `` — the `[data-theme]` selector targets `html`) 4. Listen for OS theme changes when no user override is set The accent vars (`--accent`, `--accent-soft`, `--accent-ink`, `--accent-on`) are written inline on `` by the App effect (so an "accent off" toggle could mute them at runtime). In production you can drop the inline writes — the CSS rules suffice. --- ## Production checklist - [ ] Replace `proto-data.jsx` mock with real API/queries - [ ] Replace inline-React + Babel-standalone setup with the codebase's normal build (Vite/Next/etc.) - [ ] Strip `tweaks-panel.jsx` and the `useTweaks` plumbing - [ ] Wire real router (`/`, `/search?q=…`, `/session/:id`, `/project/:cwd`, `/stats`, `/health`, `/settings/:section?`) - [ ] Implement remaining filter dimensions (`cwd`, `model`, `has-tool-calls`) — popover already supports them, just unstub - [ ] Add real saved-searches store (currently a stub link) - [ ] Build out Settings → Auth, Backup, Export, Tags, Saved searches (placeholder cards in prototype) - [ ] Persist `theme`, `density`, `showToolCalls` to localStorage; sync `theme` with `prefers-color-scheme` on first load - [ ] Wire `↵` on cursored row across all list routes (Home is the only one wired in the prototype) - [ ] Real FTS in the search route — the prototype does substring matching against in-memory data - [ ] Confirm the per-tool color palette matches your branding (terracotta/forest/purple/ochre/steel-blue) — these are constant across themes - [ ] Decide whether the top bar stays "always dark" in light mode (the prototype keeps it dark for visual continuity); easy to flip via `--topbar-bg` / `--topbar-fg` --- ## Notes for the implementer - **Density first**: "compact" is the default. Don't loosen padding "for accessibility" — this UI is for terminal-dwellers who want max info per screen. Comfortable mode exists for users who explicitly opt in. - **Mono for data, sans for prose**. Never mix. If you find yourself rendering a number, path, ID, or timestamp in sans, that's a bug. - **Accent is a spotlight, not a coat of paint**. Use it for: active tab, primary chip, sparkline highlight, FTS hit, ASSISTANT turn left-bar, cursor row, "stale" indicators in Health. That's the full set. - **No tooltips for column heads**. The headers are uppercase 10 px mono; if a column needs explanation, the column itself is wrong. - **Tool turns are evidence, not conversation**. Render them in mono, smaller, dimmer, and let users hide them entirely.