fix: live-reload theme CSS vars on editor save and frontmatter changes

Wire notifyThemeSaved through the frontmatter update chain so CSS
variables update immediately when:
- The user edits a theme note in raw mode and presses Cmd+S
- A frontmatter property is changed via the inspector panel

Also expose CodeMirror EditorView on the DOM for Playwright test access,
add unit tests for onNotePersisted, and a new Playwright smoke test
that verifies the full raw-editor → save → CSS-vars-update flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-19 00:57:34 +01:00
parent 7db3c2f00a
commit 8f6da24ef5
6 changed files with 178 additions and 8 deletions

View File

@@ -159,7 +159,7 @@ function App() {
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles, replaceEntry: vault.replaceEntry, onFrontmatterContentChanged: themeManager.notifyThemeSaved })
// Keep tab entries in sync with vault entries so banners (trash/archive)
// and read-only state react immediately without reopening the note.

View File

@@ -65,20 +65,24 @@ export interface FrontmatterOpOptions {
silent?: boolean
}
/** Run a frontmatter update/delete and apply the result to state. */
/** Run a frontmatter update/delete and apply the result to state.
* Returns the new file content on success, or undefined on failure. */
export async function runFrontmatterAndApply(
op: 'update' | 'delete', path: string, key: string, value: FrontmatterValue | undefined,
callbacks: { updateTab: (p: string, c: string) => void; updateEntry: (p: string, patch: Partial<VaultEntry>) => void; toast: (m: string | null) => void },
options?: FrontmatterOpOptions,
): Promise<void> {
): Promise<string | undefined> {
try {
callbacks.updateTab(path, await executeFrontmatterOp(op, path, key, value))
const newContent = await executeFrontmatterOp(op, path, key, value)
callbacks.updateTab(path, newContent)
const patch = frontmatterToEntryPatch(op, key, value)
if (Object.keys(patch).length > 0) callbacks.updateEntry(path, patch)
if (!options?.silent) callbacks.toast(op === 'update' ? 'Property updated' : 'Property deleted')
return newContent
} catch (err) {
console.error(`Failed to ${op} frontmatter:`, err)
if (options?.silent) throw err
callbacks.toast(`Failed to ${op} property`)
return undefined
}
}

View File

