From 8f6da24ef5f49ca4e9714755394966bd1ede5ca0 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 19 Mar 2026 00:57:34 +0100 Subject: [PATCH] fix: live-reload theme CSS vars on editor save and frontmatter changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/App.tsx | 2 +- src/hooks/frontmatterOps.ts | 10 +- src/hooks/useCodeMirror.ts | 5 + src/hooks/useEditorSave.test.ts | 37 ++++++ src/hooks/useNoteActions.ts | 17 ++- tests/smoke/theme-live-reload-rework.spec.ts | 115 +++++++++++++++++++ 6 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 tests/smoke/theme-live-reload-rework.spec.ts diff --git a/src/App.tsx b/src/App.tsx index a855a581..6c8511f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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. diff --git a/src/hooks/frontmatterOps.ts b/src/hooks/frontmatterOps.ts index 33fd3ad4..bf412cd6 100644 --- a/src/hooks/frontmatterOps.ts +++ b/src/hooks/frontmatterOps.ts @@ -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) => void; toast: (m: string | null) => void }, options?: FrontmatterOpOptions, -): Promise { +): Promise { 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 } } diff --git a/src/hooks/useCodeMirror.ts b/src/hooks/useCodeMirror.ts index 1ad764ee..aa1d05ed 100644 --- a/src/hooks/useCodeMirror.ts +++ b/src/hooks/useCodeMirror.ts @@ -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 } diff --git a/src/hooks/useEditorSave.test.ts b/src/hooks/useEditorSave.test.ts index 897de224..2cfc764b 100644 --- a/src/hooks/useEditorSave.test.ts +++ b/src/hooks/useEditorSave.test.ts @@ -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() diff --git a/src/hooks/useNoteActions.ts b/src/hooks/useNoteActions.ts index 83940523..be1ea12e 100644 --- a/src/hooks/useNoteActions.ts +++ b/src/hooks/useNoteActions.ts @@ -27,6 +27,8 @@ export interface NoteActionsConfig { markContentPending?: (path: string, content: string) => void onNewNotePersisted?: () => void replaceEntry?: (oldPath: string, patch: Partial & { 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, } } diff --git a/tests/smoke/theme-live-reload-rework.spec.ts b/tests/smoke/theme-live-reload-rework.spec.ts new file mode 100644 index 00000000..fa80adde --- /dev/null +++ b/tests/smoke/theme-live-reload-rework.spec.ts @@ -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 { + 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') + }) +})