feat: move vault UI config from localStorage to vault files

Add VaultConfig infrastructure (store, hook, migration) that persists
zoom, view mode, section visibility, tag/status colors, and property
display modes to config/ui.config.md in the vault instead of localStorage.

- New vaultConfigStore module with subscribe/notify pattern
- useVaultConfig hook loads config via Tauri, binds store, runs migration
- One-time silent migration from localStorage on first load
- Config type excluded from note search and unified search
- All hooks/utils updated to read/write through vault config store
- Tests updated to use vault config store instead of localStorage mocks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-05 15:26:21 +01:00
parent 418ea8a7a8
commit 013cf0ffe1
22 changed files with 483 additions and 208 deletions

View File

@@ -30,6 +30,7 @@ import { useAutoSync } from './hooks/useAutoSync'
import { useConflictResolver } from './hooks/useConflictResolver'
import { useIndexing } from './hooks/useIndexing'
import { useZoom } from './hooks/useZoom'
import { useVaultConfig } from './hooks/useVaultConfig'
import { useBuildNumber } from './hooks/useBuildNumber'
import { useOnboarding } from './hooks/useOnboarding'
import { useThemeManager } from './hooks/useThemeManager'
@@ -102,6 +103,7 @@ function App() {
// When onboarding resolves to a different vault path, update the switcher
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
useVaultConfig(resolvedPath)
const { settings, saveSettings } = useSettings()
const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)

View File