@@ -126,6 +126,9 @@ export function useCodeMirror(
const view = new EditorView({ state, parent })
viewRef.current = view
// Expose EditorView on the parent DOM for Playwright test access
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(parent as any).__cmView = view
// When CSS zoom changes on the document, CodeMirror's cached measurements
// (scaleX/scaleY, line heights, character widths) become stale because
@@ -136,6 +139,8 @@ export function useCodeMirror(
return () => {
window.removeEventListener('laputa-zoom-change', handleZoomChange)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (parent as any).__cmView
view.destroy()
viewRef.current = null
}

View File

@@ -219,6 +219,43 @@ describe('useEditorSave', () => {
expect(updateVaultContent).toHaveBeenCalledWith('/vault/note.md', edited)
})
it('calls onNotePersisted with path and content after saving pending content', async () => {
const onNotePersisted = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
)
act(() => {
result.current.handleContentChange('/vault/theme/default.md', '---\nbackground: "#FFD700"\n---\n')
})
await act(async () => {
await result.current.handleSave()
})
expect(onNotePersisted).toHaveBeenCalledWith(
'/vault/theme/default.md',
'---\nbackground: "#FFD700"\n---\n',
)
})
it('calls onNotePersisted for unsaved fallback when no pending content', async () => {
const onNotePersisted = vi.fn()
const { result } = renderHook(() =>
useEditorSave({ updateVaultContent, setTabs, setToastMessage, onNotePersisted })
)
// No handleContentChange — simulate Cmd+S on a newly created unsaved note
await act(async () => {
await result.current.handleSave({ path: '/vault/theme/default.md', content: '---\nbackground: "#FF0000"\n---\n' })
})
expect(onNotePersisted).toHaveBeenCalledWith(
'/vault/theme/default.md',
'---\nbackground: "#FF0000"\n---\n',
)
})
it('successive edits and saves persist each version correctly', async () => {
const { result } = renderSaveHook()

View File

@@ -27,6 +27,8 @@ export interface NoteActionsConfig {
markContentPending?: (path: string, content: string) => void
onNewNotePersisted?: () => void
replaceEntry?: (oldPath: string, patch: Partial<VaultEntry> & { path: string }) => void
/** Called after frontmatter is written to disk — used for live-reloading theme CSS vars. */
onFrontmatterContentChanged?: (path: string, content: string) => void
}
function isTitleKey(key: string): boolean {
@@ -131,7 +133,8 @@ export function useNoteActions(config: NoteActionsConfig) {
handleCreateType: creation.handleCreateType,
createTypeEntrySilent: creation.createTypeEntrySilent,
handleUpdateFrontmatter: useCallback(async (path: string, key: string, value: FrontmatterValue, options?: FrontmatterOpOptions) => {
await runFrontmatterOp('update', path, key, value, options)
const newContent = await runFrontmatterOp('update', path, key, value, options)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
if (shouldRenameOnTitleUpdate(key, value)) {
try {
await renameAfterTitleChange(path, value, {
@@ -142,9 +145,15 @@ export function useNoteActions(config: NoteActionsConfig) {
console.error('Failed to rename note after title change:', err)
}
}
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback((path: string, key: string, options?: FrontmatterOpOptions) => runFrontmatterOp('delete', path, key, undefined, options), [runFrontmatterOp]),
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
}, [runFrontmatterOp, config, rename.tabsRef, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage, updateTabContent]),
handleDeleteProperty: useCallback(async (path: string, key: string, options?: FrontmatterOpOptions) => {
const newContent = await runFrontmatterOp('delete', path, key, undefined, options)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
}, [runFrontmatterOp, config]),
handleAddProperty: useCallback(async (path: string, key: string, value: FrontmatterValue) => {
const newContent = await runFrontmatterOp('update', path, key, value)
if (newContent) config.onFrontmatterContentChanged?.(path, newContent)
}, [runFrontmatterOp, config]),
handleRenameNote: rename.handleRenameNote,
}
}

View File

@@ -0,0 +1,115 @@
import { test, expect, type Page } from '@playwright/test'
import { openCommandPalette, executeCommand } from './helpers'
async function getCssVar(page: Page, name: string): Promise<string> {
return page.evaluate(
(n) => document.documentElement.style.getPropertyValue(n),
name,
)
}
/** Replace all text in the CodeMirror editor via the exposed __cmView. */
async function setCmContent(page: Page, newContent: string) {
await page.evaluate((text) => {
const container = document.querySelector('[data-testid="raw-editor-codemirror"]')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const view = (container as any)?.__cmView
if (!view) throw new Error('No __cmView on raw-editor-codemirror container')
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
})
}, newContent)
}
test.describe('Theme live reload on raw editor save (rework)', () => {
test.beforeEach(async ({ page }) => {
// Block vault API so the app uses mock handlers
await page.route('**/api/vault/ping', (route) =>
route.fulfill({ status: 404, body: 'blocked for testing' }),
)
await page.goto('/')
await page.waitForLoadState('networkidle')
})
test('editing theme frontmatter in raw mode and saving updates CSS vars', async ({ page }) => {
// 1. Switch to the default theme
await openCommandPalette(page)
await executeCommand(page, 'Switch to Default Theme')
await expect(async () => {
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
}).toPass({ timeout: 5000 })
// 2. Open the theme note in the editor
await openCommandPalette(page)
await executeCommand(page, 'Edit Default Theme')
await page.waitForTimeout(500)
// 3. Switch to raw editor mode
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw Editor')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 3000 })
// 4. Replace content with a changed background color
const updatedContent = [
'---',
'type: Theme',
'Description: Light theme with warm, paper-like tones',
'background: "#FFD700"',
'foreground: "#37352F"',
'primary: "#155DFF"',
'sidebar: "#F7F6F3"',
'text-primary: "#37352F"',
'---',
'',
'# Default',
'',
'Light theme with warm, paper-like tones.',
].join('\n')
await setCmContent(page, updatedContent)
// Wait for debounce to flush (RawEditorView has 500ms debounce)
await page.waitForTimeout(700)
// 5. Save with Ctrl+S (works on all platforms in browser mode)
await page.keyboard.press('Control+s')
// 6. Verify CSS vars updated live
await expect(async () => {
expect(await getCssVar(page, '--background')).toBe('#FFD700')
}).toPass({ timeout: 5000 })
// Verify other vars also updated
expect(await getCssVar(page, '--sidebar')).toBe('#F7F6F3')
expect(await getCssVar(page, '--foreground')).toBe('#37352F')
})
test('saving a non-theme note does not affect active theme CSS', async ({ page }) => {
// 1. Switch to the default theme
await openCommandPalette(page)
await executeCommand(page, 'Switch to Default Theme')
await expect(async () => {
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
}).toPass({ timeout: 5000 })
// 2. Open a regular note (first in the note list)
const noteList = page.locator('[data-testid="note-list-container"]')
await noteList.waitFor({ timeout: 5000 })
await noteList.locator('.cursor-pointer').first().click()
await page.waitForTimeout(300)
// 3. Switch to raw editor mode
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw Editor')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 3000 })
// 4. Type something and save
await page.locator('.cm-content').click()
await page.keyboard.type('test edit ')
await page.waitForTimeout(600)
await page.keyboard.press('Control+s')
await page.waitForTimeout(500)
// 5. Theme CSS vars should be unchanged
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
})
})