From 7cffe38a672c0f5fc825235f496c72bebc9ee2b3 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Mon, 27 Apr 2026 10:19:40 +0300 Subject: [PATCH] web: add display preference modules --- web/src/lib/density.test.ts | 91 +++++++++++++++++++++++++++++++++++ web/src/lib/density.ts | 25 ++++++++++ web/src/lib/theme.test.ts | 18 ++++++- web/src/lib/theme.ts | 10 +++- web/src/lib/toolCalls.test.ts | 91 +++++++++++++++++++++++++++++++++++ web/src/lib/toolCalls.ts | 25 ++++++++++ 6 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 web/src/lib/density.test.ts create mode 100644 web/src/lib/density.ts create mode 100644 web/src/lib/toolCalls.test.ts create mode 100644 web/src/lib/toolCalls.ts diff --git a/web/src/lib/density.test.ts b/web/src/lib/density.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb22fdaa3c27b6f10659b8a551d3ab38933067dc --- /dev/null +++ b/web/src/lib/density.test.ts @@ -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 = {} + 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') + }) +}) diff --git a/web/src/lib/density.ts b/web/src/lib/density.ts new file mode 100644 index 0000000000000000000000000000000000000000..58abeff2915f7f5add8d6999032cb098d6b54374 --- /dev/null +++ b/web/src/lib/density.ts @@ -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 + } +} diff --git a/web/src/lib/theme.test.ts b/web/src/lib/theme.test.ts index 3483394638a97ba4dadae7b0df886be5455c93ee..db7cd9a83e5f41ec34d221e27dad3add9dededaa 100644 --- a/web/src/lib/theme.test.ts +++ b/web/src/lib/theme.test.ts @@ -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') + }) +}) diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts index 89c4e7dcd77098fe7ee14e499161343cad675539..c9131b58527c7dfa91e1c9e55ff9f77e484aa396 100644 --- a/web/src/lib/theme.ts +++ b/web/src/lib/theme.ts @@ -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)' diff --git a/web/src/lib/toolCalls.test.ts b/web/src/lib/toolCalls.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a36282c74bcca84360ded821e65b0f8931357be --- /dev/null +++ b/web/src/lib/toolCalls.test.ts @@ -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 = {} + 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') + }) +}) diff --git a/web/src/lib/toolCalls.ts b/web/src/lib/toolCalls.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1c60681d575100a684e7311d5d66776b78e26f6 --- /dev/null +++ b/web/src/lib/toolCalls.ts @@ -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 + } +}