~bigbes/lethe

1af5bcbe871cc5f262c5c9a1a310d657b4a158d9 — Eugene Blikh a month ago 85a2dd3
docs(lethe-web-ui-foundation): import design handoff, add task file

Bring in the React prototype bundle from the claude-design handoff
(`docs/design_handoff_assistant_log/`) and the new task file
(`docs/tasks/lethe-web-ui-foundation.md`) covering the foundation slice
of the assistant-log web UI: scaffold, embed pipeline, shell, Home, and
Session view. Backend additions to `/api/v1/sessions` (summary,
turn_count, token totals, model) are part of this task; Projects, Stats,
Search, Health, and Settings are split into deferred sibling tasks.
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=&lt;user&gt;</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.