~bigbes/lethe

0b51b8ee59a86f13b764e305ebffa0c60507ec12 — Eugene Blikh a month ago 76a281a
web: shell, theme, keyboard, stub routes, palette skeleton

- lib/theme.ts: bootstrapTheme() + setTheme() with OS/localStorage sync
- lib/keyboard.ts: g-prefix nav, j/k cursor, ⌘K palette, Esc, Enter
- lib/theme.test.ts + lib/keyboard.test.ts: 26 vitest tests (TDD)
- shell/TopBar.tsx: brand crumb, search trigger, tab nav using router pathname
- shell/SubBar.tsx: slot component with optional right section
- shell/Palette.tsx: modal overlay, JUMP items, synthetic SEARCH row
- styles/shell.css + styles/palette.css: ported verbatim from prototype.css
- routes/__root.tsx: wires TopBar, Outlet, Palette, keyboard controller, bootstrapTheme
- routes/index.tsx + projects/stats/health/settings/search.tsx: EmptyState stubs
- main.tsx: replace scaffold div with RouterProvider
- vitest.config.ts: add jsdom url for localStorage support (Node 25 compat)
M internal/server/web/dist/index.html => internal/server/web/dist/index.html +19 -1
@@ 1,1 1,19 @@
<!doctype html><title>lethe</title><body>SPA not built — run <code>just web-build</code></body>
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Lethe</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap"
      rel="stylesheet"
    />
    <script type="module" crossorigin src="/assets/index-BEzVvo_X.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-BrQo9wF5.css">
  </head>
  <body class="density-compact">
    <div id="root"></div>
  </body>
</html>

A web/src/lib/keyboard.test.ts => web/src/lib/keyboard.test.ts +249 -0
@@ 0,0 1,249 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { createKeyboardController } from './keyboard'
import type { RouteName } from './keyboard'

// ── Helpers ──────────────────────────────────────────────────────────────────

function makeOpts() {
  const opts = {
    go:            vi.fn(),
    openPalette:   vi.fn(),
    closePalette:  vi.fn(),
    isPaletteOpen: vi.fn().mockReturnValue(false) as () => boolean,
    cursor: {
      move:     vi.fn(),
      activate: vi.fn(),
    },
  }
  return opts
}

type Opts = ReturnType<typeof makeOpts>

function makeCtrl(opts: Opts) {
  // Cast to KeyboardOpts — vi.fn() satisfies the shape at runtime.
  return createKeyboardController(opts as Parameters<typeof createKeyboardController>[0])
}

function makeKey(
  key: string,
  init: Partial<KeyboardEventInit> & { target?: EventTarget } = {},
): KeyboardEvent {
  const { target: tgt, ...rest } = init
  const e = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...rest })
  if (tgt) {
    Object.defineProperty(e, 'target', { value: tgt, configurable: true })
  }
  return e
}

// ── Tests ────────────────────────────────────────────────────────────────────

describe('g + letter navigation', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })
  afterEach(() => {
    vi.useRealTimers()
  })

  it('g then h within 800 ms → go("home") once; gPending reset', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('g'))
    vi.advanceTimersByTime(400) // still within 800 ms window
    ctrl.onKeyDown(makeKey('h'))

    expect(opts.go).toHaveBeenCalledOnce()
    expect(opts.go).toHaveBeenCalledWith('home' satisfies RouteName)

    // gPending should be cleared — a second 'h' without 'g' should NOT call go
    opts.go.mockClear()
    ctrl.onKeyDown(makeKey('h'))
    expect(opts.go).not.toHaveBeenCalled()
  })

  it('g then idle 1 s, then h → no call', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('g'))
    vi.advanceTimersByTime(1000) // timeout fires, gPending cleared
    ctrl.onKeyDown(makeKey('h'))

    expect(opts.go).not.toHaveBeenCalled()
  })

  it('g then p → go("projects")', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('g'))
    ctrl.onKeyDown(makeKey('p'))

    expect(opts.go).toHaveBeenCalledWith('projects' satisfies RouteName)
  })

  it('g then s → go("stats")', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('g'))
    ctrl.onKeyDown(makeKey('s'))

    expect(opts.go).toHaveBeenCalledWith('stats' satisfies RouteName)
  })

  it('g then i → go("health")', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('g'))
    ctrl.onKeyDown(makeKey('i'))

    expect(opts.go).toHaveBeenCalledWith('health' satisfies RouteName)
  })

  it('teardown clears pending timer', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('g'))
    ctrl.teardown() // should not throw; timer cleared
    vi.advanceTimersByTime(1000)

    ctrl.onKeyDown(makeKey('h'))
    expect(opts.go).not.toHaveBeenCalled()
  })
})

