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,
},