A web/src/lib/density.test.ts => web/src/lib/density.test.ts +91 -0
@@ 0,0 1,91 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { bootstrapDensity, setDensity, getDensityPreference } from './density'
+
+// ── localStorage stub ────────────────────────────────────────────────────────
+
+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
+}
+
+// ── Setup / teardown ─────────────────────────────────────────────────────────
+
+let lsStub: Storage
+
+beforeEach(() => {
+ lsStub = makeLocalStorageStub()
+ vi.stubGlobal('localStorage', lsStub)
+
+ delete document.documentElement.dataset['density']
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+})
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe('getDensityPreference', () => {
+ it('no stored value → "cozy"', () => {
+ expect(getDensityPreference()).toBe('cozy')
+ })
+
+ it('stored "compact" → "compact"', () => {
+ lsStub.setItem('density', 'compact')
+ expect(getDensityPreference()).toBe('compact')
+ })
+
+ it('stored "cozy" → "cozy"', () => {
+ lsStub.setItem('density', 'cozy')
+ expect(getDensityPreference()).toBe('cozy')
+ })
+})
+
+describe('bootstrapDensity', () => {
+ it('no stored value → data-density="cozy"', () => {
+ bootstrapDensity()
+ expect(document.documentElement.dataset['density']).toBe('cozy')
+ })
+
+ it('stored "compact" → data-density="compact"', () => {
+ lsStub.setItem('density', 'compact')
+ bootstrapDensity()
+ expect(document.documentElement.dataset['density']).toBe('compact')
+ })
+
+ it('does not register a matchMedia listener', () => {
+ const spy = vi.spyOn(window, 'matchMedia')
+ bootstrapDensity()
+ expect(spy).not.toHaveBeenCalled()
+ })
+})
+
+describe('setDensity', () => {
+ it('setDensity("compact") stores and sets data-density', () => {
+ setDensity('compact')
+ expect(lsStub.getItem('density')).toBe('compact')
+ expect(document.documentElement.dataset['density']).toBe('compact')
+ })
+
+ it('setDensity("cozy") stores and sets data-density', () => {
+ setDensity('cozy')
+ expect(lsStub.getItem('density')).toBe('cozy')
+ expect(document.documentElement.dataset['density']).toBe('cozy')
+ })
+
+ it('setDensity(null) removes key and applies default "cozy"', () => {
+ lsStub.setItem('density', 'compact')
+ document.documentElement.dataset['density'] = 'compact'
+ setDensity(null)
+ expect(lsStub.getItem('density')).toBeNull()
+ expect(document.documentElement.dataset['density']).toBe('cozy')
+ })
+})
A web/src/lib/density.ts => web/src/lib/density.ts +25 -0
@@ 0,0 1,25 @@
+// Density preference — side-effect-free at import time.
+// Call bootstrapDensity() once from main.tsx before render.
+
+export type DensityPreference = 'cozy' | 'compact'
+
+export function getDensityPreference(): DensityPreference {
+ const stored = localStorage.getItem('density')
+ if (stored === 'compact') return 'compact'
+ return 'cozy'
+}
+
+export function bootstrapDensity(): void {
+ const val = getDensityPreference()
+ document.documentElement.dataset['density'] = val
+}
+
+export function setDensity(density: DensityPreference | null): void {
+ if (density === null) {
+ localStorage.removeItem('density')
+ document.documentElement.dataset['density'] = 'cozy'
+ } else {
+ localStorage.setItem('density', density)
+ document.documentElement.dataset['density'] = density
+ }
+}
M web/src/lib/theme.test.ts => web/src/lib/theme.test.ts +17 -1
@@ 1,5 1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
-import { bootstrapTheme, setTheme } from './theme'
+import { bootstrapTheme, setTheme, getThemePreference } from './theme'
// ── localStorage stub ────────────────────────────────────────────────────────
// Node 25 ships a built-in `localStorage` that is a stub with no methods.
@@ 170,3 170,19 @@ describe('setTheme', () => {
expect(document.documentElement.dataset['theme']).toBe('light')
})
})
+
+describe('getThemePreference', () => {
+ it('no stored key → "system"', () => {
+ expect(getThemePreference()).toBe('system')
+ })
+
+ it('stored "light" → "light"', () => {
+ lsStub.setItem('theme', 'light')
+ expect(getThemePreference()).toBe('light')
+ })
+
+ it('stored "dark" → "dark"', () => {
+ lsStub.setItem('theme', 'dark')
+ expect(getThemePreference()).toBe('dark')
+ })
+})
M web/src/lib/theme.ts => web/src/lib/theme.ts +9 -1
@@ 1,5 1,13 @@
// Theme management — side-effect-free at import time.
-// Call bootstrapTheme() once from main.tsx after the React root is mounted.
+// Call bootstrapTheme() once from main.tsx before render.
+
+export type ThemePreference = 'light' | 'dark' | 'system'
+
+export function getThemePreference(): ThemePreference {
+ const stored = localStorage.getItem('theme')
+ if (stored === 'light' || stored === 'dark') return stored
+ return 'system'
+}
const MEDIA_QUERY = '(prefers-color-scheme: dark)'
A web/src/lib/toolCalls.test.ts => web/src/lib/toolCalls.test.ts +91 -0
@@ 0,0 1,91 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { bootstrapToolCalls, setToolCalls, getToolCallsPreference } from './toolCalls'
+
+// ── localStorage stub ────────────────────────────────────────────────────────
+
+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
+}
+
+// ── Setup / teardown ─────────────────────────────────────────────────────────
+
+let lsStub: Storage
+
+beforeEach(() => {
+ lsStub = makeLocalStorageStub()
+ vi.stubGlobal('localStorage', lsStub)
+
+ delete document.documentElement.dataset['showToolcalls']
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+})
+
+// ── Tests ────────────────────────────────────────────────────────────────────
+
+describe('getToolCallsPreference', () => {
+ it('no stored value → "yes"', () => {
+ expect(getToolCallsPreference()).toBe('yes')
+ })
+
+ it('stored "no" → "no"', () => {
+ lsStub.setItem('showToolCalls', 'no')
+ expect(getToolCallsPreference()).toBe('no')
+ })
+
+ it('stored "yes" → "yes"', () => {
+ lsStub.setItem('showToolCalls', 'yes')
+ expect(getToolCallsPreference()).toBe('yes')
+ })
+})
+
+describe('bootstrapToolCalls', () => {
+ it('no stored value → data-show-toolcalls="yes"', () => {
+ bootstrapToolCalls()
+ expect(document.documentElement.dataset['showToolcalls']).toBe('yes')
+ })
+
+ it('stored "no" → data-show-toolcalls="no"', () => {
+ lsStub.setItem('showToolCalls', 'no')
+ bootstrapToolCalls()
+ expect(document.documentElement.dataset['showToolcalls']).toBe('no')
+ })
+
+ it('does not register a matchMedia listener', () => {
+ const spy = vi.spyOn(window, 'matchMedia')
+ bootstrapToolCalls()
+ expect(spy).not.toHaveBeenCalled()
+ })
+})
+
+describe('setToolCalls', () => {
+ it('setToolCalls("no") stores and sets data-show-toolcalls', () => {
+ setToolCalls('no')
+ expect(lsStub.getItem('showToolCalls')).toBe('no')
+ expect(document.documentElement.dataset['showToolcalls']).toBe('no')
+ })
+
+ it('setToolCalls("yes") stores and sets data-show-toolcalls', () => {
+ setToolCalls('yes')
+ expect(lsStub.getItem('showToolCalls')).toBe('yes')
+ expect(document.documentElement.dataset['showToolcalls']).toBe('yes')
+ })
+
+ it('setToolCalls(null) removes key and applies default "yes"', () => {
+ lsStub.setItem('showToolCalls', 'no')
+ document.documentElement.dataset['showToolcalls'] = 'no'
+ setToolCalls(null)
+ expect(lsStub.getItem('showToolCalls')).toBeNull()
+ expect(document.documentElement.dataset['showToolcalls']).toBe('yes')
+ })
+})
A web/src/lib/toolCalls.ts => web/src/lib/toolCalls.ts +25 -0
@@ 0,0 1,25 @@
+// Tool-calls display preference — side-effect-free at import time.
+// Call bootstrapToolCalls() once from main.tsx before render.
+
+export type ToolCallsPreference = 'yes' | 'no'
+
+export function getToolCallsPreference(): ToolCallsPreference {
+ const stored = localStorage.getItem('showToolCalls')
+ if (stored === 'no') return 'no'
+ return 'yes'
+}
+
+export function bootstrapToolCalls(): void {
+ const val = getToolCallsPreference()
+ document.documentElement.dataset['showToolcalls'] = val
+}
+
+export function setToolCalls(value: ToolCallsPreference | null): void {
+ if (value === null) {
+ localStorage.removeItem('showToolCalls')
+ document.documentElement.dataset['showToolcalls'] = 'yes'
+ } else {
+ localStorage.setItem('showToolCalls', value)
+ document.documentElement.dataset['showToolcalls'] = value
+ }
+}