describe('j / k cursor movement', () => {
  it('j while body is target → cursor.move(1)', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('j', { target: document.body }))

    expect(opts.cursor.move).toHaveBeenCalledWith(1)
  })

  it('k while body is target → cursor.move(-1)', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('k', { target: document.body }))

    expect(opts.cursor.move).toHaveBeenCalledWith(-1)
  })

  it('j while target is <input> → no cursor.move', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    const input = document.createElement('input')
    ctrl.onKeyDown(makeKey('j', { target: input }))

    expect(opts.cursor.move).not.toHaveBeenCalled()
  })

  it('j while target is <textarea> → no cursor.move', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    const ta = document.createElement('textarea')
    ctrl.onKeyDown(makeKey('j', { target: ta }))

    expect(opts.cursor.move).not.toHaveBeenCalled()
  })

  it('j while target is [contenteditable] → no cursor.move', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    const div = document.createElement('div')
    div.setAttribute('contenteditable', 'true')
    ctrl.onKeyDown(makeKey('j', { target: div }))

    expect(opts.cursor.move).not.toHaveBeenCalled()
  })

  it('j while palette is open → no cursor.move', () => {
    const opts = makeOpts()
    opts.isPaletteOpen = vi.fn().mockReturnValue(true)
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('j', { target: document.body }))

    expect(opts.cursor.move).not.toHaveBeenCalled()
  })
})

describe('⌘K / Ctrl+K', () => {
  it('⌘K → openPalette called, preventDefault', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    const e = makeKey('k', { metaKey: true })
    const spy = vi.spyOn(e, 'preventDefault')
    ctrl.onKeyDown(e)

    expect(opts.openPalette).toHaveBeenCalledOnce()
    expect(spy).toHaveBeenCalledOnce()
  })

  it('Ctrl+K → openPalette called, preventDefault', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    const e = makeKey('k', { ctrlKey: true })
    const spy = vi.spyOn(e, 'preventDefault')
    ctrl.onKeyDown(e)

    expect(opts.openPalette).toHaveBeenCalledOnce()
    expect(spy).toHaveBeenCalledOnce()
  })
})

describe('Escape', () => {
  it('Esc while isPaletteOpen()===true → closePalette called', () => {
    const opts = makeOpts()
    opts.isPaletteOpen = vi.fn().mockReturnValue(true)
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('Escape'))

    expect(opts.closePalette).toHaveBeenCalledOnce()
  })

  it('Esc while palette closed → closePalette NOT called', () => {
    const opts = makeOpts()
    // isPaletteOpen already returns false by default
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('Escape'))

    expect(opts.closePalette).not.toHaveBeenCalled()
  })
})

describe('Enter', () => {
  it('Enter while palette closed + target is body → cursor.activate()', () => {
    const opts = makeOpts()
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('Enter', { target: document.body }))

    expect(opts.cursor.activate).toHaveBeenCalledOnce()
  })

  it('Enter while palette open → cursor.activate NOT called', () => {
    const opts = makeOpts()
    opts.isPaletteOpen = vi.fn().mockReturnValue(true)
    const ctrl = makeCtrl(opts)

    ctrl.onKeyDown(makeKey('Enter', { target: document.body }))

    expect(opts.cursor.activate).not.toHaveBeenCalled()
  })
})

A web/src/lib/keyboard.ts => web/src/lib/keyboard.ts +97 -0
@@ 0,0 1,97 @@
// Keyboard controller — side-effect-free at import time.
// The consumer wires document.addEventListener('keydown', controller.onKeyDown)
// and pairs the cleanup with teardown().

export type RouteName = 'home' | 'projects' | 'stats' | 'health'

export interface KeyboardOpts {
  go: (r: RouteName) => void
  openPalette: () => void
  closePalette: () => void
  isPaletteOpen: () => boolean
  cursor: { move(d: 1 | -1): void; activate(): void }
}

const G_TIMEOUT_MS = 800

function isEditableTarget(target: EventTarget | null): boolean {
  if (!(target instanceof Element)) return false
  const tag = target.tagName.toLowerCase()
  if (tag === 'input' || tag === 'textarea') return true
  if (target.hasAttribute('contenteditable')) return true
  return false
}

