From 0b51b8ee59a86f13b764e305ebffa0c60507ec12 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 06:53:01 +0300 Subject: [PATCH] web: shell, theme, keyboard, stub routes, palette skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- internal/server/web/dist/index.html | 20 ++- web/src/lib/keyboard.test.ts | 249 ++++++++++++++++++++++++++++ web/src/lib/keyboard.ts | 97 +++++++++++ web/src/lib/theme.test.ts | 172 +++++++++++++++++++ web/src/lib/theme.ts | 47 ++++++ web/src/main.tsx | 16 +- web/src/routeTree.gen.ts | 137 ++++++++++++++- web/src/routes/__root.tsx | 64 ++++++- web/src/routes/health.tsx | 21 +++ web/src/routes/index.tsx | 22 +++ web/src/routes/projects.tsx | 21 +++ web/src/routes/search.tsx | 32 ++++ web/src/routes/settings.tsx | 21 +++ web/src/routes/stats.tsx | 21 +++ web/src/shell/Palette.tsx | 148 +++++++++++++++++ web/src/shell/SubBar.tsx | 18 ++ web/src/shell/TopBar.tsx | 50 ++++++ web/src/styles/palette.css | 126 ++++++++++++++ web/src/styles/shell.css | 184 ++++++++++++++++++++ web/vitest.config.ts | 5 + 20 files changed, 1456 insertions(+), 15 deletions(-) create mode 100644 web/src/lib/keyboard.test.ts create mode 100644 web/src/lib/keyboard.ts create mode 100644 web/src/lib/theme.test.ts create mode 100644 web/src/lib/theme.ts create mode 100644 web/src/routes/health.tsx create mode 100644 web/src/routes/index.tsx create mode 100644 web/src/routes/projects.tsx create mode 100644 web/src/routes/search.tsx create mode 100644 web/src/routes/settings.tsx create mode 100644 web/src/routes/stats.tsx create mode 100644 web/src/shell/Palette.tsx create mode 100644 web/src/shell/SubBar.tsx create mode 100644 web/src/shell/TopBar.tsx create mode 100644 web/src/styles/palette.css create mode 100644 web/src/styles/shell.css diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html index e21151f805353981de67a56e42b185b0fc8ecc8c..a3dffa21c5e8d0f550e5531f916732e20cd0bc2e 100644 --- a/internal/server/web/dist/index.html +++ b/internal/server/web/dist/index.html @@ -1 +1,19 @@ -letheSPA not built — run just web-build + + + + + + Lethe + + + + + + + +
+ + diff --git a/web/src/lib/keyboard.test.ts b/web/src/lib/keyboard.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4811327ba87905ead4335e18cd67d40e4e2bedb --- /dev/null +++ b/web/src/lib/keyboard.test.ts @@ -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 + +function makeCtrl(opts: Opts) { + // Cast to KeyboardOpts — vi.fn() satisfies the shape at runtime. + return createKeyboardController(opts as Parameters[0]) +} + +function makeKey( + key: string, + init: Partial & { 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 → 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