@@ -2,20 +2,8 @@ import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel'
import type { VaultEntry } from '../types'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (i: number) => Object.keys(store)[i] ?? null,
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
import { initDisplayModeOverrides } from '../utils/propertyTypes'
// Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM
beforeAll(() => {
@@ -847,7 +835,12 @@ describe('DynamicPropertiesPanel', () => {
describe('display mode override', () => {
beforeEach(() => {
localStorageMock.clear()
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
initDisplayModeOverrides({})
})
it('renders display mode trigger on property rows', () => {
@@ -880,7 +873,7 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByTestId('display-mode-option-url')).toBeInTheDocument()
})
it('persists override to localStorage when mode selected', () => {
it('persists override to vault config when mode selected', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -891,12 +884,13 @@ describe('DynamicPropertiesPanel', () => {
)
fireEvent.click(screen.getByTestId('display-mode-trigger'))
fireEvent.click(screen.getByTestId('display-mode-option-status'))
const stored = JSON.parse(localStorageMock.getItem('laputa:display-mode-overrides') ?? '{}')
const stored = getVaultConfig().property_display_modes as Record<string, string>
expect(stored).toBeTruthy()
expect(stored.cadence).toBe('status')
})
it('overrides rendering to status badge when status mode selected', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ cadence: 'status' }))
initDisplayModeOverrides({ cadence: 'status' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -909,7 +903,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders boolean toggle for string "true" when boolean mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -923,7 +917,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders boolean toggle for string "false" when boolean mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -937,7 +931,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('toggles string boolean from false to true', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ draft: 'boolean' }))
initDisplayModeOverrides({ draft: 'boolean' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -951,7 +945,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders date picker for empty value when date mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ due: 'date' }))
initDisplayModeOverrides({ due: 'date' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -965,7 +959,7 @@ describe('DynamicPropertiesPanel', () => {
})
it('renders date picker for non-date string when date mode overridden', () => {
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ deadline: 'date' }))
initDisplayModeOverrides({ deadline: 'date' })
render(
<DynamicPropertiesPanel
entry={makeEntry()}

View File

@@ -2,18 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Sidebar } from './Sidebar'
import type { VaultEntry, SidebarSelection } from '../types'
// Mock localStorage for section visibility tests
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
const mockEntries: VaultEntry[] = [
{
@@ -689,7 +678,11 @@ describe('Sidebar', () => {
describe('customize section visibility', () => {
beforeEach(() => {
localStorageMock.clear()
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
})
it('renders a "Customize sections" button', () => {
@@ -735,19 +728,23 @@ describe('Sidebar', () => {
expect(peopleElements.length).toBe(2)
})
it('persists hidden sections in localStorage', () => {
it('persists hidden sections in vault config', () => {
const { unmount } = render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByTitle('Customize sections'))
fireEvent.click(screen.getByLabelText('Toggle Events'))
unmount()
// Verify localStorage was updated
const stored = JSON.parse(localStorage.getItem('laputa-hidden-sections') || '[]')
// Verify vault config was updated
const stored = getVaultConfig().hidden_sections
expect(stored).toContain('Event')
})
it('restores hidden sections from localStorage on mount', () => {
localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Person', 'Event']))
it('restores hidden sections from vault config on mount', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Person', 'Event'] },
vi.fn(),
)
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// People and Events section headers should be hidden
@@ -760,7 +757,11 @@ describe('Sidebar', () => {
})
it('does not affect All Notes or other sidebar filters when sections are hidden', () => {
localStorage.setItem('laputa-hidden-sections', JSON.stringify(['Project', 'Person']))
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: ['Project', 'Person'] },
vi.fn(),
)
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
// Top nav items still present

View File

@@ -72,7 +72,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
aliases: [], belongsTo: [], relatedTo: [],
status, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
modifiedAt: now, createdAt: now, fileSize: 0,
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, properties: {},
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, properties: {},
}
}
@@ -167,6 +167,7 @@ export function frontmatterToEntryPatch(
order: { order: typeof value === 'number' ? value : null },
template: { template: str },
sort: { sort: str },
view: { view: str },
}
return updates[k] ?? {}
}

View File

@@ -24,25 +24,33 @@ function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): Note
}
}
/** Types excluded from note search results (internal infrastructure). */
const SEARCH_EXCLUDED_TYPES = new Set(['Config'])
export function useNoteSearch(entries: VaultEntry[], query: string, maxResults = DEFAULT_MAX_RESULTS) {
const [selectedIndex, setSelectedIndex] = useState(0)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const searchableEntries = useMemo(
() => entries.filter((e) => !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')),
[entries],
)
const results: NoteSearchResult[] = useMemo(() => {
const mapResult = (e: VaultEntry) => toResult(e, typeEntryMap)
if (!query.trim()) {
return [...entries]
return [...searchableEntries]
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
.slice(0, maxResults)
.map(mapResult)
}
return entries
return searchableEntries
.map((e) => ({ entry: e, ...fuzzyMatch(query, e.title) }))
.filter((r) => r.match)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults)
.map((r) => mapResult(r.entry))
}, [entries, query, maxResults, typeEntryMap])
}, [searchableEntries, query, maxResults, typeEntryMap])
useEffect(() => {
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change

View File

@@ -1,27 +1,36 @@
import { useState, useCallback } from 'react'
const STORAGE_KEY = 'laputa-hidden-sections'
import { useState, useCallback, useEffect } from 'react'
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
function loadHiddenSections(): Set<string> {
const fromConfig = getVaultConfig().hidden_sections
if (fromConfig && fromConfig.length > 0) return new Set(fromConfig)
// Fallback to localStorage during initial load
try {
const raw = localStorage.getItem(STORAGE_KEY)
const raw = localStorage.getItem('laputa-hidden-sections')
if (raw) {
const arr = JSON.parse(raw)
if (Array.isArray(arr)) return new Set(arr)
if (Array.isArray(arr)) return new Set(arr as string[])
}
} catch {
// ignore corrupt data
}
} catch { /* ignore */ }
return new Set()
}
function saveHiddenSections(hidden: Set<string>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...hidden]))
function saveHiddenSections(hidden: Set<string>): void {
const arr = [...hidden]
updateVaultConfigField('hidden_sections', arr.length > 0 ? arr : null)
}
export function useSectionVisibility() {
const [hiddenSections, setHiddenSections] = useState<Set<string>>(loadHiddenSections)
// Re-sync when vault config becomes available
useEffect(() => {
return subscribeVaultConfig(() => {
const sections = getVaultConfig().hidden_sections
if (sections) setHiddenSections(new Set(sections))
})
}, [])
const toggleSection = useCallback((type: string) => {
setHiddenSections((prev) => {
const next = new Set(prev)

View File

@@ -47,6 +47,7 @@ function mapResults(raw: SearchResultData[]): SearchResult[] {
}))
.filter(r => {
if (seen.has(r.path)) return false
if (r.noteType === 'Config') return false
seen.add(r.path)
return true
})

View File

@@ -0,0 +1,67 @@
import { useEffect, useCallback, useSyncExternalStore } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultConfig } from '../types'
import { initStatusColors } from '../utils/statusStyles'
import { initTagColors } from '../utils/tagStyles'
import { initDisplayModeOverrides } from '../utils/propertyTypes'
import {
getVaultConfig,
bindVaultConfigStore,
resetVaultConfigStore,
updateVaultConfigField,
subscribeVaultConfig,
} from '../utils/vaultConfigStore'
import { migrateLocalStorageToVaultConfig } from '../utils/configMigration'
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
}
function applyToModules(c: VaultConfig): void {
initStatusColors(c.status_colors ?? {})
initTagColors(c.tag_colors ?? {})
initDisplayModeOverrides(c.property_display_modes ?? {})
}
function persistConfig(vaultPath: string, config: VaultConfig): void {
tauriCall<void>('save_vault_config', { vaultPath, config })
.catch((err) => console.warn('Failed to save vault config:', err))
}
export function useVaultConfig(vaultPath: string) {
const config = useSyncExternalStore(subscribeVaultConfig, getVaultConfig)
useEffect(() => {
resetVaultConfigStore()
tauriCall<VaultConfig>('get_vault_config', { vaultPath })
.then((loaded) => {
const migrated = migrateLocalStorageToVaultConfig(loaded)
const needsSave = migrated !== loaded
bindVaultConfigStore(migrated, (c) => persistConfig(vaultPath, c))
applyToModules(migrated)
if (needsSave) persistConfig(vaultPath, migrated)
})
.catch((err) => {
console.warn('Failed to load vault config:', err)
const migrated = migrateLocalStorageToVaultConfig(null)
bindVaultConfigStore(migrated, (c) => persistConfig(vaultPath, c))
applyToModules(migrated)
if (migrated.zoom !== null || migrated.tag_colors !== null || migrated.status_colors !== null) {
persistConfig(vaultPath, migrated)
}
})
return () => resetVaultConfigStore()
}, [vaultPath])
const update = useCallback(<K extends keyof VaultConfig>(key: K, value: VaultConfig[K]) => {
updateVaultConfigField(key, value)
// Re-apply to modules for color/property changes
const next = getVaultConfig()
applyToModules(next)
}, [])
return { config, updateConfig: update }
}

View File

@@ -1,25 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useViewMode } from './useViewMode'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
}))
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
describe('useViewMode', () => {
beforeEach(() => {
localStorageMock.clear()
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
})
it('defaults to "all" when no stored value', () => {
@@ -29,21 +23,25 @@ describe('useViewMode', () => {
expect(result.current.noteListVisible).toBe(true)
})
it('loads persisted view mode from localStorage', () => {
localStorageMock.setItem('laputa-view-mode', 'editor-only')
it('loads persisted view mode from vault config', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: 'editor-only', tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
const { result } = renderHook(() => useViewMode())
expect(result.current.viewMode).toBe('editor-only')
expect(result.current.sidebarVisible).toBe(false)
expect(result.current.noteListVisible).toBe(false)
})
it('setViewMode updates state and persists to localStorage', () => {
it('setViewMode updates state and persists to vault config', () => {
const { result } = renderHook(() => useViewMode())
act(() => result.current.setViewMode('editor-list'))
expect(result.current.viewMode).toBe('editor-list')
expect(result.current.sidebarVisible).toBe(false)
expect(result.current.noteListVisible).toBe(true)
expect(localStorageMock.getItem('laputa-view-mode')).toBe('editor-list')
expect(getVaultConfig().view_mode).toBe('editor-list')
})
it('editor-only hides both sidebar and note list', () => {
@@ -68,8 +66,12 @@ describe('useViewMode', () => {
expect(result.current.noteListVisible).toBe(true)
})
it('ignores invalid localStorage values', () => {
localStorageMock.setItem('laputa-view-mode', 'garbage')
it('ignores invalid vault config values', () => {
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: 'garbage' as never, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
const { result } = renderHook(() => useViewMode())
expect(result.current.viewMode).toBe('all')
})

View File

@@ -1,13 +1,19 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect } from 'react'
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
export type ViewMode = 'editor-only' | 'editor-list' | 'all'
const STORAGE_KEY = 'laputa-view-mode'
function isViewMode(v: string | null | undefined): v is ViewMode {
return v === 'editor-only' || v === 'editor-list' || v === 'all'
}
function loadViewMode(): ViewMode {
const stored = getVaultConfig().view_mode
if (isViewMode(stored)) return stored
// Fallback to localStorage during initial load (before vault config is ready)
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'editor-only' || stored === 'editor-list' || stored === 'all') return stored
const ls = localStorage.getItem('laputa-view-mode')
if (isViewMode(ls)) return ls
} catch { /* ignore */ }
return 'all'
}
@@ -15,9 +21,17 @@ function loadViewMode(): ViewMode {
export function useViewMode() {
const [viewMode, setViewModeState] = useState<ViewMode>(loadViewMode)
// Re-sync when vault config becomes available
useEffect(() => {
return subscribeVaultConfig(() => {
const stored = getVaultConfig().view_mode
if (isViewMode(stored)) setViewModeState(stored)
})
}, [])
const setViewMode = useCallback((mode: ViewMode) => {
setViewModeState(mode)
try { localStorage.setItem(STORAGE_KEY, mode) } catch { /* ignore */ }
updateVaultConfigField('view_mode', mode)
}, [])
const sidebarVisible = viewMode === 'all'

View File

@@ -1,22 +1,14 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useZoom } from './useZoom'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from '../utils/vaultConfigStore'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const DEFAULT_VC = { zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null } as const
describe('useZoom', () => {
beforeEach(() => {
localStorageMock.clear()
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC }, vi.fn())
document.documentElement.style.removeProperty('zoom')
})
@@ -25,20 +17,23 @@ describe('useZoom', () => {
expect(result.current.zoomLevel).toBe(100)
})
it('restores persisted zoom level from localStorage', () => {
localStorageMock.setItem('laputa:zoom-level', '120')
it('restores persisted zoom level from vault config', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.2 }, vi.fn())
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(120)
})
it('ignores invalid persisted values', () => {
localStorageMock.setItem('laputa:zoom-level', 'banana')
it('defaults to 100 when vault config zoom is null', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: null }, vi.fn())
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
it('ignores out-of-range persisted values', () => {
localStorageMock.setItem('laputa:zoom-level', '200')
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 2.0 }, vi.fn())
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})
@@ -47,36 +42,39 @@ describe('useZoom', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(110)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('110')
expect(getVaultConfig().zoom).toBe(1.1)
})
it('zoomOut decreases level by 10', () => {
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomOut())
expect(result.current.zoomLevel).toBe(90)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('90')
expect(getVaultConfig().zoom).toBe(0.9)
})
it('zoomIn clamps at 150', () => {
localStorageMock.setItem('laputa:zoom-level', '150')
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.5 }, vi.fn())
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomIn())
expect(result.current.zoomLevel).toBe(150)
})
it('zoomOut clamps at 80', () => {
localStorageMock.setItem('laputa:zoom-level', '80')
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 0.8 }, vi.fn())
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomOut())
expect(result.current.zoomLevel).toBe(80)
})
it('zoomReset returns to 100', () => {
localStorageMock.setItem('laputa:zoom-level', '130')
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC, zoom: 1.3 }, vi.fn())
const { result } = renderHook(() => useZoom())
act(() => result.current.zoomReset())
expect(result.current.zoomLevel).toBe(100)
expect(localStorageMock.getItem('laputa:zoom-level')).toBe('100')
expect(getVaultConfig().zoom).toBe(1.0)
})
it('applies CSS zoom property to document element', () => {
@@ -105,8 +103,9 @@ describe('useZoom', () => {
expect(result.current.zoomLevel).toBe(130)
})
it('handles localStorage getItem throwing', () => {
localStorageMock.getItem.mockImplementationOnce(() => { throw new Error('no storage') })
it('defaults to 100 when vault config store is empty', () => {
resetVaultConfigStore()
bindVaultConfigStore({ ...DEFAULT_VC }, vi.fn())
const { result } = renderHook(() => useZoom())
expect(result.current.zoomLevel).toBe(100)
})

View File

@@ -1,19 +1,29 @@
import { useState, useEffect, useCallback } from 'react'
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
const ZOOM_KEY = 'laputa:zoom-level'
const MIN_ZOOM = 80
const MAX_ZOOM = 150
const STEP = 10
const DEFAULT_ZOOM = 100
/** Convert vault config zoom (0.81.5 fraction) to percentage (80150). */
function configToPercent(zoom: number | null): number | null {
if (zoom === null) return null
const pct = Math.round(zoom * 100)
return pct >= MIN_ZOOM && pct <= MAX_ZOOM ? pct : null
}
function loadPersistedZoom(): number {
const fromConfig = configToPercent(getVaultConfig().zoom)
if (fromConfig !== null) return fromConfig
// Fallback to localStorage during initial load
try {
const stored = localStorage.getItem(ZOOM_KEY)
const stored = localStorage.getItem('laputa:zoom-level')
if (stored !== null) {
const val = Number(stored)
if (val >= MIN_ZOOM && val <= MAX_ZOOM && val % STEP === 0) return val
}
} catch { /* localStorage unavailable */ }
} catch { /* ignore */ }
return DEFAULT_ZOOM
}
@@ -22,7 +32,7 @@ function applyZoomToDocument(level: number): void {
}
function persistZoom(level: number): void {
try { localStorage.setItem(ZOOM_KEY, String(level)) } catch { /* ignore */ }
updateVaultConfigField('zoom', level / 100)
}
export function useZoom() {
@@ -33,6 +43,17 @@ export function useZoom() {
applyZoomToDocument(zoomLevel)
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- only on mount
// Re-sync when vault config becomes available
useEffect(() => {
return subscribeVaultConfig(() => {
const pct = configToPercent(getVaultConfig().zoom)
if (pct !== null) {
setZoomLevel(pct)
applyZoomToDocument(pct)
}
})
}, [])
const zoomIn = useCallback(() => {
setZoomLevel(prev => {
const next = Math.min(MAX_ZOOM, prev + STEP)

View File

@@ -36,7 +36,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['quarter/q1-2026', 'topic/software-development', 'person/matteo-cellini', 'person/maria-bianchi', 'person/marco-verdi'],
properties: { Priority: 'High', 'Due date': '2026-06-15' },
},
@@ -73,7 +73,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['essay/on-writing-well', 'essay/engineering-leadership-101', 'essay/ai-agents-primer', 'topic/growth', 'topic/writing'],
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly' },
},
@@ -104,7 +104,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['person/matteo-cellini'],
properties: {},
},
@@ -135,7 +135,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -166,7 +166,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['responsibility/manage-sponsorships'],
properties: {},
},
@@ -198,7 +198,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['topic/trading', 'topic/algorithmic-trading', 'data/ema200-backtest-results'],
properties: { Priority: 'Low', 'Due date': '2026-03-01' },
},
@@ -230,7 +230,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['project/26q1-laputa-app', 'topic/growth', 'topic/ads'],
properties: { Priority: 'Medium', Rating: 4 },
},
@@ -261,7 +261,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['project/26q1-laputa-app'],
properties: {},
},
@@ -291,7 +291,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
},
@@ -321,7 +321,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: { Company: 'TechStart', Role: 'Product Manager' },
},
@@ -351,7 +351,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -381,7 +381,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -412,7 +412,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['project/26q1-laputa-app', 'person/matteo-cellini'],
properties: {},
},
@@ -443,7 +443,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -474,7 +474,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -505,7 +505,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -537,7 +537,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['responsibility/grow-newsletter', 'topic/software-development'],
properties: {},
},
@@ -568,7 +568,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: ['responsibility/grow-newsletter'],
properties: {},
},
@@ -597,7 +597,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 0,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -625,7 +625,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 1,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -653,7 +653,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 2,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -681,7 +681,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 3,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -709,7 +709,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 4,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -737,7 +737,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 5,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -765,7 +765,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 6,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -793,7 +793,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 7,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -821,7 +821,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: 8,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -850,7 +850,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'orange',
order: 9,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -878,7 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: 'green',
order: 10,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -909,7 +909,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
},
@@ -939,7 +939,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
},
@@ -971,7 +971,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -1001,7 +1001,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -1032,7 +1032,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -1055,7 +1055,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 120,
@@ -1086,7 +1086,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
modifiedAt: now - 86400 * 90,
@@ -1151,7 +1151,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
order: null,
outgoingLinks: Array.from({ length: i % 8 }, (_j, j) => `note/link-target-${(i + j) % 50}`),
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
properties: {},
})
}
@@ -1184,7 +1184,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -1212,7 +1212,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},
@@ -1240,7 +1240,7 @@ MOCK_ENTRIES.push(
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
template: null, sort: null, view: null,
outgoingLinks: [],
properties: {},
},