export function createKeyboardController(opts: KeyboardOpts): {
  onKeyDown(e: KeyboardEvent): void
  teardown(): void
} {
  let gPending = false
  let gTimer: ReturnType<typeof setTimeout> | null = null

  function clearG(): void {
    gPending = false
    if (gTimer !== null) {
      clearTimeout(gTimer)
      gTimer = null
    }
  }

  function onKeyDown(e: KeyboardEvent): void {
    // ⌘K / Ctrl+K → open palette
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
      e.preventDefault()
      opts.openPalette()
      return
    }

    // Esc → close palette if open
    if (e.key === 'Escape') {
      if (opts.isPaletteOpen()) {
        opts.closePalette()
      }
      return
    }

    // g-prefix navigation: consume 'g' then route letter
    if (gPending) {
      let route: RouteName | null = null
      if (e.key === 'h') route = 'home'
      else if (e.key === 'p') route = 'projects'
      else if (e.key === 's') route = 'stats'
      else if (e.key === 'i') route = 'health'

      if (route !== null) {
        clearG()
        opts.go(route)
        return
      }
      // unrecognised key after g — clear pending and fall through
      clearG()
    }

    if (e.key === 'g') {
      gPending = true
      gTimer = setTimeout(clearG, G_TIMEOUT_MS)
      return
    }

    // j / k — cursor movement (only when palette closed and target not editable)
    if (!opts.isPaletteOpen() && !isEditableTarget(e.target)) {
      if (e.key === 'j') { opts.cursor.move(1); return }
      if (e.key === 'k') { opts.cursor.move(-1); return }
    }

    // Enter — activate cursor item when palette closed and target is body
    if (e.key === 'Enter' && !opts.isPaletteOpen() && e.target === document.body) {
      opts.cursor.activate()
      return
    }
  }

  function teardown(): void {
    clearG()
  }

  return { onKeyDown, teardown }
}

A web/src/lib/theme.test.ts => web/src/lib/theme.test.ts +172 -0
@@ 0,0 1,172 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { bootstrapTheme, setTheme } from './theme'

// ── localStorage stub ────────────────────────────────────────────────────────
// Node 25 ships a built-in `localStorage` that is a stub with no methods.
// Replace it with a real in-memory implementation for our tests.

function makeLocalStorageStub(): Storage {
  const store: Record<string, string> = {}
  return {
    getItem: (key: string) => store[key] ?? null,
    setItem: (key: string, val: string) => { store[key] = val },
    removeItem: (key: string) => { delete store[key] },
    clear: () => { Object.keys(store).forEach(k => delete store[k]) },
    key: (index: number) => Object.keys(store)[index] ?? null,
    get length() { return Object.keys(store).length },
  } as Storage
}

// ── matchMedia stub helpers ──────────────────────────────────────────────────

type MediaQueryListener = (e: MediaQueryListEvent) => void

interface MockMQL {
  matches: boolean
  _listeners: MediaQueryListener[]
  addEventListener(type: 'change', fn: MediaQueryListener): void
  removeEventListener(type: 'change', fn: MediaQueryListener): void
  dispatchChange(matches: boolean): void
}

function makeMockMQL(matches: boolean): MockMQL {
  const mql: MockMQL = {
    matches,
    _listeners: [],
    addEventListener(_type: 'change', fn: MediaQueryListener) {
      mql._listeners.push(fn)
    },
    removeEventListener(_type: 'change', fn: MediaQueryListener) {
      mql._listeners = mql._listeners.filter(l => l !== fn)
    },
    dispatchChange(newMatches: boolean) {
      mql.matches = newMatches
      const ev = { matches: newMatches } as MediaQueryListEvent
      mql._listeners.forEach(fn => fn(ev))
    },
  }
  return mql
}

function stubMatchMedia(mql: MockMQL) {
  Object.defineProperty(window, 'matchMedia', {
    writable: true,
    configurable: true,
    value: vi.fn().mockReturnValue(mql),
  })
}

// ── Setup / teardown ─────────────────────────────────────────────────────────

let lsStub: Storage

beforeEach(() => {
  // Install a fresh localStorage stub before each test
  lsStub = makeLocalStorageStub()
  vi.stubGlobal('localStorage', lsStub)

  // Reset data-theme before each test
  delete document.documentElement.dataset['theme']
})

afterEach(() => {
  vi.restoreAllMocks()
  vi.unstubAllGlobals()
})

// ── Tests ────────────────────────────────────────────────────────────────────

describe('bootstrapTheme', () => {
  it('no localStorage.theme + OS=dark → data-theme=dark', () => {
    const mql = makeMockMQL(true)
    stubMatchMedia(mql)

    bootstrapTheme()

    expect(document.documentElement.dataset['theme']).toBe('dark')
  })

  it('localStorage.theme=light + OS=dark → data-theme=light', () => {
    const mql = makeMockMQL(true)
    stubMatchMedia(mql)

    lsStub.setItem('theme', 'light')
    bootstrapTheme()

    expect(document.documentElement.dataset['theme']).toBe('light')
  })

  it('OS theme changes while no override → flips data-theme', () => {
    const mql = makeMockMQL(false) // starts light
    stubMatchMedia(mql)

    bootstrapTheme()
    expect(document.documentElement.dataset['theme']).toBe('light')

    mql.dispatchChange(true) // OS → dark
    expect(document.documentElement.dataset['theme']).toBe('dark')

    mql.dispatchChange(false) // OS → light
    expect(document.documentElement.dataset['theme']).toBe('light')
  })

  it('OS theme changes while override is set → data-theme does NOT change', () => {
    const mql = makeMockMQL(false) // starts light
    stubMatchMedia(mql)

    lsStub.setItem('theme', 'light')
    bootstrapTheme()
    expect(document.documentElement.dataset['theme']).toBe('light')

    mql.dispatchChange(true) // OS → dark, but override active
    expect(document.documentElement.dataset['theme']).toBe('light')
  })
})

