~bigbes/lethe

7cffe38a672c0f5fc825235f496c72bebc9ee2b3 — Eugene Blikh 30 days ago cfa98be
web: add display preference modules
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
  }
}