A docs/design_handoff_assistant_log/Prototype.html => docs/design_handoff_assistant_log/Prototype.html +208 -0
@@ 0,0 1,208 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <title>assistant-log</title>
+ <link rel="stylesheet" href="prototype.css?v=3" />
+
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
+ <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
+</head>
+<body>
+ <div id="root"></div>
+
+ <script type="text/babel" src="tweaks-panel.jsx"></script>
+ <script type="text/babel" src="proto-data.jsx"></script>
+ <script type="text/babel" src="proto-atoms.jsx"></script>
+ <script type="text/babel" src="proto-shell.jsx"></script>
+ <script type="text/babel" src="proto-home.jsx"></script>
+ <script type="text/babel" src="proto-search.jsx"></script>
+ <script type="text/babel" src="proto-session.jsx"></script>
+ <script type="text/babel" src="proto-pages.jsx"></script>
+ <script type="text/babel" src="proto-palette.jsx"></script>
+
+ <script type="text/babel">
+ const ACCENT_HUE = 120; // locked
+ const TWEAKS = /*EDITMODE-BEGIN*/{
+ "density": "compact",
+ "showToolCalls": true,
+ "accent": true,
+ "theme": "dark",
+ "viewAsOwner": "self"
+ }/*EDITMODE-END*/;
+
+ function App() {
+ const [t, setTweak] = useTweaks(TWEAKS);
+ const [route, setRoute] = React.useState({ name: 'home' });
+ const [paletteOpen, setPaletteOpen] = React.useState(false);
+ const [cursor, setCursor] = React.useState(0);
+ const [query, setQuery] = React.useState('');
+ const rootRef = React.useRef(null);
+
+ // Theme → data-theme on <html>
+ React.useEffect(() => {
+ document.documentElement.dataset.theme = t.theme === 'dark' ? 'dark' : 'light';
+ }, [t.theme]);
+
+ // Apply tweaks → CSS vars (accent on/off, theme-aware)
+ React.useEffect(() => {
+ const r = document.documentElement;
+ const h = ACCENT_HUE;
+ const dark = t.theme === 'dark';
+ if (t.accent) {
+ if (dark) {
+ r.style.setProperty('--accent', `oklch(0.78 0.16 ${h})`);
+ r.style.setProperty('--accent-soft', `oklch(0.30 0.08 ${h})`);
+ r.style.setProperty('--accent-ink', `oklch(0.86 0.16 ${h})`);
+ r.style.setProperty('--accent-on', `oklch(0.18 0.06 ${h})`);
+ } else {
+ r.style.setProperty('--accent', `oklch(0.78 0.18 ${h})`);
+ r.style.setProperty('--accent-soft', `oklch(0.94 0.05 ${h})`);
+ r.style.setProperty('--accent-ink', `oklch(0.30 0.10 ${h})`);
+ r.style.setProperty('--accent-on', `oklch(0.20 0.08 ${h})`);
+ }
+ } else {
+ if (dark) {
+ r.style.setProperty('--accent', '#807a6b');
+ r.style.setProperty('--accent-soft', '#24221a');
+ r.style.setProperty('--accent-ink', '#e8e3d4');
+ r.style.setProperty('--accent-on', '#0d0c08');
+ } else {
+ r.style.setProperty('--accent', '#4a453d');
+ r.style.setProperty('--accent-soft', '#e6e1d2');
+ r.style.setProperty('--accent-ink', '#1c1a17');
+ r.style.setProperty('--accent-on', '#fdfcf8');
+ }
+ }
+ }, [t.accent, t.theme]);
+
+ const go = React.useCallback((r) => {
+ setRoute(r);
+ if (r.name === 'search' && r.q != null) setQuery(r.q);
+ setCursor(0);
+ }, []);
+
+ const openPalette = React.useCallback(() => setPaletteOpen(true), []);
+
+ // Keyboard
+ const gPressed = React.useRef(false);
+ const gTimer = React.useRef(null);
+
+ const onKeyDown = (e) => {
+ // ⌘K / Ctrl+K
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
+ e.preventDefault(); setPaletteOpen(true); return;
+ }
+ if (paletteOpen) return;
+ // 'g' leader
+ if (e.key === 'g' && !e.metaKey && !e.ctrlKey) {
+ gPressed.current = true;
+ clearTimeout(gTimer.current);
+ gTimer.current = setTimeout(() => { gPressed.current = false; }, 800);
+ return;
+ }
+ if (gPressed.current) {
+ gPressed.current = false; clearTimeout(gTimer.current);
+ if (e.key === 'h') { go({ name: 'home' }); e.preventDefault(); return; }
+ if (e.key === 's') { go({ name: 'stats' }); e.preventDefault(); return; }
+ if (e.key === 'p') { go({ name: 'projects' }); e.preventDefault(); return; }
+ if (e.key === 'i') { go({ name: 'health' }); e.preventDefault(); return; }
+ }
+ const listRoutes = ['home', 'projects', 'project', 'search', 'health'];
+ if (!listRoutes.includes(route.name)) return;
+ if (e.key === 'j') { setCursor(c => c + 1); e.preventDefault(); }
+ else if (e.key === 'k') { setCursor(c => Math.max(0, c - 1)); e.preventDefault(); }
+ else if (e.key === 'Enter') {
+ if (route.name === 'home') {
+ const s = SESSIONS[Math.min(cursor, SESSIONS.length - 1)];
+ if (s) go({ name: 'session', id: s.id });
+ }
+ e.preventDefault();
+ }
+ };
+
+ React.useEffect(() => {
+ const el = rootRef.current;
+ if (el) el.focus();
+ }, []);
+
+ const screenLabel = (() => {
+ switch (route.name) {
+ case 'home': return '01 Recent';
+ case 'search': return '02 Search results';
+ case 'session': return '03 Session';
+ case 'projects': return '04 Projects index';
+ case 'project': return '05 Project view';
+ case 'stats': return '06 Stats';
+ case 'health': return '07 Ingestion health';
+ case 'settings': return '08 Settings';
+ default: return route.name;
+ }
+ })();
+
+ let screen = null;
+ switch (route.name) {
+ case 'home': screen = <HomeScreen />; break;
+ case 'search': screen = <SearchScreen />; break;
+ case 'session': screen = <SessionScreen />; break;
+ case 'projects': screen = <ProjectsScreen />; break;
+ case 'project': screen = <ProjectScreen />; break;
+ case 'stats': screen = <StatsScreen />; break;
+ case 'health': screen = <HealthScreen />; break;
+ case 'settings': screen = <SettingsScreen />; break;
+ default: screen = <HomeScreen />;
+ }
+
+ const cls = [
+ 'app',
+ t.density === 'compact' ? 'density-compact' : '',
+ t.showToolCalls ? '' : 'hide-tool-calls',
+ ].filter(Boolean).join(' ');
+
+ const ownerScope = t.viewAsOwner && t.viewAsOwner !== 'self' ? t.viewAsOwner : null;
+
+ return (
+ <RouterCtx.Provider value={{ route: { ...route, ownerScope }, go, openPalette, query, setQuery, cursor, setCursor }}>
+ <div ref={rootRef} tabIndex={0} onKeyDown={onKeyDown}
+ className={cls} data-screen-label={screenLabel}
+ style={{ outline: 'none' }}>
+ <TopBar />
+ {screen}
+ {paletteOpen && <Palette onClose={() => setPaletteOpen(false)} />}
+ <div className="hint">⌘K · j/k · ↵ · g h/p/s/i</div>
+ </div>
+
+ <TweaksPanel title="Tweaks">
+ <TweakSection label="Display" />
+ <TweakRadio label="Density" value={t.density}
+ options={['compact', 'comfortable']}
+ onChange={v => setTweak('density', v)} />
+ <TweakToggle label="Show tool calls" value={t.showToolCalls}
+ onChange={v => setTweak('showToolCalls', v)} />
+ <TweakToggle label="Accent color" value={t.accent}
+ onChange={v => setTweak('accent', v)} />
+ <TweakRadio label="Theme" value={t.theme}
+ options={['light', 'dark']}
+ onChange={v => setTweak('theme', v)} />
+ <TweakSection label="Navigation" />
+ <TweakButton label="Open ⌘K palette"
+ onClick={() => setPaletteOpen(true)} />
+ <TweakRadio label="Jump to"
+ value={route.name}
+ options={['home', 'projects', 'stats', 'health', 'settings']}
+ onChange={v => go({ name: v })} />
+ <TweakSection label={`Admin · view as (${ME.user} is admin)`} />
+ <TweakSelect label="?owner=" value={t.viewAsOwner}
+ options={['self', 'rin', 'noor', '*']}
+ onChange={v => setTweak('viewAsOwner', v)} />
+ </TweaksPanel>
+ </RouterCtx.Provider>
+ );
+ }
+
+ ReactDOM.createRoot(document.getElementById('root')).render(<App />);
+ </script>
+</body>
+</html>
A docs/design_handoff_assistant_log/README.md => docs/design_handoff_assistant_log/README.md +438 -0
@@ 0,0 1,438 @@
+# 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 `<mark>` 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 `<html>`.
+
+### 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 `<html>`** 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 `<polyline>`, `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 `"<query>"` in 12 px mono-bold + result count + "save as ★ saved search" action
+- **Rows**: hit type (`turn` | `session` | `path`), session ref, snippet with `<mark class="fts">` 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 `<Tag class="click">` 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 "<query>"` 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 `<TweaksPanel>` 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. `<html data-theme="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 `<html>` (NOT `<body>` — 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 `<html>` 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.
A docs/design_handoff_assistant_log/original-spec.md => docs/design_handoff_assistant_log/original-spec.md +95 -0
@@ 0,0 1,95 @@
+# Direction 4 · Dense Data UI — `assistant-log`
+
+## The product
+
+A self-hosted, single-user log aggregator for CLI AI assistants (Claude Code, opencode, crush, pi, kimi). Server runs on a home server (`phoebe`) behind Tailscale; collectors run as systemd user units on each machine and POST normalized turns to SQLite + FTS5. Server-rendered HTML UI, no SPA. Single user. Full transcripts of every session across every tool across every machine, searchable in one place.
+
+## Aesthetic + personality
+
+GitHub/Linear-style dense data UI for power scanning. Built on the assumption the user lives in terminals all day and wants information density, not chrome.
+
+- **Top bar**: dark (`#1c1a17`), monospace, contains breadcrumb (`assistant-log / scarlet`), an always-visible search input that responds to ⌘K, and tab nav (Recent / Projects / Stats / Health / Settings).
+- **Body**: warm off-white (`#fdfcf8`), Inter for prose, JetBrains Mono for all data (timestamps, paths, tokens, IDs).
+- **Accent**: terracotta `#c96442`, used sparingly — selected tab, primary chip, sparkline highlight, FTS hit highlight, "stale" warnings.
+- **Type scale**: 11–13 px body. Tight (1.4) line-height. Uppercase 10 px column headers with 0.05 em tracking and `var(--ink-3)` color.
+- **Rows**: 28–32 px tall (compact mode 22–24), 1 px `--rule-2` dividers, hover `#f3f0e7`.
+- **Tags**: small mono pills — neutral `#ebe7d8`, host-green `#d9e6dd`, accent terracotta. Per-tool a 8 px square color dot (`claude-code` terracotta, `opencode` green, `crush` purple, `pi` ochre, `kimi` blue).
+- **Sparklines**: 60–140 × 12–18 px inline SVG polylines, stroke 1.2, accent-colored when row is "the user's main thing".
+
+## Layout pattern (every page)
+
+1. Dark mono top bar (search + nav).
+2. Light sub-bar with active filters as removable chips, plus a contextual stat strip on the right (e.g. turns/day sparkline + total).
+3. Tight grid header row (uppercase mono labels).
+4. Scrollable rows with `display: grid; grid-template-columns: …`, mono in time/path/numeric columns, sans in prose columns.
+5. Optional footer strip for global state (backfill progress, last error).
+
+## Screens
+
+1. **Home (Recent)** — Reverse-chronological feed of sessions across all tools/hosts. Columns: when · tool tag · host tag · session question + cwd · turns · tok · turns/h sparkline. Top sub-bar carries `tool: any · host: any · since: 30d` chips and a global turns/day sparkline.
+
+2. **Search results** — Results are a flat table of *turns* (not sessions), one match per row, with the snippet truncated and FTS hits wrapped in a terracotta `<mark>`. Filters in chips above. Save-search action lives next to the chip row.
+
+3. **Session view** — Two-column. Left aside (240 px) is a turn list with `# · role glyph · preview · token count`, current turn highlighted with a left accent border. Right pane is the linear transcript: each turn is a row separated by 1 px rules, role label is mono uppercase color-coded (`USER` neutral, `ASSISTANT` accent, `TOOL` muted), tool calls render as monospace cards with a faint background tint. Sticky header carries `cwd · title · tool tag · host tag · model · turn count · tokens · open dot`.
+
+4. **Project view (cwd)** — Header shows `~/code/` breadcrumb + `tt-bundle` in mono, count chips, and a sparkline of activity. Saved-search chips below. Then the same dense session table filtered to that cwd.
+
+5. **Stats** — Top sub-bar has range pills (7d / 30d / 90d / all) and group-by pills (tool / host / project / model). Content is a grid of cards: per-tool stat strip (turns / tokens / cost / sparkline / share %), a stacked turns/day chart (60 days), a 12-week activity heatmap, and a top-cwd bar list.
+
+6. **Ingestion health** — Status pills in sub-bar (`● 7 ok · ● 1 warn`). Table of every (host, tool, source) row with: status dot · host · tool · source path · lag · outbox depth · last-OK · events 24h. Stale rows get accent-colored lag, non-empty outboxes get accent-colored count. Footer strip: backfill progress bar + last parser error inline.
+
+7. **Settings** — Sidebar nav (Sources / Display / Auth / Backup / Export / Tags / Saved searches). Sources page is the dense source table with status dot, tool tag, path, poll interval, event count, last-OK, edit. Display has compact tag-pair toggles (density, tool calls, accent).
+
+## Interaction model
+
+- Always-on global search (⌘K focuses the top input from any page).
+- Filter chips are removable and additive; "+ add filter" opens a small popover.
+- All paths are clickable and open the corresponding Project view.
+- Each row in any list is clickable and opens its session/turn permalink.
+- Keyboard: `j/k` move row, `↵` open, `g h/s/p/i` jump to home / stats / projects / health.
+
+## Mock data shape
+
+**Sample sessions** (across `claude-code`, `opencode`, `crush`, `pi`, `kimi` on `laptop` and `workpc`):
+
+| time | tool | host | cwd | first prompt | turns | tok | model |
+|--------|-------------|--------|----------------------------------|-----------------------------------------------------------------------------|-------|-------|------------------|
+| 14:22 | claude-code | laptop | ~/code/tt-bundle | lockfile design for the tt bundle — should we hash inputs or outputs? | 84 | 12.4k | claude-opus-4-7 |
+| 11:08 | opencode | workpc | ~/work/atelier/migrations | why does the v3 migration drop the partial index on (org, ts) | 12 | 2.1k | gpt-5-mini |
+| 10:52 | claude-code | workpc | ~/work/scarlet-svc | is there a way to make this retry loop tolerate 429s without a sleep | 41 | 7.7k | claude-opus-4-7 |
+| 09:14 | crush | laptop | ~/code/dotfiles | rewrite this zsh prompt to show ssh host only when on a remote | 6 | 0.9k | gemini-2-flash |
+| Yest | pi | laptop | ~/notes | help me draft a one-pager for the offsite session on incident response | 19 | 4.0k | inflection-3 |
+| Yest | claude-code | laptop | ~/code/tt-bundle | when do we need to re-resolve transitive deps after a lock bump | 28 | 5.3k | claude-opus-4-7 |
+| Yest | kimi | workpc | ~/work/atelier | translate these comments to english but keep the tone | 4 | 0.7k | kimi-k2 |
+
+**Ingestion health** — 8 collector rows (one per host × tool):
+
+| host | tool | source | lag | outbox | last ok | status |
+|--------|-------------|---------------------------------|-----|--------|---------|--------|
+| laptop | claude-code | ~/.claude/projects | 4s | 0 | now | ok |
+| laptop | opencode | ~/.local/share/opencode | 12s | 0 | 12s | ok |
+| laptop | crush | ~/.cache/crush | 28s | 0 | 28s | ok |
+| laptop | pi | ~/.config/pi/history | 1m | 0 | 1m | ok |
+| workpc | claude-code | ~/.claude/projects | 7s | 0 | now | ok |
+| workpc | opencode | ~/.local/share/opencode | 18s | 0 | 18s | ok |
+| workpc | kimi | ~/.kimi-cli/history.jsonl | 2m | 127 | 2m | warn |
+| workpc | crush | ~/.cache/crush | 8m | 0 | 8m | stale |
+
+**Per-tool 30-day rollups:**
+
+- claude-code — 1820 turns / 482k tok / $0 (Max sub, cost unknowable)
+- opencode — 211 turns / 18k tok / $4.12
+- crush — 64 turns / 2k tok / $0.71
+- pi — 32 turns / 7k tok / $0
+- kimi — 11 turns / 1k tok / $0
+
+## Tech assumptions for the build
+
+- React 18.3.1 + Babel standalone for the prototype.
+- Inter + JetBrains Mono via Google Fonts.
+- All data is hard-coded mock data. No real backend.
+- Single HTML file with split JSX modules per screen for maintainability.
+
+## Suggested first prompt for the new chat
+
+> Build a hi-fi clickable prototype of a personal CLI AI assistant log aggregator UI in the **Dense Data UI** style described in the attached spec. One HTML file. Real navigation between screens (top-bar tabs, row clicks, ⌘K search focus, filter chips). Cover Home / Search / Session / Project / Stats / Health / Settings. Use the mock data shape and visual system specified.
A docs/design_handoff_assistant_log/proto-atoms.jsx => docs/design_handoff_assistant_log/proto-atoms.jsx +126 -0
@@ 0,0 1,126 @@
+// Atoms reused across screens
+
+const Spark = ({ w = 60, h = 16, accent = false, seed = 1 }) => {
+ const pts = [];
+ let v = h * 0.5;
+ for (let i = 0; i < 14; i++) {
+ v += (Math.sin(seed * i * 1.7) + Math.cos(seed * i * 0.9)) * 2.5;
+ v = Math.max(2, Math.min(h - 2, v));
+ pts.push(`${(i / 13) * (typeof w === 'number' ? w : 100)},${v.toFixed(1)}`);
+ }
+ return (
+ <svg width={w} height={h} viewBox={`0 0 ${typeof w === 'number' ? w : 100} ${h}`}
+ preserveAspectRatio="none" style={{ display: 'block' }}>
+ <polyline points={pts.join(' ')} className={'spark' + (accent ? ' accent' : '')} />
+ </svg>
+ );
+};
+
+const ToolDot = ({ tool }) => (
+ <span className="tooldot" style={{ background: TOOL_COLORS[tool] || '#888' }} />
+);
+
+const ToolTag = ({ tool }) => (
+ <span className="tag"><ToolDot tool={tool} /> {tool}</span>
+);
+
+const HostTag = ({ host }) => <span className="tag host">{host}</span>;
+
+const StatusDot = ({ status }) => <span className={'statusdot ' + status} />;
+
+// Filter chip popover
+const FilterPopover = ({ dim, value, onPick, onClose, anchor }) => {
+ const ref = React.useRef(null);
+ React.useEffect(() => {
+ const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
+ setTimeout(() => document.addEventListener('mousedown', onDoc), 0);
+ return () => document.removeEventListener('mousedown', onDoc);
+ }, [onClose]);
+ const opts = FILTER_DIMS[dim] || [];
+ const top = (anchor?.top || 30) + 4;
+ const left = anchor?.left || 14;
+ return (
+ <div ref={ref} className="popover" style={{ top, left }}>
+ <div className="ph">{dim}</div>
+ {opts.map(o => (
+ <div key={o} className="pi" onClick={() => { onPick(o); onClose(); }}>
+ <span style={{
+ width: 10, height: 10, borderRadius: 99,
+ border: '1px solid var(--ink-3)',
+ background: o === value ? 'var(--accent)' : 'transparent',
+ display: 'inline-block', flex: 'none'
+ }} />
+ <span className="mono">{o}</span>
+ </div>
+ ))}
+ </div>
+ );
+};
+
+// Removable filter chip with click-through to popover
+const FilterChip = ({ dim, value, onChange, onRemove }) => {
+ const [open, setOpen] = React.useState(false);
+ const [anchor, setAnchor] = React.useState(null);
+ const ref = React.useRef(null);
+ return (
+ <>
+ <span ref={ref} className={'tag click' + (value !== 'any' ? ' accent' : '')}
+ onClick={(e) => {
+ const r = ref.current.getBoundingClientRect();
+ const parent = ref.current.closest('.app').getBoundingClientRect();
+ setAnchor({ top: r.bottom - parent.top, left: r.left - parent.left });
+ setOpen(true);
+ }}>
+ {dim}: {value}
+ {onRemove && (
+ <span style={{ opacity: 0.6, marginLeft: 2 }}
+ onClick={(e) => { e.stopPropagation(); onRemove(); }}>×</span>
+ )}
+ </span>
+ {open && <FilterPopover dim={dim} value={value} anchor={anchor}
+ onPick={onChange} onClose={() => setOpen(false)} />}
+ </>
+ );
+};
+
+// Add-filter dashed chip + popover for which dim
+const AddFilterChip = ({ availableDims, onAdd }) => {
+ const [open, setOpen] = React.useState(false);
+ const [anchor, setAnchor] = React.useState(null);
+ const ref = React.useRef(null);
+ return (
+ <>
+ <span ref={ref} className="tag dashed"
+ onClick={() => {
+ const r = ref.current.getBoundingClientRect();
+ const parent = ref.current.closest('.app').getBoundingClientRect();
+ setAnchor({ top: r.bottom - parent.top, left: r.left - parent.left });
+ setOpen(true);
+ }}>+ filter</span>
+ {open && (
+ <div className="popover" style={{ top: anchor.top + 4, left: anchor.left }}
+ onMouseLeave={() => setOpen(false)}>
+ <div className="ph">add filter</div>
+ {availableDims.map(d => (
+ <div key={d} className="pi" onClick={() => { onAdd(d); setOpen(false); }}>
+ <span className="mono">{d}</span>
+ </div>
+ ))}
+ </div>
+ )}
+ </>
+ );
+};
+
+const Empty = ({ glyph = '∅', title, hint }) => (
+ <div className="empty">
+ <div className="glyph">{glyph}</div>
+ <div style={{ fontSize: 13, color: 'var(--ink-2)' }}>{title}</div>
+ {hint && <div style={{ marginTop: 4 }} className="muted mono">{hint}</div>}
+ </div>
+);
+
+Object.assign(window, {
+ Spark, ToolDot, ToolTag, HostTag, StatusDot,
+ FilterPopover, FilterChip, AddFilterChip, Empty,
+});
A docs/design_handoff_assistant_log/proto-data.jsx => docs/design_handoff_assistant_log/proto-data.jsx +127 -0
@@ 0,0 1,127 @@
+// Mock data for the prototype
+
+const TOOLS = ['claude-code', 'opencode', 'crush', 'pi', 'kimi'];
+const HOSTS = ['laptop', 'workpc'];
+
+// Authenticated identity. Server derives `owner` from this on every ingest;
+// the wire format has no `owner` field. Admins can switch with ?owner=
+const ME = { user: 'bigbes', isAdmin: true, via: 'forward-auth' };
+
+// Other owners (visible only when current user is admin and ?owner=* is set)
+const OWNERS = [
+ { user: 'bigbes', sessions: 121, ktok: 482, last: 'Today 14:22', via: 'forward-auth' },
+ { user: 'rin', sessions: 34, ktok: 61, last: 'Today 09:51', via: 'oidc-bearer' },
+ { user: 'noor', sessions: 18, ktok: 22, last: 'Apr 23 18:14', via: 'oidc-bearer' },
+];
+const TOOL_COLORS = {
+ 'claude-code': '#c96442',
+ 'opencode': '#3b6e3b',
+ 'crush': '#7a4ea8',
+ 'pi': '#b8902a',
+ 'kimi': '#2a6e9c',
+};
+
+const SESSIONS = [
+ { id: 'a1', tool: 'claude-code', host: 'laptop', cwd: '~/code/tt-bundle', q: 'lockfile design for the tt bundle — should we hash inputs or outputs?', turns: 84, tok: '12.4k', when: 'Today 14:22', day: 0, model: 'claude-opus-4-7' },
+ { id: 'a2', tool: 'opencode', host: 'workpc', cwd: '~/work/atelier/migrations', q: 'why does the v3 migration drop the partial index on (org, ts)', turns: 12, tok: '2.1k', when: 'Today 11:08', day: 0, model: 'gpt-5-mini' },
+ { id: 'a3', tool: 'claude-code', host: 'workpc', cwd: '~/work/scarlet-svc', q: 'is there a way to make this retry loop tolerate 429s without a sleep', turns: 41, tok: '7.7k', when: 'Today 10:52', day: 0, model: 'claude-opus-4-7' },
+ { id: 'a4', tool: 'crush', host: 'laptop', cwd: '~/code/dotfiles', q: 'rewrite this zsh prompt to show ssh host only when on a remote', turns: 6, tok: '0.9k', when: 'Today 09:14', day: 0, model: 'gemini-2-flash' },
+ { id: 'a5', tool: 'pi', host: 'laptop', cwd: '~/notes', q: 'help me draft a one-pager for the offsite session on incident response', turns: 19, tok: '4.0k', when: 'Yest. 22:01', day: 1, model: 'inflection-3' },
+ { id: 'a6', tool: 'claude-code', host: 'laptop', cwd: '~/code/tt-bundle', q: 'when do we need to re-resolve transitive deps after a lock bump', turns: 28, tok: '5.3k', when: 'Yest. 18:40', day: 1, model: 'claude-opus-4-7' },
+ { id: 'a7', tool: 'kimi', host: 'workpc', cwd: '~/work/atelier', q: 'translate these comments to english but keep the tone', turns: 4, tok: '0.7k', when: 'Yest. 15:12', day: 1, model: 'kimi-k2' },
+ { id: 'a8', tool: 'claude-code', host: 'workpc', cwd: '~/work/scarlet-svc', q: 'add a structured logging adapter for the worker pool', turns: 22, tok: '4.8k', when: 'Yest. 11:30', day: 1, model: 'claude-opus-4-7' },
+ { id: 'a9', tool: 'opencode', host: 'workpc', cwd: '~/work/atelier/migrations', q: 'rollback strategy for v3 if we hit prod with the wrong index', turns: 8, tok: '1.4k', when: 'Apr 23 16:42', day: 2, model: 'gpt-5-mini' },
+ { id: 'a10', tool: 'claude-code', host: 'laptop', cwd: '~/code/tt-bundle', q: 'sketch a content-addressed cache for the build outputs', turns: 51, tok: '9.1k', when: 'Apr 23 09:18', day: 2, model: 'claude-opus-4-7' },
+ { id: 'a11', tool: 'pi', host: 'laptop', cwd: '~/notes', q: 'expand my standup notes into a proper status email', turns: 3, tok: '0.5k', when: 'Apr 22 19:00', day: 3, model: 'inflection-3' },
+ { id: 'a12', tool: 'crush', host: 'laptop', cwd: '~/code/dotfiles', q: 'fix this nvim mapping that breaks in tmux', turns: 4, tok: '0.6k', when: 'Apr 22 13:22', day: 3, model: 'gemini-2-flash' },
+ { id: 'a13', tool: 'claude-code', host: 'workpc', cwd: '~/work/scarlet-svc', q: 'redo the connection pool to support graceful drain', turns: 38, tok: '6.9k', when: 'Apr 21 14:00', day: 4, model: 'claude-opus-4-7' },
+ { id: 'a14', tool: 'claude-code', host: 'laptop', cwd: '~/code/tt-bundle', q: 'why does build.zig double-resolve when I touch only README', turns: 17, tok: '3.4k', when: 'Apr 21 10:11', day: 4, model: 'claude-opus-4-7' },
+ { id: 'a15', tool: 'opencode', host: 'workpc', cwd: '~/work/atelier', q: 'is there a cleaner way to express this state machine', turns: 11, tok: '1.8k', when: 'Apr 20 17:42', day: 5, model: 'gpt-5-mini' },
+];
+
+// Project rollups
+const PROJECTS = [
+ { cwd: '~/code/tt-bundle', sessions: 41, tok: '213k', last: 'Today 14:22', topTool: 'claude-code', tok30: 213 },
+ { cwd: '~/work/scarlet-svc', sessions: 28, tok: '142k', last: 'Today 10:52', topTool: 'claude-code', tok30: 142 },
+ { cwd: '~/work/atelier/migrations', sessions: 14, tok: '38k', last: 'Today 11:08', topTool: 'opencode', tok30: 38 },
+ { cwd: '~/code/dotfiles', sessions: 7, tok: '4.1k', last: 'Today 09:14', topTool: 'crush', tok30: 4.1 },
+ { cwd: '~/notes', sessions: 5, tok: '11k', last: 'Yest 22:01', topTool: 'pi', tok30: 11 },
+ { cwd: '~/work/atelier', sessions: 4, tok: '2.0k', last: 'Yest 15:12', topTool: 'kimi', tok30: 2.0 },
+];
+
+// Per-tool rollups (30d)
+const TOOL_ROLLUPS = [
+ { tool: 'claude-code', turns: 1820, ktok: 482, cost: 0, share: 0.92 },
+ { tool: 'opencode', turns: 211, ktok: 18, cost: 4.12, share: 0.18 },
+ { tool: 'crush', turns: 64, ktok: 2, cost: 0.71, share: 0.08 },
+ { tool: 'pi', turns: 32, ktok: 7, cost: 0, share: 0.05 },
+ { tool: 'kimi', turns: 11, ktok: 1, cost: 0, share: 0.02 },
+];
+
+// Health collectors
+const COLLECTORS = [
+ { host: 'laptop', tool: 'claude-code', src: '~/.claude/projects', poll: '30s', lag: '4s', out: 0, last: 'now', ev: 412, status: 'ok' },
+ { host: 'laptop', tool: 'opencode', src: '~/.local/share/opencode', poll: '30s', lag: '12s', out: 0, last: '12s', ev: 38, status: 'ok' },
+ { host: 'laptop', tool: 'crush', src: '~/.cache/crush', poll: '60s', lag: '28s', out: 0, last: '28s', ev: 9, status: 'ok' },
+ { host: 'laptop', tool: 'pi', src: '~/.config/pi/history', poll: '60s', lag: '1m', out: 0, last: '1m', ev: 7, status: 'ok' },
+ { host: 'workpc', tool: 'claude-code', src: '~/.claude/projects', poll: '30s', lag: '7s', out: 0, last: 'now', ev: 228, status: 'ok' },
+ { host: 'workpc', tool: 'opencode', src: '~/.local/share/opencode', poll: '30s', lag: '18s', out: 0, last: '18s', ev: 54, status: 'ok' },
+ { host: 'workpc', tool: 'kimi', src: '~/.kimi-cli/history.jsonl', poll: '60s', lag: '2m', out: 127, last: '2m', ev: 3, status: 'warn' },
+ { host: 'workpc', tool: 'crush', src: '~/.cache/crush', poll: '60s', lag: '8m', out: 0, last: '8m', ev: 0, status: 'stale' },
+];
+
+// Long sample turns for session view
+const TURNS = [
+ { role: 'user', text: 'lockfile design for the tt bundle — should we hash inputs or outputs?' },
+ { role: 'assistant', text: "Both, but for different reasons. Input hashes give you the 'should we rebuild' signal; output hashes give you the 'is this artifact equivalent' signal. The input hash lives in the lockfile next to each module entry; the output hash is computed post-build and stored in the artifact index.", model: 'claude-opus-4-7', tIn: 412, tOut: 980 },
+ { role: 'tool', text: 'Read("~/code/tt-bundle/build.zig")', kind: 'read' },
+ { role: 'tool', text: '→ 14.2 KB, 318 lines', kind: 'result' },
+ { role: 'assistant', text: "Looking at your current build.zig, the input set is already explicit — module list, compiler flags, target triple. So input-hashing is straightforward: sha256 over the canonical-JSON encoding of (module_list, flags, target).", model: 'claude-opus-4-7', tIn: 530, tOut: 1240 },
+ { role: 'user', text: 'ok so what about transitive deps that come from a vendored dir' },
+ { role: 'assistant', text: "Transitive deps need a separate manifest pass. The lockfile records the *resolved* tree, not the declared one — so when you bump a direct dep you re-resolve and rewrite the transitive section. The cache key is sha256(direct + transitive sorted).", model: 'claude-opus-4-7', tIn: 88, tOut: 760 },
+ { role: 'tool', text: 'Grep("vendor/", glob: "*.zig")', kind: 'grep' },
+ { role: 'tool', text: '→ 47 matches across 12 files', kind: 'result' },
+ { role: 'assistant', text: "Three of those are header-only style — they don't have their own deps. The other nine each declare 1–4 transitives. So your lock should expand to ~30 entries on first resolve.", model: 'claude-opus-4-7', tIn: 220, tOut: 540 },
+ { role: 'user', text: 'how would the cache key look if I bumped one transitive but nothing else changed' },
+ { role: 'assistant', text: "sha256 over the sorted list, so any change ripples up — but only the affected output keys flip. Direct deps that didn't move keep their input hash; their *output* hash also doesn't move because output depends on the resolved-tree slice they actually consume.", model: 'claude-opus-4-7', tIn: 110, tOut: 880 },
+];
+
+// Saved searches per project (for tt-bundle)
+const SAVED_SEARCHES = ['lockfile', 'retry/backoff', 'vendored deps', 'build.zig', 'transitive'];
+
+// Filter dimensions for popover
+const FILTER_DIMS = {
+ tool: ['any', 'claude-code', 'opencode', 'crush', 'pi', 'kimi'],
+ host: ['any', 'laptop', 'workpc'],
+ since: ['24h', '7d', '30d', '90d', 'all'],
+ model: ['any', 'claude-opus-4-7', 'gpt-5-mini', 'gemini-2-flash', 'inflection-3', 'kimi-k2'],
+};
+
+// Auth config (mirrors internal/config Auth substruct)
+const AUTH_CONFIG = {
+ bind: '127.0.0.1:8401',
+ allowedUsers: ['bigbes', 'rin', 'noor'],
+ admins: ['bigbes'],
+ forwardAuth: { enabled: true, userHeader: 'Remote-User' },
+ oidc: { enabled: true,
+ issuer: 'https://auth.example.com',
+ audience: 'lethe',
+ usernameClaim: 'preferred_username',
+ jwksLastFetch: '4m ago' },
+};
+
+// Recent auth events for the Auth settings panel
+const AUTH_EVENTS = [
+ { t: '14:22:14', user: 'bigbes', via: 'forward-auth', path: 'GET /api/v1/sessions', status: 200 },
+ { t: '14:18:02', user: 'rin', via: 'oidc-bearer', path: 'POST /api/v1/ingest', status: 200 },
+ { t: '14:11:48', user: 'rin', via: 'oidc-bearer', path: 'GET /api/v1/sessions/.../...', status: 200 },
+ { t: '13:54:30', user: '—', via: 'oidc-bearer', path: 'POST /api/v1/ingest', status: 401, note: 'expired jwt' },
+ { t: '13:40:19', user: 'noor', via: 'oidc-bearer', path: 'GET /api/v1/sessions?owner=*', status: 403, note: 'admin-only param' },
+ { t: '13:02:11', user: 'bigbes', via: 'forward-auth', path: 'GET /api/v1/sessions?owner=rin', status: 200 },
+];
+
+Object.assign(window, {
+ TOOLS, HOSTS, TOOL_COLORS, SESSIONS, PROJECTS, TOOL_ROLLUPS, COLLECTORS,
+ TURNS, SAVED_SEARCHES, FILTER_DIMS,
+ ME, OWNERS, AUTH_CONFIG, AUTH_EVENTS,
+});
A docs/design_handoff_assistant_log/proto-home.jsx => docs/design_handoff_assistant_log/proto-home.jsx +76 -0
@@ 0,0 1,76 @@
+// Home (Recent) — feed of sessions across tools/hosts
+
+const HomeScreen = () => {
+ const { go, cursor, setCursor } = useRouter();
+ const [filters, setFilters] = React.useState({
+ tool: 'any', host: 'any', since: '30d',
+ });
+ const [activeFilters, setActiveFilters] = React.useState(['tool', 'host', 'since']);
+
+ const removeFilter = (k) =>
+ setActiveFilters(fs => fs.filter(f => f !== k));
+ const addFilter = (k) =>
+ setActiveFilters(fs => fs.includes(k) ? fs : [...fs, k]);
+ const setF = (k, v) =>
+ setFilters(f => ({ ...f, [k]: v }));
+
+ const rows = SESSIONS.filter(s => {
+ if (filters.tool !== 'any' && activeFilters.includes('tool') && s.tool !== filters.tool) return false;
+ if (filters.host !== 'any' && activeFilters.includes('host') && s.host !== filters.host) return false;
+ return true;
+ });
+
+ const cols = '120px 110px 70px 1fr 50px 60px 90px';
+
+ return (
+ <>
+ <div className="subbar">
+ {activeFilters.map(k => (
+ <FilterChip key={k} dim={k} value={filters[k]}
+ onChange={(v) => setF(k, v)}
+ onRemove={() => removeFilter(k)} />
+ ))}
+ <AddFilterChip
+ availableDims={Object.keys(FILTER_DIMS).filter(d => !activeFilters.includes(d))}
+ onAdd={addFilter} />
+ <span style={{ flex: 1 }} />
+ <span className="muted mono" style={{ fontSize: 10.5 }}>turns/day</span>
+ <Spark w={140} h={18} accent />
+ <span className="mono">2,138</span>
+ <span className="muted mono" style={{ fontSize: 10.5 }}>· 30d</span>
+ </div>
+
+ <div className="thead" style={{ gridTemplateColumns: cols }}>
+ <span>when</span><span>tool</span><span>host</span><span>session · cwd</span>
+ <span className="right">turns</span><span className="right">tok</span><span className="right">turns/h</span>
+ </div>
+ <div className="body">
+ {rows.length === 0 ? (
+ <Empty glyph="∅" title="No sessions match these filters."
+ hint="remove a chip or expand `since`" />
+ ) : rows.map((s, i) => (
+ <div key={s.id}
+ className={'row' + (cursor === i ? ' cursor' : '')}
+ style={{ gridTemplateColumns: cols }}
+ onClick={() => go({ name: 'session', id: s.id })}>
+ <span className="mono muted">{s.when}</span>
+ <span><ToolTag tool={s.tool} /></span>
+ <span><HostTag host={s.host} /></span>
+ <span className="flex">
+ <span className="truncate flex-1">{s.q}</span>
+ <span className="mono muted truncate" style={{ flex: 'none', maxWidth: 240 }}
+ onClick={(e) => { e.stopPropagation(); go({ name: 'project', cwd: s.cwd }); }}>
+ {s.cwd}
+ </span>
+ </span>
+ <span className="right mono">{s.turns}</span>
+ <span className="right mono muted">{s.tok}</span>
+ <span className="right"><Spark w={70} h={12} seed={i + 1} /></span>
+ </div>
+ ))}
+ </div>
+ </>
+ );
+};
+
+Object.assign(window, { HomeScreen });
A docs/design_handoff_assistant_log/proto-pages.jsx => docs/design_handoff_assistant_log/proto-pages.jsx +552 -0
@@ 0,0 1,552 @@
+// Projects index, single project, stats, health, settings
+
+const ProjectsScreen = () => {
+ const { go } = useRouter();
+ const cols = '1fr 90px 90px 110px 110px 90px';
+ return (
+ <>
+ <div className="subbar">
+ <span className="mono muted">{PROJECTS.length} projects · ranked by recent activity</span>
+ <span style={{ flex: 1 }} />
+ <FilterChip dim="since" value="30d" onChange={() => {}} />
+ </div>
+ <div className="thead" style={{ gridTemplateColumns: cols }}>
+ <span>cwd</span><span className="right">sessions</span><span className="right">tok</span>
+ <span className="right">last</span><span>top tool</span><span className="right">activity</span>
+ </div>
+ <div className="body">
+ {PROJECTS.map((p, i) => (
+ <div key={p.cwd} className="row" style={{ gridTemplateColumns: cols }}
+ onClick={() => go({ name: 'project', cwd: p.cwd })}>
+ <span className="mono">{p.cwd}</span>
+ <span className="right mono">{p.sessions}</span>
+ <span className="right mono muted">{p.tok}</span>
+ <span className="right mono muted">{p.last}</span>
+ <span><ToolTag tool={p.topTool} /></span>
+ <span className="right"><Spark w={70} h={12} seed={i + 3} accent={i === 0} /></span>
+ </div>
+ ))}
+ </div>
+ </>
+ );
+};
+
+const ProjectScreen = () => {
+ const { go, route } = useRouter();
+ const cwd = route.cwd || '~/code/tt-bundle';
+ const meta = PROJECTS.find(p => p.cwd === cwd) || { sessions: 0, tok: '0', topTool: 'claude-code' };
+ const lastSeg = cwd.split('/').filter(Boolean).pop() || cwd;
+ const parent = cwd.slice(0, cwd.length - lastSeg.length);
+ const sessionList = SESSIONS.filter(s => s.cwd === cwd);
+
+ const cols = '90px 110px 70px 1fr 50px 60px';
+
+ return (
+ <>
+ <div style={{ padding: '10px 14px', borderBottom: '1px solid var(--rule-2)', background: 'var(--paper-3)', flex: 'none' }}>
+ <div className="mono muted" style={{ fontSize: 11 }}>{parent}</div>
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
+ <span className="mono" style={{ fontSize: 18, fontWeight: 600 }}>{lastSeg}</span>
+ <span className="tag accent">{meta.sessions} sessions</span>
+ <span className="tag">{meta.tok} tok</span>
+ {Array.from(new Set(sessionList.map(s => s.host))).map(h => <HostTag key={h} host={h} />)}
+ <span style={{ flex: 1 }} />
+ <Spark w={120} h={18} accent />
+ </div>
+ </div>
+
+ {sessionList.length > 0 && (
+ <div className="subbar">
+ <span className="mono muted">★ saved:</span>
+ {SAVED_SEARCHES.map(s => (
+ <span key={s} className="tag click">★ {s}</span>
+ ))}
+ <span className="tag dashed">+ new</span>
+ </div>
+ )}
+
+ <div className="thead" style={{ gridTemplateColumns: cols }}>
+ <span>date</span><span>tool</span><span>host</span><span>session</span>
+ <span className="right">turns</span><span className="right">tok</span>
+ </div>
+ <div className="body">
+ {sessionList.length === 0 ? (
+ <Empty glyph="∅" title={`No sessions yet in ${lastSeg}.`}
+ hint="run a CLI assistant in this cwd and they'll appear here within 30s" />
+ ) : sessionList.map((s) => (
+ <div key={s.id} className="row" style={{ gridTemplateColumns: cols }}
+ onClick={() => go({ name: 'session', id: s.id })}>
+ <span className="mono muted">{s.when.replace(/^Today /, '').replace(/^Yest\. /, 'Yest ')}</span>
+ <span><ToolTag tool={s.tool} /></span>
+ <span><HostTag host={s.host} /></span>
+ <span className="truncate">{s.q}</span>
+ <span className="right mono">{s.turns}</span>
+ <span className="right mono muted">{s.tok}</span>
+ </div>
+ ))}
+ </div>
+ </>
+ );
+};
+
+const StatsScreen = () => {
+ const { go } = useRouter();
+ const [range, setRange] = React.useState('30d');
+ const [groupBy, setGroupBy] = React.useState('tool');
+
+ return (
+ <>
+ <div className="subbar">
+ <span className="mono">range:</span>
+ {['7d', '30d', '90d', 'all'].map(r => (
+ <span key={r} className={'tag click' + (range === r ? ' accent' : '')}
+ onClick={() => setRange(r)}>{r}</span>
+ ))}
+ <span style={{ flex: 1 }} />
+ <span className="mono muted">group by:</span>
+ {['tool', 'host', 'project', 'model'].map(g => (
+ <span key={g} className={'tag click' + (groupBy === g ? ' accent' : '')}
+ onClick={() => setGroupBy(g)}>{g}</span>
+ ))}
+ </div>
+
+ <div className="body">
+ <div className="stats-grid">
+ {/* per-tool strip */}
+ <div className="full">
+ <div className="uppercase-mono" style={{ marginBottom: 6 }}>per tool · last {range}</div>
+ <div className="card" style={{ padding: 0 }}>
+ {TOOL_ROLLUPS.map((r, i) => (
+ <div key={r.tool} style={{
+ display: 'grid',
+ gridTemplateColumns: '140px 70px 70px 70px 1fr 60px',
+ gap: 10, alignItems: 'center',
+ padding: '6px 12px',
+ borderBottom: i < TOOL_ROLLUPS.length - 1 ? '1px solid var(--rule-2)' : 'none',
+ background: i % 2 ? 'var(--paper-3)' : 'var(--paper-4)'
+ }}>
+ <span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
+ <ToolDot tool={r.tool} /> <span className="mono">{r.tool}</span>
+ </span>
+ <span className="right mono">{r.turns.toLocaleString()}</span>
+ <span className="right mono muted">{r.ktok}k tok</span>
+ <span className="right mono">{r.cost > 0 ? `$${r.cost.toFixed(2)}` : '—'}</span>
+ <span style={{ paddingLeft: 12 }}>
+ <Spark w="100%" h={16} seed={i + 2} accent={i === 0} />
+ </span>
+ <span className="right mono muted">{(r.share * 100).toFixed(0)}%</span>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* turns/day stacked */}
+ <div className="card full">
+ <div className="card-head"><span>turns/day · stacked by tool</span>
+ <span style={{ flex: 1 }} />
+ <span className="flex" style={{ gap: 10, fontSize: 10 }}>
+ <span className="flex" style={{ gap: 4 }}><span className="tooldot" style={{ background: 'var(--accent)' }} /> claude-code</span>
+ <span className="flex" style={{ gap: 4 }}><span className="tooldot" style={{ background: '#3b6e3b' }} /> opencode</span>
+ <span className="flex" style={{ gap: 4 }}><span className="tooldot" style={{ background: '#7a4ea8' }} /> crush</span>
+ </span>
+ </div>
+ <div className="card-body">
+ <svg viewBox="0 0 600 130" style={{ width: '100%', height: 130 }} preserveAspectRatio="none">
+ {/* Y axis grid */}
+ {[0, 30, 60, 90, 120].map(y => (
+ <line key={y} x1="0" x2="600" y1={130 - y} y2={130 - y} stroke="var(--rule-2)" strokeWidth="0.5" />
+ ))}
+ {Array.from({ length: 60 }).map((_, i) => {
+ const claude = 8 + Math.abs(Math.sin(i * 0.5)) * 60;
+ const oc = 2 + Math.abs(Math.cos(i * 0.9)) * 7;
+ const crush = 1 + Math.abs(Math.sin(i * 1.2)) * 3;
+ const x = i * 10 + 2;
+ return (
+ <g key={i}>
+ <rect x={x} y={130 - claude} width={8} height={claude} fill="var(--accent)" opacity="0.85" />
+ <rect x={x} y={130 - claude - oc} width={8} height={oc} fill="#3b6e3b" opacity="0.75" />
+ <rect x={x} y={130 - claude - oc - crush} width={8} height={crush} fill="#7a4ea8" opacity="0.7" />
+ </g>
+ );
+ })}
+ </svg>
+ <div className="flex" style={{ justifyContent: 'space-between', marginTop: 4, fontSize: 9.5 }}>
+ <span className="mono muted">60d ago</span>
+ <span className="mono muted">today</span>
+ </div>
+ </div>
+ </div>
+
+ {/* heatmap */}
+ <div className="card">
+ <div className="card-head">activity · 12 weeks</div>
+ <div className="card-body">
+ <svg viewBox="0 0 250 80" style={{ width: '100%', height: 80 }}>
+ {Array.from({ length: 12 * 7 }).map((_, i) => {
+ const v = (Math.sin(i * 0.6) + Math.cos(i * 0.3) + 1.5) / 3;
+ const w = Math.floor(i / 7), d = i % 7;
+ const op = 0.1 + v * 0.85;
+ return <rect key={i} x={w * 20} y={d * 11} width={18} height={9} fill="var(--accent)" opacity={op} rx="1.5" />;
+ })}
+ </svg>
+ <div className="flex muted mono" style={{ marginTop: 6, fontSize: 9.5, gap: 4 }}>
+ <span>less</span>
+ {[0.15, 0.35, 0.55, 0.8, 1].map(o => (
+ <span key={o} style={{ width: 10, height: 10, background: 'var(--accent)', opacity: o, borderRadius: 1 }} />
+ ))}
+ <span>more</span>
+ </div>
+ </div>
+ </div>
+
+ {/* top cwd */}
+ <div className="card">
+ <div className="card-head">top cwd</div>
+ <div className="card-body">
+ {[
+ ['~/code/tt-bundle', 41, 1.0],
+ ['~/work/scarlet-svc', 28, 0.68],
+ ['~/work/atelier/migrations', 14, 0.34],
+ ['~/code/dotfiles', 7, 0.17],
+ ['~/notes', 5, 0.12],
+ ].map(([p, n, r]) => (
+ <div key={p} className="click"
+ style={{ display: 'grid', gridTemplateColumns: '1fr 90px 30px',
+ gap: 8, padding: '4px 0', borderBottom: '1px dotted var(--rule-2)', alignItems: 'center' }}
+ onClick={() => go({ name: 'project', cwd: p })}>
+ <span className="mono truncate" style={{ fontSize: 11 }}>{p}</span>
+ <span style={{ height: 5, background: 'var(--paper-2)', borderRadius: 2 }}>
+ <div style={{ width: `${r * 100}%`, height: '100%', background: 'var(--accent)', borderRadius: 2 }} />
+ </span>
+ <span className="right mono muted">{n}</span>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* hour-of-day */}
+ <div className="card">
+ <div className="card-head">turns by hour</div>
+ <div className="card-body">
+ <svg viewBox="0 0 240 70" style={{ width: '100%', height: 70 }} preserveAspectRatio="none">
+ {Array.from({ length: 24 }).map((_, i) => {
+ const v = i < 7 ? 4 + Math.sin(i) * 3
+ : i < 12 ? 30 + Math.sin(i * 0.7) * 18
+ : i < 18 ? 45 + Math.cos(i * 0.6) * 14
+ : 18 + Math.sin(i * 1.1) * 10;
+ const h = Math.max(2, v);
+ return <rect key={i} x={i * 10 + 1} y={70 - h} width={8} height={h}
+ fill="var(--accent)" opacity={i < 9 || i > 19 ? 0.45 : 0.85} />;
+ })}
+ </svg>
+ <div className="flex muted mono" style={{ justifyContent: 'space-between', fontSize: 9.5, marginTop: 4 }}>
+ <span>00</span><span>06</span><span>12</span><span>18</span><span>24</span>
+ </div>
+ </div>
+ </div>
+
+ {/* host split */}
+ <div className="card">
+ <div className="card-head">by host</div>
+ <div className="card-body">
+ {[
+ ['laptop', 1240, 0.72, '#3b6e3b'],
+ ['workpc', 480, 0.28, 'var(--accent)'],
+ ].map(([h, n, r, c]) => (
+ <div key={h} style={{ marginBottom: 8 }}>
+ <div className="flex" style={{ justifyContent: 'space-between', marginBottom: 3 }}>
+ <HostTag host={h} />
+ <span className="mono" style={{ fontSize: 11 }}>{n.toLocaleString()} <span className="muted">({(r * 100).toFixed(0)}%)</span></span>
+ </div>
+ <div style={{ height: 6, background: 'var(--paper-2)', borderRadius: 3 }}>
+ <div style={{ width: `${r * 100}%`, height: '100%', background: c, borderRadius: 3 }} />
+ </div>
+ </div>
+ ))}
+ <div className="muted mono" style={{ fontSize: 10, marginTop: 8 }}>1,720 turns · 30d</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ );
+};
+
+const HealthScreen = () => {
+ const okCount = COLLECTORS.filter(c => c.status === 'ok').length;
+ const warnCount = COLLECTORS.filter(c => c.status === 'warn').length;
+ const staleCount = COLLECTORS.filter(c => c.status === 'stale').length;
+ const cols = '20px 90px 130px 1fr 60px 70px 80px 80px';
+
+ return (
+ <>
+ <div className="subbar">
+ <span className="mono" style={{ fontWeight: 600 }}>collectors</span>
+ <span className="tag ok">● {okCount} ok</span>
+ {warnCount > 0 && <span className="tag warn">● {warnCount} warn</span>}
+ {staleCount > 0 && <span className="tag err">● {staleCount} stale</span>}
+ <span style={{ flex: 1 }} />
+ <span className="mono muted">poll: 30s · ingesting as <span className="mono" style={{ color: 'var(--accent-ink)' }}>{ME.user}</span> via {ME.via} → 127.0.0.1:8401</span>
+ </div>
+
+ <div className="thead" style={{ gridTemplateColumns: cols }}>
+ <span></span><span>host</span><span>tool</span><span>source</span>
+ <span className="right">lag</span><span className="right">outbox</span>
+ <span className="right">last ok</span><span className="right">events 24h</span>
+ </div>
+ <div className="body">
+ {COLLECTORS.map((r, i) => (
+ <div key={i} className="row no-click" style={{ gridTemplateColumns: cols, cursor: 'default' }}>
+ <StatusDot status={r.status} />
+ <span className="mono">{r.host}</span>
+ <span><ToolTag tool={r.tool} /></span>
+ <span className="mono muted truncate">{r.src}</span>
+ <span className={'right mono ' + (r.status === 'stale' ? 'accent-c' : (r.status === 'warn' ? '' : 'muted'))}
+ style={r.status === 'stale' ? { color: 'var(--err)', fontWeight: 600 } : {}}>{r.lag}</span>
+ <span className={'right mono ' + (r.out > 0 ? '' : 'muted')}
+ style={r.out > 0 ? { color: 'var(--warn)', fontWeight: 600 } : {}}>{r.out}</span>
+ <span className="right mono muted">{r.last}</span>
+ <span className="right mono">{r.ev}</span>
+ </div>
+ ))}
+ </div>
+
+ <div className="footstrip">
+ <span className="mono muted">backfill:</span>
+ <span className="mono">claude-code/workpc</span>
+ <span className="mono muted">6/9 files</span>
+ <span style={{ width: 200, height: 6, background: 'var(--paper-2)', borderRadius: 3 }}>
+ <div style={{ width: '67%', height: '100%', background: 'var(--accent)', borderRadius: 3 }} />
+ </span>
+ <span className="mono">67%</span>
+ <span className="mono muted">~3m</span>
+ <span style={{ flex: 1 }} />
+ <span className="mono" style={{ color: 'var(--err)' }}>● last error: 09:08 crush "tool_call_v2" → metadata fallback</span>
+ </div>
+ </>
+ );
+};
+
+const SettingsScreen = () => {
+ const [section, setSection] = React.useState('Sources');
+ const sections = ['Sources', 'Display', 'Auth', 'Backup', 'Export', 'Tags', 'Saved searches'];
+
+ return (
+ <div className="settings-grid">
+ <aside className="settings-nav">
+ {sections.map(s => (
+ <div key={s} className={'item' + (section === s ? ' active' : '')}
+ onClick={() => setSection(s)}>{s}</div>
+ ))}
+ </aside>
+ <main>
+ {section === 'Sources' && <SettingsSources />}
+ {section === 'Display' && <SettingsDisplay />}
+ {section === 'Auth' && <SettingsAuth />}
+ {section === 'Backup' && <SettingsStub title="Backup" subtitle="Daily sqlite3 .backup snapshot via cron — documented in README; not configured in-app." />}
+ {section === 'Export' && <SettingsStub title="Export" subtitle="Bulk-export sessions to JSONL or markdown." />}
+ {section === 'Tags' && <SettingsStub title="Tags" subtitle="Manage custom tags applied to sessions." />}
+ {section === 'Saved searches' && <SettingsStub title="Saved searches" subtitle="Manage starred queries across all projects." />}
+ </main>
+ </div>
+ );
+};
+
+const SettingsSources = () => {
+ const cols = '14px 110px 1fr 60px 90px 70px 50px';
+ return (
+ <>
+ <div className="mono" style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>Sources</div>
+ <div className="muted" style={{ fontSize: 11.5, marginBottom: 10 }}>
+ <span className="mono">~/.config/assistant-log/config.toml</span> · per-host
+ </div>
+ <div className="card" style={{ padding: 0 }}>
+ <div className="thead" style={{ gridTemplateColumns: cols, borderRadius: '4px 4px 0 0' }}>
+ <span></span><span>tool</span><span>path</span>
+ <span className="right">poll</span><span className="right">events</span>
+ <span className="right">last ok</span><span></span>
+ </div>
+ {[
+ ['ok', 'claude-code', '~/.claude/projects', '30s', 12418, 'now'],
+ ['ok', 'opencode', '~/.local/share/opencode', '30s', 312, '12s'],
+ ['ok', 'crush', '~/.cache/crush', '60s', 88, '28s'],
+ ['ok', 'pi', '~/.config/pi/history', '60s', 47, '1m'],
+ ['warn', 'kimi', '~/.kimi-cli/history.jsonl', '60s', 12, '2m'],
+ ].map(([s, t, p, poll, ev, last], i, arr) => (
+ <div key={t} style={{
+ display: 'grid', gridTemplateColumns: cols, gap: 10,
+ padding: '5px 14px', alignItems: 'center',
+ borderBottom: i < arr.length - 1 ? '1px solid var(--rule-2)' : 'none',
+ fontSize: 11.5
+ }}>
+ <StatusDot status={s} />
+ <span><ToolTag tool={t} /></span>
+ <span className="mono muted truncate">{p}</span>
+ <span className="right mono">{poll}</span>
+ <span className="right mono">{ev.toLocaleString()}</span>
+ <span className="right mono muted">{last}</span>
+ <span className="right mono click" style={{ fontSize: 11, color: 'var(--accent-ink)' }}>edit</span>
+ </div>
+ ))}
+ </div>
+ <div style={{ marginTop: 8 }}>
+ <span className="tag dashed">+ add source</span>
+ </div>
+
+ <div className="card" style={{ marginTop: 20 }}>
+ <div className="card-head">server</div>
+ <div className="card-body" style={{ display: 'grid', gridTemplateColumns: '140px 1fr', rowGap: 6, columnGap: 14, fontSize: 11.5 }}>
+ <span className="muted mono">module</span><span className="mono">sourcecraft.dev/bigbes/lethe</span>
+ <span className="muted mono">bind</span><span className="mono">{AUTH_CONFIG.bind} <span className="muted">· loopback-only, behind reverse proxy</span></span>
+ <span className="muted mono">db</span><span className="mono">~/.local/share/lethe/store.sqlite (412 MB) · WAL · busy_timeout=5s</span>
+ <span className="muted mono">fts</span><span className="mono">turns_fts · tool_outputs_fts · 24,118 turns indexed</span>
+ <span className="muted mono">migrations</span><span className="mono">0001_init · applied on startup via embed.FS</span>
+ <span className="muted mono">api</span><span className="mono">/api/v1 · /healthz · /readyz · /metrics</span>
+ <span className="muted mono">uptime</span><span className="mono">14d 03:12 · since boot</span>
+ </div>
+ </div>
+ </>
+ );
+};
+
+const SettingsAuth = () => {
+ const a = AUTH_CONFIG;
+ return (
+ <>
+ <div className="mono" style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Auth</div>
+ <div className="muted" style={{ fontSize: 11.5, marginBottom: 12 }}>
+ Two independent paths, both gated by the same allowlist. Server binds <span className="mono">127.0.0.1</span> only —
+ a reverse proxy on phoebe terminates TLS and forwards. Editing requires rewriting <span className="mono">config.yaml</span> and restarting.
+ </div>
+
+ <div className="card" style={{ marginBottom: 14 }}>
+ <div className="card-head">allowlist · <span className="muted">auth.allowed_users</span></div>
+ <div className="card-body" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
+ {a.allowedUsers.map(u => (
+ <span key={u} className="tag" style={{ fontSize: 11 }}>
+ {u}{a.admins.includes(u) && <span style={{ marginLeft: 4, fontSize: 9, padding: '0 4px', borderRadius: 2, background: 'var(--accent)', color: 'var(--accent-on)', fontWeight: 700, letterSpacing: '0.05em' }}>ADMIN</span>}
+ </span>
+ ))}
+ <span className="tag dashed">+ add</span>
+ </div>
+ </div>
+
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 14 }}>
+ <div className="card">
+ <div className="card-head">
+ <span>forward-auth (header trust)</span>
+ <span style={{ flex: 1 }} />
+ <span className={'tag ' + (a.forwardAuth.enabled ? 'ok' : 'outline')}>{a.forwardAuth.enabled ? '● enabled' : '○ disabled'}</span>
+ </div>
+ <div className="card-body" style={{ display: 'grid', gridTemplateColumns: '110px 1fr', rowGap: 5, columnGap: 12, fontSize: 11 }}>
+ <span className="muted mono">user header</span><span className="mono">{a.forwardAuth.userHeader}</span>
+ <span className="muted mono">trust source</span><span className="mono">Caddy → Authelia forward-auth</span>
+ <span className="muted mono">used by</span><span>browser sessions w/ Authelia cookie</span>
+ </div>
+ <div className="card-body" style={{ borderTop: '1px solid var(--rule-2)', paddingTop: 8, fontSize: 10.5 }}>
+ <div className="uppercase-mono" style={{ marginBottom: 4 }}>caddy snippet</div>
+ <pre className="mono muted" style={{ margin: 0, fontSize: 10, lineHeight: 1.5, whiteSpace: 'pre-wrap' }}>{`forward_auth authelia.internal:9091 {
+ uri /api/verify?rd=https://auth/
+ copy_headers Remote-User Remote-Email
+}
+reverse_proxy 127.0.0.1:8401`}</pre>
+ </div>
+ </div>
+
+ <div className="card">
+ <div className="card-head">
+ <span>oidc bearer</span>
+ <span style={{ flex: 1 }} />
+ <span className={'tag ' + (a.oidc.enabled ? 'ok' : 'outline')}>{a.oidc.enabled ? '● enabled' : '○ disabled'}</span>
+ </div>
+ <div className="card-body" style={{ display: 'grid', gridTemplateColumns: '110px 1fr', rowGap: 5, columnGap: 12, fontSize: 11 }}>
+ <span className="muted mono">issuer</span><span className="mono truncate">{a.oidc.issuer}</span>
+ <span className="muted mono">audience</span><span className="mono">{a.oidc.audience}</span>
+ <span className="muted mono">claim</span><span className="mono">{a.oidc.usernameClaim} <span className="muted">→ fallback sub</span></span>
+ <span className="muted mono">jwks</span><span className="mono">cached · last fetch {a.oidc.jwksLastFetch}</span>
+ <span className="muted mono">used by</span><span>collector, scripted clients</span>
+ </div>
+ <div className="card-body" style={{ borderTop: '1px solid var(--rule-2)', paddingTop: 8, fontSize: 10.5 }}>
+ <div className="uppercase-mono" style={{ marginBottom: 4 }}>resolution order</div>
+ <div className="mono muted" style={{ fontSize: 10, lineHeight: 1.55 }}>
+ 1. <span className="mono">Authorization: Bearer …</span> validated → user from JWT<br/>
+ 2. else <span className="mono">{a.forwardAuth.userHeader}</span> taken from proxy<br/>
+ 3. else 401 · invalid bearer never falls back (fail-closed)
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="card" style={{ padding: 0 }}>
+ <div className="card-head" style={{ padding: '6px 12px' }}>recent auth events</div>
+ <div className="thead" style={{ gridTemplateColumns: '70px 80px 100px 1fr 50px 1fr', borderRadius: 0 }}>
+ <span>time</span><span>user</span><span>via</span><span>path</span><span className="right">code</span><span>note</span>
+ </div>
+ {AUTH_EVENTS.map((e, i) => (
+ <div key={i} style={{
+ display: 'grid', gridTemplateColumns: '70px 80px 100px 1fr 50px 1fr', gap: 10,
+ padding: '4px 14px', alignItems: 'center', fontSize: 11,
+ borderBottom: i < AUTH_EVENTS.length - 1 ? '1px solid var(--rule-2)' : 'none',
+ background: e.status >= 400 ? 'var(--err-bg)' : 'transparent',
+ }}>
+ <span className="mono muted">{e.t}</span>
+ <span className="mono">{e.user}</span>
+ <span className="mono"><span className={'tag ' + (e.via === 'forward-auth' ? 'outline' : 'host')} style={{ fontSize: 9 }}>{e.via}</span></span>
+ <span className="mono truncate">{e.path}</span>
+ <span className="right mono" style={{ color: e.status >= 400 ? 'var(--err)' : 'var(--ok)' }}>{e.status}</span>
+ <span className="muted mono" style={{ fontSize: 10.5 }}>{e.note || ''}</span>
+ </div>
+ ))}
+ </div>
+
+ <div style={{ marginTop: 14, padding: 10, background: 'var(--paper-3)', border: '1px dashed var(--rule)', borderRadius: 4, fontSize: 11 }}>
+ <div className="uppercase-mono" style={{ marginBottom: 4 }}>trust model</div>
+ <div className="muted">
+ <span className="mono">owner</span> is server-derived from the authenticated user on every ingest write.
+ The wire format in <span className="mono">internal/shared/wire/</span> has no <span className="mono">owner</span> field — collectors cannot impersonate.
+ Read endpoints filter to <span className="mono">owner = current_user</span>; admins (<span className="mono">{a.admins.join(', ')}</span>) may pass <span className="mono">?owner=<user></span> or <span className="mono">?owner=*</span> to override. Non-admins passing <span className="mono">?owner=</span> at all → 403.
+ A session belonging to another owner returns 404 (existence is never leaked).
+ </div>
+ </div>
+ </>
+ );
+};
+
+const SettingsDisplay = () => (
+ <>
+ <div className="mono" style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>Display</div>
+ <div className="muted" style={{ fontSize: 11.5, marginBottom: 10 }}>Mirror of the Tweaks panel — these settings sync.</div>
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
+ {[
+ ['density', ['compact', 'comfortable']],
+ ['tool calls', ['expanded', 'collapsed']],
+ ['accent color', ['on', 'off']],
+ ['hour format', ['24h', '12h']],
+ ].map(([l, opts]) => (
+ <div key={l} className="card" style={{ padding: 10 }}>
+ <div className="uppercase-mono" style={{ marginBottom: 5 }}>{l}</div>
+ <div style={{ display: 'flex', gap: 4 }}>
+ {opts.map((o, j) => (
+ <span key={o} className={'tag click' + (j === 0 ? ' accent' : '')}>{o}</span>
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ </>
+);
+
+const SettingsStub = ({ title, subtitle }) => (
+ <>
+ <div className="mono" style={{ fontSize: 13, fontWeight: 600, marginBottom: 6 }}>{title}</div>
+ <div className="muted" style={{ fontSize: 11.5, marginBottom: 14 }}>{subtitle}</div>
+ <div className="empty" style={{ padding: 40, border: '1px dashed var(--rule)', borderRadius: 4 }}>
+ <div className="glyph">⚙</div>
+ <div>Detailed config UI · stubbed for prototype</div>
+ </div>
+ </>
+);
+
+Object.assign(window, {
+ ProjectsScreen, ProjectScreen, StatsScreen, HealthScreen, SettingsScreen,
+});
A docs/design_handoff_assistant_log/proto-palette.jsx => docs/design_handoff_assistant_log/proto-palette.jsx +91 -0
@@ 0,0 1,91 @@
+// Command palette overlay
+
+const Palette = ({ onClose }) => {
+ const { go } = useRouter();
+ const [q, setQ] = React.useState('');
+ const [sel, setSel] = React.useState(0);
+ const inputRef = React.useRef(null);
+ React.useEffect(() => { inputRef.current && inputRef.current.focus(); }, []);
+ React.useEffect(() => { setSel(0); }, [q]);
+
+ const jumps = [
+ { kind: 'jump', label: 'Recent', hint: 'g h', go: { name: 'home' } },
+ { kind: 'jump', label: 'Projects', hint: 'g p', go: { name: 'projects' } },
+ { kind: 'jump', label: 'Stats', hint: 'g s', go: { name: 'stats' } },
+ { kind: 'jump', label: 'Health', hint: 'g i', go: { name: 'health' } },
+ { kind: 'jump', label: 'Settings', hint: '', go: { name: 'settings' } },
+ ];
+ const sessionItems = SESSIONS.map(s => ({
+ kind: 'session', label: s.q, hint: `${s.tool} · ${s.cwd}`,
+ go: { name: 'session', id: s.id }
+ }));
+ const projectItems = PROJECTS.map(p => ({
+ kind: 'project', label: p.cwd, hint: `${p.sessions} sessions`,
+ go: { name: 'project', cwd: p.cwd }
+ }));
+ const all = [...jumps, ...projectItems, ...sessionItems];
+
+ const filtered = q.trim() === '' ? all : all.filter(x =>
+ (x.label + ' ' + x.hint).toLowerCase().includes(q.toLowerCase())
+ );
+ const showSearch = q.trim() !== '';
+ const total = filtered.length + (showSearch ? 1 : 0);
+
+ const fire = (idx) => {
+ if (showSearch && idx === 0) { go({ name: 'search', q }); onClose(); return; }
+ const item = filtered[showSearch ? idx - 1 : idx];
+ if (item) { go(item.go); onClose(); }
+ };
+
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); onClose(); }
+ else if (e.key === 'ArrowDown') { e.preventDefault(); setSel(s => Math.min(total - 1, s + 1)); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); setSel(s => Math.max(0, s - 1)); }
+ else if (e.key === 'Enter') { e.preventDefault(); fire(sel); }
+ };
+
+ return (
+ <div className="scrim" onClick={onClose}>
+ <div className="palette" onClick={e => e.stopPropagation()}>
+ <div className="pin">
+ <span className="mono muted" style={{ fontSize: 11 }}>⌘K</span>
+ <input ref={inputRef} value={q} onChange={e => setQ(e.target.value)}
+ onKeyDown={onKey}
+ placeholder="search turns, jump to a page, pick a session…" />
+ </div>
+ <div className="plist">
+ {showSearch && (
+ <div className={'pitem' + (sel === 0 ? ' active' : '')}
+ onClick={() => fire(0)}>
+ <span className="kind accent-c">search</span>
+ <span style={{ flex: 1 }}>"{q}"</span>
+ <span className="mono muted" style={{ fontSize: 10 }}>↵</span>
+ </div>
+ )}
+ {filtered.map((item, i) => {
+ const idx = showSearch ? i + 1 : i;
+ const active = idx === sel;
+ return (
+ <div key={i} className={'pitem' + (active ? ' active' : '')}
+ onClick={() => fire(idx)}>
+ <span className="kind">{item.kind}</span>
+ <span className="truncate" style={{ flex: 1 }}>{item.label}</span>
+ <span className="mono muted" style={{ fontSize: 10.5 }}>{item.hint}</span>
+ </div>
+ );
+ })}
+ {filtered.length === 0 && !showSearch && (
+ <div className="muted" style={{ padding: 14, fontSize: 11.5 }}>no matches</div>
+ )}
+ </div>
+ <div className="pfoot">
+ <span>↑↓ move</span><span>↵ open</span><span>esc close</span>
+ <span style={{ flex: 1 }} />
+ <span>g h · g p · g s · g i</span>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+Object.assign(window, { Palette });
A docs/design_handoff_assistant_log/proto-search.jsx => docs/design_handoff_assistant_log/proto-search.jsx +78 -0
@@ 0,0 1,78 @@
+// Search results — flat turn-level results with FTS marks
+
+const SearchScreen = () => {
+ const { query, go } = useRouter();
+ const q = (query || '').trim();
+
+ // Fake search corpus — turns with snippets that match the query
+ const corpus = [
+ { ts: '04-23 10:54', cwd: '~/work/scarlet-svc', n: 18, tool: 'claude-code', sid: 'a3', text: "use jittered exp backoff with 30s cap, but always read Retry-After from server", hits: ['backoff', 'Retry-After', 'retry'] },
+ { ts: '04-23 10:52', cwd: '~/work/scarlet-svc', n: 14, tool: 'claude-code', sid: 'a3', text: "tolerate 429s without sleep — retry should respect Retry-After", hits: ['429', 'retry', 'Retry-After'] },
+ { ts: '04-19 16:30', cwd: '~/code/tt-bundle', n: 8, tool: 'claude-code', sid: 'a1', text: "rate limited; we retry once at the call site, anything beyond is real 429", hits: ['retry', '429'] },
+ { ts: '04-15 09:11', cwd: '~/work/atelier', n: 3, tool: 'opencode', sid: 'a2', text: "queue worker hits 429 bursts when upstream cache misses", hits: ['429'] },
+ { ts: '04-12 14:02', cwd: '~/work/scarlet-svc', n: 22, tool: 'claude-code', sid: 'a3', text: "the backoff library you're using has Decorrelated Jitter built in", hits: ['backoff'] },
+ { ts: '04-09 11:45', cwd: '~/code/tt-bundle', n: 12, tool: 'claude-code', sid: 'a1', text: "for the retry loop in the resolver, bump to 4 attempts but cap at 30s", hits: ['retry'] },
+ { ts: '04-04 17:20', cwd: '~/code/tt-bundle', n: 5, tool: 'claude-code', sid: 'a6', text: "bumping a transitive dep should not invalidate every output", hits: ['transitive'] },
+ { ts: '04-02 10:08', cwd: '~/code/tt-bundle', n: 19, tool: 'claude-code', sid: 'a1', text: "lockfile entries store the resolved transitive tree, not declared", hits: ['lockfile', 'transitive'] },
+ ];
+
+ const lc = q.toLowerCase();
+ const results = !q
+ ? []
+ : corpus.filter(r => r.text.toLowerCase().includes(lc) || r.hits.some(h => h.toLowerCase().includes(lc)));
+
+ const renderSnippet = (text, hits) => {
+ if (!q) return text;
+ // Build a regex from query + hit list
+ const terms = [q, ...hits].filter(Boolean).map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
+ const re = new RegExp(`(${terms.join('|')})`, 'gi');
+ const parts = text.split(re);
+ return parts.map((p, i) =>
+ re.test(p) ? <mark key={i} className="fts">{p}</mark> : <span key={i}>{p}</span>
+ );
+ };
+
+ const cols = '110px 110px 200px 50px 1fr';
+ const sids = new Set(results.map(r => r.sid));
+
+ return (
+ <>
+ <div className="subbar" style={{ flexWrap: 'wrap' }}>
+ <span className="mono" style={{ fontSize: 12, fontWeight: 600 }}>"{q || '(empty)'}"</span>
+ <span className="muted mono">→ {results.length} turns / {sids.size} sessions / 0.012s</span>
+ <span style={{ flex: 1 }} />
+ <FilterChip dim="tool" value="claude-code" onChange={() => {}} onRemove={() => {}} />
+ <FilterChip dim="host" value="workpc" onChange={() => {}} onRemove={() => {}} />
+ <FilterChip dim="since" value="7d" onChange={() => {}} onRemove={() => {}} />
+ <span className="tag dashed click">★ save</span>
+ </div>
+ <div className="thead" style={{ gridTemplateColumns: cols }}>
+ <span>ts</span><span>tool</span><span>cwd</span><span className="right">#</span><span>snippet</span>
+ </div>
+ <div className="body">
+ {!q && (
+ <Empty glyph="⌕" title="Type a query in ⌘K to search every turn."
+ hint="full-text across all tools, hosts, and projects" />
+ )}
+ {q && results.length === 0 && (
+ <Empty glyph="∅" title={`No turns match "${q}".`}
+ hint="try a shorter query or remove a filter" />
+ )}
+ {results.map((r, i) => (
+ <div key={i} className="row"
+ style={{ gridTemplateColumns: cols }}
+ onClick={() => go({ name: 'session', id: r.sid })}>
+ <span className="mono muted">{r.ts}</span>
+ <span><ToolTag tool={r.tool} /></span>
+ <span className="mono muted truncate" style={{ cursor: 'pointer' }}
+ onClick={(e) => { e.stopPropagation(); go({ name: 'project', cwd: r.cwd }); }}>{r.cwd}</span>
+ <span className="right mono muted">#{r.n}</span>
+ <span className="truncate">{renderSnippet(r.text, r.hits)}</span>
+ </div>
+ ))}
+ </div>
+ </>
+ );
+};
+
+Object.assign(window, { SearchScreen });
A docs/design_handoff_assistant_log/proto-session.jsx => docs/design_handoff_assistant_log/proto-session.jsx +76 -0
@@ 0,0 1,76 @@
+// Session view — turn list aside + linear transcript
+
+const SessionScreen = () => {
+ const { go, route } = useRouter();
+ const session = SESSIONS.find(s => s.id === (route.id || 'a1')) || SESSIONS[0];
+
+ const turnList = [
+ [1, 'U', 'lockfile design for the …', 32],
+ [2, 'A', 'Both, but for diff reasons …', 980],
+ [3, '⚙', 'Read("build.zig")', 0],
+ [4, '⚙', '→ 14.2 KB, 318 lines', 0],
+ [5, 'A', 'Looking at your build.zig …', 1240, true],
+ [6, 'U', 'ok so transitive deps from…', 18],
+ [7, 'A', 'Transitive deps need a sep…', 760],
+ [8, '⚙', 'Grep("vendor/")', 0],
+ [9, '⚙', '→ 47 matches', 0],
+ [10, 'A', 'Three of those are header-…', 540],
+ [11, 'U', 'how would the cache key …', 22],
+ [12, 'A', 'sha256 over the sorted…', 880],
+ ];
+
+ return (
+ <>
+ <div className="subbar">
+ <span className="mono muted click" onClick={() => go({ name: 'project', cwd: session.cwd })}>{session.cwd}</span>
+ <span style={{ fontWeight: 600 }}>{session.q.slice(0, 56)}{session.q.length > 56 ? '…' : ''}</span>
+ <ToolTag tool={session.tool} />
+ <HostTag host={session.host} />
+ <span className="tag">{session.model.replace('claude-', '')}</span>
+ <span style={{ flex: 1 }} />
+ <span className="mono muted">{session.turns} turns · {session.tok} tok · started {session.when} · ●open</span>
+ </div>
+
+ <div style={{ flex: 1, display: 'grid', gridTemplateColumns: '240px 1fr', overflow: 'hidden', minHeight: 0 }}>
+ <aside style={{ borderRight: '1px solid var(--rule-2)', overflow: 'auto', background: 'var(--paper-3)', minHeight: 0 }}>
+ <div className="thead" style={{ gridTemplateColumns: '24px 14px 1fr 36px', padding: '4px 10px' }}>
+ <span>#</span><span></span><span>preview</span><span className="right">tok</span>
+ </div>
+ {turnList.map(([n, r, t, tok, sel]) => (
+ <div key={n} style={{
+ display: 'grid', gridTemplateColumns: '24px 14px 1fr 36px',
+ padding: '3px 10px', fontSize: 11, gap: 8,
+ background: sel ? 'var(--paper-2)' : 'transparent',
+ borderLeft: sel ? '2px solid var(--accent)' : '2px solid transparent',
+ cursor: 'pointer',
+ alignItems: 'center',
+ }}>
+ <span className="mono muted">{n}</span>
+ <span style={{ color: r === 'A' ? 'var(--accent-ink)' : 'var(--ink-3)', fontWeight: 700, fontFamily: 'var(--mono)' }}>{r}</span>
+ <span className="truncate">{t}</span>
+ <span className="right mono muted" style={{ fontSize: 10 }}>{tok || '—'}</span>
+ </div>
+ ))}
+ </aside>
+
+ <main style={{ overflow: 'auto', minHeight: 0 }}>
+ {TURNS.map((t, i) => (
+ <div key={i} className={'turn ' + t.role + (t.kind ? ' tool' : '')}>
+ <div className="meta">
+ <span className={'role-' + t.role.toUpperCase()}>{t.role.toUpperCase()}</span>
+ {t.kind && <span className="tag" style={{ fontSize: 9.5 }}>{t.kind}</span>}
+ {t.model && <span className="mono">{t.model}</span>}
+ {t.tIn != null && <span className="mono">in {t.tIn} · out {t.tOut}</span>}
+ <span style={{ flex: 1 }} />
+ <span className="mono" style={{ opacity: 0.5 }}>#{i + 1}</span>
+ </div>
+ <div className="body">{t.text}</div>
+ </div>
+ ))}
+ </main>
+ </div>
+ </>
+ );
+};
+
+Object.assign(window, { SessionScreen });
A docs/design_handoff_assistant_log/proto-shell.jsx => docs/design_handoff_assistant_log/proto-shell.jsx +46 -0
@@ 0,0 1,46 @@
+// Top bar + sub-bar + router context shared across screens
+
+const RouterCtx = React.createContext(null);
+const useRouter = () => React.useContext(RouterCtx);
+
+const TopBar = () => {
+ const { route, go, openPalette, query } = useRouter();
+ const tabs = [
+ { id: 'home', label: 'Recent', match: ['home', 'search', 'session'] },
+ { id: 'projects', label: 'Projects', match: ['projects', 'project'] },
+ { id: 'stats', label: 'Stats', match: ['stats'] },
+ { id: 'health', label: 'Health', match: ['health'] },
+ { id: 'settings', label: 'Settings', match: ['settings'] },
+ ];
+ const ownerScope = route.ownerScope; // set by ProjectsScreen / HomeScreen when admin uses ?owner=
+ return (
+ <div className="topbar">
+ <span className="brand" onClick={() => go({ name: 'home' })}>assistant-log</span>
+ <span className="brand-sep">/</span>
+ <span className="brand-host">scarlet</span>
+ <div className="search" onClick={openPalette}>
+ {query
+ ? <span className="mono">"{query}"</span>
+ : <span className="ghost">search turns, sessions, paths…</span>}
+ <span className="kbd">⌘K</span>
+ </div>
+ <nav>
+ {tabs.map(t => (
+ <span key={t.id}
+ className={'tab' + (t.match.includes(route.name) ? ' active' : '')}
+ onClick={() => go({ name: t.id })}>{t.label}</span>
+ ))}
+ </nav>
+ <span className="who" onClick={() => go({ name: 'settings' })} title={`signed in via ${ME.via}`}>
+ <span className="who-dot" />
+ <span className="mono">{ME.user}</span>
+ {ME.isAdmin && <span className="who-admin">admin</span>}
+ {ownerScope && ownerScope !== ME.user && (
+ <span className="who-scope mono">viewing: {ownerScope === '*' ? 'all owners' : ownerScope}</span>
+ )}
+ </span>
+ </div>
+ );
+};
+
+Object.assign(window, { RouterCtx, useRouter, TopBar });
A docs/design_handoff_assistant_log/prototype.css => docs/design_handoff_assistant_log/prototype.css +560 -0
@@ 0,0 1,560 @@
+/* assistant-log · prototype */
+
+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap');
+
+:root {
+ /* Accent locked at hue 120 (true green) */
+ --accent: oklch(0.78 0.18 120);
+ --accent-soft: oklch(0.94 0.05 120);
+ --accent-ink: oklch(0.30 0.10 120);
+ --accent-on: oklch(0.20 0.08 120); /* fg on bright accent bg */
+
+ /* Surfaces */
+ --paper: #fdfcf8; /* main bg */
+ --paper-2: #f3f0e7; /* hover */
+ --paper-3: #fbfaf2; /* sub-bar / table head */
+ --paper-4: #ffffff; /* card body */
+
+ /* Ink */
+ --ink: #1c1a17;
+ --ink-2: #4a453d;
+ --ink-3: #7a7468;
+ --ink-4: #b3ad9f;
+
+ /* Rules */
+ --rule: #d8d3c4;
+ --rule-2: #e6e1d2;
+
+ /* Top bar */
+ --topbar-bg: #1c1a17;
+ --topbar-fg: #fdfcf8;
+ --topbar-input-bg: rgba(255,255,255,0.06);
+ --topbar-input-bd: rgba(255,255,255,0.15);
+
+ /* Tag base */
+ --tag-bg: #ebe7d8;
+ --tag-host-bg: #d9e6dd;
+ --tag-host-fg: #2f5a3f;
+
+ /* Turn backgrounds */
+ --turn-user: #f5f0e0;
+ --turn-asst: #ffffff;
+ --turn-tool: #f8f5e9;
+
+ /* Status */
+ --ok: #3b6e3b; --ok-bg: #d6efd6;
+ --warn: #c89a3a; --warn-bg: #fdebcc;
+ --err: #b04a2a; --err-bg: #f5dccf;
+
+ /* Misc */
+ --scrim: rgba(28,26,23,0.45);
+ --shadow: 0 20px 60px rgba(0,0,0,0.25);
+ --shadow-pop: 0 8px 24px rgba(0,0,0,0.18);
+
+ --mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
+ --sans: 'Inter', system-ui, sans-serif;
+}
+
+[data-theme="dark"] {
+ /* Same accent hue but tuned for dark surface contrast */
+ --accent: oklch(0.78 0.16 120);
+ --accent-soft: oklch(0.30 0.08 120);
+ --accent-ink: oklch(0.86 0.16 120);
+ --accent-on: oklch(0.18 0.06 120); /* fg on bright accent bg */
+
+ --paper: #15140f;
+ --paper-2: #211f17;
+ --paper-3: #1a1813;
+ --paper-4: #1d1b14;
+
+ --ink: #e8e3d4;
+ --ink-2: #b8b2a0;
+ --ink-3: #807a6b;
+ --ink-4: #4a4538;
+
+ --rule: #2c2920;
+ --rule-2: #24221a;
+
+ --topbar-bg: #0d0c08;
+ --topbar-fg: #e8e3d4;
+ --topbar-input-bg: rgba(255,255,255,0.04);
+ --topbar-input-bd: rgba(255,255,255,0.10);
+
+ --tag-bg: #2a2820;
+ --tag-host-bg: #1f3327;
+ --tag-host-fg: #8fc09e;
+
+ --turn-user: #1f1d15;
+ --turn-asst: #15140f;
+ --turn-tool: #211f17;
+
+ --ok: #6fb46f; --ok-bg: #1f3327;
+ --warn: #d4a648; --warn-bg: #3a2e15;
+ --err: #d97052; --err-bg: #3a1f15;
+
+ --scrim: rgba(0,0,0,0.65);
+ --shadow: 0 20px 60px rgba(0,0,0,0.6);
+ --shadow-pop: 0 8px 24px rgba(0,0,0,0.5);
+}
+
+* { box-sizing: border-box; }
+html, body, #root { height: 100%; margin: 0; padding: 0; }
+body {
+ background: var(--paper);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: 12px;
+ line-height: 1.4;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ─── App shell ─── */
+.app {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+ background: var(--paper);
+ color: var(--ink);
+}
+
+/* ─── Top bar ─── */
+.topbar {
+ background: var(--topbar-bg);
+ color: var(--topbar-fg);
+ padding: 6px 14px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-family: var(--mono);
+ font-size: 11px;
+ flex: none;
+ border-bottom: 1px solid #000;
+}
+.topbar .brand { font-weight: 700; cursor: pointer; }
+.topbar .brand-sep { opacity: 0.4; }
+.topbar .brand-host { opacity: 0.7; cursor: pointer; }
+.topbar .search {
+ flex: 1;
+ background: var(--topbar-input-bg);
+ border: 1px solid var(--topbar-input-bd);
+ color: var(--topbar-fg);
+ padding: 4px 10px;
+ font-family: var(--mono);
+ font-size: 11px;
+ border-radius: 4px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+.topbar .search .ghost { opacity: 0.45; }
+.topbar .search .kbd { margin-left: auto; opacity: 0.5; font-size: 10px; }
+.topbar nav { display: flex; gap: 4px; }
+.topbar nav .tab {
+ padding: 3px 8px;
+ border-bottom: 2px solid transparent;
+ opacity: 0.7;
+ cursor: pointer;
+ user-select: none;
+}
+.topbar nav .tab:hover { opacity: 1; }
+.topbar nav .tab.active {
+ opacity: 1;
+ border-bottom-color: var(--accent);
+}
+
+/* ─── Identity badge in topbar ─── */
+.topbar .who {
+ display: flex; align-items: center; gap: 6px;
+ padding: 3px 8px;
+ border-left: 1px solid rgba(255,255,255,0.12);
+ cursor: pointer;
+ margin-left: 4px;
+}
+.topbar .who:hover { background: rgba(255,255,255,0.04); }
+.topbar .who-dot {
+ width: 6px; height: 6px; border-radius: 50%;
+ background: oklch(0.78 0.16 145);
+ box-shadow: 0 0 6px oklch(0.78 0.16 145 / 0.5);
+}
+.topbar .who-admin {
+ font-size: 9px;
+ padding: 1px 5px;
+ margin-left: 2px;
+ border-radius: 2px;
+ background: transparent;
+ color: var(--accent-ink);
+ border: 1px solid var(--accent-ink);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-weight: 600;
+ opacity: 0.75;
+}
+.topbar .who-scope {
+ font-size: 10px;
+ padding: 1px 5px;
+ border-radius: 2px;
+ background: rgba(255,180,80,0.18);
+ color: #f0c060;
+ border: 1px solid rgba(255,180,80,0.3);
+}
+
+/* ─── Sub-bar (filter chips) ─── */
+.subbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 14px;
+ border-bottom: 1px solid var(--rule-2);
+ background: var(--paper-3);
+ font-size: 11px;
+ flex: none;
+ position: relative;
+}
+
+/* ─── Tags / chips ─── */
+.tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 6px;
+ border-radius: 3px;
+ background: var(--tag-bg);
+ color: var(--ink-2);
+ font-family: var(--mono);
+ font-size: 10px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+.tag.accent { background: var(--accent); color: var(--accent-on); }
+.tag.host { background: var(--tag-host-bg); color: var(--tag-host-fg); }
+.tag.ok { background: var(--ok-bg); color: var(--ok); }
+.tag.warn { background: var(--warn-bg); color: var(--warn); }
+.tag.err { background: var(--err-bg); color: var(--err); }
+.tag.dashed { background: transparent; border: 1px dashed var(--ink-4); cursor: pointer; color: var(--ink-3); }
+.tag.outline { background: transparent; border: 1px solid var(--rule); }
+.tag.click { cursor: pointer; }
+.tag.click:hover { filter: brightness(1.08); }
+[data-theme="dark"] .tag.click:hover { filter: brightness(1.2); }
+
+/* ─── Tool dot ─── */
+.tooldot {
+ display: inline-block;
+ width: 8px; height: 8px;
+ border-radius: 2px;
+ flex: none;
+}
+
+/* ─── Grid rows ─── */
+.thead {
+ display: grid;
+ padding: 5px 14px;
+ background: var(--paper-3);
+ font-size: 10px;
+ color: var(--ink-3);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-family: var(--mono);
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid var(--rule-2);
+ flex: none;
+}
+.row {
+ display: grid;
+ padding: 4px 14px;
+ border-bottom: 1px solid var(--rule-2);
+ align-items: center;
+ gap: 10px;
+ font-size: 11.5px;
+ cursor: pointer;
+ color: var(--ink);
+}
+.row.no-click { cursor: default; }
+.row:hover { background: var(--paper-2); }
+.row.cursor {
+ background: var(--accent-soft);
+ border-left: 2px solid var(--accent);
+ padding-left: 12px;
+}
+
+.body { flex: 1; overflow: auto; min-height: 0; }
+.body-pad { padding: 14px; }
+
+/* ─── Helpers ─── */
+.mono { font-family: var(--mono); }
+.muted { color: var(--ink-3); }
+.dim { color: var(--ink-4); }
+.accent-c { color: var(--accent-ink); }
+.right { text-align: right; }
+.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
+.flex { display: flex; align-items: center; gap: 8px; }
+.flex-1 { flex: 1; min-width: 0; }
+.click { cursor: pointer; }
+.uppercase-mono {
+ font-family: var(--mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--ink-3);
+}
+
+/* ─── Sparkline ─── */
+.spark { stroke: var(--ink-2); stroke-width: 1.2; fill: none; }
+.spark.accent { stroke: var(--accent); stroke-width: 1.4; }
+
+/* ─── Card ─── */
+.card {
+ border: 1px solid var(--rule-2);
+ border-radius: 4px;
+ background: var(--paper-4);
+}
+.card .card-head {
+ font-family: var(--mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--ink-3);
+ padding: 8px 10px 6px;
+ border-bottom: 1px solid var(--rule-2);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.card .card-body { padding: 10px; }
+
+/* ─── Dot status ─── */
+.statusdot { width: 8px; height: 8px; border-radius: 99px; flex: none; }
+.statusdot.ok { background: var(--ok); }
+.statusdot.warn { background: var(--warn); }
+.statusdot.stale { background: var(--err); }
+
+/* ─── FTS mark ─── */
+mark.fts {
+ background: var(--accent);
+ color: var(--accent-on);
+ padding: 0 2px;
+ border-radius: 2px;
+ font-weight: 600;
+}
+
+/* ─── Session view ─── */
+.turn {
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--rule-2);
+ border-left: 3px solid transparent;
+}
+.turn.user { background: var(--turn-user); border-left-color: var(--ink-4); }
+.turn.assistant { background: var(--turn-asst); border-left-color: var(--accent); }
+.turn.tool { background: var(--turn-tool); }
+.turn .meta {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 4px;
+ font-size: 10.5px;
+ color: var(--ink-3);
+}
+.turn .role-USER { font-family: var(--mono); font-weight: 700; color: var(--ink-2); letter-spacing: 0.04em; }
+.turn .role-ASSISTANT { font-family: var(--mono); font-weight: 700; color: var(--accent-ink); letter-spacing: 0.04em; }
+.turn .role-TOOL { font-family: var(--mono); font-weight: 700; color: var(--ink-3); letter-spacing: 0.04em; }
+.turn .body {
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--ink);
+ flex: none;
+ overflow: visible;
+}
+.turn.tool .body { font-family: var(--mono); font-size: 11.5px; }
+
+/* ─── Compact density ─── */
+.density-compact .row { padding-top: 3px; padding-bottom: 3px; }
+.density-compact .turn { padding: 7px 16px; }
+
+/* ─── Hide tool calls tweak ─── */
+.hide-tool-calls .turn.tool { display: none; }
+
+/* ─── Palette overlay ─── */
+.scrim {
+ position: absolute; inset: 0;
+ background: var(--scrim);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 80px;
+ z-index: 50;
+}
+.palette {
+ width: 620px;
+ max-height: 480px;
+ background: var(--paper);
+ border: 1px solid var(--ink-3);
+ border-radius: 6px;
+ box-shadow: var(--shadow);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.palette .pin {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--rule-2);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.palette input {
+ flex: 1;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-family: var(--mono);
+ font-size: 13px;
+ color: var(--ink);
+}
+.palette .plist { flex: 1; overflow: auto; padding: 4px 0; min-height: 0; }
+.palette .pitem {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px 14px;
+ cursor: pointer;
+ font-size: 12.5px;
+ border-left: 2px solid transparent;
+ color: var(--ink);
+}
+.palette .pitem .kind {
+ font-family: var(--mono);
+ font-size: 10.5px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--ink-3);
+ width: 70px;
+ flex: none;
+}
+.palette .pitem.active {
+ background: var(--accent-soft);
+ border-left-color: var(--accent);
+}
+.palette .pfoot {
+ padding: 6px 14px;
+ border-top: 1px solid var(--rule-2);
+ background: var(--paper-3);
+ font-size: 10px;
+ color: var(--ink-3);
+ display: flex;
+ gap: 14px;
+ font-family: var(--mono);
+}
+
+/* ─── Filter chip popover ─── */
+.popover {
+ position: absolute;
+ background: var(--paper);
+ border: 1px solid var(--ink-3);
+ border-radius: 4px;
+ box-shadow: var(--shadow-pop);
+ padding: 4px 0;
+ z-index: 40;
+ min-width: 160px;
+ font-size: 11.5px;
+ color: var(--ink);
+}
+.popover .ph {
+ font-family: var(--mono);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--ink-3);
+ padding: 6px 10px 4px;
+}
+.popover .pi {
+ padding: 4px 10px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.popover .pi:hover { background: var(--paper-2); }
+
+/* ─── Empty state ─── */
+.empty {
+ padding: 60px 20px;
+ text-align: center;
+ color: var(--ink-3);
+ font-size: 12px;
+}
+.empty .glyph {
+ font-family: var(--mono);
+ font-size: 28px;
+ color: var(--ink-4);
+ margin-bottom: 8px;
+}
+
+/* ─── Bottom hint chip ─── */
+.hint {
+ position: absolute;
+ right: 10px; bottom: 10px;
+ font-family: var(--mono);
+ font-size: 10px;
+ background: rgba(28,26,23,0.85);
+ color: #fdfcf8;
+ padding: 4px 8px;
+ border-radius: 3px;
+ pointer-events: none;
+ letter-spacing: 0.04em;
+ z-index: 30;
+}
+[data-theme="dark"] .hint { background: rgba(0,0,0,0.85); color: var(--ink); border: 1px solid var(--rule); }
+
+/* ─── Settings sidebar ─── */
+.settings-grid {
+ display: grid;
+ grid-template-columns: 180px 1fr;
+ gap: 14px;
+ padding: 14px;
+ height: 100%;
+ overflow: auto;
+ align-content: start;
+}
+.settings-nav .item {
+ font-family: var(--mono);
+ padding: 5px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+ color: var(--ink-2);
+ font-size: 11.5px;
+}
+.settings-nav .item.active {
+ background: var(--accent-soft);
+ color: var(--accent-ink);
+ font-weight: 600;
+}
+.settings-nav .item:hover:not(.active) { background: var(--paper-2); }
+
+/* ─── Stats grid ─── */
+.stats-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+ padding: 14px;
+}
+.stats-grid .full { grid-column: 1 / -1; }
+
+/* ─── Footer strip ─── */
+.footstrip {
+ border-top: 1px solid var(--rule-2);
+ background: var(--paper-3);
+ padding: 7px 14px;
+ font-size: 11px;
+ display: flex;
+ gap: 14px;
+ align-items: center;
+ flex: none;
+ color: var(--ink);
+}
+
+/* ─── Tweaks panel positioning ─── */
+.tweaks-panel { z-index: 20; }
A docs/design_handoff_assistant_log/tweaks-panel.jsx => docs/design_handoff_assistant_log/tweaks-panel.jsx +419 -0
@@ 0,0 1,419 @@
+
+// tweaks-panel.jsx
+// Reusable Tweaks shell + form-control helpers.
+//
+// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
+// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
+// individual prototypes don't re-roll it. Ships a consistent set of controls so you
+// don't hand-draw <input type="range">, segmented radios, steppers, etc.
+//
+// Usage (in an HTML file that loads React + Babel):
+//
+// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
+// "primaryColor": "#D97757",
+// "fontSize": 16,
+// "density": "regular",
+// "dark": false
+// }/*EDITMODE-END*/;
+//
+// function App() {
+// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
+// return (
+// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
+// Hello
+// <TweaksPanel>
+// <TweakSection label="Typography" />
+// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
+// onChange={(v) => setTweak('fontSize', v)} />
+// <TweakRadio label="Density" value={t.density}
+// options={['compact', 'regular', 'comfy']}
+// onChange={(v) => setTweak('density', v)} />
+// <TweakSection label="Theme" />
+// <TweakColor label="Primary" value={t.primaryColor}
+// onChange={(v) => setTweak('primaryColor', v)} />
+// <TweakToggle label="Dark mode" value={t.dark}
+// onChange={(v) => setTweak('dark', v)} />
+// </TweaksPanel>
+// </div>
+// );
+// }
+//
+// ─────────────────────────────────────────────────────────────────────────────
+
+const __TWEAKS_STYLE = `
+ .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
+ max-height:calc(100vh - 32px);display:flex;flex-direction:column;
+ background:rgba(250,249,247,.78);color:#29261b;
+ -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
+ border:.5px solid rgba(255,255,255,.6);border-radius:14px;
+ box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
+ font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
+ .twk-hd{display:flex;align-items:center;justify-content:space-between;
+ padding:10px 8px 10px 14px;cursor:move;user-select:none}
+ .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
+ .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
+ width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
+ .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
+ .twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
+ overflow-y:auto;overflow-x:hidden;min-height:0;
+ scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
+ .twk-body::-webkit-scrollbar{width:8px}
+ .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
+ .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
+ border:2px solid transparent;background-clip:content-box}
+ .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
+ border:2px solid transparent;background-clip:content-box}
+ .twk-row{display:flex;flex-direction:column;gap:5px}
+ .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
+ .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
+ color:rgba(41,38,27,.72)}
+ .twk-lbl>span:first-child{font-weight:500}
+ .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
+
+ .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
+ color:rgba(41,38,27,.45);padding:10px 0 0}
+ .twk-sect:first-child{padding-top:0}
+
+ .twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;
+ background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
+ .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
+ select.twk-field{padding-right:22px;
+ background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
+ background-repeat:no-repeat;background-position:right 8px center}
+
+ .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
+ border-radius:999px;background:rgba(0,0,0,.12);outline:none}
+ .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
+ width:14px;height:14px;border-radius:50%;background:#fff;
+ border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+ .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
+ background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
+
+ .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
+ background:rgba(0,0,0,.06);user-select:none}
+ .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
+ background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
+ transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
+ .twk-seg.dragging .twk-seg-thumb{transition:none}
+ .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
+ background:transparent;color:inherit;font:inherit;font-weight:500;height:22px;
+ border-radius:6px;cursor:default;padding:0}
+
+ .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
+ background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
+ .twk-toggle[data-on="1"]{background:#34c759}
+ .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
+ background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
+ .twk-toggle[data-on="1"] i{transform:translateX(14px)}
+
+ .twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
+ .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
+ user-select:none;padding-right:8px}
+ .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
+ font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
+ outline:none;color:inherit;-moz-appearance:textfield}
+ .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
+ -webkit-appearance:none;margin:0}
+ .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
+
+ .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
+ background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
+ .twk-btn:hover{background:rgba(0,0,0,.88)}
+ .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
+ .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
+
+ .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
+ border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
+ background:transparent;flex-shrink:0}
+ .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
+ .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
+ .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
+`;
+
+// ── useTweaks ───────────────────────────────────────────────────────────────
+// Single source of truth for tweak values. setTweak persists via the host
+// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
+function useTweaks(defaults) {
+ const [values, setValues] = React.useState(defaults);
+ const setTweak = React.useCallback((key, val) => {
+ setValues((prev) => ({ ...prev, [key]: val }));
+ window.parent.postMessage({ type: '__edit_mode_set_keys', edits: { [key]: val } }, '*');
+ }, []);
+ return [values, setTweak];
+}
+
+// ── TweaksPanel ─────────────────────────────────────────────────────────────
+// Floating shell. Registers the protocol listener BEFORE announcing
+// availability — if the announce ran first, the host's activate could land
+// before our handler exists and the toolbar toggle would silently no-op.
+// The close button posts __edit_mode_dismissed so the host's toolbar toggle
+// flips off in lockstep; the host echoes __deactivate_edit_mode back which
+// is what actually hides the panel.
+function TweaksPanel({ title = 'Tweaks', children }) {
+ const [open, setOpen] = React.useState(false);
+ const dragRef = React.useRef(null);
+ const offsetRef = React.useRef({ x: 16, y: 16 });
+ const PAD = 16;
+
+ const clampToViewport = React.useCallback(() => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const w = panel.offsetWidth, h = panel.offsetHeight;
+ const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
+ const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
+ offsetRef.current = {
+ x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
+ y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
+ };
+ panel.style.right = offsetRef.current.x + 'px';
+ panel.style.bottom = offsetRef.current.y + 'px';
+ }, []);
+
+ React.useEffect(() => {
+ if (!open) return;
+ clampToViewport();
+ if (typeof ResizeObserver === 'undefined') {
+ window.addEventListener('resize', clampToViewport);
+ return () => window.removeEventListener('resize', clampToViewport);
+ }
+ const ro = new ResizeObserver(clampToViewport);
+ ro.observe(document.documentElement);
+ return () => ro.disconnect();
+ }, [open, clampToViewport]);
+
+ React.useEffect(() => {
+ const onMsg = (e) => {
+ const t = e?.data?.type;
+ if (t === '__activate_edit_mode') setOpen(true);
+ else if (t === '__deactivate_edit_mode') setOpen(false);
+ };
+ window.addEventListener('message', onMsg);
+ window.parent.postMessage({ type: '__edit_mode_available' }, '*');
+ return () => window.removeEventListener('message', onMsg);
+ }, []);
+
+ const dismiss = () => {
+ setOpen(false);
+ window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
+ };
+
+ const onDragStart = (e) => {
+ const panel = dragRef.current;
+ if (!panel) return;
+ const r = panel.getBoundingClientRect();
+ const sx = e.clientX, sy = e.clientY;
+ const startRight = window.innerWidth - r.right;
+ const startBottom = window.innerHeight - r.bottom;
+ const move = (ev) => {
+ offsetRef.current = {
+ x: startRight - (ev.clientX - sx),
+ y: startBottom - (ev.clientY - sy),
+ };
+ clampToViewport();
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', move);
+ window.removeEventListener('mouseup', up);
+ };
+ window.addEventListener('mousemove', move);
+ window.addEventListener('mouseup', up);
+ };
+
+ if (!open) return null;
+ return (
+ <>
+ <style>{__TWEAKS_STYLE}</style>
+ <div ref={dragRef} className="twk-panel"
+ style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
+ <div className="twk-hd" onMouseDown={onDragStart}>
+ <b>{title}</b>
+ <button className="twk-x" aria-label="Close tweaks"
+ onMouseDown={(e) => e.stopPropagation()}
+ onClick={dismiss}>✕</button>
+ </div>
+ <div className="twk-body">{children}</div>
+ </div>
+ </>
+ );
+}
+
+// ── Layout helpers ──────────────────────────────────────────────────────────
+
+function TweakSection({ label, children }) {
+ return (
+ <>
+ <div className="twk-sect">{label}</div>
+ {children}
+ </>
+ );
+}
+
+function TweakRow({ label, value, children, inline = false }) {
+ return (
+ <div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
+ <div className="twk-lbl">
+ <span>{label}</span>
+ {value != null && <span className="twk-val">{value}</span>}
+ </div>
+ {children}
+ </div>
+ );
+}
+
+// ── Controls ────────────────────────────────────────────────────────────────
+
+function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
+ return (
+ <TweakRow label={label} value={`${value}${unit}`}>
+ <input type="range" className="twk-slider" min={min} max={max} step={step}
+ value={value} onChange={(e) => onChange(Number(e.target.value))} />
+ </TweakRow>
+ );
+}
+
+function TweakToggle({ label, value, onChange }) {
+ return (
+ <div className="twk-row twk-row-h">
+ <div className="twk-lbl"><span>{label}</span></div>
+ <button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
+ role="switch" aria-checked={!!value}
+ onClick={() => onChange(!value)}><i /></button>
+ </div>
+ );
+}
+
+function TweakRadio({ label, value, options, onChange }) {
+ const trackRef = React.useRef(null);
+ const [dragging, setDragging] = React.useState(false);
+ const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
+ const idx = Math.max(0, opts.findIndex((o) => o.value === value));
+ const n = opts.length;
+
+ // The active value is read by pointer-move handlers attached for the lifetime
+ // of a drag — ref it so a stale closure doesn't fire onChange for every move.
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ const segAt = (clientX) => {
+ const r = trackRef.current.getBoundingClientRect();
+ const inner = r.width - 4;
+ const i = Math.floor(((clientX - r.left - 2) / inner) * n);
+ return opts[Math.max(0, Math.min(n - 1, i))].value;
+ };
+
+ const onPointerDown = (e) => {
+ setDragging(true);
+ const v0 = segAt(e.clientX);
+ if (v0 !== valueRef.current) onChange(v0);
+ const move = (ev) => {
+ if (!trackRef.current) return;
+ const v = segAt(ev.clientX);
+ if (v !== valueRef.current) onChange(v);
+ };
+ const up = () => {
+ setDragging(false);
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+
+ return (
+ <TweakRow label={label}>
+ <div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
+ className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
+ <div className="twk-seg-thumb"
+ style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
+ width: `calc((100% - 4px) / ${n})` }} />
+ {opts.map((o) => (
+ <button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
+ {o.label}
+ </button>
+ ))}
+ </div>
+ </TweakRow>
+ );
+}
+
+function TweakSelect({ label, value, options, onChange }) {
+ return (
+ <TweakRow label={label}>
+ <select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
+ {options.map((o) => {
+ const v = typeof o === 'object' ? o.value : o;
+ const l = typeof o === 'object' ? o.label : o;
+ return <option key={v} value={v}>{l}</option>;
+ })}
+ </select>
+ </TweakRow>
+ );
+}
+
+function TweakText({ label, value, placeholder, onChange }) {
+ return (
+ <TweakRow label={label}>
+ <input className="twk-field" type="text" value={value} placeholder={placeholder}
+ onChange={(e) => onChange(e.target.value)} />
+ </TweakRow>
+ );
+}
+
+function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
+ const clamp = (n) => {
+ if (min != null && n < min) return min;
+ if (max != null && n > max) return max;
+ return n;
+ };
+ const startRef = React.useRef({ x: 0, val: 0 });
+ const onScrubStart = (e) => {
+ e.preventDefault();
+ startRef.current = { x: e.clientX, val: value };
+ const decimals = (String(step).split('.')[1] || '').length;
+ const move = (ev) => {
+ const dx = ev.clientX - startRef.current.x;
+ const raw = startRef.current.val + dx * step;
+ const snapped = Math.round(raw / step) * step;
+ onChange(clamp(Number(snapped.toFixed(decimals))));
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ };
+ return (
+ <div className="twk-num">
+ <span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
+ <input type="number" value={value} min={min} max={max} step={step}
+ onChange={(e) => onChange(clamp(Number(e.target.value)))} />
+ {unit && <span className="twk-num-unit">{unit}</span>}
+ </div>
+ );
+}
+
+function TweakColor({ label, value, onChange }) {
+ return (
+ <div className="twk-row twk-row-h">
+ <div className="twk-lbl"><span>{label}</span></div>
+ <input type="color" className="twk-swatch" value={value}
+ onChange={(e) => onChange(e.target.value)} />
+ </div>
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+ <button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
+ onClick={onClick}>{label}</button>
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
A docs/tasks/lethe-web-ui-foundation.md => docs/tasks/lethe-web-ui-foundation.md +297 -0
@@ 0,0 1,297 @@
+# lethe-web-ui-foundation
+
+**Status:** Design
+**Module:** `sourcecraft.dev/bigbes/lethe`
+**Branch:** master
+**Worktree:** none
+**Parent RFC:** Personal AI Assistant Log Aggregator (2026-04-25)
+**Design source:** `docs/design_handoff_assistant_log/` (Direction 4 — Dense Data UI)
+**Sibling tasks:**
+- `lethe-server.md` (#1, ✓ verified)
+- `lethe-collector-claude-code.md` (#2, deferred)
+- `lethe-search-and-opencode.md` (#3, deferred)
+- `lethe-web-ui-aggregates.md` (#5, deferred — Projects/Stats endpoints + screens)
+- `lethe-web-ui-palette-savedsearch.md` (#6, deferred — full ⌘K + saved searches)
+- `lethe-web-ui-settings-display.md` (#7, deferred — Settings → Display)
+- `lethe-web-ui-search.md` (#8, blocked on #3)
+- `lethe-web-ui-health-sources.md` (#9, blocked on #2)
+
+## Design
+
+### Purpose
+
+Stand up the Lethe web UI: a single-page React app served by the lethe binary, providing a usable browse-and-read experience over the existing `/api/v1/sessions` surface. This is the foundation that follow-on tasks build on (aggregates, palette, settings, search, health). **In scope**: project skeleton, build pipeline, embed/serve, theming primitives, Home, Session view. **Out of scope**: Projects, Stats, Search, Health, Settings, ⌘K palette beyond a stub, saved searches.
+
+### Chosen approach
+
+**Stack**: React 18 + TypeScript + Vite + TanStack Router + TanStack Query, vanilla CSS using the tokens shipped in the handoff's `prototype.css`. (React over alternatives because the handoff is a React prototype — porting JSX directly is the cheapest route to fidelity.)
+
+**Layout**: source at `web/` (sibling to `cmd/`, `internal/`); `web/dist/` is git-ignored; production embeds via `//go:embed all:dist/*` from `internal/server/web/embed.go` and serves the SPA as a fallback under the **unauthed** mux. `/api/v1` stays auth-gated; the SPA shell loads even if unauthenticated, and a 401 from any data fetch flips the UI into an auth-error state.
+
+**Dev**: `just web-dev` starts vite at `:5173` with a proxy to `127.0.0.1:18888` for `/api/v1`, `/healthz`, `/readyz`, `/metrics`. The proxy injects `Remote-User: bigbes` on every outbound request so forward-auth passes without a real reverse proxy. Go code is **not** modified for dev auth.
+
+### Routes shipped
+
+| Path | Screen |
+|---|---|
+| `/` | Home (recent sessions, real data) |
+| `/session/:tool/:host/:id` | Session view (turn list aside + transcript, real data) |
+| `/projects`, `/stats`, `/health`, `/settings` | Stub: TopBar tab is functional, screen renders a small "coming in a later task" `EmptyState` card |
+| `/search` | Stub (palette's synthetic `SEARCH for "<q>"` row routes here) |
+| anything else | 404 (the prototype's `EmptyState`) |
+
+Stubs ship to keep the shell coherent (TopBar tabs and `g h/p/s/i` keybindings all "work" — they navigate). They're cheap and avoid TopBar dead-clicks.
+
+### Backend changes (additive only)
+
+`GET /api/v1/sessions` response gains five fields per row, all derivable from the current schema:
+
+| Field | SQL |
+|---|---|
+| `summary` | first user turn's `content`, truncated to 200 chars |
+| `turn_count` | `COUNT(*)` over turns scoped to the session |
+| `tokens_in_total`, `tokens_out_total` | `SUM(tokens_in)`, `SUM(tokens_out)` over the same scope |
+| `model` | latest turn's `model` (`ORDER BY seq DESC LIMIT 1`) |
+
+Implemented with a single SQL using correlated subqueries or a derived join, in `internal/domain/session/repository.go`. Repository tests cover each aggregate. **No wire-type changes**, **no schema changes**, **no removed fields** — the response stays a strict superset.
+
+`branch` and `has_error` (in the handoff's `Session` TS type) are **deferred** — neither is in the schema; will land in a future task that introduces them.
+
+### Frontend layout (under `web/`)
+
+```
+web/
+├── package.json
+├── vite.config.ts # proxy + Remote-User injection
+├── tsconfig.json
+├── index.html # entry; <html data-theme="…">
+├── public/
+│ └── (fonts: Inter, JetBrains Mono — vendored)
+└── src/
+ ├── main.tsx # router + query client init
+ ├── routes/ # TanStack Router route tree
+ │ ├── _app.tsx # shell: TopBar, SubBar slot, Body slot
+ │ ├── index.tsx # Home
+ │ ├── session.$tool.$host.$id.tsx
+ │ └── ...stub routes
+ ├── shell/ # TopBar, SubBar, hint-chip, palette skeleton
+ ├── primitives/ # Tag, ToolDot, Spark, StatusDot, EmptyState, Sub
+ ├── features/
+ │ ├── home/ # SessionsTable, FilterChips, useSessions
+ │ └── session/ # TurnList, Transcript, useSession
+ ├── api/
+ │ ├── client.ts # fetch wrapper + RFC 7807 error normalization
+ │ └── adapters.ts # Go DTO → prototype TS shape
+ ├── styles/
+ │ ├── tokens.css # :root + [data-theme="dark"] from prototype.css
+ │ ├── shell.css
+ │ ├── primitives.css
+ │ ├── home.css
+ │ └── session.css
+ └── lib/
+ ├── theme.ts # prefers-color-scheme + localStorage
+ └── keyboard.ts # j/k/↵, g-leader (800ms), Esc, ⌘K
+```
+
+### Build pipeline
+
+- New Justfile targets: `web-install`, `web-dev`, `web-build`, `web-test`, `web-lint`.
+- `just build` becomes: `web-build` then `go build`. The Go embed directive fails the build if `web/dist/` is missing — so the binary cannot be built without the SPA bundle.
+- CI: add a `web` job that runs `npm ci && npm run build && npm test && npm run lint`. Existing Go job stays as-is.
+- Dockerfile: builder stage gains a `node:20-alpine` step that produces `web/dist/` before the Go build.
+
+### Dev experience
+
+- `just web-dev` → vite at `:5173`, proxies API to `127.0.0.1:18888`, injects `Remote-User: bigbes`.
+- `just run` → Go server at `:18888` (today's behavior).
+- Workflow: two terminals, vite for the SPA, Go for the API. Hot reload on the frontend; Air handles the backend.
+
+### Theme / density
+
+- **Theme**: `prefers-color-scheme` on first load; no user toggle in foundation. (Toggle ships with Settings.) `data-theme="light|dark"` attribute on `<html>`.
+- **Density**: hard-coded `compact` in foundation (README's recommended default). Toggle ships with Settings.
+- Inline-on-`<html>` accent variable writes from `Prototype.html` are dropped — the CSS rules suffice; the runtime "accent off" toggle is exploration-only and not shipped.
+
+### Keyboard map (foundation)
+
+| Key | Action |
+|---|---|
+| `j` / `k` | move row cursor in Home |
+| `↵` | open cursored session (Home only) |
+| `g h` | go to Home |
+| `g p` / `g s` / `g i` | go to stub screens (so the chord is "real") |
+| `⌘K` / `Ctrl+K` | open palette skeleton (single input, no items; SEARCH synthetic row routes to `/search` stub) |
+| `Esc` | close palette |
+
+Full palette items (JUMP/PROJECT/SESSION) ship in `lethe-web-ui-palette-savedsearch`.
+
+### Verification at end of execute
+
+- `go test ./... -race -count=1` green; new aggregate tests covered.
+- `just web-build` produces `web/dist/`; `just build` produces a binary that, when run, serves the SPA at `/`.
+- Manual smoke: hit `/` in a browser via `just web-dev` → see Home with real sessions; click a row → Session view loads with turns; press `g` then `p` → land on Projects stub; press `⌘K` → palette overlay opens and closes on `Esc`. Light + dark mode both render correctly (toggle OS theme).
+- `internal/shared/wire/` untouched (grep verification).
+
+### TDD: yes (scoped)
+
+Per the `up:test-driven-development` applicability rule — "deterministic, reusable code where regressions would warrant a CI red light":
+
+- **Yes** for: the SQL aggregate query (Go repository test), the Go→TS adapter (`api/adapters.ts`), the keyboard-state state machine (`lib/keyboard.ts` — g-leader timeout, palette cursor, Esc handling), the theme bootstrap (`lib/theme.ts` — prefers-color-scheme + localStorage interaction).
+- **No** for: visual primitives (`Tag`, `ToolDot`, `Spark`), shell layout components, routing wiring — verified by eye against the prototype.
+
+### Unknowns (resolve during execute)
+
+- **Aggregates query plan at scale**: the correlated-subquery shape is cheap on the existing indexes, but if it regresses materially we may add a covering index. Bench during execute against the existing fixtures and on a synthetic 10k-session DB.
+- **SPA-static auth posture**: shell loads unauthenticated; first `/api/v1` call shows the auth-error state. Decide what that state looks like during execute (probably: a centered error card mirroring `EmptyState`'s tone, copy "not authenticated"). Not novel work, just deferred styling.
+
+### Invariants
+
+- `web/dist/` is git-ignored. Production embeds via `//go:embed all:dist/*` only — no committed build output.
+- The forward-auth middleware code is unchanged. Dev-time auth is solved entirely in `web/vite.config.ts` (header injection).
+- `internal/shared/wire/` is untouched. New aggregate fields live only in the HTTP response DTO (`internal/domain/session/repository.go::Session`), not the wire contract.
+- All API calls go through TanStack Query. No raw `fetch` in components.
+- All sortable/data values render in `var(--mono)`; prose renders in `var(--sans)`. No mixing.
+- `GET /api/v1/sessions` response is a strict superset of today's — no removed or renamed fields.
+- `tweaks-panel.jsx` is **not** ported. Neither is the runtime accent-off toggle.
+- Routes that are shipped match the prototype pixel-for-pixel (tokens, type scale, row heights, spacing); stub routes render a single `EmptyState` and do not reserve layout for unimplemented content.
+
+### Principles
+
+- Pixel-match the prototype for shipped routes. The README's tokens, type scale, and spacing are authoritative; resist "polishing" them.
+- Reconcile Go DTO ↔ prototype TS shape in a **single** adapter layer at the query boundary (`api/adapters.ts`). Components consume the prototype shapes; nothing else touches snake_case.
+- Foundation ships shell + 2 routes; resist pre-building for routes that aren't shipped here. Stubs are placeholders, not skeletons.
+- No CSS-in-JS, no Tailwind, no UI kit. Plain CSS files with the token names from `prototype.css`.
+- Prefer the prototype's component boundaries (e.g. `proto-atoms.jsx` → `primitives/`, `proto-home.jsx` → `features/home/`) when porting, but rename to match repo conventions (PascalCase TS components, camelCase hooks).
+
+## Plan
+
+Approach: six commits — Phase 1 ships the backend aggregates Home depends on (Go-only, independently shippable); Phases 2–3 lay the frontend toolchain and Go-side embed; Phases 4–6 build shell + Home + Session in dependency order. TDD applies to the SQL aggregate, the API adapter, theme, and keyboard machine; visual port work is verified by eye against the handoff.
+
+### Phase 1 — Session List aggregates (Go)
+
+- **1.1** `internal/domain/session/repository.go:97-107` (modify) — `Session` struct gains five fields, all with matching `db` and `json` tags:
+ - `Summary string` (json `summary`)
+ - `TurnCount int64` (json `turn_count`)
+ - `TokensInTotal int64` (json `tokens_in_total`)
+ - `TokensOutTotal int64` (json `tokens_out_total`)
+ - `Model *string` (json `model,omitempty`)
+- **1.2** `internal/domain/session/repository.go:176-189` (modify) — `sessionSelectColumns` stays as-is for `Get` path (which uses `SessionWithTurns` and computes nothing). Add a new `sessionListSelectColumns` const that wraps the base columns plus four correlated subqueries and a join to the `turns` table for `summary`/`model`. Centralize the SQL fragment so repository tests can assert column order.
+ - Invariant: response is a strict superset; `Get` path unchanged.
+- **1.3** `internal/domain/session/repository.go:194-242` (modify) — `Repository.List` swaps to `sessionListSelectColumns`. ORDER/LIMIT/WHERE clauses unchanged.
+- **1.4** `internal/domain/session/repository_test.go` (modify) — add `TestList_Aggregates` covering: zero turns (turn_count=0, tokens=0, summary="", model=nil); one user turn (summary truncated at 200 chars when content > 200); multiple turns with mixed roles (model = newest turn's model regardless of role); NULL token columns (sums treat NULL as 0).
+- **1.5** `internal/domain/session/handler_test.go` (modify if any test asserts exact JSON shape) — extend fixtures so list-response assertions cover the new fields.
+- Commit: `session: extend List response with summary, turn_count, token totals, model`
+
+### Phase 2 — Frontend scaffold + tokens + primitives
+
+- **2.1** `web/package.json` (create) — deps: `react`, `react-dom`, `@tanstack/react-router`, `@tanstack/react-query`, `@tanstack/router-vite-plugin`. devDeps: `vite`, `@vitejs/plugin-react`, `typescript`, `@types/react`, `@types/react-dom`, `vitest`, `@testing-library/react`, `eslint`, `@typescript-eslint/*`, `prettier`. Scripts: `dev`, `build`, `test`, `lint`, `typecheck`.
+- **2.2** `web/vite.config.ts` (create) — react plugin + router plugin; `server.proxy` for `/api/v1`, `/healthz`, `/readyz`, `/metrics` to `http://127.0.0.1:18888`; proxy `configure` hook injects `req.headers['Remote-User'] = 'bigbes'` on every forwarded request.
+- **2.3** `web/tsconfig.json`, `web/tsconfig.node.json`, `web/.eslintrc.cjs`, `web/.prettierrc` (create) — strict mode, react-jsx, ES2022 target.
+- **2.4** `web/index.html` (create) — `<html lang="en">`, body class `density-compact`, font preconnects.
+- **2.5** `web/src/main.tsx` (create) — React root, `QueryClientProvider`, `RouterProvider`, theme bootstrap call.
+- **2.6** `web/src/styles/tokens.css` (create) — port `prototype.css` `:root` and `[data-theme="dark"]` blocks verbatim. **Drop** the `[data-theme="dark"] { --accent: ...; ... }` inline-on-`<html>` accent writes from `Prototype.html` (the CSS rules suffice).
+- **2.7** `web/src/styles/primitives.css` (create) — port `.tag`, `.tool-dot`, `.spark`, `.status-dot`, `.empty-state`, `.sub` rules from `prototype.css`.
+- **2.8** `web/src/primitives/{Tag,ToolDot,Spark,StatusDot,EmptyState,Sub}.tsx` (create) — direct ports from `proto-atoms.jsx`, retyped. Signatures:
+ - `Tag(props: { kind?: 'host'|'tool'|'accent'|'neutral'; onClick?: () => void; children: React.ReactNode }): JSX.Element`
+ - `ToolDot(props: { tool: 'claude-code'|'opencode'|'crush'|'pi'|'kimi' }): JSX.Element`
+ - `Spark(props: { points: number[]; w?: number; h?: number; accent?: boolean }): JSX.Element`
+ - `StatusDot(props: { status: 'ok'|'warn'|'err' }): JSX.Element`
+ - `EmptyState(props: { glyph: string; copy: string }): JSX.Element`
+- **2.9** `web/.gitignore` (create) — `node_modules/`, `dist/`, `coverage/`.
+- **2.10** Root `.gitignore` (modify) — append `web/dist/`, `web/node_modules/`.
+- Commit: `web: scaffold vite/react/ts project, port design tokens and primitives`
+
+### Phase 3 — Go embed + build pipeline
+
+- **3.1** `internal/server/web/embed.go` (create) — `package web`. `//go:embed all:dist/*` on a `var distFS embed.FS`. Export `Handler() http.Handler` returning a `http.FileServer(http.FS(...))` wrapped with a SPA-fallback shim: any 404 from the file server rewrites to `index.html` and serves that with 200; paths starting with `/api/`, `/healthz`, `/readyz`, `/metrics` short-circuit to a `next` handler so the mount point doesn't swallow API routes.
+ - Signature: `func Handler() http.Handler`
+ - Invariant: `web/dist/` ignored; embed fails the build if absent.
+- **3.2** `internal/server/web/dist/.gitkeep` (create) + `internal/server/web/dist/index.html` (create, placeholder) — required so `go build` succeeds in dev without first running `just web-build`. Real assets overwrite at build time.
+- **3.3** `internal/server/server.go:91-99` (modify) — mount the embed handler. Order: API routes first (still under `/api/v1` with auth middleware), `/healthz`/`/readyz`/`/metrics` unchanged, then a catch-all `r.Handle("/*", web.Handler())`. The web handler returns 404 → SPA fallback → `index.html`. Auth middleware does not run on `/`.
+- **3.4** `internal/server/server_test.go` (modify) — add `TestRouter_ServesSPAAtRoot` (GET `/` → 200 with HTML body), `TestRouter_SPAFallbackForNonAPIPath` (GET `/session/foo/bar/baz` → 200 HTML, not 404), `TestRouter_APIPathsBypassSPA` (GET `/api/v1/sessions` without auth → 401 problem+json, not HTML).
+- **3.5** `Justfile` (modify, line 1-) — add targets:
+ - `web-install: cd web && npm ci`
+ - `web-dev: cd web && npm run dev`
+ - `web-build: cd web && npm run build`
+ - `web-test: cd web && npm test`
+ - `web-lint: cd web && npm run lint && npm run typecheck`
+ - Modify existing `build` to depend on `web-build` (or document `just web-build && just build`).
+- **3.6** `Dockerfile` (modify) — add `node:20-alpine` builder stage that runs `npm ci && npm run build` against `web/`, copies `web/dist/` into the Go builder context before `go build`.
+- **3.7** `.github/workflows/*` or `.sourcecraft/ci.yml` (modify if exists; create otherwise) — add a `web` job: `npm ci`, `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`. Existing Go job stays.
+- Commit: `server: embed web SPA at /, wire build pipeline`
+
+### Phase 4 — Shell + theme + keyboard + stubs + palette skeleton
+
+- **4.1** `web/src/lib/theme.ts` (create) — TDD.
+ - `bootstrapTheme(): void` — runs once at startup. Reads `localStorage.theme`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')`. Sets `<html data-theme>` accordingly. Adds a `change` listener on the media query that updates only when no `localStorage.theme` is set.
+ - `setTheme(theme: 'light' | 'dark' | null): void` — `null` clears the override (re-syncs with OS).
+- **4.2** `web/src/lib/theme.test.ts` (create) — TDD targets: bootstrap with no localStorage and OS=dark → `data-theme=dark`; with `localStorage.theme=light` → `light` even when OS=dark; OS change while no override flips `data-theme`; `setTheme(null)` resyncs.
+- **4.3** `web/src/lib/keyboard.ts` (create) — TDD.
+ - `createKeyboardController(opts: { go: (route: 'home'|'projects'|'stats'|'health') => void; openPalette: () => void; closePalette: () => void; cursor: { move(d: 1|-1): void; activate(): void } }): { onKeyDown(e: KeyboardEvent): void; teardown(): void }` — internal `gLeader` ref + 800 ms `setTimeout`. Cmd/Ctrl-K opens palette; Esc closes; `j`/`k` move cursor (only when not in `<input>`); `↵` activates; `g` then `h|p|s|i` navigates.
+- **4.4** `web/src/lib/keyboard.test.ts` (create) — TDD targets: `g` then `h` within 800 ms calls `go('home')`; `g` then nothing for 1 s → next `h` does nothing; `j`/`k` ignored when target is an input; ⌘K opens; Esc closes when palette open.
+- **4.5** `web/src/shell/TopBar.tsx` (create) — port `proto-shell.jsx`. Brand crumb (`assistant-log / lethe`), search trigger button (`⌘K`), tab nav. Active tab via current router match. Dark always (`--topbar-bg`).
+ - Signature: `TopBar(props: { onPaletteOpen: () => void }): JSX.Element`
+- **4.6** `web/src/shell/SubBar.tsx` (create) — outlet/slot used by routes; renders nothing if no children.
+ - Signature: `SubBar(props: { children?: React.ReactNode }): JSX.Element`
+- **4.7** `web/src/shell/Palette.tsx` (create) — modal scrim; single `<input>`; cursor list rendered from a single `JUMP` items array (`home`, `projects`, `stats`, `health`, `settings`); SEARCH synthetic row when input non-empty and no JUMP match. `↵` activates; `Esc` closes. Footer hint chip.
+ - Signature: `Palette(props: { open: boolean; onClose: () => void }): JSX.Element`
+- **4.8** `web/src/styles/{shell,palette}.css` (create) — port `.topbar`, `.subbar`, `.palette` rules from `prototype.css`.
+- **4.9** `web/src/routes/_app.tsx` (create) — TanStack root route; renders `<TopBar>`, `<Outlet>`, `<Palette>`. Wires `keyboardController` to `document` on mount, `teardown()` on unmount.
+- **4.10** `web/src/routes/{projects,stats,health,settings,search}.tsx` (create) — five files, each renders `<EmptyState glyph="∅" copy="coming in <task-slug>" />` and a `<Sub>` strip.
+- **4.11** `web/src/routes/__root.tsx`, `web/src/routeTree.gen.ts` (create — generated by router plugin on first build). Route tree wiring follows TanStack Router file-based convention.
+- Commit: `web: shell, theme, keyboard, stub routes, palette skeleton`
+
+### Phase 5 — Home route (real data)
+
+- **5.1** `web/src/api/client.ts` (create) — `apiFetch<T>(path: string, init?: RequestInit): Promise<T>` — 401 throws `AuthError`, 4xx/5xx with `application/problem+json` body throws `APIError(detail, code, status)`. JSON decode otherwise.
+- **5.2** `web/src/api/adapters.ts` (create) — TDD.
+ - `type SessionDTO = { owner; tool; host; session_id; started_at; ended_at; working_dir?; summary; turn_count; tokens_in_total; tokens_out_total; model? }` (Go shape)
+ - `type Session = { id; tool; host; cwd; model?; started; ended; summary; turns; tokensIn; tokensOut; hasError }` (prototype shape)
+ - `adaptSession(d: SessionDTO): Session` — `id = ${tool}/${host}/${session_id}`; `cwd = working_dir ?? ''`; `started/ended` = ISO from unix; `hasError = false` (deferred).
+- **5.3** `web/src/api/adapters.test.ts` (create) — TDD targets: ISO conversion for unix=0; missing working_dir → cwd=''; missing model → `model` undefined; round-trip composite ID.
+- **5.4** `web/src/features/home/useSessions.ts` (create) — TanStack Query hook keyed `['sessions', filters]`; calls `apiFetch('/api/v1/sessions?…')`; pipes through `adaptSession`.
+ - Signature: `function useSessions(filters: HomeFilters): UseQueryResult<Session[]>`
+ - `type HomeFilters = { since?: '1d'|'7d'|'30d'|'90d'|'all'; tool?: Tool; host?: Host }` — `since` translates to a unix `since` query param.
+- **5.5** `web/src/features/home/FilterChips.tsx` (create) — chip bar: `since`, `tool`, `host`, `+ filter`. Each chip a `<Tag class="click">`; click opens an absolute-positioned popover. State held in URL search params (TanStack Router-managed).
+- **5.6** `web/src/features/home/SessionsTable.tsx` (create) — port `proto-home.jsx`. Grid columns: `STARTED · TOOL · HOST · SUMMARY · TURNS · TOK · CWD`. Cursor row `--accent-soft` bg + 2 px `--accent` left border. Click row → router push `/session/$tool/$host/$session_id`. EmptyState when result is empty after filters.
+ - Signature: `SessionsTable(props: { sessions: Session[]; cursor: number; onCursor: (i: number) => void; onOpen: (s: Session) => void }): JSX.Element`
+- **5.7** `web/src/features/home/useHomeCursor.ts` (create) — small hook holding the cursor index, exposing `move(d)` and `activate()` for the keyboard controller.
+- **5.8** `web/src/routes/index.tsx` (create) — Home route. Reads filters from URL, calls `useSessions`, wires `useHomeCursor`, registers cursor actions with the shell's keyboard controller via context, renders `<SubBar><FilterChips/></SubBar><SessionsTable/>`.
+- **5.9** `web/src/styles/home.css` (create) — port `.home-table`, `.home-row`, etc. from `prototype.css`.
+- Commit: `web: home route with real session list, filters, keyboard cursor`
+
+### Phase 6 — Session route (real data)
+
+- **6.1** `web/src/features/session/useSession.ts` (create) — TanStack Query hook keyed `['session', tool, host, sessionId]`; returns `{ session, turns }`.
+ - `adaptTurn(d: TurnDTO): Turn` — `i = seq`, role passthrough, body = content, optional model/tokens, `toolName/toolKind` parsed from `tool_calls` raw JSON when role=tool (best-effort; fall back to displaying raw content).
+- **6.2** `web/src/features/session/TurnList.tsx` (create) — 240 px aside; rows of `# · role-glyph · preview · tok`. Selected row `--paper-2` bg + 2 px accent left border. Scrolls independently.
+- **6.3** `web/src/features/session/Transcript.tsx` (create) — linear list of turn cards. USER (`--ink-4` left bar), ASSISTANT (`--accent` left bar), TOOL (no bar, `--turn-tool` bg, mono content). Markdown for user/assistant via `react-markdown` (or a 30-line minimal renderer if the dep is undesirable; flag in execute).
+- **6.4** `web/src/routes/session.$tool.$host.$id.tsx` (create) — Session route. SubBar = breadcrumb + tags. Body = `<TurnList/> <Transcript/>` two-column. Click in TurnList scrolls to corresponding turn in Transcript and updates the cursor.
+- **6.5** `web/src/styles/session.css` (create) — port `.session-aside`, `.turn`, `.turn.user`, `.turn.assistant`, `.turn.tool` rules.
+- Commit: `web: session view with turn list and transcript`
+
+### Test strategy
+
+- **Phase 1** (TDD): the four behaviors enumerated under 1.4. SQL is exercised end-to-end through `Repository.List` against the in-memory test SQLite.
+- **Phase 3**: SPA fallback + API bypass coverage in `server_test.go` (3.4).
+- **Phase 4** (TDD): `theme.test.ts`, `keyboard.test.ts` per 4.2 and 4.4.
+- **Phase 5** (TDD): `adapters.test.ts` per 5.3.
+- **Phase 6**: not TDD'd; visual primitives + integration verified manually against the prototype.
+
+### Order & dependencies
+
+- Phase 1 ⊥ Phases 2–6 (Go-only; can land first or in parallel).
+- Phase 2 → Phase 3 (embed needs `dist/` to exist; placeholder `.gitkeep` + stub `index.html` makes Go build green before npm build runs).
+- Phase 3 → Phase 4 (router needs index.html to be served).
+- Phase 4 → Phase 5 → Phase 6 (Home opens Session; Home needs aggregates from Phase 1).
+- A phase 5 PR ships unblocked even if Phase 6 hasn't started — Home is the user-visible value gate.
+
+### Open questions / risks / rollback
+
+- **Markdown renderer (Phase 6.3)**: prototype uses none (it ships raw markdown text). Adding `react-markdown` is ~50 KB gzip; a 30-line custom renderer covers the 90% case. Decide during execute; default to the custom renderer unless the prototype's session screenshots show full markdown rendering (which they do — code blocks, lists, headings). Likely outcome: ship `react-markdown`.
+- **Aggregate query plan (Phase 1)**: correlated subqueries are O(n) in the result page (40 by default). On `LIMIT 40` this is fine; on `LIMIT 200` it could matter. If `EXPLAIN QUERY PLAN` shows table scans on `turns`, add a covering index `(owner, tool, host, session_id, role, seq)` in a sibling commit. Not blocking the phase.
+- **Rollback**: Phase 1 alone is safe to revert (additive JSON fields). Phases 2–6 are SPA-only; reverting any phase leaves the Go server fully functional with the existing `/api/v1`. The embed in Phase 3 means a revert there leaves the binary serving 404 on `/` — the current behavior — so it's a no-op for API users.
+
+### Backwards-compat check
+
+Restating from Design: only `GET /api/v1/sessions` changes shape, additively. The five new fields (`summary`, `turn_count`, `tokens_in_total`, `tokens_out_total`, `model`) are added; nothing is removed or renamed. No client today depends on the response shape (only consumer is the integration test, which we update). No schema migrations; no wire-type changes; no config-key changes.