describe('setTheme', () => {
  it('setTheme(null) re-syncs with current OS preference (dark)', () => {
    const mql = makeMockMQL(true) // OS = dark
    stubMatchMedia(mql)

    lsStub.setItem('theme', 'light')
    document.documentElement.dataset['theme'] = 'light'

    setTheme(null)

    expect(lsStub.getItem('theme')).toBeNull()
    expect(document.documentElement.dataset['theme']).toBe('dark')
  })

  it('setTheme(null) re-syncs with current OS preference (light)', () => {
    const mql = makeMockMQL(false) // OS = light
    stubMatchMedia(mql)

    lsStub.setItem('theme', 'dark')
    document.documentElement.dataset['theme'] = 'dark'

    setTheme(null)

    expect(lsStub.getItem('theme')).toBeNull()
    expect(document.documentElement.dataset['theme']).toBe('light')
  })

  it('setTheme("dark") writes localStorage and updates data-theme', () => {
    const mql = makeMockMQL(false)
    stubMatchMedia(mql)

    setTheme('dark')

    expect(lsStub.getItem('theme')).toBe('dark')
    expect(document.documentElement.dataset['theme']).toBe('dark')
  })

  it('setTheme("light") writes localStorage and updates data-theme', () => {
    const mql = makeMockMQL(true)
    stubMatchMedia(mql)

    setTheme('light')

    expect(lsStub.getItem('theme')).toBe('light')
    expect(document.documentElement.dataset['theme']).toBe('light')
  })
})

A web/src/lib/theme.ts => web/src/lib/theme.ts +47 -0
@@ 0,0 1,47 @@
// Theme management — side-effect-free at import time.
// Call bootstrapTheme() once from main.tsx after the React root is mounted.

const MEDIA_QUERY = '(prefers-color-scheme: dark)'

function getOSDark(): boolean {
  return window.matchMedia(MEDIA_QUERY).matches
}

function applyTheme(theme: 'light' | 'dark'): void {
  document.documentElement.dataset['theme'] = theme
}

// Listener reference so the media-query change handler can check localStorage
// at call time (not at setup time).
function handleOSChange(e: MediaQueryListEvent): void {
  if (localStorage.getItem('theme') !== null) return
  applyTheme(e.matches ? 'dark' : 'light')
}

/**
 * bootstrapTheme — reads stored preference (or falls back to OS),
 * applies data-theme, and registers a media-query change listener.
 * Call exactly once from main.tsx.
 */
export function bootstrapTheme(): void {
  const stored = localStorage.getItem('theme')
  const isDark = stored !== null ? stored === 'dark' : getOSDark()
  applyTheme(isDark ? 'dark' : 'light')

  window.matchMedia(MEDIA_QUERY).addEventListener('change', handleOSChange)
}

/**
 * setTheme — explicit override.
 * null: clears override, re-syncs with current OS preference.
 * 'light' | 'dark': stores override, applies immediately.
 */
export function setTheme(theme: 'light' | 'dark' | null): void {
  if (theme === null) {
    localStorage.removeItem('theme')
    applyTheme(getOSDark() ? 'dark' : 'light')
  } else {
    localStorage.setItem('theme', theme)
    applyTheme(theme)
  }
}

M web/src/main.tsx => web/src/main.tsx +12 -4
@@ 1,18 1,26 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import './styles/tokens.css'
import './styles/primitives.css'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

const queryClient = new QueryClient()

const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Root element #root not found')

createRoot(rootEl).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <div>scaffold ready</div>
      <RouterProvider router={router} />
    </QueryClientProvider>
  </React.StrictMode>
  </React.StrictMode>,
)

M web/src/routeTree.gen.ts => web/src/routeTree.gen.ts +129 -8
@@ 9,27 9,148 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as StatsRouteImport } from './routes/stats'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as SearchRouteImport } from './routes/search'
import { Route as ProjectsRouteImport } from './routes/projects'
import { Route as HealthRouteImport } from './routes/health'
import { Route as IndexRouteImport } from './routes/index'