View File

@@ -3,7 +3,7 @@
* Each handler simulates a Tauri backend command.
*/
import type { VaultEntry, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings } from '../types'
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings } from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
@@ -314,6 +314,8 @@ line-height-base: 1.6
},
ensure_vault_themes: (): null => null,
restore_default_themes: (): string => 'Default themes restored',
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null }),
save_vault_config: (): null => null,
}
export function addMockEntry(_entry: VaultEntry, content: string): void {

View File

@@ -31,6 +31,8 @@ export interface VaultEntry {
template: string | null
/** Default sort preference for the note list of this Type. Format: "option:direction". */
sort: string | null
/** Default view mode for the note list of this Type: "all", "editor-list", or "editor-only". */
view: string | null
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
outgoingLinks: string[]
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
@@ -139,6 +141,16 @@ export interface VaultSettings {
theme: string | null
}
/** Vault-wide UI configuration stored in config/ui.config.md. */
export interface VaultConfig {
zoom: number | null
view_mode: string | null
tag_colors: Record<string, string> | null
status_colors: Record<string, string> | null
property_display_modes: Record<string, string> | null
hidden_sections: string[] | null
}
export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes'
export type SidebarSelection =

View File

@@ -0,0 +1,95 @@
import type { VaultConfig } from '../types'
const MIGRATION_FLAG = 'laputa:config-migrated-to-vault'
/** Keys to migrate from localStorage to vault config file. */
const LS_KEYS = {
zoom: 'laputa:zoom-level',
viewMode: 'laputa-view-mode',
tagColors: 'laputa:tag-color-overrides',
statusColors: 'laputa:status-color-overrides',
propertyModes: 'laputa:display-mode-overrides',
hiddenSections: 'laputa-hidden-sections',
} as const
function readJson<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) as T : null
} catch {
return null
}
}
/**
* One-time migration: read localStorage values and merge into vault config.
* Returns the merged config. If already migrated (flag set), returns the loaded config unchanged.
* Passing null for `loaded` means the vault file didn't exist yet.
*/
export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): VaultConfig {
const base: VaultConfig = loaded ?? {
zoom: null, view_mode: null, tag_colors: null,
status_colors: null, property_display_modes: null, hidden_sections: null,
}
// Skip migration if already done
try {
if (localStorage.getItem(MIGRATION_FLAG) === '1') return base
} catch {
return base
}
const result = { ...base }
// Zoom (localStorage stores as string "80""150", vault config stores as fraction 0.81.5)
if (result.zoom === null) {
try {
const raw = localStorage.getItem(LS_KEYS.zoom)
if (raw !== null) {
const val = Number(raw)
if (val >= 80 && val <= 150) result.zoom = val / 100
}
} catch { /* ignore */ }
}
// View mode
if (result.view_mode === null) {
try {
const raw = localStorage.getItem(LS_KEYS.viewMode)
if (raw === 'editor-only' || raw === 'editor-list' || raw === 'all') {
result.view_mode = raw
}
} catch { /* ignore */ }
}
// Tag colors
if (result.tag_colors === null) {
const colors = readJson<Record<string, string>>(LS_KEYS.tagColors)
if (colors && Object.keys(colors).length > 0) result.tag_colors = colors
}
// Status colors
if (result.status_colors === null) {
const colors = readJson<Record<string, string>>(LS_KEYS.statusColors)
if (colors && Object.keys(colors).length > 0) result.status_colors = colors
}
// Property display modes
if (result.property_display_modes === null) {
const modes = readJson<Record<string, string>>(LS_KEYS.propertyModes)
if (modes && Object.keys(modes).length > 0) result.property_display_modes = modes
}
// Hidden sections
if (result.hidden_sections === null) {
const sections = readJson<string[]>(LS_KEYS.hiddenSections)
if (sections && sections.length > 0) result.hidden_sections = sections
}
// Mark migration as done
try {
localStorage.setItem(MIGRATION_FLAG, '1')
} catch { /* ignore */ }
return result
}