export interface FileRoutesByFullPath {}
export interface FileRoutesByTo {}
const StatsRoute = StatsRouteImport.update({
  id: '/stats',
  path: '/stats',
  getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
  id: '/settings',
  path: '/settings',
  getParentRoute: () => rootRouteImport,
} as any)
const SearchRoute = SearchRouteImport.update({
  id: '/search',
  path: '/search',
  getParentRoute: () => rootRouteImport,
} as any)
const ProjectsRoute = ProjectsRouteImport.update({
  id: '/projects',
  path: '/projects',
  getParentRoute: () => rootRouteImport,
} as any)
const HealthRoute = HealthRouteImport.update({
  id: '/health',
  path: '/health',
  getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
  id: '/',
  path: '/',
  getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
  '/': typeof IndexRoute
  '/health': typeof HealthRoute
  '/projects': typeof ProjectsRoute
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
}
export interface FileRoutesByTo {
  '/': typeof IndexRoute
  '/health': typeof HealthRoute
  '/projects': typeof ProjectsRoute
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
}
export interface FileRoutesById {
  __root__: typeof rootRouteImport
  '/': typeof IndexRoute
  '/health': typeof HealthRoute
  '/projects': typeof ProjectsRoute
  '/search': typeof SearchRoute
  '/settings': typeof SettingsRoute
  '/stats': typeof StatsRoute
}
export interface FileRouteTypes {
  fileRoutesByFullPath: FileRoutesByFullPath
  fullPaths: never
  fullPaths: '/' | '/health' | '/projects' | '/search' | '/settings' | '/stats'
  fileRoutesByTo: FileRoutesByTo
  to: never
  id: '__root__'
  to: '/' | '/health' | '/projects' | '/search' | '/settings' | '/stats'
  id:
    | '__root__'
    | '/'
    | '/health'
    | '/projects'
    | '/search'
    | '/settings'
    | '/stats'
  fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {}
export interface RootRouteChildren {
  IndexRoute: typeof IndexRoute
  HealthRoute: typeof HealthRoute
  ProjectsRoute: typeof ProjectsRoute
  SearchRoute: typeof SearchRoute
  SettingsRoute: typeof SettingsRoute
  StatsRoute: typeof StatsRoute
}

declare module '@tanstack/react-router' {
  interface FileRoutesByPath {}
  interface FileRoutesByPath {
    '/stats': {
      id: '/stats'
      path: '/stats'
      fullPath: '/stats'
      preLoaderRoute: typeof StatsRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/settings': {
      id: '/settings'
      path: '/settings'
      fullPath: '/settings'
      preLoaderRoute: typeof SettingsRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/search': {
      id: '/search'
      path: '/search'
      fullPath: '/search'
      preLoaderRoute: typeof SearchRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/projects': {
      id: '/projects'
      path: '/projects'
      fullPath: '/projects'
      preLoaderRoute: typeof ProjectsRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/health': {
      id: '/health'
      path: '/health'
      fullPath: '/health'
      preLoaderRoute: typeof HealthRouteImport
      parentRoute: typeof rootRouteImport
    }
    '/': {
      id: '/'
      path: '/'
      fullPath: '/'
      preLoaderRoute: typeof IndexRouteImport
      parentRoute: typeof rootRouteImport
    }
  }
}

const rootRouteChildren: RootRouteChildren = {}
const rootRouteChildren: RootRouteChildren = {
  IndexRoute: IndexRoute,
  HealthRoute: HealthRoute,
  ProjectsRoute: ProjectsRoute,
  SearchRoute: SearchRoute,
  SettingsRoute: SettingsRoute,
  StatsRoute: StatsRoute,
}
export const routeTree = rootRouteImport
  ._addFileChildren(rootRouteChildren)
  ._addFileTypes<FileRouteTypes>()

M web/src/routes/__root.tsx => web/src/routes/__root.tsx +62 -2
@@ 1,5 1,65 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
import React, { useState, useEffect, useRef } from 'react'
import { createRootRoute, Outlet, useNavigate } from '@tanstack/react-router'
import { bootstrapTheme } from '../lib/theme'
import { createKeyboardController } from '../lib/keyboard'
import type { RouteName } from '../lib/keyboard'
import { TopBar } from '../shell/TopBar'
import { Palette } from '../shell/Palette'
import '../styles/tokens.css'
import '../styles/primitives.css'
import '../styles/shell.css'
import '../styles/palette.css'

export const Route = createRootRoute({
  component: () => <Outlet />,
  component: RootComponent,
})

function RootComponent(): React.JSX.Element {
  const [paletteOpen, setPaletteOpen] = useState(false)
  const navigate = useNavigate()

  // Ref so keyboard controller callbacks always see current state
  const paletteOpenRef = useRef(paletteOpen)
  useEffect(() => {
    paletteOpenRef.current = paletteOpen
  }, [paletteOpen])

  useEffect(() => {
    // Bootstrap theme once on mount
    bootstrapTheme()

    const controller = createKeyboardController({
      go: (route: RouteName) => {
        const paths: Record<RouteName, string> = {
          home:     '/',
          projects: '/projects',
          stats:    '/stats',
          health:   '/health',
        }
        void navigate({ to: paths[route] })
      },
      openPalette:   () => setPaletteOpen(true),
      closePalette:  () => setPaletteOpen(false),
      isPaletteOpen: () => paletteOpenRef.current,
      cursor: {
        move:     (_d: 1 | -1) => { /* reserved for Phase 5 row cursor */ },
        activate: ()           => { /* reserved for Phase 5 row cursor */ },
      },
    })

    document.addEventListener('keydown', controller.onKeyDown)
    return () => {
      document.removeEventListener('keydown', controller.onKeyDown)
      controller.teardown()
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []) // navigate is stable; intentional empty-dep mount effect

  return (
    <div className="app">
      <TopBar onPaletteOpen={() => setPaletteOpen(true)} />
      <Outlet />
      <Palette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
    </div>
  )
}

A web/src/routes/health.tsx => web/src/routes/health.tsx +21 -0
@@ 0,0 1,21 @@
import { createFileRoute } from '@tanstack/react-router'
import React from 'react'
import { EmptyState, Tag } from '../primitives'
import { SubBar } from '../shell/SubBar'

export const Route = createFileRoute('/health')({
  component: HealthRoute,
})

function HealthRoute(): React.JSX.Element {
  return (
    <>
      <SubBar>
        <Tag kind="neutral">health</Tag>
      </SubBar>
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="coming in a later task" />
      </div>
    </>
  )
}

A web/src/routes/index.tsx => web/src/routes/index.tsx +22 -0
@@ 0,0 1,22 @@
import { createFileRoute } from '@tanstack/react-router'
import React from 'react'
import { EmptyState } from '../primitives'
import { SubBar } from '../shell/SubBar'
import { Tag } from '../primitives'

export const Route = createFileRoute('/')({
  component: HomeRoute,
})

function HomeRoute(): React.JSX.Element {
  return (
    <>
      <SubBar>
        <Tag kind="neutral">recent</Tag>
      </SubBar>
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="coming in Phase 5" />
      </div>
    </>
  )
}

A web/src/routes/projects.tsx => web/src/routes/projects.tsx +21 -0
@@ 0,0 1,21 @@
import { createFileRoute } from '@tanstack/react-router'
import React from 'react'
import { EmptyState, Tag } from '../primitives'
import { SubBar } from '../shell/SubBar'

export const Route = createFileRoute('/projects')({
  component: ProjectsRoute,
})

function ProjectsRoute(): React.JSX.Element {
  return (
    <>
      <SubBar>
        <Tag kind="neutral">projects</Tag>
      </SubBar>
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="coming in a later task" />
      </div>
    </>
  )
}

A web/src/routes/search.tsx => web/src/routes/search.tsx +32 -0
@@ 0,0 1,32 @@
import { createFileRoute } from '@tanstack/react-router'
import React from 'react'
import { EmptyState, Tag } from '../primitives'
import { SubBar } from '../shell/SubBar'

type SearchParams = {
  q?: string
}

export const Route = createFileRoute('/search')({
  validateSearch: (search: Record<string, unknown>): SearchParams => ({
    q: typeof search['q'] === 'string' ? search['q'] : undefined,
  }),
  component: SearchRoute,
})

function SearchRoute(): React.JSX.Element {
  const { q } = Route.useSearch()
  return (
    <>
      <SubBar>
        <Tag kind="neutral">search</Tag>
        {q != null && q !== '' && (
          <Tag kind="neutral">"{q}"</Tag>
        )}
      </SubBar>
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="coming in a later task" />
      </div>
    </>
  )
}

A web/src/routes/settings.tsx => web/src/routes/settings.tsx +21 -0
@@ 0,0 1,21 @@
import { createFileRoute } from '@tanstack/react-router'
import React from 'react'
import { EmptyState, Tag } from '../primitives'
import { SubBar } from '../shell/SubBar'

export const Route = createFileRoute('/settings')({
  component: SettingsRoute,
})

function SettingsRoute(): React.JSX.Element {
  return (
    <>
      <SubBar>
        <Tag kind="neutral">settings</Tag>
      </SubBar>
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="coming in a later task" />
      </div>
    </>
  )
}

A web/src/routes/stats.tsx => web/src/routes/stats.tsx +21 -0
@@ 0,0 1,21 @@
import { createFileRoute } from '@tanstack/react-router'
import React from 'react'
import { EmptyState, Tag } from '../primitives'
import { SubBar } from '../shell/SubBar'

export const Route = createFileRoute('/stats')({
  component: StatsRoute,
})

function StatsRoute(): React.JSX.Element {
  return (
    <>
      <SubBar>
        <Tag kind="neutral">stats</Tag>
      </SubBar>
      <div className="body body-pad">
        <EmptyState glyph="∅" copy="coming in a later task" />
      </div>
    </>
  )
}

A web/src/shell/Palette.tsx => web/src/shell/Palette.tsx +148 -0
@@ 0,0 1,148 @@
import React, { useState, useEffect, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'

interface PaletteProps {
  open: boolean
  onClose: () => void
}

interface JumpItem {
  kind: 'jump'
  label: string
  hint: string
  path: string
}

const JUMP_ITEMS: JumpItem[] = [
  { kind: 'jump', label: 'Recent',   hint: 'g h', path: '/' },
  { kind: 'jump', label: 'Projects', hint: 'g p', path: '/projects' },
  { kind: 'jump', label: 'Stats',    hint: 'g s', path: '/stats' },
  { kind: 'jump', label: 'Health',   hint: 'g i', path: '/health' },
  { kind: 'jump', label: 'Settings', hint: '',    path: '/settings' },
]

export function Palette({ open, onClose }: PaletteProps): React.JSX.Element {
  const navigate = useNavigate()
  const [query, setQuery] = useState('')
  const [cursor, setCursor] = useState(0)
  const inputRef = useRef<HTMLInputElement>(null)

  // Autofocus when palette opens; reset state when it closes
  useEffect(() => {
    if (open) {
      setQuery('')
      setCursor(0)
      // Small defer so the element is visible before focus
      requestAnimationFrame(() => {
        inputRef.current?.focus()
      })
    }
  }, [open])

  // Reset cursor when query changes
  useEffect(() => {
    setCursor(0)
  }, [query])

  if (!open) return <></>

  const q = query.trim()
  const filtered: JumpItem[] = q === ''
    ? JUMP_ITEMS
    : JUMP_ITEMS.filter(item =>
        item.label.toLowerCase().includes(q.toLowerCase()),
      )

  const showSearch = q !== '' && filtered.length === 0
  const total = filtered.length + (showSearch ? 1 : 0)

  function fire(idx: number): void {
    if (showSearch && idx === 0) {
      void navigate({ to: '/search', search: { q: query } })
      onClose()
      return
    }
    const item = filtered[idx]
    if (item) {
      void navigate({ to: item.path })
      onClose()
    }
  }

  function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>): void {
    if (e.key === 'Escape') {
      e.preventDefault()
      onClose()
    } else if (e.key === 'ArrowDown') {
      e.preventDefault()
      setCursor(c => Math.min(total - 1, c + 1))
    } else if (e.key === 'ArrowUp') {
      e.preventDefault()
      setCursor(c => Math.max(0, c - 1))
    } else if (e.key === 'Enter') {
      e.preventDefault()
      fire(cursor)
    }
  }

  return (
    <div className="scrim" onClick={onClose}>
      <div className="palette" onClick={e => e.stopPropagation()}>
        {/* Input row */}
        <div className="palette-input-row">
          <span className="palette-hint-badge mono muted">⌘K</span>
          <input
            ref={inputRef}
            className="palette-input"
            value={query}
            onChange={e => setQuery(e.target.value)}
            onKeyDown={onKeyDown}
            placeholder="search turns, jump to a page, pick a session…"
          />
        </div>

        {/* Item list */}
        <div className="palette-list">
          {showSearch && (
            <div
              className={'palette-row' + (cursor === 0 ? ' cursor' : '')}
              onClick={() => fire(0)}
            >
              <span className="kind accent-c">search</span>
              <span className="label">"{query}"</span>
              <span className="row-hint mono muted">↵</span>
            </div>
          )}
          {filtered.map((item, i) => {
            const idx = showSearch ? i + 1 : i
            return (
              <div
                key={item.label}
                className={'palette-row' + (idx === cursor ? ' cursor' : '')}
                onClick={() => fire(idx)}
              >
                <span className="kind">{item.kind}</span>
                <span className="label">{item.label}</span>
                {item.hint !== '' && (
                  <span className="row-hint mono muted">{item.hint}</span>
                )}
              </div>
            )
          })}
          {filtered.length === 0 && !showSearch && (
            <div className="palette-empty muted">no matches</div>
          )}
        </div>

        {/* Footer */}
        <div className="palette-hint">
          <span>↑↓ navigate</span>
          <span>↵ open</span>
          <span>esc close</span>
          <span className="spacer" />
          <span>g h · g p · g s · g i</span>
        </div>
      </div>
    </div>
  )
}

A web/src/shell/SubBar.tsx => web/src/shell/SubBar.tsx +18 -0
@@ 0,0 1,18 @@
import React from 'react'

interface SubBarProps {
  children?: React.ReactNode
  right?: React.ReactNode
}

export function SubBar({ children, right }: SubBarProps): React.JSX.Element {
  if (children == null && right == null) {
    return <></>
  }
  return (
    <div className="subbar">
      {children}
      {right != null && <div className="subbar-right">{right}</div>}
    </div>
  )
}

A web/src/shell/TopBar.tsx => web/src/shell/TopBar.tsx +50 -0
@@ 0,0 1,50 @@
import React from 'react'
import { useRouterState, useNavigate } from '@tanstack/react-router'

interface TopBarProps {
  onPaletteOpen: () => void
}

const tabs = [
  { id: 'home',     label: 'Recent',   path: '/',          match: ['/', '/search'] },
  { id: 'projects', label: 'Projects', path: '/projects',  match: ['/projects'] },
  { id: 'stats',    label: 'Stats',    path: '/stats',     match: ['/stats'] },
  { id: 'health',   label: 'Health',   path: '/health',    match: ['/health'] },
  { id: 'settings', label: 'Settings', path: '/settings',  match: ['/settings'] },
]

export function TopBar({ onPaletteOpen }: TopBarProps): React.JSX.Element {
  const pathname = useRouterState({ select: s => s.location.pathname })
  const navigate = useNavigate()

  function isActive(tab: typeof tabs[number]): boolean {
    return tab.match.includes(pathname)
  }

  return (
    <div className="topbar">
      <span className="brand" onClick={() => void navigate({ to: '/' })}>
        assistant-log
      </span>
      <span className="brand-sep">/</span>
      <span className="brand-host">lethe</span>

      <div className="search" onClick={onPaletteOpen} role="button" aria-label="Open command palette">
        <span className="ghost">search turns, sessions, paths…</span>
        <span className="kbd">⌘K</span>
      </div>

      <nav>
        {tabs.map(tab => (
          <span
            key={tab.id}
            className={'tab' + (isActive(tab) ? ' active' : '')}
            onClick={() => void navigate({ to: tab.path })}
          >
            {tab.label}
          </span>
        ))}
      </nav>
    </div>
  )
}

A web/src/styles/palette.css => web/src/styles/palette.css +126 -0
@@ 0,0 1,126 @@
/* Palette overlay styles — ported verbatim from prototype.css */

/* ─── Scrim ─── */
.scrim {
  position: fixed;
  inset: 0;
  background: var(--scrim);
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding-top: 80px;
  z-index: 50;
}

/* ─── Palette box ─── */
.palette {
  width: 620px;
  max-width: calc(100vw - 32px);
  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 input row ─── */
.palette-input-row {
  padding: 10px 14px;
  border-bottom: 1px solid var(--rule-2);
  display: flex;
  align-items: center;
  gap: 10px;
}
.palette-input-row .palette-hint-badge {
  font-family: var(--mono);
  font-size: 11px;
  color: var(--ink-3);
  flex: none;
}
.palette-input {
  flex: 1;
  border: none;
  outline: none;
  background: transparent;
  font-family: var(--mono);
  font-size: 13px;
  color: var(--ink);
}
.palette-input::placeholder {
  color: var(--ink-4);
}

/* ─── Palette list ─── */
.palette-list {
  flex: 1;
  overflow: auto;
  padding: 4px 0;
  min-height: 0;
}

/* ─── Palette row ─── */
.palette-row {
  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-row:hover {
  background: var(--paper-2);
}
.palette-row.cursor {
  background: var(--accent-soft);
  border-left-color: var(--accent);
}
.palette-row .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-row .kind.accent-c {
  color: var(--accent-ink);
}
.palette-row .label {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.palette-row .row-hint {
  font-family: var(--mono);
  font-size: 10.5px;
  color: var(--ink-3);
  flex: none;
}

/* ─── Palette footer ─── */
.palette-hint {
  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);
}
.palette-hint .spacer { flex: 1; }

/* ─── No matches ─── */
.palette-empty {
  padding: 14px;
  font-size: 11.5px;
  color: var(--ink-3);
}

A web/src/styles/shell.css => web/src/styles/shell.css +184 -0
@@ 0,0 1,184 @@
/* Shell styles — ported verbatim from prototype.css */

/* ─── 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);
}

/* ─── 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;
}
.subbar .subbar-right {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 8px;
}

/* ─── Body scroll area ─── */
.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);
}

/* ─── 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;
}

/* ─── 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; }

/* ─── 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); }

M web/vitest.config.ts => web/vitest.config.ts +5 -0
@@ 7,6 7,11 @@ export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    environmentOptions: {
      jsdom: {
        url: 'http://localhost/',
      },
    },
    setupFiles: ['./src/test-setup.ts'],
    globals: true,
  },