View File

@@ -1,5 +1,6 @@
import type { FrontmatterValue } from '../components/Inspector'
import { isValidCssColor, isColorKeyName } from './colorUtils'
import { updateVaultConfigField } from './vaultConfigStore'
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
@@ -42,7 +43,15 @@ export function detectPropertyType(key: string, value: FrontmatterValue): Proper
const STORAGE_KEY = 'laputa:display-mode-overrides'
let vaultOverrides: Record<string, PropertyDisplayMode> | null = null
/** Initialize display mode overrides from vault config (replaces localStorage). */
export function initDisplayModeOverrides(overrides: Record<string, string>): void {
vaultOverrides = overrides as Record<string, PropertyDisplayMode>
}
export function loadDisplayModeOverrides(): Record<string, PropertyDisplayMode> {
if (vaultOverrides !== null) return { ...vaultOverrides }
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
@@ -51,16 +60,22 @@ export function loadDisplayModeOverrides(): Record<string, PropertyDisplayMode>
}
}
function persistDisplayModeOverrides(overrides: Record<string, PropertyDisplayMode>): void {
vaultOverrides = { ...overrides }
const snapshot = Object.keys(overrides).length > 0 ? { ...overrides } : null
updateVaultConfigField('property_display_modes', snapshot as Record<string, string> | null)
}
export function saveDisplayModeOverride(propertyName: string, mode: PropertyDisplayMode): void {
const overrides = loadDisplayModeOverrides()
overrides[propertyName] = mode
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides))
persistDisplayModeOverrides(overrides)
}
export function removeDisplayModeOverride(propertyName: string): void {
const overrides = loadDisplayModeOverrides()
delete overrides[propertyName]
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides))
persistDisplayModeOverrides(overrides)
}
export function getEffectiveDisplayMode(

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
getStatusStyle,
getStatusColorKey,
@@ -6,31 +6,19 @@ import {
getStatusColorOverrides,
STATUS_STYLES,
DEFAULT_STATUS_STYLE,
initStatusColors,
} from './statusStyles'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (i: number) => Object.keys(store)[i] ?? null,
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const STORAGE_KEY = 'laputa:status-color-overrides'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from './vaultConfigStore'
describe('statusStyles — color overrides', () => {
beforeEach(() => {
localStorageMock.clear()
// Reset module-level cache by clearing all overrides
for (const key of Object.keys(getStatusColorOverrides())) {
setStatusColor(key, null)
}
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
// Reset module-level cache by re-initializing with empty overrides
initStatusColors({})
})
it('returns built-in style when no override exists', () => {
@@ -48,7 +36,9 @@ describe('statusStyles — color overrides', () => {
it('setStatusColor persists a color override', () => {
setStatusColor('Active', 'red')
expect(getStatusColorKey('Active')).toBe('red')
expect(localStorage.getItem(STORAGE_KEY)).toContain('"Active":"red"')
const stored = getVaultConfig().status_colors as Record<string, string>
expect(stored).toBeTruthy()
expect(stored['Active']).toBe('red')
})
it('getStatusStyle uses override when set', () => {

View File

@@ -1,4 +1,5 @@
import { ACCENT_COLORS } from './typeColors'
import { updateVaultConfigField } from './vaultConfigStore'
export interface StatusStyle {
bg: string
@@ -46,7 +47,12 @@ const COLOR_KEY_TO_STYLE: Record<string, StatusStyle> = Object.fromEntries(
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
)
const colorOverrides: Record<string, string> = loadColorOverrides()
let colorOverrides: Record<string, string> = loadColorOverrides()
/** Initialize status color overrides from vault config (replaces localStorage). */
export function initStatusColors(overrides: Record<string, string>): void {
colorOverrides = { ...overrides }
}
function loadColorOverrides(): Record<string, string> {
try {
@@ -67,9 +73,8 @@ export function setStatusColor(status: string, colorKey: string | null): void {
} else {
colorOverrides[status] = colorKey
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(colorOverrides))
} catch { /* storage full — silently ignore */ }
const snapshot = { ...colorOverrides }
updateVaultConfigField('status_colors', Object.keys(snapshot).length > 0 ? snapshot : null)
}
export function getStatusColorKey(status: string): string | null {

View File

@@ -1,35 +1,22 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
getTagStyle,
getTagColorKey,
setTagColor,
DEFAULT_TAG_STYLE,
initTagColors,
} from './tagStyles'
// Mock localStorage (jsdom's may be incomplete)
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => { store[key] = value },
removeItem: (key: string) => { delete store[key] },
clear: () => { store = {} },
get length() { return Object.keys(store).length },
key: (i: number) => Object.keys(store)[i] ?? null,
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
const STORAGE_KEY = 'laputa:tag-color-overrides'
import { bindVaultConfigStore, getVaultConfig, resetVaultConfigStore } from './vaultConfigStore'
describe('tagStyles — color overrides', () => {
beforeEach(() => {
localStorageMock.clear()
// Reset module-level cache by clearing known overrides
// We can't easily list all, but clearing known test keys suffices
for (const tag of ['React', 'TypeScript', 'Tauri', 'CustomTag']) {
setTagColor(tag, null)
}
resetVaultConfigStore()
bindVaultConfigStore(
{ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null, hidden_sections: null },
vi.fn(),
)
// Reset module-level cache
initTagColors({})
})
it('returns default style when no override exists', () => {
@@ -43,7 +30,9 @@ describe('tagStyles — color overrides', () => {
it('setTagColor persists a color override', () => {
setTagColor('React', 'blue')
expect(getTagColorKey('React')).toBe('blue')
expect(localStorage.getItem(STORAGE_KEY)).toContain('"React":"blue"')
const stored = getVaultConfig().tag_colors as Record<string, string>
expect(stored).toBeTruthy()
expect(stored['React']).toBe('blue')
})
it('getTagStyle uses override when set', () => {
@@ -74,10 +63,10 @@ describe('tagStyles — color overrides', () => {
expect(getTagStyle('React')).toEqual(DEFAULT_TAG_STYLE)
})
it('persists multiple overrides to localStorage', () => {
it('persists multiple overrides to vault config', () => {
setTagColor('React', 'blue')
setTagColor('Tauri', 'orange')
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
const stored = getVaultConfig().tag_colors as Record<string, string>
expect(stored).toEqual({ React: 'blue', Tauri: 'orange' })
})
})

View File

@@ -1,4 +1,5 @@
import { ACCENT_COLORS } from './typeColors'
import { updateVaultConfigField } from './vaultConfigStore'
export interface TagStyle {
bg: string
@@ -16,7 +17,12 @@ const COLOR_KEY_TO_STYLE: Record<string, TagStyle> = Object.fromEntries(
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
)
const colorOverrides: Record<string, string> = loadColorOverrides()
let colorOverrides: Record<string, string> = loadColorOverrides()
/** Initialize tag color overrides from vault config (replaces localStorage). */
export function initTagColors(overrides: Record<string, string>): void {
colorOverrides = { ...overrides }
}
function loadColorOverrides(): Record<string, string> {
try {
@@ -33,9 +39,8 @@ export function setTagColor(tag: string, colorKey: string | null): void {
} else {
colorOverrides[tag] = colorKey
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(colorOverrides))
} catch { /* storage full — silently ignore */ }
const snapshot = { ...colorOverrides }
updateVaultConfigField('tag_colors', Object.keys(snapshot).length > 0 ? snapshot : null)
}
export function getTagColorKey(tag: string): string | null {

View File

@@ -0,0 +1,43 @@
import type { VaultConfig } from '../types'
type SaveFn = (config: VaultConfig) => void
type Listener = () => void
const DEFAULT_CONFIG: VaultConfig = {
zoom: null, view_mode: null, tag_colors: null,
status_colors: null, property_display_modes: null, hidden_sections: null,
}
let config: VaultConfig = DEFAULT_CONFIG
let saveFn: SaveFn | null = null
const listeners: Set<Listener> = new Set()
export function getVaultConfig(): VaultConfig {
return config
}
export function bindVaultConfigStore(initial: VaultConfig, save: SaveFn): void {
config = initial
saveFn = save
notify()
}
export function resetVaultConfigStore(): void {
config = DEFAULT_CONFIG
saveFn = null
}
export function updateVaultConfigField<K extends keyof VaultConfig>(key: K, value: VaultConfig[K]): void {
config = { ...config, [key]: value }
saveFn?.(config)
notify()
}
export function subscribeVaultConfig(listener: Listener): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
function notify(): void {
for (const fn of listeners) fn()
}