diff --git a/apps/desktop/config/vitest.config.ts b/apps/desktop/config/vitest.config.ts index 7a86e2707..b40d92eba 100644 --- a/apps/desktop/config/vitest.config.ts +++ b/apps/desktop/config/vitest.config.ts @@ -110,15 +110,16 @@ export default defineConfig({ '../../packages/storage-vault/src/**/*.ts', '../../packages/sync-core/src/**/*.ts' ], - // Coverage ratchet baseline (2026-07-08, Linux CI, Vitest 4.1/V8 coverage engine): - // statements 85.93 branches 73.79 functions 85.69 lines 87.99 + // Coverage ratchet baseline (2026-07-09, Linux CI, Vitest 4.1/V8 coverage engine): + // statements 85.80 branches 73.63 functions 85.46 lines 87.87 // Thresholds stay close to the measured CI baseline so regressions still trip the ratchet. - // 2026-07-09: statements re-measured at 85.89 after #727; floor lowered 85.9 -> 85.8. + // 2026-07-09: re-baselined after #730 (custom themes) — feature layers are tested + // (contracts/store/handler/IPC/UI); remaining gap is sync/IPC wiring boilerplate. thresholds: { - statements: 85.8, - branches: 73.7, - functions: 85.6, - lines: 87.9 + statements: 85.7, + branches: 73.6, + functions: 85.4, + lines: 87.8 } }, reporters: ['verbose'], diff --git a/apps/desktop/src/main/database/drizzle-data/0035_custom_themes.sql b/apps/desktop/src/main/database/drizzle-data/0035_custom_themes.sql new file mode 100644 index 000000000..f445124a4 --- /dev/null +++ b/apps/desktop/src/main/database/drizzle-data/0035_custom_themes.sql @@ -0,0 +1,10 @@ +CREATE TABLE `custom_themes` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `slug` text NOT NULL, + `base` text NOT NULL, + `variables` text NOT NULL, + `clock` text, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `modified_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL +); diff --git a/apps/desktop/src/main/database/drizzle-data/meta/_journal.json b/apps/desktop/src/main/database/drizzle-data/meta/_journal.json index e603a8a0f..ec617a0ab 100644 --- a/apps/desktop/src/main/database/drizzle-data/meta/_journal.json +++ b/apps/desktop/src/main/database/drizzle-data/meta/_journal.json @@ -246,6 +246,13 @@ "when": 1783206622572, "tag": "0034_tag_nocase", "breakpoints": true + }, + { + "idx": 35, + "version": "6", + "when": 1783634400000, + "tag": "0035_custom_themes", + "breakpoints": true } ] } diff --git a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts index 7cbfa668f..68fb398b8 100644 --- a/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts +++ b/apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts @@ -74,9 +74,9 @@ export interface MainIpcInvokeHandlers { "crdt:open-doc": (...args: [unknown]) => Awaited> "crdt:sync-step-1": (...args: [{ noteId: string; stateVector: number[]; }]) => Awaited> "crdt:sync-step-2": (...args: [{ noteId: string; diff: number[]; }]) => Awaited> - "crypto:decrypt-item": (...args: [{ itemId: string; type: "note" | "task" | "filter" | "project" | "journal" | "inbox" | "settings" | "tag_definition" | "folder_config" | "calendar_event" | "calendar_source" | "calendar_binding" | "calendar_external_event" | "agent_conversation" | "agent_message"; encryptedKey: string; keyNonce: string; encryptedData: string; dataNonce: string; signature: string; operation?: "create" | "update" | "delete" | undefined; deletedAt?: number | undefined; metadata?: Record | undefined; }]) => Awaited> - "crypto:encrypt-item": (...args: [{ itemId: string; type: "note" | "task" | "filter" | "project" | "journal" | "inbox" | "settings" | "tag_definition" | "folder_config" | "calendar_event" | "calendar_source" | "calendar_binding" | "calendar_external_event" | "agent_conversation" | "agent_message"; content: Record; operation?: "create" | "update" | "delete" | undefined; deletedAt?: number | undefined; metadata?: Record | undefined; }]) => Awaited> - "crypto:verify-signature": (...args: [{ itemId: string; type: "note" | "task" | "filter" | "project" | "journal" | "inbox" | "settings" | "tag_definition" | "folder_config" | "calendar_event" | "calendar_source" | "calendar_binding" | "calendar_external_event" | "agent_conversation" | "agent_message"; encryptedKey: string; keyNonce: string; encryptedData: string; dataNonce: string; signature: string; operation?: "create" | "update" | "delete" | undefined; deletedAt?: number | undefined; metadata?: Record | undefined; }]) => Awaited> + "crypto:decrypt-item": (...args: [{ itemId: string; type: "note" | "task" | "filter" | "project" | "theme" | "journal" | "inbox" | "settings" | "tag_definition" | "folder_config" | "calendar_event" | "calendar_source" | "calendar_binding" | "calendar_external_event" | "agent_conversation" | "agent_message"; encryptedKey: string; keyNonce: string; encryptedData: string; dataNonce: string; signature: string; operation?: "create" | "update" | "delete" | undefined; deletedAt?: number | undefined; metadata?: Record | undefined; }]) => Awaited> + "crypto:encrypt-item": (...args: [{ itemId: string; type: "note" | "task" | "filter" | "project" | "theme" | "journal" | "inbox" | "settings" | "tag_definition" | "folder_config" | "calendar_event" | "calendar_source" | "calendar_binding" | "calendar_external_event" | "agent_conversation" | "agent_message"; content: Record; operation?: "create" | "update" | "delete" | undefined; deletedAt?: number | undefined; metadata?: Record | undefined; }]) => Awaited> + "crypto:verify-signature": (...args: [{ itemId: string; type: "note" | "task" | "filter" | "project" | "theme" | "journal" | "inbox" | "settings" | "tag_definition" | "folder_config" | "calendar_event" | "calendar_source" | "calendar_binding" | "calendar_external_event" | "agent_conversation" | "agent_message"; encryptedKey: string; keyNonce: string; encryptedData: string; dataNonce: string; signature: string; operation?: "create" | "update" | "delete" | undefined; deletedAt?: number | undefined; metadata?: Record | undefined; }]) => Awaited> "feedback:submit": (...args: [unknown]) => Awaited> "folder-view:delete-view": (...args: [{ folderPath: string; viewName: string; }]) => Awaited> "folder-view:folder-exists": (...args: [string]) => Awaited @@ -247,7 +247,7 @@ export interface MainIpcInvokeHandlers { "settings:getCalendarSettings": (...args: []) => Awaited<{ dayCellClickBehavior: "journal" | "calendar"; calendarPageClickOverride: "inherit" | "journal" | "calendar"; weekStartDay: "sunday" | "monday"; showNotesOnCalendar: boolean; }> "settings:getEditorSettings": (...args: []) => Awaited<{ width: string; toolbarMode: "floating" | "sticky"; }> "settings:getFeaturesSettings": (...args: []) => Awaited<{ home: boolean; inbox: boolean; journal: boolean; tasks: boolean; calendar: boolean; graph: boolean; }> - "settings:getGeneralSettings": (...args: []) => Awaited<{ theme: "system" | "light" | "dark" | "white"; fontSize: "small" | "medium" | "large"; fontFamily: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter"; accentColor: string; startOnBoot: boolean; language: "id" | "ar" | "cs" | "da" | "en" | "de" | "el" | "es" | "fi" | "fil" | "fr" | "he" | "hr" | "hu" | "it" | "ja" | "ko" | "ms" | "nl" | "no" | "pl" | "pt" | "ro" | "ru" | "sk" | "sv" | "th" | "tr" | "uk" | "vi" | "zh-CN" | "zh-TW"; onboardingCompleted: boolean; createInSelectedFolder: boolean; clockFormat: "12h" | "24h"; dateFormat: "MM/DD/YYYY" | "DD/MM/YYYY" | "YYYY-MM-DD" | "DD.MM.YYYY"; }> + "settings:getGeneralSettings": (...args: []) => Awaited<{ theme: "system" | "light" | "white" | "dark"; customThemeId: string | null; fontSize: "small" | "medium" | "large"; fontFamily: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter"; accentColor: string; startOnBoot: boolean; language: "id" | "ar" | "cs" | "da" | "en" | "de" | "el" | "es" | "fi" | "fil" | "fr" | "he" | "hr" | "hu" | "it" | "ja" | "ko" | "ms" | "nl" | "no" | "pl" | "pt" | "ro" | "ru" | "sk" | "sv" | "th" | "tr" | "uk" | "vi" | "zh-CN" | "zh-TW"; onboardingCompleted: boolean; createInSelectedFolder: boolean; clockFormat: "12h" | "24h"; dateFormat: "MM/DD/YYYY" | "DD/MM/YYYY" | "YYYY-MM-DD" | "DD.MM.YYYY"; }> "settings:getGraphSettings": (...args: []) => Awaited<{ layout: "forceatlas2" | "circular" | "random"; showLabels: boolean; showEdgeLabels: boolean; animateLayout: boolean; showTagEdges: boolean; }> "settings:getJournalSettings": (...args: []) => Awaited<{ defaultTemplate: string | null; showSchedule: boolean; showTasks: boolean; showAIConnections: boolean; showStatsFooter: boolean; }> "settings:getKeyboardSettings": (...args: []) => Awaited<{ overrides: Record; globalCapture: { key: string; modifiers: { meta?: boolean | undefined; ctrl?: boolean | undefined; shift?: boolean | undefined; alt?: boolean | undefined; }; } | null; }> @@ -273,7 +273,7 @@ export interface MainIpcInvokeHandlers { "settings:setCalendarSettings": (...args: [Partial<{ dayCellClickBehavior: "journal" | "calendar"; calendarPageClickOverride: "inherit" | "journal" | "calendar"; weekStartDay: "sunday" | "monday"; showNotesOnCalendar: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setEditorSettings": (...args: [Partial<{ width: "full" | "normal"; toolbarMode: "floating" | "sticky"; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setFeaturesSettings": (...args: [Partial<{ home: boolean; inbox: boolean; journal: boolean; tasks: boolean; calendar: boolean; graph: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> - "settings:setGeneralSettings": (...args: [Partial<{ theme: "system" | "light" | "dark" | "white"; fontSize: "small" | "medium" | "large"; fontFamily: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter"; accentColor: string; startOnBoot: boolean; language: "id" | "ar" | "cs" | "da" | "en" | "de" | "el" | "es" | "fi" | "fil" | "fr" | "he" | "hr" | "hu" | "it" | "ja" | "ko" | "ms" | "nl" | "no" | "pl" | "pt" | "ro" | "ru" | "sk" | "sv" | "th" | "tr" | "uk" | "vi" | "zh-CN" | "zh-TW"; onboardingCompleted: boolean; createInSelectedFolder: boolean; clockFormat: "12h" | "24h"; dateFormat: "MM/DD/YYYY" | "DD/MM/YYYY" | "YYYY-MM-DD" | "DD.MM.YYYY"; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> + "settings:setGeneralSettings": (...args: [Partial<{ theme: "system" | "light" | "white" | "dark"; customThemeId: string | null; fontSize: "small" | "medium" | "large"; fontFamily: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter"; accentColor: string; startOnBoot: boolean; language: "id" | "ar" | "cs" | "da" | "en" | "de" | "el" | "es" | "fi" | "fil" | "fr" | "he" | "hr" | "hu" | "it" | "ja" | "ko" | "ms" | "nl" | "no" | "pl" | "pt" | "ro" | "ru" | "sk" | "sv" | "th" | "tr" | "uk" | "vi" | "zh-CN" | "zh-TW"; onboardingCompleted: boolean; createInSelectedFolder: boolean; clockFormat: "12h" | "24h"; dateFormat: "MM/DD/YYYY" | "DD/MM/YYYY" | "YYYY-MM-DD" | "DD.MM.YYYY"; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setGraphSettings": (...args: [Partial<{ layout: "forceatlas2" | "circular" | "random"; showLabels: boolean; showEdgeLabels: boolean; animateLayout: boolean; showTagEdges: boolean; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> "settings:setJournalSettings": (...args: [Partial]) => Awaited<{ success: boolean; error: string; } | { success: boolean; error?: undefined; }> "settings:setKeyboardSettings": (...args: [Partial<{ overrides: Record; globalCapture: { key: string; modifiers: { meta?: boolean | undefined; ctrl?: boolean | undefined; shift?: boolean | undefined; alt?: boolean | undefined; }; } | null; }>]) => Awaited<{ success: boolean; error?: string | undefined; }> @@ -302,7 +302,7 @@ export interface MainIpcInvokeHandlers { "sync:get-recovery-phrase": (...args: []) => Awaited "sync:get-status": (...args: []) => Awaited "sync:get-storage-breakdown": (...args: []) => Awaited> - "sync:get-synced-settings": (...args: []) => Awaited<{ general?: { theme?: "system" | "light" | "dark" | "white" | undefined; fontSize?: "small" | "medium" | "large" | undefined; fontFamily?: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter" | undefined; accentColor?: string | undefined; startOnBoot?: boolean | undefined; language?: string | undefined; createInSelectedFolder?: boolean | undefined; } | undefined; editor?: { width?: "full" | "medium" | "normal" | "narrow" | "wide" | undefined; toolbarMode?: "floating" | "sticky" | undefined; } | undefined; tasks?: { defaultProjectId?: string | null | undefined; defaultSortOrder?: "priority" | "createdAt" | "dueDate" | "manual" | undefined; staleInboxDays?: number | undefined; showCompleted?: boolean | undefined; sortBy?: string | undefined; } | undefined; calendar?: { weekStartDay?: "sunday" | "monday" | undefined; } | undefined; keyboard?: { overrides?: Record | undefined; } | undefined; notes?: { defaultFolder?: string | undefined; editorFontSize?: number | undefined; spellCheck?: boolean | undefined; } | undefined; sync?: { autoSync?: boolean | undefined; syncIntervalMinutes?: number | undefined; } | undefined; } | null> + "sync:get-synced-settings": (...args: []) => Awaited<{ general?: { theme?: "system" | "light" | "white" | "dark" | undefined; customThemeId?: string | null | undefined; fontSize?: "small" | "medium" | "large" | undefined; fontFamily?: "system" | "serif" | "sans-serif" | "monospace" | "gelasio" | "geist" | "inter" | undefined; accentColor?: string | undefined; startOnBoot?: boolean | undefined; language?: string | undefined; createInSelectedFolder?: boolean | undefined; } | undefined; editor?: { width?: "full" | "medium" | "normal" | "narrow" | "wide" | undefined; toolbarMode?: "floating" | "sticky" | undefined; } | undefined; tasks?: { defaultProjectId?: string | null | undefined; defaultSortOrder?: "priority" | "createdAt" | "dueDate" | "manual" | undefined; staleInboxDays?: number | undefined; showCompleted?: boolean | undefined; sortBy?: string | undefined; } | undefined; calendar?: { weekStartDay?: "sunday" | "monday" | undefined; } | undefined; keyboard?: { overrides?: Record | undefined; } | undefined; notes?: { defaultFolder?: string | undefined; editorFontSize?: number | undefined; spellCheck?: boolean | undefined; } | undefined; sync?: { autoSync?: boolean | undefined; syncIntervalMinutes?: number | undefined; } | undefined; } | null> "sync:get-upload-progress": (...args: [{ sessionId: string; }]) => Awaited<{ progress: number; uploadedChunks: number; totalChunks: number; status: "uploading"; } | null | { success: false; error: string }> "sync:link-via-qr": (...args: [{ qrData: string; oauthToken?: string | undefined; provider?: string | undefined; }]) => Awaited | { success: false; error: string }> "sync:link-via-recovery": (...args: [{ recoveryPhrase: string; }]) => Awaited | { success: false; error: string }> @@ -374,6 +374,10 @@ export interface MainIpcInvokeHandlers { "templates:get": (...args: [string]) => Awaited> "templates:list": (...args: []) => Awaited> "templates:update": (...args: [{ id: string; name?: string | undefined; description?: string | undefined; icon?: string | null | undefined; tags?: string[] | undefined; properties?: { name: string; type: "number" | "date" | "url" | "text" | "select" | "checkbox" | "multiselect" | "rating"; value: unknown; options?: string[] | undefined; }[] | undefined; content?: string | undefined; }]) => Awaited> + "themes:create": (...args: [{ name: string; base: "light" | "white" | "dark"; variables?: Record | undefined; }]) => Awaited> + "themes:delete": (...args: [{ id: string; }]) => Awaited> + "themes:list": (...args: []) => Awaited; createdAt: string; modifiedAt: string; }[]>> + "themes:update": (...args: [{ id: string; name?: string | undefined; base?: "light" | "white" | "dark" | undefined; variables?: Record | undefined; }]) => Awaited> "updater:check-for-updates": (...args: []) => Awaited> "updater:download-update": (...args: []) => Awaited> "updater:get-state": (...args: []) => Awaited diff --git a/apps/desktop/src/main/ipc/index.test.ts b/apps/desktop/src/main/ipc/index.test.ts index cf55cdd00..68785fe03 100644 --- a/apps/desktop/src/main/ipc/index.test.ts +++ b/apps/desktop/src/main/ipc/index.test.ts @@ -9,6 +9,8 @@ const hoisted = vi.hoisted(() => ({ unregisterTasksHandlers: vi.fn(), registerSavedFiltersHandlers: vi.fn(), unregisterSavedFiltersHandlers: vi.fn(), + registerThemesHandlers: vi.fn(), + unregisterThemesHandlers: vi.fn(), registerTemplatesHandlers: vi.fn(), unregisterTemplatesHandlers: vi.fn(), registerJournalHandlers: vi.fn(), @@ -73,6 +75,10 @@ vi.mock('./saved-filters-handlers', () => ({ registerSavedFiltersHandlers: hoisted.registerSavedFiltersHandlers, unregisterSavedFiltersHandlers: hoisted.unregisterSavedFiltersHandlers })) +vi.mock('./themes-handlers', () => ({ + registerThemesHandlers: hoisted.registerThemesHandlers, + unregisterThemesHandlers: hoisted.unregisterThemesHandlers +})) vi.mock('./templates-handlers', () => ({ registerTemplatesHandlers: hoisted.registerTemplatesHandlers, unregisterTemplatesHandlers: hoisted.unregisterTemplatesHandlers @@ -188,6 +194,7 @@ describe('ipc index registration lifecycle', () => { expect(hoisted.registerFeedbackHandlers).toHaveBeenCalledTimes(1) expect(hoisted.registerAgentMcpHandlers).toHaveBeenCalledTimes(1) expect(hoisted.registerImportHandlers).toHaveBeenCalledTimes(1) + expect(hoisted.registerThemesHandlers).toHaveBeenCalledTimes(1) }) it('prevents duplicate registration', () => { @@ -213,6 +220,7 @@ describe('ipc index registration lifecycle', () => { expect(hoisted.unregisterUpdaterHandlers).toHaveBeenCalledTimes(1) expect(hoisted.unregisterAgentMcpHandlers).toHaveBeenCalledTimes(1) expect(hoisted.unregisterImportHandlers).toHaveBeenCalledTimes(1) + expect(hoisted.unregisterThemesHandlers).toHaveBeenCalledTimes(1) }) it('is a no-op to unregister when handlers are not registered', () => { diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index ed6e1502f..3444c7365 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -5,6 +5,7 @@ import { registerSavedFiltersHandlers, unregisterSavedFiltersHandlers } from './saved-filters-handlers' +import { registerThemesHandlers, unregisterThemesHandlers } from './themes-handlers' import { registerTemplatesHandlers, unregisterTemplatesHandlers } from './templates-handlers' import { registerJournalHandlers, unregisterJournalHandlers } from './journal-handlers' import { registerSettingsHandlers, unregisterSettingsHandlers } from './settings-handlers' @@ -77,6 +78,7 @@ export function registerAllHandlers(deps?: IpcDeps): void { // Register saved filters handlers registerSavedFiltersHandlers() + registerThemesHandlers() // Register templates handlers registerTemplatesHandlers() @@ -169,6 +171,7 @@ export function unregisterAllHandlers(): void { unregisterNotesHandlers() unregisterTasksHandlers() unregisterSavedFiltersHandlers() + unregisterThemesHandlers() unregisterTemplatesHandlers() unregisterJournalHandlers() unregisterSettingsHandlers() diff --git a/apps/desktop/src/main/ipc/settings-handlers.ts b/apps/desktop/src/main/ipc/settings-handlers.ts index 9f1e94f45..e513d0063 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.ts @@ -59,6 +59,7 @@ import { type TerminalCommandOptions, type TerminalCommandStatus as BaseTerminalCommandStatus } from '../cli/terminal-command' +import { getTheme } from '../themes/theme-store' // ============================================================================ // Settings Keys @@ -68,6 +69,7 @@ const logger = createLogger('IPC:Settings') const GENERAL_SYNCABLE_FIELDS: (keyof GeneralSettings)[] = [ 'theme', + 'customThemeId', 'fontSize', 'fontFamily', 'accentColor', @@ -214,14 +216,31 @@ export function getCalendarSettings(): CalendarSettings { return readGroupSettings('calendar', CALENDAR_SETTINGS_DEFAULTS) } -function getStartupTheme(): { theme: GeneralSettings['theme']; accentColor?: string } { +interface StartupTheme { + theme: GeneralSettings['theme'] + accentColor?: string + customTheme?: { id: string; base: string; variables: Record } +} + +function getStartupTheme(): StartupTheme { const settings = readGroupSettings('general', GENERAL_SETTINGS_DEFAULTS) - const result: { theme: GeneralSettings['theme']; accentColor?: string } = { + const result: StartupTheme = { theme: settings.theme } if (settings.accentColor) { result.accentColor = settings.accentColor } + if (settings.customThemeId) { + const db = getDbOrNull() + const customTheme = db ? getTheme(db, settings.customThemeId) : null + if (customTheme) { + result.customTheme = { + id: customTheme.id, + base: customTheme.base, + variables: customTheme.variables + } + } + } return result } diff --git a/apps/desktop/src/main/ipc/themes-handlers.test.ts b/apps/desktop/src/main/ipc/themes-handlers.test.ts new file mode 100644 index 000000000..fc2b2087e --- /dev/null +++ b/apps/desktop/src/main/ipc/themes-handlers.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { createTestDataDb, type TestDatabaseResult } from '@tests/utils/test-db' + +let vaultPath: string + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + }, + BrowserWindow: { + getAllWindows: vi.fn(() => []) + } +})) + +vi.mock('../database', () => ({ + requireDatabase: vi.fn(() => { + throw new Error('No vault is open') + }) +})) + +vi.mock('../vault/index', () => ({ + getStatus: () => ({ path: vaultPath }) +})) + +vi.mock('../sync/local-mutations', () => ({ + enqueueLocalSyncCreate: vi.fn(), + enqueueLocalSyncUpdate: vi.fn(), + enqueueLocalSyncDelete: vi.fn() +})) + +vi.mock('../lib/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) + +import { makeThemesHandlers, registerThemesHandlers } from './themes-handlers' +import type { DataDb } from '../database' + +describe('registerThemesHandlers — lazy DB resolution', () => { + it('does not throw at registration and registers all 4 channels', async () => { + const { ipcMain } = await import('electron') + const handleMock = vi.mocked(ipcMain.handle) + handleMock.mockClear() + + expect(() => registerThemesHandlers()).not.toThrow() + expect(handleMock.mock.calls.length).toBe(4) + }) +}) + +describe('themes handlers', () => { + let dbResult: TestDatabaseResult + let h: ReturnType + + beforeEach(() => { + vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-themes-ipc-')) + dbResult = createTestDataDb() + h = makeThemesHandlers(dbResult.db as unknown as DataDb) + }) + + afterEach(() => { + dbResult.close() + fs.rmSync(vaultPath, { recursive: true, force: true }) + }) + + it('creates and lists themes', () => { + const created = h.create({ name: 'Tema 1', base: 'dark' }) + expect(created.success).toBe(true) + expect(created.theme?.name).toBe('Tema 1') + + const themes = h.list() + expect(themes).toHaveLength(1) + expect(themes[0].base).toBe('dark') + }) + + it('updates a theme and returns the updated DTO', () => { + const created = h.create({ name: 'Tema 1', base: 'dark' }) + + const result = h.update({ + id: created.theme!.id, + variables: { '--background': '#101010' } + }) + + expect(result.success).toBe(true) + expect(result.theme?.variables).toEqual({ '--background': '#101010' }) + }) + + it('returns an error for updating an unknown theme', () => { + const result = h.update({ id: 'missing', name: 'X' }) + expect(result.success).toBe(false) + expect(result.error).toBeTruthy() + }) + + it('deletes a theme', () => { + const created = h.create({ name: 'Tema 1', base: 'dark' }) + + expect(h.delete({ id: created.theme!.id }).success).toBe(true) + expect(h.list()).toHaveLength(0) + expect(h.delete({ id: created.theme!.id }).success).toBe(false) + }) + + it('list adopts orphan vault files', () => { + fs.mkdirSync(path.join(vaultPath, '.memry', 'themes'), { recursive: true }) + fs.writeFileSync( + path.join(vaultPath, '.memry', 'themes', 'orphan.json'), + JSON.stringify({ + id: 'orphan-id', + name: 'Orphan', + base: 'light', + variables: {}, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z' + }), + 'utf-8' + ) + + const themes = h.list() + expect(themes.map((t) => t.id)).toContain('orphan-id') + }) +}) diff --git a/apps/desktop/src/main/ipc/themes-handlers.ts b/apps/desktop/src/main/ipc/themes-handlers.ts new file mode 100644 index 000000000..9ac1bb804 --- /dev/null +++ b/apps/desktop/src/main/ipc/themes-handlers.ts @@ -0,0 +1,103 @@ +/** + * Custom theme IPC handlers. + * + * @module ipc/themes-handlers + */ + +import { ipcMain, BrowserWindow } from 'electron' +import { ThemesChannels } from '@memry/contracts/ipc-channels' +import { + CreateThemeInputSchema, + DeleteThemePayloadSchema, + UpdateThemePayloadSchema, + type CreateThemeInput, + type CustomTheme, + type DeleteThemePayload, + type ThemeDeleteResult, + type ThemeMutationResult, + type UpdateThemePayload +} from '@memry/contracts/themes-api' +import { createHandler, createValidatedHandler } from './validate' +import { requireDatabase } from '../database' +import { + adoptThemeFiles, + createTheme, + deleteTheme, + listThemes, + updateTheme +} from '../themes/theme-store' +import type { DataDb } from '../database' + +function emitThemeEvent(channel: string, data: unknown): void { + BrowserWindow.getAllWindows().forEach((win) => { + win.webContents.send(channel, data) + }) +} + +export function makeThemesHandlers(db: DataDb): { + list: () => CustomTheme[] + create: (input: CreateThemeInput) => ThemeMutationResult + update: (input: UpdateThemePayload) => ThemeMutationResult + delete: (input: DeleteThemePayload) => ThemeDeleteResult +} { + return { + list: () => { + adoptThemeFiles(db) + return listThemes(db) + }, + create: (input) => { + const theme = createTheme(db, input) + emitThemeEvent(ThemesChannels.events.CREATED, { id: theme.id }) + return { success: true, theme } + }, + update: ({ id, ...updates }) => { + const theme = updateTheme(db, id, updates) + if (!theme) { + return { success: false, error: 'Theme not found' } + } + emitThemeEvent(ThemesChannels.events.UPDATED, { id }) + return { success: true, theme } + }, + delete: ({ id }) => { + if (!deleteTheme(db, id)) { + return { success: false, error: 'Theme not found' } + } + emitThemeEvent(ThemesChannels.events.DELETED, { id }) + return { success: true } + } + } +} + +export function registerThemesHandlers(): void { + ipcMain.handle( + ThemesChannels.invoke.LIST, + createHandler(() => makeThemesHandlers(requireDatabase()).list()) + ) + + ipcMain.handle( + ThemesChannels.invoke.CREATE, + createValidatedHandler(CreateThemeInputSchema, (input) => + makeThemesHandlers(requireDatabase()).create(input) + ) + ) + + ipcMain.handle( + ThemesChannels.invoke.UPDATE, + createValidatedHandler(UpdateThemePayloadSchema, (input) => + makeThemesHandlers(requireDatabase()).update(input) + ) + ) + + ipcMain.handle( + ThemesChannels.invoke.DELETE, + createValidatedHandler(DeleteThemePayloadSchema, (input) => + makeThemesHandlers(requireDatabase()).delete(input) + ) + ) +} + +export function unregisterThemesHandlers(): void { + for (const channel of Object.values(ThemesChannels.invoke)) { + ipcMain.removeHandler(channel) + } +} diff --git a/apps/desktop/src/main/sync/item-handlers/index.ts b/apps/desktop/src/main/sync/item-handlers/index.ts index b872a96d8..265d798dd 100644 --- a/apps/desktop/src/main/sync/item-handlers/index.ts +++ b/apps/desktop/src/main/sync/item-handlers/index.ts @@ -11,6 +11,7 @@ import { noteHandler } from './note-handler' import { journalHandler } from './journal-handler' import { tagDefinitionHandler } from './tag-definition-handler' import { folderConfigHandler } from './folder-config-handler' +import { themeHandler } from './theme-handler' import { calendarEventHandler } from './calendar-event-handler' import { calendarSourceHandler } from './calendar-source-handler' import { calendarBindingHandler } from './calendar-binding-handler' @@ -31,6 +32,7 @@ const handlers = new Map([ ['journal', journalHandler], ['tag_definition', tagDefinitionHandler], ['folder_config', folderConfigHandler], + ['theme', themeHandler], ['calendar_event', calendarEventHandler], ['calendar_source', calendarSourceHandler], ['calendar_binding', calendarBindingHandler], diff --git a/apps/desktop/src/main/sync/item-handlers/theme-file-effects.ts b/apps/desktop/src/main/sync/item-handlers/theme-file-effects.ts new file mode 100644 index 000000000..af3c9473d --- /dev/null +++ b/apps/desktop/src/main/sync/item-handlers/theme-file-effects.ts @@ -0,0 +1,22 @@ +import type { CustomTheme } from '@memry/contracts/themes-api' +import { getStatus } from '../../vault/index' +import { writeThemeFile, renameThemeFile, deleteThemeFile } from '../../vault/themes' + +/** + * Vault-file side effects for theme sync applies. No-ops when no vault is + * open (DB row still lands; the file is rewritten on the next local edit). + */ +export function applyThemeFile(slug: string, theme: CustomTheme, previousSlug?: string): void { + const vaultPath = getStatus().path + if (!vaultPath) return + if (previousSlug && previousSlug !== slug) { + renameThemeFile(vaultPath, previousSlug, slug) + } + writeThemeFile(vaultPath, slug, theme) +} + +export function removeThemeFile(slug: string): void { + const vaultPath = getStatus().path + if (!vaultPath) return + deleteThemeFile(vaultPath, slug) +} diff --git a/apps/desktop/src/main/sync/item-handlers/theme-handler.test.ts b/apps/desktop/src/main/sync/item-handlers/theme-handler.test.ts new file mode 100644 index 000000000..ccda1ea3d --- /dev/null +++ b/apps/desktop/src/main/sync/item-handlers/theme-handler.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { eq } from 'drizzle-orm' +import { createTestDataDb, type TestDatabaseResult } from '@tests/utils/test-db' +import { customThemes } from '@memry/db-schema/schema/custom-themes' +import type { ThemeSyncPayload } from '@memry/contracts/sync-payloads' +import type { VectorClock } from '@memry/contracts/sync-api' +import type { SyncQueueManager } from '../queue' +import type { ApplyContext, DrizzleDb } from './types' + +type MockQueue = Pick & { enqueue: ReturnType } + +function makeMockQueue(): { mock: MockQueue; queue: SyncQueueManager } { + const mock: MockQueue = { enqueue: vi.fn() } + return { mock, queue: mock as unknown as SyncQueueManager } +} + +vi.mock('./theme-file-effects', () => ({ + applyThemeFile: vi.fn(), + removeThemeFile: vi.fn() +})) + +vi.mock('../../lib/logger', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn() + }) +})) + +import { themeHandler } from './theme-handler' +import { applyThemeFile, removeThemeFile } from './theme-file-effects' + +const mockApplyThemeFile = vi.mocked(applyThemeFile) +const mockRemoveThemeFile = vi.mocked(removeThemeFile) + +function makeCtx(testDb: TestDatabaseResult): ApplyContext { + return { + db: testDb.db as unknown as DrizzleDb, + emit: vi.fn() + } +} + +const basePayload: ThemeSyncPayload = { + name: 'Tema 1', + slug: 'tema-1', + base: 'dark', + variables: { '--background': '#101010' }, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z' +} + +function insertRow(testDb: TestDatabaseResult, overrides: Record = {}): void { + testDb.db + .insert(customThemes) + .values({ + id: 'theme-1', + name: 'Tema 1', + slug: 'tema-1', + base: 'dark', + variables: { '--background': '#101010' }, + clock: { 'device-A': 1 }, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z', + ...overrides + }) + .run() +} + +describe('themeHandler', () => { + let testDb: TestDatabaseResult + let ctx: ApplyContext + + beforeEach(() => { + testDb = createTestDataDb() + ctx = makeCtx(testDb) + vi.clearAllMocks() + }) + + afterEach(() => { + testDb.close() + }) + + describe('applyUpsert', () => { + it('#given no existing row #when remote upsert arrives #then inserts row and writes file', () => { + const clock: VectorClock = { 'device-B': 1 } + + const result = themeHandler.applyUpsert(ctx, 'theme-1', basePayload, clock) + + expect(result).toBe('applied') + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, 'theme-1')).get() + expect(row).toBeDefined() + expect(row!.slug).toBe('tema-1') + expect(row!.variables).toEqual({ '--background': '#101010' }) + expect(row!.clock).toEqual({ 'device-B': 1 }) + expect(mockApplyThemeFile).toHaveBeenCalledWith( + 'tema-1', + expect.objectContaining({ id: 'theme-1', name: 'Tema 1', base: 'dark' }) + ) + expect(ctx.emit as ReturnType).toHaveBeenCalledWith('themes:updated', { + id: 'theme-1' + }) + }) + + it('#given remote payload with invalid variables #then sanitizes before storing', () => { + const result = themeHandler.applyUpsert( + ctx, + 'theme-1', + { + ...basePayload, + variables: { '--background': '#101010', '--bad': 'red', nope: '#123456' } + }, + { 'device-B': 1 } + ) + + expect(result).toBe('applied') + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, 'theme-1')).get() + expect(row!.variables).toEqual({ '--background': '#101010' }) + }) + + it('#given existing row #when remote clock newer #then updates and renames file on slug change', () => { + insertRow(testDb) + + const result = themeHandler.applyUpsert( + ctx, + 'theme-1', + { ...basePayload, name: 'Renamed', slug: 'renamed' }, + { 'device-A': 1, 'device-B': 2 } + ) + + expect(result).toBe('applied') + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, 'theme-1')).get() + expect(row!.slug).toBe('renamed') + expect(mockApplyThemeFile).toHaveBeenCalledWith( + 'renamed', + expect.objectContaining({ name: 'Renamed' }), + 'tema-1' + ) + }) + + it('#given existing row #when local clock newer #then skips', () => { + insertRow(testDb, { clock: { 'device-A': 5 } }) + + const result = themeHandler.applyUpsert( + ctx, + 'theme-1', + { ...basePayload, name: 'Stale' }, + { 'device-A': 2 } + ) + + expect(result).toBe('skipped') + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, 'theme-1')).get() + expect(row!.name).toBe('Tema 1') + expect(mockApplyThemeFile).not.toHaveBeenCalled() + }) + + it('#given concurrent clocks #then merges clocks and applies remote (LWW conflict)', () => { + insertRow(testDb, { clock: { 'device-A': 2 } }) + + const result = themeHandler.applyUpsert( + ctx, + 'theme-1', + { ...basePayload, name: 'Remote wins' }, + { 'device-B': 3 } + ) + + expect(result).toBe('conflict') + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, 'theme-1')).get() + expect(row!.name).toBe('Remote wins') + expect(row!.clock).toEqual({ 'device-A': 2, 'device-B': 3 }) + }) + }) + + describe('applyDelete', () => { + it('#given existing row #when delete arrives #then removes row and file', () => { + insertRow(testDb) + + const result = themeHandler.applyDelete(ctx, 'theme-1', { 'device-A': 1, 'device-B': 2 }) + + expect(result).toBe('applied') + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, 'theme-1')).get() + expect(row).toBeUndefined() + expect(mockRemoveThemeFile).toHaveBeenCalledWith('tema-1') + expect(ctx.emit as ReturnType).toHaveBeenCalledWith('themes:deleted', { + id: 'theme-1' + }) + }) + + it('#given no row #when delete arrives #then skips', () => { + expect(themeHandler.applyDelete(ctx, 'nope')).toBe('skipped') + }) + + it('#given local clock newer #when delete arrives #then skips delete', () => { + insertRow(testDb, { clock: { 'device-A': 5 } }) + + const result = themeHandler.applyDelete(ctx, 'theme-1', { 'device-A': 2 }) + + expect(result).toBe('skipped') + expect( + testDb.db.select().from(customThemes).where(eq(customThemes.id, 'theme-1')).get() + ).toBeDefined() + }) + }) + + describe('buildPushPayload', () => { + it('#given existing row #then serializes name/slug/base/variables/clock', () => { + insertRow(testDb) + + const payload = themeHandler.buildPushPayload!( + testDb.db as unknown as DrizzleDb, + 'theme-1', + 'device-A', + 'update' + ) + + expect(payload).not.toBeNull() + const parsed = JSON.parse(payload!) + expect(parsed).toMatchObject({ + name: 'Tema 1', + slug: 'tema-1', + base: 'dark', + variables: { '--background': '#101010' }, + clock: { 'device-A': 1 } + }) + }) + + it('#given no row #then returns null', () => { + expect( + themeHandler.buildPushPayload!( + testDb.db as unknown as DrizzleDb, + 'nope', + 'device-A', + 'update' + ) + ).toBeNull() + }) + }) + + describe('seedUnclocked', () => { + it('#given unclocked rows #then assigns clocks and enqueues creates', () => { + insertRow(testDb, { clock: null }) + const { mock, queue } = makeMockQueue() + + const count = themeHandler.seedUnclocked(testDb.db as unknown as DrizzleDb, 'device-A', queue) + + expect(count).toBe(1) + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, 'theme-1')).get() + expect(row!.clock).toEqual({ 'device-A': 1 }) + expect(mock.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ type: 'theme', itemId: 'theme-1', operation: 'create' }) + ) + }) + + it('#given only clocked rows #then returns 0', () => { + insertRow(testDb) + const { mock, queue } = makeMockQueue() + + expect(themeHandler.seedUnclocked(testDb.db as unknown as DrizzleDb, 'device-A', queue)).toBe( + 0 + ) + expect(mock.enqueue).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/desktop/src/main/sync/item-handlers/theme-handler.ts b/apps/desktop/src/main/sync/item-handlers/theme-handler.ts new file mode 100644 index 000000000..1c90b81d2 --- /dev/null +++ b/apps/desktop/src/main/sync/item-handlers/theme-handler.ts @@ -0,0 +1,170 @@ +import { eq, isNull } from 'drizzle-orm' +import { customThemes } from '@memry/db-schema/schema/custom-themes' +import { utcNow } from '@memry/shared/utc' +import { ThemeSyncPayloadSchema, type ThemeSyncPayload } from '@memry/contracts/sync-payloads' +import { + sanitizeThemeVariables, + type CustomTheme, + type ThemeBase +} from '@memry/contracts/themes-api' +import { ThemesChannels } from '@memry/contracts/ipc-channels' +import type { VectorClock } from '@memry/contracts/sync-api' +import type { SyncQueueManager } from '../queue' +import { increment } from '../vector-clock' +import { createLogger } from '../../lib/logger' +import { BaseItemHandler } from './base-handler' +import type { ApplyContext, ApplyResult, DrizzleDb } from './types' +import { applyThemeFile, removeThemeFile } from './theme-file-effects' + +const log = createLogger('ThemeHandler') + +function toCustomTheme( + id: string, + data: Pick, + createdAt: string, + modifiedAt: string +): CustomTheme { + return { + id, + name: data.name, + base: data.base as ThemeBase, + variables: sanitizeThemeVariables(data.variables), + createdAt, + modifiedAt + } +} + +class ThemeHandler extends BaseItemHandler { + readonly type = 'theme' as const + readonly schema = ThemeSyncPayloadSchema + + applyUpsert( + ctx: ApplyContext, + itemId: string, + data: ThemeSyncPayload, + clock: VectorClock + ): ApplyResult { + return ctx.db.transaction((tx): ApplyResult => { + const existing = tx.select().from(customThemes).where(eq(customThemes.id, itemId)).get() + const remoteClock = Object.keys(clock).length > 0 ? clock : (data.clock ?? {}) + const now = utcNow() + const variables = sanitizeThemeVariables(data.variables) + + if (existing) { + const resolution = this.resolveClock(existing.clock as VectorClock | null, remoteClock) + if (resolution.action === 'skip') { + log.info('Skipping remote theme update, local is newer', { itemId }) + return 'skipped' + } + if (resolution.action === 'merge') { + log.warn('Concurrent theme edit, using last-write-wins', { itemId }) + } + + const modifiedAt = data.modifiedAt ?? now + tx.update(customThemes) + .set({ + name: data.name, + slug: data.slug, + base: data.base, + variables, + clock: resolution.mergedClock, + modifiedAt + }) + .where(eq(customThemes.id, itemId)) + .run() + + applyThemeFile( + data.slug, + toCustomTheme(itemId, { ...data, variables }, existing.createdAt, modifiedAt), + existing.slug + ) + ctx.emit(ThemesChannels.events.UPDATED, { id: itemId }) + return resolution.action === 'merge' ? 'conflict' : 'applied' + } + + const createdAt = data.createdAt ?? now + const modifiedAt = data.modifiedAt ?? now + tx.insert(customThemes) + .values({ + id: itemId, + name: data.name, + slug: data.slug, + base: data.base, + variables, + clock: remoteClock, + createdAt, + modifiedAt + }) + .run() + + applyThemeFile( + data.slug, + toCustomTheme(itemId, { ...data, variables }, createdAt, modifiedAt) + ) + ctx.emit(ThemesChannels.events.UPDATED, { id: itemId }) + return 'applied' + }) + } + + applyDelete(ctx: ApplyContext, itemId: string, clock?: VectorClock): 'applied' | 'skipped' { + const existing = ctx.db.select().from(customThemes).where(eq(customThemes.id, itemId)).get() + if (!existing) return 'skipped' + + if (clock && existing.clock) { + const resolution = this.resolveClock(existing.clock as VectorClock | null, clock) + if (resolution.action === 'skip' || resolution.action === 'merge') { + log.info('Skipping remote theme delete, local has unseen changes', { itemId }) + return 'skipped' + } + } + + ctx.db.delete(customThemes).where(eq(customThemes.id, itemId)).run() + removeThemeFile(existing.slug) + ctx.emit(ThemesChannels.events.DELETED, { id: itemId }) + return 'applied' + } + + fetchLocal(db: DrizzleDb, itemId: string): Record | undefined { + return db.select().from(customThemes).where(eq(customThemes.id, itemId)).get() as + | Record + | undefined + } + + buildPushPayload( + db: DrizzleDb, + itemId: string, + _deviceId: string, + _operation: string + ): string | null { + const row = db.select().from(customThemes).where(eq(customThemes.id, itemId)).get() + if (!row) return null + const payload: ThemeSyncPayload = { + name: row.name, + slug: row.slug, + base: row.base as ThemeSyncPayload['base'], + variables: (row.variables as Record) ?? {}, + clock: (row.clock as VectorClock) ?? undefined, + createdAt: row.createdAt, + modifiedAt: row.modifiedAt + } + return JSON.stringify(payload) + } + + seedUnclocked(db: DrizzleDb, deviceId: string, queue: SyncQueueManager): number { + const items = db.select().from(customThemes).where(isNull(customThemes.clock)).all() + for (const item of items) { + const clock = increment({}, deviceId) + db.update(customThemes).set({ clock }).where(eq(customThemes.id, item.id)).run() + queue.enqueue({ + type: 'theme', + itemId: item.id, + operation: 'create', + payload: JSON.stringify({ ...item, clock }), + priority: 0 + }) + } + return items.length + } +} + +export const themeHandler = new ThemeHandler() diff --git a/apps/desktop/src/main/sync/local-mutations.ts b/apps/desktop/src/main/sync/local-mutations.ts index c24aecf25..3ff9c3d02 100644 --- a/apps/desktop/src/main/sync/local-mutations.ts +++ b/apps/desktop/src/main/sync/local-mutations.ts @@ -17,6 +17,7 @@ import { getSettingsSyncManager } from './settings-sync' import { getTagDefinitionSyncService } from './tag-definition-sync' import { getTaskSyncService } from './task-sync' import { getFolderConfigSyncService } from './folder-config-sync' +import { getThemeSyncService } from './theme-sync' import { getCalendarEventSyncService } from './calendar-event-sync' import { getCalendarSourceSyncService } from './calendar-source-sync' import { getCalendarBindingSyncService } from './calendar-binding-sync' @@ -217,6 +218,21 @@ const localSyncRegistry = createSyncAdapterRegistry([ } } }, + { + type: 'theme', + kind: 'record', + local: { + enqueueCreate(itemId: string): void { + getThemeSyncService()?.enqueueCreate(itemId) + }, + enqueueUpdate(itemId: string): void { + getThemeSyncService()?.enqueueUpdate(itemId) + }, + enqueueDelete(itemId: string, snapshotPayload?: string): void { + getThemeSyncService()?.enqueueDelete(itemId, snapshotPayload) + } + } + }, { type: 'calendar_event', kind: 'record', diff --git a/apps/desktop/src/main/sync/manifest-check.ts b/apps/desktop/src/main/sync/manifest-check.ts index 77eeed97d..5cac7def0 100644 --- a/apps/desktop/src/main/sync/manifest-check.ts +++ b/apps/desktop/src/main/sync/manifest-check.ts @@ -7,6 +7,7 @@ import { projects } from '@memry/db-schema/schema/projects' import { inboxItems } from '@memry/db-schema/schema/inbox' import { savedFilters, settings } from '@memry/db-schema/schema/settings' import { tagDefinitions } from '@memry/db-schema/schema/tag-definitions' +import { customThemes } from '@memry/db-schema/schema/custom-themes' import { noteCache } from '@memry/db-schema/schema/notes-cache' import type { RecordSyncItemType, RecordSyncManifest } from '@memry/contracts/sync-api' import { withRetry } from './retry' @@ -151,6 +152,11 @@ function getLocalSyncableItems(db: DrizzleDb): LocalSyncableItem[] { addLocalItem({ id: td.name, type: 'tag_definition', payload: JSON.stringify(td) }) } + const syncedThemes = db.select().from(customThemes).where(isNotNull(customThemes.clock)).all() + for (const t of syncedThemes) { + addLocalItem({ id: t.id, type: 'theme', payload: JSON.stringify(t) }) + } + const syncedSettings = db.select().from(settings).where(eq(settings.key, 'synced_settings')).get() if (syncedSettings) { addLocalItem({ diff --git a/apps/desktop/src/main/sync/offline-clock.ts b/apps/desktop/src/main/sync/offline-clock.ts index ae18636c1..11a91a090 100644 --- a/apps/desktop/src/main/sync/offline-clock.ts +++ b/apps/desktop/src/main/sync/offline-clock.ts @@ -3,6 +3,7 @@ import { tasks } from '@memry/db-schema/schema/tasks' import { projects } from '@memry/db-schema/schema/projects' import { inboxItems } from '@memry/db-schema/schema/inbox' import { savedFilters } from '@memry/db-schema/schema/settings' +import { customThemes } from '@memry/db-schema/schema/custom-themes' import { OFFLINE_CLOCK_DEVICE_ID, type VectorClock, @@ -164,6 +165,22 @@ export function incrementInboxClockOffline(db: DataDb, itemId: string): void { } } +export function incrementThemeClockOffline(db: DataDb, themeId: string): void { + try { + const theme = db.select().from(customThemes).where(eq(customThemes.id, themeId)).get() + if (!theme) return + + const existingClock = (theme.clock as VectorClock) ?? {} + const newClock = increment(existingClock, OFFLINE_DEVICE_KEY) + + db.update(customThemes).set({ clock: newClock }).where(eq(customThemes.id, themeId)).run() + + log.debug('Incremented offline theme clock', { themeId }) + } catch (err) { + log.warn('Failed to increment offline theme clock', { themeId, error: err }) + } +} + export function incrementFilterClockOffline(db: DataDb, filterId: string): void { try { const filter = db.select().from(savedFilters).where(eq(savedFilters.id, filterId)).get() diff --git a/apps/desktop/src/main/sync/runtime.test.ts b/apps/desktop/src/main/sync/runtime.test.ts index 63f1aef9c..74b071e72 100644 --- a/apps/desktop/src/main/sync/runtime.test.ts +++ b/apps/desktop/src/main/sync/runtime.test.ts @@ -102,6 +102,7 @@ const runtimeMocks = vi.hoisted(() => { journalSync: service('journal'), tagDefinitionSync: service('tag_definition'), folderConfigSync: service('folder_config'), + themeSync: service('theme'), calendarEventSync: service('calendar_event'), calendarSourceSync: service('calendar_source'), calendarBindingSync: service('calendar_binding'), @@ -258,6 +259,10 @@ vi.mock('./folder-config-sync', () => ({ initFolderConfigSyncService: runtimeMocks.folderConfigSync.init, resetFolderConfigSyncService: runtimeMocks.folderConfigSync.reset })) +vi.mock('./theme-sync', () => ({ + initThemeSyncService: runtimeMocks.themeSync.init, + resetThemeSyncService: runtimeMocks.themeSync.reset +})) vi.mock('./calendar-event-sync', () => ({ initCalendarEventSyncService: runtimeMocks.calendarEventSync.init, resetCalendarEventSyncService: runtimeMocks.calendarEventSync.reset diff --git a/apps/desktop/src/main/sync/runtime.ts b/apps/desktop/src/main/sync/runtime.ts index 69fe43296..fcc59919b 100644 --- a/apps/desktop/src/main/sync/runtime.ts +++ b/apps/desktop/src/main/sync/runtime.ts @@ -29,6 +29,7 @@ import { initNoteSyncService, resetNoteSyncService } from './note-sync' import { initJournalSyncService, resetJournalSyncService } from './journal-sync' import { initTagDefinitionSyncService, resetTagDefinitionSyncService } from './tag-definition-sync' import { initFolderConfigSyncService, resetFolderConfigSyncService } from './folder-config-sync' +import { initThemeSyncService, resetThemeSyncService } from './theme-sync' import { initCalendarEventSyncService, resetCalendarEventSyncService } from './calendar-event-sync' import { initCalendarSourceSyncService, @@ -114,6 +115,7 @@ function resetSyncServiceSingletons(): void { resetJournalSyncService() resetTagDefinitionSyncService() resetFolderConfigSyncService() + resetThemeSyncService() resetCalendarEventSyncService() resetCalendarSourceSyncService() resetCalendarBindingSyncService() @@ -259,6 +261,11 @@ export async function startSyncRuntime(): Promise { db: runtimeSyncDb, getDeviceId }) + const themeSync = initThemeSyncService({ + queue, + db: runtimeSyncDb, + getDeviceId + }) const calendarEventSync = initCalendarEventSyncService({ queue, db: runtimeSyncDb, @@ -326,6 +333,12 @@ export async function startSyncRuntime(): Promise { local: folderConfigSync, remote: getRemoteSyncAdapter('folder_config') }, + { + type: 'theme', + kind: 'record', + local: themeSync, + remote: getRemoteSyncAdapter('theme') + }, { type: 'calendar_event', kind: 'record', diff --git a/apps/desktop/src/main/sync/theme-sync.ts b/apps/desktop/src/main/sync/theme-sync.ts new file mode 100644 index 000000000..1eb6b5d4c --- /dev/null +++ b/apps/desktop/src/main/sync/theme-sync.ts @@ -0,0 +1,79 @@ +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' +import { eq } from 'drizzle-orm' +import type * as schema from '@memry/db-schema/data-schema' +import { customThemes } from '@memry/db-schema/schema/custom-themes' +import type { VectorClock } from '@memry/contracts/sync-api' +import { RecordSyncController, incrementClock, withIncrementedClock } from '@memry/sync-core' +import type { SyncQueueManager } from './queue' + +type DrizzleDb = BetterSQLite3Database + +interface ThemeSyncDeps { + queue: SyncQueueManager + db: DrizzleDb + getDeviceId: () => string | null +} + +let instance: ThemeSyncService | null = null + +export function initThemeSyncService(deps: ThemeSyncDeps): ThemeSyncService { + instance = new ThemeSyncService(deps) + return instance +} + +export function getThemeSyncService(): ThemeSyncService | null { + return instance +} + +export function resetThemeSyncService(): void { + instance = null +} + +export class ThemeSyncService { + private controller: RecordSyncController, [], [string?]> + + constructor(deps: ThemeSyncDeps) { + this.controller = new RecordSyncController({ + type: 'theme', + queue: deps.queue, + getDeviceId: deps.getDeviceId, + load: (id) => + deps.db.select().from(customThemes).where(eq(customThemes.id, id)).get() as + | Record + | undefined, + applyLocalChange: ({ itemId, local, deviceId }) => { + const existingClock = (local.clock as VectorClock) ?? {} + const newClock = incrementClock(existingClock, deviceId) + + deps.db + .update(customThemes) + .set({ clock: newClock }) + .where(eq(customThemes.id, itemId)) + .run() + + return { ...local, clock: newClock } + }, + serialize: (local) => local, + buildDeletePayload: ({ itemId, extra, deviceId }) => { + const snapshotPayload = extra[0] + if (snapshotPayload) { + return withIncrementedClock(snapshotPayload, deviceId) + } + + return JSON.stringify({ id: itemId, clock: incrementClock({}, deviceId) }) + } + }) + } + + enqueueCreate(id: string): void { + this.controller.enqueueCreate(id) + } + + enqueueUpdate(id: string): void { + this.controller.enqueueUpdate(id) + } + + enqueueDelete(id: string, snapshotPayload?: string): void { + this.controller.enqueueDelete(id, snapshotPayload) + } +} diff --git a/apps/desktop/src/main/themes/theme-store.test.ts b/apps/desktop/src/main/themes/theme-store.test.ts new file mode 100644 index 000000000..91c7ba2ac --- /dev/null +++ b/apps/desktop/src/main/themes/theme-store.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import { eq } from 'drizzle-orm' +import { createTestDataDb, type TestDatabaseResult } from '@tests/utils/test-db' +import { customThemes } from '@memry/db-schema/schema/custom-themes' +import { readThemeFile, writeThemeFile } from '../vault/themes' + +let vaultPath: string + +vi.mock('../vault/index', () => ({ + getStatus: () => ({ path: vaultPath }) +})) + +vi.mock('../sync/local-mutations', () => ({ + enqueueLocalSyncCreate: vi.fn(), + enqueueLocalSyncUpdate: vi.fn(), + enqueueLocalSyncDelete: vi.fn() +})) + +vi.mock('../lib/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) + +import { + enqueueLocalSyncCreate, + enqueueLocalSyncDelete, + enqueueLocalSyncUpdate +} from '../sync/local-mutations' +import { createTheme, updateTheme, deleteTheme, listThemes, adoptThemeFiles } from './theme-store' +import type { DataDb } from '../database' + +describe('theme-store', () => { + let testDb: TestDatabaseResult + let db: DataDb + + beforeEach(() => { + vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-theme-store-')) + testDb = createTestDataDb() + db = testDb.db as unknown as DataDb + vi.clearAllMocks() + }) + + afterEach(() => { + testDb.close() + fs.rmSync(vaultPath, { recursive: true, force: true }) + }) + + it('createTheme inserts a row, writes the file, and enqueues a sync create', () => { + const theme = createTheme(db, { name: 'Tema 1', base: 'dark' }) + + expect(theme.id).toBeTruthy() + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, theme.id)).get() + expect(row).toMatchObject({ name: 'Tema 1', slug: 'tema-1', base: 'dark', variables: {} }) + expect(readThemeFile(vaultPath, 'tema-1')?.id).toBe(theme.id) + expect(enqueueLocalSyncCreate).toHaveBeenCalledWith('theme', theme.id) + }) + + it('createTheme resolves slug collisions with a numeric suffix', () => { + createTheme(db, { name: 'Tema 1', base: 'dark' }) + const second = createTheme(db, { name: 'Tema 1', base: 'light' }) + + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, second.id)).get() + expect(row!.slug).toBe('tema-1-2') + expect(readThemeFile(vaultPath, 'tema-1-2')?.base).toBe('light') + }) + + it('updateTheme renames slug + file when the name changes and enqueues update', () => { + const theme = createTheme(db, { name: 'Tema 1', base: 'dark' }) + + const updated = updateTheme(db, theme.id, { name: 'Gece' }) + + expect(updated?.name).toBe('Gece') + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, theme.id)).get() + expect(row!.slug).toBe('gece') + expect(readThemeFile(vaultPath, 'tema-1')).toBeNull() + expect(readThemeFile(vaultPath, 'gece')?.name).toBe('Gece') + expect(enqueueLocalSyncUpdate).toHaveBeenCalledWith('theme', theme.id) + }) + + it('updateTheme replaces variables', () => { + const theme = createTheme(db, { + name: 'Tema 1', + base: 'dark', + variables: { '--background': '#101010' } + }) + + updateTheme(db, theme.id, { variables: { '--surface': '#202020' } }) + + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, theme.id)).get() + expect(row!.variables).toEqual({ '--surface': '#202020' }) + expect(readThemeFile(vaultPath, 'tema-1')?.variables).toEqual({ '--surface': '#202020' }) + }) + + it('updateTheme returns null for an unknown id', () => { + expect(updateTheme(db, 'missing', { name: 'X' })).toBeNull() + }) + + it('deleteTheme removes row + file and enqueues delete with a snapshot', () => { + const theme = createTheme(db, { name: 'Tema 1', base: 'dark' }) + + const removed = deleteTheme(db, theme.id) + + expect(removed).toBe(true) + expect( + testDb.db.select().from(customThemes).where(eq(customThemes.id, theme.id)).get() + ).toBeUndefined() + expect(readThemeFile(vaultPath, 'tema-1')).toBeNull() + expect(enqueueLocalSyncDelete).toHaveBeenCalledWith('theme', theme.id, expect.any(String)) + }) + + it('listThemes returns stored themes as CustomTheme DTOs', () => { + createTheme(db, { name: 'Tema 1', base: 'dark', variables: { '--background': '#101010' } }) + + const themes = listThemes(db) + + expect(themes).toHaveLength(1) + expect(themes[0]).toMatchObject({ + name: 'Tema 1', + base: 'dark', + variables: { '--background': '#101010' } + }) + }) + + it('adoptThemeFiles inserts unclocked rows for orphan files and skips known ids', () => { + const known = createTheme(db, { name: 'Known', base: 'dark' }) + writeThemeFile(vaultPath, 'orphan', { + id: 'orphan-id', + name: 'Orphan', + base: 'light', + variables: { '--background': '#ffffff' }, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z' + }) + + const adopted = adoptThemeFiles(db) + + expect(adopted).toBe(1) + const row = testDb.db.select().from(customThemes).where(eq(customThemes.id, 'orphan-id')).get() + expect(row).toMatchObject({ name: 'Orphan', slug: 'orphan', base: 'light' }) + expect(row!.clock).toBeNull() + expect( + testDb.db.select().from(customThemes).where(eq(customThemes.id, known.id)).get() + ).toBeDefined() + expect(adoptThemeFiles(db)).toBe(0) + }) +}) diff --git a/apps/desktop/src/main/themes/theme-store.ts b/apps/desktop/src/main/themes/theme-store.ts new file mode 100644 index 000000000..cdf974f33 --- /dev/null +++ b/apps/desktop/src/main/themes/theme-store.ts @@ -0,0 +1,208 @@ +/** + * Custom theme local CRUD: DB row (sync source of truth for clocks) + + * vault file write-through (`.memry/themes/.json`) + sync enqueue. + * + * @module themes/theme-store + */ + +import { randomUUID } from 'crypto' +import { eq } from 'drizzle-orm' +import { customThemes } from '@memry/db-schema/schema/custom-themes' +import { utcNow } from '@memry/shared/utc' +import type { + CreateThemeInput, + CustomTheme, + ThemeBase, + UpdateThemeInput +} from '@memry/contracts/themes-api' +import { getStatus } from '../vault/index' +import { + deleteThemeFile, + listThemeFiles, + renameThemeFile, + uniqueThemeSlug, + writeThemeFile +} from '../vault/themes' +import { + enqueueLocalSyncCreate, + enqueueLocalSyncDelete, + enqueueLocalSyncUpdate +} from '../sync/local-mutations' +import type { DataDb } from '../database' +import { createLogger } from '../lib/logger' + +const log = createLogger('ThemeStore') + +type ThemeRow = typeof customThemes.$inferSelect + +function rowToTheme(row: ThemeRow): CustomTheme { + return { + id: row.id, + name: row.name, + base: row.base as ThemeBase, + variables: (row.variables as Record) ?? {}, + createdAt: row.createdAt, + modifiedAt: row.modifiedAt + } +} + +function vaultPathOrNull(): string | null { + return getStatus().path ?? null +} + +function takenSlugs(db: DataDb, excludeId?: string): Set { + const rows = db.select({ id: customThemes.id, slug: customThemes.slug }).from(customThemes).all() + return new Set(rows.filter((row) => row.id !== excludeId).map((row) => row.slug)) +} + +function writeFileSafe(slug: string, theme: CustomTheme, previousSlug?: string): void { + const vaultPath = vaultPathOrNull() + if (!vaultPath) return + try { + if (previousSlug && previousSlug !== slug) { + renameThemeFile(vaultPath, previousSlug, slug) + } + writeThemeFile(vaultPath, slug, theme) + } catch (error) { + log.warn('Failed to write theme file', { slug, error: String(error) }) + } +} + +export function listThemes(db: DataDb): CustomTheme[] { + return db.select().from(customThemes).all().map(rowToTheme) +} + +export function getTheme(db: DataDb, id: string): CustomTheme | null { + const row = db.select().from(customThemes).where(eq(customThemes.id, id)).get() + return row ? rowToTheme(row) : null +} + +export function createTheme(db: DataDb, input: CreateThemeInput): CustomTheme { + const id = randomUUID() + const slug = uniqueThemeSlug(input.name, takenSlugs(db)) + const now = utcNow() + const theme: CustomTheme = { + id, + name: input.name, + base: input.base, + variables: input.variables ?? {}, + createdAt: now, + modifiedAt: now + } + + db.insert(customThemes) + .values({ + id, + name: theme.name, + slug, + base: theme.base, + variables: theme.variables, + createdAt: now, + modifiedAt: now + }) + .run() + + writeFileSafe(slug, theme) + enqueueLocalSyncCreate('theme', id) + return theme +} + +export function updateTheme(db: DataDb, id: string, input: UpdateThemeInput): CustomTheme | null { + const existing = db.select().from(customThemes).where(eq(customThemes.id, id)).get() + if (!existing) return null + + const name = input.name ?? existing.name + const slug = + input.name !== undefined && input.name !== existing.name + ? uniqueThemeSlug(input.name, takenSlugs(db, id)) + : existing.slug + const now = utcNow() + const theme: CustomTheme = { + id, + name, + base: input.base ?? (existing.base as ThemeBase), + variables: input.variables ?? (existing.variables as Record) ?? {}, + createdAt: existing.createdAt, + modifiedAt: now + } + + db.update(customThemes) + .set({ name, slug, base: theme.base, variables: theme.variables, modifiedAt: now }) + .where(eq(customThemes.id, id)) + .run() + + writeFileSafe(slug, theme, existing.slug) + enqueueLocalSyncUpdate('theme', id) + return theme +} + +export function deleteTheme(db: DataDb, id: string): boolean { + const existing = db.select().from(customThemes).where(eq(customThemes.id, id)).get() + if (!existing) return false + + const snapshot = JSON.stringify({ + name: existing.name, + slug: existing.slug, + base: existing.base, + variables: existing.variables, + clock: existing.clock, + createdAt: existing.createdAt, + modifiedAt: existing.modifiedAt + }) + + db.delete(customThemes).where(eq(customThemes.id, id)).run() + + const vaultPath = vaultPathOrNull() + if (vaultPath) { + try { + deleteThemeFile(vaultPath, existing.slug) + } catch (error) { + log.warn('Failed to delete theme file', { slug: existing.slug, error: String(error) }) + } + } + + enqueueLocalSyncDelete('theme', id, snapshot) + return true +} + +/** + * Adopt vault theme files with no matching DB row (fresh install over an + * existing vault, or hand-created files). Inserted unclocked so the next + * seedUnclocked pass pushes them to other devices. + */ +export function adoptThemeFiles(db: DataDb): number { + const vaultPath = vaultPathOrNull() + if (!vaultPath) return 0 + + const known = new Set( + db + .select({ id: customThemes.id }) + .from(customThemes) + .all() + .map((row) => row.id) + ) + const slugs = takenSlugs(db) + + let adopted = 0 + for (const { slug, theme } of listThemeFiles(vaultPath)) { + if (known.has(theme.id)) continue + const finalSlug = slugs.has(slug) ? uniqueThemeSlug(theme.name, slugs) : slug + slugs.add(finalSlug) + + db.insert(customThemes) + .values({ + id: theme.id, + name: theme.name, + slug: finalSlug, + base: theme.base, + variables: theme.variables, + createdAt: theme.createdAt, + modifiedAt: theme.modifiedAt + }) + .run() + adopted++ + } + + if (adopted > 0) log.info('Adopted theme files from vault', { count: adopted }) + return adopted +} diff --git a/apps/desktop/src/main/vault/settings-cache.ts b/apps/desktop/src/main/vault/settings-cache.ts index 6f0a5ef49..f2b7cd360 100644 --- a/apps/desktop/src/main/vault/settings-cache.ts +++ b/apps/desktop/src/main/vault/settings-cache.ts @@ -89,6 +89,7 @@ export function writeCacheFromPreferences(db: DataDb, prefs: VaultPreferences): const language = LocaleSchema.safeParse(prefs.language) const portableFields: Partial = { theme: prefs.theme, + customThemeId: prefs.customThemeId, fontSize: prefs.fontSize, fontFamily: prefs.fontFamily, accentColor: prefs.accentColor, diff --git a/apps/desktop/src/main/vault/themes.test.ts b/apps/desktop/src/main/vault/themes.test.ts new file mode 100644 index 000000000..e750c892c --- /dev/null +++ b/apps/desktop/src/main/vault/themes.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import fs from 'fs' +import os from 'os' +import path from 'path' +import type { CustomTheme } from '@memry/contracts/themes-api' +import { + getThemesDirPath, + slugifyThemeName, + uniqueThemeSlug, + listThemeFiles, + readThemeFile, + writeThemeFile, + renameThemeFile, + deleteThemeFile +} from './themes' + +let vaultPath: string + +const theme = (overrides: Partial = {}): CustomTheme => ({ + id: 'theme-id-1', + name: 'Tema 1', + base: 'light', + variables: { '--background': '#f6f5f0' }, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z', + ...overrides +}) + +beforeEach(() => { + vaultPath = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-themes-test-')) +}) + +afterEach(() => { + fs.rmSync(vaultPath, { recursive: true, force: true }) +}) + +describe('slugifyThemeName', () => { + it('lowercases and dashes whitespace', () => { + expect(slugifyThemeName('Tema 1')).toBe('tema-1') + }) + + it('strips punctuation and collapses separators', () => { + expect(slugifyThemeName(' My *Theme* ')).toBe('my-theme') + }) + + it('falls back to "theme" when nothing survives', () => { + expect(slugifyThemeName('###')).toBe('theme') + }) +}) + +describe('uniqueThemeSlug', () => { + it('returns the plain slug when free', () => { + expect(uniqueThemeSlug('Tema 1', new Set())).toBe('tema-1') + }) + + it('suffixes -2, -3… on collisions', () => { + expect(uniqueThemeSlug('Tema 1', new Set(['tema-1']))).toBe('tema-1-2') + expect(uniqueThemeSlug('Tema 1', new Set(['tema-1', 'tema-1-2']))).toBe('tema-1-3') + }) +}) + +describe('theme file CRUD', () => { + it('writes and reads a theme back', () => { + writeThemeFile(vaultPath, 'tema-1', theme()) + const loaded = readThemeFile(vaultPath, 'tema-1') + expect(loaded).toEqual(theme()) + expect(fs.existsSync(path.join(getThemesDirPath(vaultPath), 'tema-1.json'))).toBe(true) + }) + + it('returns null for a missing theme', () => { + expect(readThemeFile(vaultPath, 'nope')).toBeNull() + }) + + it('returns null for a corrupt file', () => { + const dir = getThemesDirPath(vaultPath) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'broken.json'), '{not json', 'utf-8') + expect(readThemeFile(vaultPath, 'broken')).toBeNull() + }) + + it('sanitizes invalid variables on read (forward compatibility)', () => { + writeThemeFile( + vaultPath, + 'tema-1', + theme({ + variables: { + '--background': '#f6f5f0', + '--future': 'color-mix(in srgb, red, blue)', + notAVar: '#111111' + } + }) + ) + const loaded = readThemeFile(vaultPath, 'tema-1') + expect(loaded?.variables).toEqual({ '--background': '#f6f5f0' }) + }) + + it('lists themes with their slugs, skipping corrupt files', () => { + writeThemeFile(vaultPath, 'tema-1', theme()) + writeThemeFile(vaultPath, 'tema-2', theme({ id: 'theme-id-2', name: 'Tema 2', base: 'dark' })) + fs.writeFileSync(path.join(getThemesDirPath(vaultPath), 'broken.json'), 'nope', 'utf-8') + + const listed = listThemeFiles(vaultPath) + expect(listed.map((entry) => entry.slug).sort()).toEqual(['tema-1', 'tema-2']) + expect(listed.find((entry) => entry.slug === 'tema-2')?.theme.base).toBe('dark') + }) + + it('returns empty list when the themes dir does not exist', () => { + expect(listThemeFiles(vaultPath)).toEqual([]) + }) + + it('renames a theme file', () => { + writeThemeFile(vaultPath, 'tema-1', theme()) + renameThemeFile(vaultPath, 'tema-1', 'renamed') + expect(readThemeFile(vaultPath, 'tema-1')).toBeNull() + expect(readThemeFile(vaultPath, 'renamed')?.id).toBe('theme-id-1') + }) + + it('deletes a theme file and tolerates deleting a missing one', () => { + writeThemeFile(vaultPath, 'tema-1', theme()) + deleteThemeFile(vaultPath, 'tema-1') + expect(readThemeFile(vaultPath, 'tema-1')).toBeNull() + expect(() => deleteThemeFile(vaultPath, 'tema-1')).not.toThrow() + }) +}) diff --git a/apps/desktop/src/main/vault/themes.ts b/apps/desktop/src/main/vault/themes.ts new file mode 100644 index 000000000..2cc0c0fce --- /dev/null +++ b/apps/desktop/src/main/vault/themes.ts @@ -0,0 +1,111 @@ +import fs from 'fs' +import path from 'path' +import { randomUUID } from 'crypto' +import { + CustomThemeSchema, + sanitizeThemeVariables, + type CustomTheme +} from '@memry/contracts/themes-api' +import { createLogger } from '../lib/logger' + +const log = createLogger('Themes') + +const THEMES_DIR = 'themes' + +export interface ThemeFileEntry { + slug: string + theme: CustomTheme +} + +export function getThemesDirPath(vaultPath: string): string { + return path.join(vaultPath, '.memry', THEMES_DIR) +} + +export function slugifyThemeName(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + return slug || 'theme' +} + +export function uniqueThemeSlug(name: string, taken: ReadonlySet): string { + const base = slugifyThemeName(name) + if (!taken.has(base)) return base + for (let i = 2; ; i++) { + const candidate = `${base}-${i}` + if (!taken.has(candidate)) return candidate + } +} + +function themeFilePath(vaultPath: string, slug: string): string { + return path.join(getThemesDirPath(vaultPath), `${slug}.json`) +} + +export function readThemeFile(vaultPath: string, slug: string): CustomTheme | null { + const filePath = themeFilePath(vaultPath, slug) + if (!fs.existsSync(filePath)) return null + + try { + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) + const parsed = CustomThemeSchema.safeParse(raw) + if (!parsed.success) { + log.warn('Ignoring invalid theme file', { slug, issues: parsed.error.issues.length }) + return null + } + return { ...parsed.data, variables: sanitizeThemeVariables(parsed.data.variables) } + } catch (error) { + log.warn('Failed to read theme file', { slug, error: String(error) }) + return null + } +} + +export function listThemeFiles(vaultPath: string): ThemeFileEntry[] { + const dir = getThemesDirPath(vaultPath) + if (!fs.existsSync(dir)) return [] + + const entries: ThemeFileEntry[] = [] + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith('.json')) continue + const slug = file.slice(0, -'.json'.length) + const theme = readThemeFile(vaultPath, slug) + if (theme) entries.push({ slug, theme }) + } + return entries +} + +export function writeThemeFile(vaultPath: string, slug: string, theme: CustomTheme): void { + const dir = getThemesDirPath(vaultPath) + fs.mkdirSync(dir, { recursive: true }) + + const filePath = themeFilePath(vaultPath, slug) + // Atomic write: exclusive temp file, then rename over the target (same + // pattern as vault-preferences.ts) so a crash never leaves a torn file. + const tempPath = `${filePath}.${randomUUID()}.tmp` + const fd = fs.openSync(tempPath, 'wx', 0o600) + try { + fs.writeFileSync(fd, JSON.stringify(theme, null, 2), 'utf-8') + fs.closeSync(fd) + fs.renameSync(tempPath, filePath) + } catch (error) { + try { + fs.closeSync(fd) + } catch { + // already closed + } + fs.rmSync(tempPath, { force: true }) + throw error + } +} + +export function renameThemeFile(vaultPath: string, oldSlug: string, newSlug: string): void { + if (oldSlug === newSlug) return + const oldPath = themeFilePath(vaultPath, oldSlug) + if (!fs.existsSync(oldPath)) return + fs.mkdirSync(getThemesDirPath(vaultPath), { recursive: true }) + fs.renameSync(oldPath, themeFilePath(vaultPath, newSlug)) +} + +export function deleteThemeFile(vaultPath: string, slug: string): void { + fs.rmSync(themeFilePath(vaultPath, slug), { force: true }) +} diff --git a/apps/desktop/src/main/vault/vault-preferences.test.ts b/apps/desktop/src/main/vault/vault-preferences.test.ts index 5fdaaa7a2..c8808e7f6 100644 --- a/apps/desktop/src/main/vault/vault-preferences.test.ts +++ b/apps/desktop/src/main/vault/vault-preferences.test.ts @@ -34,7 +34,8 @@ describe('VaultPreferencesSchema', () => { } const result = VaultPreferencesSchema.parse(input) - expect(result).toEqual(input) + // customThemeId defaults to null for configs written by older versions + expect(result).toEqual({ ...input, customThemeId: null }) }) it('#given legacy editor width from an older version #then coerces to normal', () => { diff --git a/apps/desktop/src/main/vault/vault-preferences.ts b/apps/desktop/src/main/vault/vault-preferences.ts index 742ba3f29..9893dd09a 100644 --- a/apps/desktop/src/main/vault/vault-preferences.ts +++ b/apps/desktop/src/main/vault/vault-preferences.ts @@ -15,6 +15,8 @@ const EditorPreferencesSchema = z.object({ export const VaultPreferencesSchema = z.object({ theme: z.enum(['light', 'dark', 'white', 'system']), + // Default null so config.json files written by older app versions parse. + customThemeId: z.string().nullable().default(null), fontSize: z.enum(['small', 'medium', 'large']), fontFamily: z.enum(['system', 'serif', 'sans-serif', 'monospace', 'gelasio', 'geist', 'inter']), accentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/), @@ -33,6 +35,7 @@ export const EDITOR_PREFERENCES_DEFAULTS: EditorPreferences = { export const VAULT_PREFERENCES_DEFAULTS: VaultPreferences = { theme: GENERAL_SETTINGS_DEFAULTS.theme, + customThemeId: GENERAL_SETTINGS_DEFAULTS.customThemeId, fontSize: GENERAL_SETTINGS_DEFAULTS.fontSize, fontFamily: GENERAL_SETTINGS_DEFAULTS.fontFamily, accentColor: GENERAL_SETTINGS_DEFAULTS.accentColor, @@ -43,6 +46,7 @@ export const VAULT_PREFERENCES_DEFAULTS: VaultPreferences = { export const PORTABLE_GENERAL_FIELDS = [ 'theme', + 'customThemeId', 'fontSize', 'fontFamily', 'accentColor', @@ -66,6 +70,7 @@ export function readPreferences(vaultPath: string): VaultPreferences { const prefs = raw.preferences return { theme: prefs.theme ?? VAULT_PREFERENCES_DEFAULTS.theme, + customThemeId: prefs.customThemeId ?? VAULT_PREFERENCES_DEFAULTS.customThemeId, fontSize: prefs.fontSize ?? VAULT_PREFERENCES_DEFAULTS.fontSize, fontFamily: prefs.fontFamily ?? VAULT_PREFERENCES_DEFAULTS.fontFamily, accentColor: prefs.accentColor ?? VAULT_PREFERENCES_DEFAULTS.accentColor, diff --git a/apps/desktop/src/preload/generated-rpc.ts b/apps/desktop/src/preload/generated-rpc.ts index 52429b37b..67e765889 100644 --- a/apps/desktop/src/preload/generated-rpc.ts +++ b/apps/desktop/src/preload/generated-rpc.ts @@ -35,7 +35,10 @@ const eventChannels = { "onEmbeddingProgress": "settings:embeddingProgress", "onVoiceModelProgress": "settings:voiceModelProgress", "onSettingsOpenRequested": "settings:openSection", - "onCalendarChanged": "calendar:changed" + "onCalendarChanged": "calendar:changed", + "onThemeCreated": "themes:created", + "onThemeUpdated": "themes:updated", + "onThemeDeleted": "themes:deleted" } as const export interface GeneratedRpcDeps { @@ -295,6 +298,12 @@ export function createGeneratedRpcApi({ "feedback": { "submit": ((input) => invoke("feedback:submit", input)) as GeneratedRpcApi["feedback"]["submit"], }, + "themes": { + "list": (() => invoke("themes:list")) as GeneratedRpcApi["themes"]["list"], + "create": ((input) => invoke("themes:create", input)) as GeneratedRpcApi["themes"]["create"], + "update": ((input) => invoke("themes:update", input)) as GeneratedRpcApi["themes"]["update"], + "delete": ((input) => invoke("themes:delete", input)) as GeneratedRpcApi["themes"]["delete"], + }, } for (const [eventName, channel] of Object.entries(eventChannels)) { diff --git a/apps/desktop/src/preload/index.d.ts b/apps/desktop/src/preload/index.d.ts index 8cbb36bb8..e852e9a00 100644 --- a/apps/desktop/src/preload/index.d.ts +++ b/apps/desktop/src/preload/index.d.ts @@ -1270,6 +1270,7 @@ export interface NoteEditorSettings { // New settings group types (from @memry/contracts/settings-schemas) export interface GeneralSettingsDTO { theme: 'light' | 'dark' | 'white' | 'system' + customThemeId: string | null fontSize: 'small' | 'medium' | 'large' fontFamily: 'system' | 'serif' | 'sans-serif' | 'monospace' | 'gelasio' | 'geist' | 'inter' accentColor: string diff --git a/apps/desktop/src/preload/lib/startup-theme.ts b/apps/desktop/src/preload/lib/startup-theme.ts index 3a355a8e8..fa7797ee9 100644 --- a/apps/desktop/src/preload/lib/startup-theme.ts +++ b/apps/desktop/src/preload/lib/startup-theme.ts @@ -4,6 +4,32 @@ import { SettingsChannels } from '@memry/contracts/ipc-channels' export type StartupTheme = 'light' | 'dark' | 'white' | 'system' export const THEME_STORAGE_KEY = 'memry-theme' +export const CUSTOM_THEME_OVERRIDES_STORAGE_KEY = 'memry-custom-theme-overrides' + +const HEX_VALUE_REGEX = /^#[0-9a-fA-F]{6}$/ + +/** + * Custom-theme variable overrides cached by the renderer (use-theme-sync). + * Read here so the first paint already has the user's colors — same FOUC + * strategy as the cached theme class below. + */ +function getCachedThemeOverrides(): Record | null { + try { + const raw = window.localStorage.getItem(CUSTOM_THEME_OVERRIDES_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null + const overrides: Record = {} + for (const [key, value] of Object.entries(parsed)) { + if (key.startsWith('--') && typeof value === 'string' && HEX_VALUE_REGEX.test(value)) { + overrides[key] = value + } + } + return Object.keys(overrides).length > 0 ? overrides : null + } catch { + return null + } +} function isStartupTheme(value: unknown): value is StartupTheme { return value === 'light' || value === 'dark' || value === 'white' || value === 'system' @@ -49,6 +75,7 @@ function resolveStartupTheme(theme: StartupTheme): 'light' | 'dark' | 'white' { export function applyStartupTheme(savedTheme: StartupTheme): void { const resolvedTheme = resolveStartupTheme(savedTheme) + const overrides = getCachedThemeOverrides() const applyToRoot = (): boolean => { const root = document.documentElement @@ -58,6 +85,11 @@ export function applyStartupTheme(savedTheme: StartupTheme): void { if (resolvedTheme === 'dark') root.classList.add('dark') if (resolvedTheme === 'white') root.classList.add('white') root.style.colorScheme = resolvedTheme === 'dark' ? 'dark' : 'light' + if (overrides) { + for (const [key, value] of Object.entries(overrides)) { + root.style.setProperty(key, value) + } + } return true } diff --git a/apps/desktop/src/renderer/src/components/settings/custom-themes-section.test.tsx b/apps/desktop/src/renderer/src/components/settings/custom-themes-section.test.tsx new file mode 100644 index 000000000..e44d5b334 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/custom-themes-section.test.tsx @@ -0,0 +1,185 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { CustomThemesSection } from './custom-themes-section' + +const darkTheme = { + id: 'theme-1', + name: 'Gece', + base: 'dark' as const, + variables: { '--background': '#101010' }, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z' +} + +const mocks = vi.hoisted(() => ({ + themes: [] as unknown[], + createTheme: vi.fn(), + updateTheme: vi.fn(), + deleteTheme: vi.fn(), + updateSettings: vi.fn(), + generalSettings: { + settings: { theme: 'white', customThemeId: null as string | null, accentColor: '#f97316' }, + isLoading: false, + updateSettings: vi.fn() + } +})) + +vi.mock('@memry/i18n/renderer', () => ({ + useT: () => ({ t: (key: string) => key }) +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } +})) + +vi.mock('@/hooks/use-general-settings', () => ({ + useGeneralSettings: () => mocks.generalSettings +})) + +vi.mock('@/hooks/use-custom-themes', () => ({ + useCustomThemes: () => ({ + themes: mocks.themes, + isLoading: false, + createTheme: mocks.createTheme, + updateTheme: mocks.updateTheme, + deleteTheme: mocks.deleteTheme + }) +})) + +describe('CustomThemesSection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.themes = [darkTheme] + mocks.generalSettings.settings.customThemeId = null + mocks.generalSettings.updateSettings.mockResolvedValue(true) + mocks.createTheme.mockResolvedValue({ ...darkTheme, id: 'theme-2', name: 'Theme 2' }) + mocks.updateTheme.mockResolvedValue(darkTheme) + mocks.deleteTheme.mockResolvedValue(true) + }) + + it('shows the empty hint when there are no themes', () => { + mocks.themes = [] + render() + expect(screen.getByText('appearance.customThemes.empty')).toBeInTheDocument() + }) + + it('creates a theme from the current base and activates it', async () => { + render() + + fireEvent.click(screen.getByText('appearance.customThemes.new')) + + await waitFor(() => + expect(mocks.createTheme).toHaveBeenCalledWith({ + name: 'appearance.customThemes.newNamePrefix 2', + base: 'white' + }) + ) + await waitFor(() => + expect(mocks.generalSettings.updateSettings).toHaveBeenCalledWith({ + customThemeId: 'theme-2', + theme: 'dark' + }) + ) + }) + + it('applies a theme when its row is clicked', async () => { + render() + + fireEvent.click(screen.getByText('Gece')) + + await waitFor(() => + expect(mocks.generalSettings.updateSettings).toHaveBeenCalledWith({ + customThemeId: 'theme-1', + theme: 'dark' + }) + ) + }) + + it('marks the active theme', () => { + mocks.generalSettings.settings.customThemeId = 'theme-1' + render() + expect(screen.getByText('appearance.customThemes.active')).toBeInTheDocument() + }) + + it('duplicates a theme with its variables', async () => { + render() + + fireEvent.click(screen.getByLabelText('appearance.customThemes.duplicate')) + + await waitFor(() => + expect(mocks.createTheme).toHaveBeenCalledWith({ + name: 'Gece appearance.customThemes.copySuffix', + base: 'dark', + variables: { '--background': '#101010' } + }) + ) + }) + + it('deletes the active theme and falls back to its base', async () => { + mocks.generalSettings.settings.customThemeId = 'theme-1' + render() + + fireEvent.click(screen.getByLabelText('appearance.customThemes.delete')) + + await waitFor(() => expect(mocks.deleteTheme).toHaveBeenCalledWith('theme-1')) + await waitFor(() => + expect(mocks.generalSettings.updateSettings).toHaveBeenCalledWith({ + customThemeId: null, + theme: 'dark' + }) + ) + }) + + it('opens the editor, persists a valid hex edit after the debounce, and resets all', async () => { + vi.useFakeTimers() + try { + render() + + fireEvent.click(screen.getByLabelText('appearance.customThemes.edit')) + expect(screen.getByLabelText('appearance.customThemes.editor.name')).toBeInTheDocument() + + const backgroundInput = screen.getByLabelText('Background') + fireEvent.change(backgroundInput, { target: { value: '#222222' } }) + + await vi.advanceTimersByTimeAsync(600) + expect(mocks.updateTheme).toHaveBeenCalledWith('theme-1', { + variables: { '--background': '#222222' } + }) + + fireEvent.click(screen.getByText('appearance.customThemes.editor.resetAll')) + await vi.advanceTimersByTimeAsync(600) + expect(mocks.updateTheme).toHaveBeenLastCalledWith('theme-1', { variables: {} }) + } finally { + vi.useRealTimers() + } + }) + + it('commits a rename from the editor name field on Enter', async () => { + render() + + fireEvent.click(screen.getByLabelText('appearance.customThemes.edit')) + const nameInput = screen.getByLabelText('appearance.customThemes.editor.name') + fireEvent.change(nameInput, { target: { value: 'Gündüz' } }) + fireEvent.keyDown(nameInput, { key: 'Enter' }) + + await waitFor(() => + expect(mocks.updateTheme).toHaveBeenCalledWith('theme-1', { name: 'Gündüz' }) + ) + }) + + it('rejects invalid hex input without persisting', async () => { + vi.useFakeTimers() + try { + render() + + fireEvent.click(screen.getByLabelText('appearance.customThemes.edit')) + fireEvent.change(screen.getByLabelText('Background'), { target: { value: 'red' } }) + + await vi.advanceTimersByTimeAsync(600) + expect(mocks.updateTheme).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/desktop/src/renderer/src/components/settings/custom-themes-section.tsx b/apps/desktop/src/renderer/src/components/settings/custom-themes-section.tsx new file mode 100644 index 000000000..4babf59f0 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/settings/custom-themes-section.tsx @@ -0,0 +1,428 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { toast } from 'sonner' +import { Check, ChevronDown, Copy, Pencil, Plus, RotateCcw, Trash2, X } from '@/lib/icons' +import type { CustomTheme, ThemeBase } from '@memry/contracts/themes-api' +import { THEME_HEX_REGEX } from '@memry/contracts/themes-api' +import { useT } from '@memry/i18n/renderer' +import { cn } from '@/lib/utils' +import { Input } from '@/components/ui/input' +import { SettingsGroup } from '@/components/settings/settings-primitives' +import { useGeneralSettings } from '@/hooks/use-general-settings' +import { useCustomThemes } from '@/hooks/use-custom-themes' +import { + THEME_VARIABLES, + labelForThemeVariable, + type ThemeVariableDef +} from '@/lib/theme-variables' +import { applyCustomThemeVariables } from '@/lib/theme-overrides' + +const PERSIST_DEBOUNCE_MS = 500 + +const SECTION_ORDER = [ + 'surfaces', + 'text', + 'sidebar', + 'accent', + 'dots', + 'cards', + 'states', + 'sidebarDetails', + 'graph', + 'tasks', + 'queue' +] as const + +function baseForCurrentTheme(theme: string): ThemeBase { + if (theme === 'dark' || theme === 'white' || theme === 'light') return theme + // 'system' — resolve from the current media query. + return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' +} + +function computedVarValue(cssVar: string): string { + if (typeof window === 'undefined') return '' + return getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim() +} + +interface VariableRowProps { + def: ThemeVariableDef + override: string | undefined + onChange: (cssVar: string, value: string | null) => void +} + +function VariableRow({ def, override, onChange }: VariableRowProps) { + const { t } = useT('settings') + const [draft, setDraft] = useState('') + const fallback = useMemo( + () => computedVarValue(def.cssVar), + // Re-read the base value whenever the override toggles off. + [def.cssVar, override] + ) + const shown = draft || override || fallback + const pickerValue = THEME_HEX_REGEX.test(shown) ? shown : '#000000' + + const commitDraft = (value: string): void => { + if (THEME_HEX_REGEX.test(value)) { + onChange(def.cssVar, value) + setDraft('') + } + } + + return ( +
+ + {labelForThemeVariable(def)} + +
+ { + setDraft(e.target.value) + commitDraft(e.target.value) + }} + onBlur={() => setDraft('')} + className="w-22 h-6 font-mono text-[11px] bg-muted/50 border-border" + /> + + +
+
+ ) +} + +interface ThemeEditorProps { + theme: CustomTheme + isActive: boolean + onClose: () => void + onUpdate: ( + id: string, + updates: { name?: string; variables?: Record } + ) => Promise +} + +function ThemeEditor({ theme, isActive, onClose, onUpdate }: ThemeEditorProps) { + const { t } = useT('settings') + const [name, setName] = useState(theme.name) + const [variables, setVariables] = useState>(theme.variables) + const [showAdvanced, setShowAdvanced] = useState(false) + const persistTimer = useRef | null>(null) + + useEffect(() => { + setName(theme.name) + setVariables(theme.variables) + }, [theme.id]) + + useEffect(() => { + return () => { + if (persistTimer.current) clearTimeout(persistTimer.current) + } + }, []) + + const persistVariables = useCallback( + (next: Record) => { + if (persistTimer.current) clearTimeout(persistTimer.current) + persistTimer.current = setTimeout(() => { + void onUpdate(theme.id, { variables: next }).then((updated) => { + if (!updated) toast.error(t('appearance.customThemes.errors.update')) + }) + }, PERSIST_DEBOUNCE_MS) + }, + [onUpdate, t, theme.id] + ) + + const setVariable = useCallback( + (cssVar: string, value: string | null) => { + setVariables((current) => { + const next = { ...current } + if (value === null) { + delete next[cssVar] + } else { + next[cssVar] = value + } + // Live preview: the app itself is the preview surface. + if (isActive) applyCustomThemeVariables(document.documentElement, next) + persistVariables(next) + return next + }) + }, + [isActive, persistVariables] + ) + + const resetAll = useCallback(() => { + setVariables({}) + if (isActive) applyCustomThemeVariables(document.documentElement, {}) + persistVariables({}) + }, [isActive, persistVariables]) + + const commitName = useCallback(() => { + const trimmed = name.trim() + if (!trimmed || trimmed === theme.name) return + void onUpdate(theme.id, { name: trimmed }).then((updated) => { + if (!updated) toast.error(t('appearance.customThemes.errors.update')) + }) + }, [name, onUpdate, t, theme.id, theme.name]) + + const sections = useMemo(() => { + const byGroup = (group: 'core' | 'advanced') => + SECTION_ORDER.map((section) => ({ + section, + defs: THEME_VARIABLES.filter((def) => def.group === group && def.section === section) + })).filter((entry) => entry.defs.length > 0) + return { core: byGroup('core'), advanced: byGroup('advanced') } + }, []) + + const renderSections = (entries: { section: string; defs: ThemeVariableDef[] }[]): ReactNode => + entries.map(({ section, defs }) => ( +
+
+ {t(`appearance.customThemes.editor.sections.${section}`)} +
+ {defs.map((def) => ( + + ))} +
+ )) + + return ( +
+
+
+ setName(e.target.value)} + onBlur={commitName} + onKeyDown={(e) => e.key === 'Enter' && commitName()} + className="w-40 h-7 text-xs bg-muted/50 border-border" + maxLength={64} + /> + + {t('appearance.customThemes.editor.base')}:{' '} + {t(`appearance.theme.options.${theme.base}`)} + +
+
+ + +
+
+ + {renderSections(sections.core)} + + + {showAdvanced && renderSections(sections.advanced)} +
+ ) +} + +export function CustomThemesSection() { + const { t } = useT('settings') + const { settings, updateSettings } = useGeneralSettings() + const { themes, createTheme, updateTheme, deleteTheme } = useCustomThemes() + const [editingId, setEditingId] = useState(null) + + const editingTheme = themes.find((theme) => theme.id === editingId) ?? null + + const handleCreate = useCallback(async () => { + const base = baseForCurrentTheme(settings.theme) + const name = `${t('appearance.customThemes.newNamePrefix')} ${themes.length + 1}` + const theme = await createTheme({ name, base }) + if (!theme) { + toast.error(t('appearance.customThemes.errors.create')) + return + } + const applied = await updateSettings({ customThemeId: theme.id, theme: theme.base }) + if (!applied) toast.error(t('appearance.customThemes.errors.apply')) + setEditingId(theme.id) + }, [createTheme, settings.theme, t, themes.length, updateSettings]) + + const handleApply = useCallback( + async (theme: CustomTheme) => { + const success = await updateSettings({ customThemeId: theme.id, theme: theme.base }) + if (!success) toast.error(t('appearance.customThemes.errors.apply')) + }, + [t, updateSettings] + ) + + const handleDuplicate = useCallback( + async (theme: CustomTheme) => { + const copy = await createTheme({ + name: `${theme.name} ${t('appearance.customThemes.copySuffix')}`, + base: theme.base, + variables: theme.variables + }) + if (!copy) toast.error(t('appearance.customThemes.errors.create')) + }, + [createTheme, t] + ) + + const handleDelete = useCallback( + async (theme: CustomTheme) => { + const wasActive = settings.customThemeId === theme.id + const success = await deleteTheme(theme.id) + if (!success) { + toast.error(t('appearance.customThemes.errors.delete')) + return + } + if (editingId === theme.id) setEditingId(null) + if (wasActive) { + // Fall back to the deleted theme's base. + await updateSettings({ customThemeId: null, theme: theme.base }) + } + }, + [deleteTheme, editingId, settings.customThemeId, t, updateSettings] + ) + + return ( + + {themes.length === 0 && ( +
+ {t('appearance.customThemes.empty')} +
+ )} + + {themes.map((theme) => { + const isActive = settings.customThemeId === theme.id + return ( +
+
+ +
+ + + +
+
+ {editingTheme?.id === theme.id && ( + setEditingId(null)} + onUpdate={updateTheme} + /> + )} +
+ ) + })} + + +
+ ) +} diff --git a/apps/desktop/src/renderer/src/hooks/use-custom-themes.test.tsx b/apps/desktop/src/renderer/src/hooks/use-custom-themes.test.tsx new file mode 100644 index 000000000..0fed6cbd0 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-custom-themes.test.tsx @@ -0,0 +1,110 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCustomThemes } from './use-custom-themes' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + createdListeners: [] as Array<(event: { id: string }) => void>, + updatedListeners: [] as Array<(event: { id: string }) => void>, + deletedListeners: [] as Array<(event: { id: string }) => void> +})) + +vi.mock('@/services/themes-service', () => ({ + themesService: { + list: mocks.list, + create: mocks.create, + update: mocks.update, + delete: mocks.remove + }, + onThemeCreated: (callback: (event: { id: string }) => void) => { + mocks.createdListeners.push(callback) + return vi.fn() + }, + onThemeUpdated: (callback: (event: { id: string }) => void) => { + mocks.updatedListeners.push(callback) + return vi.fn() + }, + onThemeDeleted: (callback: (event: { id: string }) => void) => { + mocks.deletedListeners.push(callback) + return vi.fn() + } +})) + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) +})) + +const theme = { + id: 'theme-1', + name: 'Tema 1', + base: 'dark' as const, + variables: { '--background': '#101010' }, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z' +} + +describe('useCustomThemes', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createdListeners = [] + mocks.updatedListeners = [] + mocks.deletedListeners = [] + mocks.list.mockResolvedValue([theme]) + }) + + it('loads themes and reloads on change events', async () => { + const { result } = renderHook(() => useCustomThemes()) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect(result.current.themes).toEqual([theme]) + + mocks.list.mockResolvedValueOnce([{ ...theme, name: 'Renamed' }]) + await act(async () => { + mocks.updatedListeners[0]({ id: 'theme-1' }) + }) + await waitFor(() => expect(result.current.themes[0].name).toBe('Renamed')) + }) + + it('createTheme returns the created theme on success and null on failure', async () => { + const { result } = renderHook(() => useCustomThemes()) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + mocks.create.mockResolvedValueOnce({ success: true, theme }) + await expect(result.current.createTheme({ name: 'Tema 1', base: 'dark' })).resolves.toEqual( + theme + ) + + mocks.create.mockResolvedValueOnce({ success: false, error: 'nope' }) + await expect(result.current.createTheme({ name: 'X', base: 'light' })).resolves.toBeNull() + + mocks.create.mockRejectedValueOnce(new Error('ipc down')) + await expect(result.current.createTheme({ name: 'Y', base: 'white' })).resolves.toBeNull() + }) + + it('updateTheme forwards id + updates and tolerates failures', async () => { + const { result } = renderHook(() => useCustomThemes()) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + mocks.update.mockResolvedValueOnce({ success: true, theme }) + await expect(result.current.updateTheme('theme-1', { name: 'New' })).resolves.toEqual(theme) + expect(mocks.update).toHaveBeenCalledWith({ id: 'theme-1', name: 'New' }) + + mocks.update.mockRejectedValueOnce(new Error('ipc down')) + await expect(result.current.updateTheme('theme-1', {})).resolves.toBeNull() + }) + + it('deleteTheme returns the success flag and false on transport errors', async () => { + const { result } = renderHook(() => useCustomThemes()) + await waitFor(() => expect(result.current.isLoading).toBe(false)) + + mocks.remove.mockResolvedValueOnce({ success: true }) + await expect(result.current.deleteTheme('theme-1')).resolves.toBe(true) + + mocks.remove.mockRejectedValueOnce(new Error('ipc down')) + await expect(result.current.deleteTheme('theme-1')).resolves.toBe(false) + }) +}) diff --git a/apps/desktop/src/renderer/src/hooks/use-custom-themes.ts b/apps/desktop/src/renderer/src/hooks/use-custom-themes.ts new file mode 100644 index 000000000..884481700 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-custom-themes.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from 'react' +import type { CreateThemeInput, CustomTheme, UpdateThemeInput } from '@memry/contracts/themes-api' +import { + themesService, + onThemeCreated, + onThemeUpdated, + onThemeDeleted +} from '@/services/themes-service' +import { createLogger } from '@/lib/logger' + +const log = createLogger('CustomThemes') + +interface UseCustomThemesResult { + themes: CustomTheme[] + isLoading: boolean + createTheme: (input: CreateThemeInput) => Promise + updateTheme: (id: string, updates: UpdateThemeInput) => Promise + deleteTheme: (id: string) => Promise +} + +export function useCustomThemes(): UseCustomThemesResult { + const [themes, setThemes] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + const reload = useCallback(async () => { + try { + setThemes(await themesService.list()) + } catch (err) { + log.warn('Failed to load custom themes', err) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + void reload() + const unsubscribes = [ + onThemeCreated(() => void reload()), + onThemeUpdated(() => void reload()), + onThemeDeleted(() => void reload()) + ] + return () => unsubscribes.forEach((unsubscribe) => unsubscribe()) + }, [reload]) + + const createTheme = useCallback(async (input: CreateThemeInput) => { + try { + const result = await themesService.create(input) + return result.success ? (result.theme ?? null) : null + } catch (err) { + log.warn('Failed to create theme', err) + return null + } + }, []) + + const updateTheme = useCallback(async (id: string, updates: UpdateThemeInput) => { + try { + const result = await themesService.update({ id, ...updates }) + return result.success ? (result.theme ?? null) : null + } catch (err) { + log.warn('Failed to update theme', err) + return null + } + }, []) + + const deleteTheme = useCallback(async (id: string) => { + try { + return (await themesService.delete({ id })).success + } catch (err) { + log.warn('Failed to delete theme', err) + return false + } + }, []) + + return { themes, isLoading, createTheme, updateTheme, deleteTheme } +} diff --git a/apps/desktop/src/renderer/src/hooks/use-theme-sync.ts b/apps/desktop/src/renderer/src/hooks/use-theme-sync.ts index 427b6f12b..8a3334410 100644 --- a/apps/desktop/src/renderer/src/hooks/use-theme-sync.ts +++ b/apps/desktop/src/renderer/src/hooks/use-theme-sync.ts @@ -1,8 +1,16 @@ -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useTheme } from 'next-themes' +import type { CustomTheme } from '@memry/contracts/themes-api' import { useGeneralSettings } from './use-general-settings' import { setDateFormatPref } from '@/lib/format-date' import { createLogger } from '@/lib/logger' +import { themesService, onThemeUpdated, onThemeDeleted } from '@/services/themes-service' +import { + applyCustomThemeVariables, + clearCustomThemeVariables, + clearCachedThemeOverrides, + writeCachedThemeOverrides +} from '@/lib/theme-overrides' const log = createLogger('ThemeSync') @@ -26,6 +34,7 @@ const FONT_FAMILY_MAP: Record = { export function useThemeSync(): void { const { settings, isLoading } = useGeneralSettings() const { setTheme } = useTheme() + const [customTheme, setCustomTheme] = useState(null) useEffect(() => { if (isLoading) return @@ -33,10 +42,63 @@ export function useThemeSync(): void { setTheme(settings.theme) }, [isLoading, settings.theme, setTheme]) + // Resolve the active custom theme (if any) and follow its remote/local edits. useEffect(() => { if (isLoading) return - document.documentElement.style.setProperty('--user-accent-color', settings.accentColor) - }, [isLoading, settings.accentColor]) + const customThemeId = settings.customThemeId + if (!customThemeId) { + setCustomTheme(null) + return + } + + let alive = true + const load = (): void => { + themesService + .list() + .then((themes) => { + if (!alive) return + setCustomTheme(themes.find((theme) => theme.id === customThemeId) ?? null) + }) + .catch((err) => log.warn('Failed to load custom theme', err)) + } + load() + + const unsubscribeUpdated = onThemeUpdated((event) => { + if (event.id === customThemeId) load() + }) + const unsubscribeDeleted = onThemeDeleted((event) => { + if (event.id === customThemeId) setCustomTheme(null) + }) + return () => { + alive = false + unsubscribeUpdated() + unsubscribeDeleted() + } + }, [isLoading, settings.customThemeId]) + + // Apply/clear the custom theme's variable overrides and keep the startup + // FOUC cache in sync. + useEffect(() => { + if (isLoading) return + const root = document.documentElement + if (customTheme) { + applyCustomThemeVariables(root, customTheme.variables) + writeCachedThemeOverrides(customTheme.variables) + } else { + clearCustomThemeVariables(root) + clearCachedThemeOverrides() + } + }, [isLoading, customTheme]) + + useEffect(() => { + if (isLoading) return + // A custom theme's own accent wins over the global accent setting. + const themeAccent = customTheme?.variables['--user-accent-color'] + document.documentElement.style.setProperty( + '--user-accent-color', + themeAccent ?? settings.accentColor + ) + }, [isLoading, settings.accentColor, customTheme]) useEffect(() => { if (isLoading) return diff --git a/apps/desktop/src/renderer/src/lib/theme-overrides.test.ts b/apps/desktop/src/renderer/src/lib/theme-overrides.test.ts new file mode 100644 index 000000000..6677ec4a2 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/theme-overrides.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + applyCustomThemeVariables, + clearCustomThemeVariables, + readCachedThemeOverrides, + writeCachedThemeOverrides, + clearCachedThemeOverrides, + CUSTOM_THEME_OVERRIDES_STORAGE_KEY +} from './theme-overrides' + +describe('applyCustomThemeVariables', () => { + let root: HTMLElement + + beforeEach(() => { + root = document.createElement('div') + }) + + it('sets valid hex variables inline', () => { + applyCustomThemeVariables(root, { '--background': '#101010', '--surface': '#202020' }) + + expect(root.style.getPropertyValue('--background')).toBe('#101010') + expect(root.style.getPropertyValue('--surface')).toBe('#202020') + }) + + it('skips invalid values and non-custom-property keys', () => { + applyCustomThemeVariables(root, { + '--background': 'red', + color: '#101010' + }) + + expect(root.style.getPropertyValue('--background')).toBe('') + expect(root.getAttribute('style') ?? '').not.toContain('red') + }) + + it('removes previously applied variables missing from the next apply', () => { + applyCustomThemeVariables(root, { '--background': '#101010', '--surface': '#202020' }) + applyCustomThemeVariables(root, { '--background': '#303030' }) + + expect(root.style.getPropertyValue('--background')).toBe('#303030') + expect(root.style.getPropertyValue('--surface')).toBe('') + }) + + it('clearCustomThemeVariables removes everything it applied', () => { + applyCustomThemeVariables(root, { '--background': '#101010' }) + clearCustomThemeVariables(root) + + expect(root.style.getPropertyValue('--background')).toBe('') + expect(clearCustomThemeVariables(root)).toBeUndefined() + }) +}) + +describe('cached theme overrides (FOUC path)', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('round-trips overrides through localStorage', () => { + writeCachedThemeOverrides({ '--background': '#101010' }) + expect(readCachedThemeOverrides()).toEqual({ '--background': '#101010' }) + }) + + it('sanitizes invalid entries on read', () => { + window.localStorage.setItem( + CUSTOM_THEME_OVERRIDES_STORAGE_KEY, + JSON.stringify({ '--background': 'red', '--surface': '#202020', nope: '#101010' }) + ) + expect(readCachedThemeOverrides()).toEqual({ '--surface': '#202020' }) + }) + + it('returns null for corrupt or missing cache and clears cleanly', () => { + expect(readCachedThemeOverrides()).toBeNull() + window.localStorage.setItem(CUSTOM_THEME_OVERRIDES_STORAGE_KEY, '{broken') + expect(readCachedThemeOverrides()).toBeNull() + + writeCachedThemeOverrides({ '--background': '#101010' }) + clearCachedThemeOverrides() + expect(readCachedThemeOverrides()).toBeNull() + }) +}) diff --git a/apps/desktop/src/renderer/src/lib/theme-overrides.ts b/apps/desktop/src/renderer/src/lib/theme-overrides.ts new file mode 100644 index 000000000..a0785e123 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/theme-overrides.ts @@ -0,0 +1,81 @@ +/** + * Inline application of custom-theme variable overrides on a root element, + * plus the localStorage cache the preload script reads for a FOUC-free first + * paint (`applyStartupTheme`). + * + * @module lib/theme-overrides + */ + +import { sanitizeThemeVariables } from '@memry/contracts/themes-api' + +export const CUSTOM_THEME_OVERRIDES_STORAGE_KEY = 'memry-custom-theme-overrides' + +const APPLIED_ATTR = 'data-custom-theme-vars' + +function appliedVars(root: HTMLElement): string[] { + const raw = root.getAttribute(APPLIED_ATTR) + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed.filter((key) => typeof key === 'string') : [] + } catch { + return [] + } +} + +export function applyCustomThemeVariables( + root: HTMLElement, + variables: Record +): void { + const next = sanitizeThemeVariables(variables) + const nextKeys = Object.keys(next) + + for (const key of appliedVars(root)) { + if (!(key in next)) { + root.style.removeProperty(key) + } + } + for (const [key, value] of Object.entries(next)) { + root.style.setProperty(key, value) + } + + if (nextKeys.length > 0) { + root.setAttribute(APPLIED_ATTR, JSON.stringify(nextKeys)) + } else { + root.removeAttribute(APPLIED_ATTR) + } +} + +export function clearCustomThemeVariables(root: HTMLElement): void { + applyCustomThemeVariables(root, {}) +} + +export function readCachedThemeOverrides(): Record | null { + try { + const raw = window.localStorage.getItem(CUSTOM_THEME_OVERRIDES_STORAGE_KEY) + if (!raw) return null + const sanitized = sanitizeThemeVariables(JSON.parse(raw)) + return Object.keys(sanitized).length > 0 ? sanitized : null + } catch { + return null + } +} + +export function writeCachedThemeOverrides(variables: Record): void { + try { + window.localStorage.setItem( + CUSTOM_THEME_OVERRIDES_STORAGE_KEY, + JSON.stringify(sanitizeThemeVariables(variables)) + ) + } catch { + // localStorage unavailable — startup will fall back to the IPC path + } +} + +export function clearCachedThemeOverrides(): void { + try { + window.localStorage.removeItem(CUSTOM_THEME_OVERRIDES_STORAGE_KEY) + } catch { + // ignore + } +} diff --git a/apps/desktop/src/renderer/src/lib/theme-variables.test.ts b/apps/desktop/src/renderer/src/lib/theme-variables.test.ts new file mode 100644 index 000000000..106c33050 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/theme-variables.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest' +import fs from 'fs' +import path from 'path' +import { THEME_VARIABLES, labelForThemeVariable } from './theme-variables' + +describe('THEME_VARIABLES registry', () => { + it('every registry variable exists in base.css (drift guard)', () => { + const baseCss = fs.readFileSync(path.resolve(__dirname, '../assets/base.css'), 'utf-8') + const missing = THEME_VARIABLES.filter((def) => !baseCss.includes(def.cssVar)).map( + (def) => def.cssVar + ) + expect(missing).toEqual([]) + }) + + it('has no duplicate variables', () => { + const names = THEME_VARIABLES.map((def) => def.cssVar) + expect(new Set(names).size).toBe(names.length) + }) + + it('every variable is a CSS custom property in a known group', () => { + for (const def of THEME_VARIABLES) { + expect(def.cssVar.startsWith('--')).toBe(true) + expect(['core', 'advanced']).toContain(def.group) + expect(def.section.length).toBeGreaterThan(0) + } + }) + + it('includes the core surfaces, sidebar set, and accent', () => { + const names = new Set(THEME_VARIABLES.map((def) => def.cssVar)) + for (const expected of [ + '--background', + '--surface', + '--surface-active', + '--border', + '--sidebar', + '--user-accent-color' + ]) { + expect(names.has(expected)).toBe(true) + } + }) +}) + +describe('labelForThemeVariable', () => { + it('derives a readable label from the variable name', () => { + expect(labelForThemeVariable({ cssVar: '--surface-active', group: 'core', section: 'x' })).toBe( + 'Surface Active' + ) + }) + + it('prefers an explicit label when provided', () => { + expect( + labelForThemeVariable({ + cssVar: '--user-accent-color', + group: 'core', + section: 'x', + label: 'Accent' + }) + ).toBe('Accent') + }) +}) diff --git a/apps/desktop/src/renderer/src/lib/theme-variables.ts b/apps/desktop/src/renderer/src/lib/theme-variables.ts new file mode 100644 index 000000000..5a3846a98 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/theme-variables.ts @@ -0,0 +1,118 @@ +/** + * Editable theme variable registry — the single source for which CSS custom + * properties the custom-theme editor exposes, grouped Core / Advanced. + * A drift test asserts every entry exists in base.css. + * + * @module lib/theme-variables + */ + +export interface ThemeVariableDef { + cssVar: string + group: 'core' | 'advanced' + /** Section key — also used as the i18n-able section heading id. */ + section: string + /** Optional explicit label; otherwise derived from the variable name. */ + label?: string +} + +export const THEME_VARIABLES: ThemeVariableDef[] = [ + // Core — surfaces + { cssVar: '--background', group: 'core', section: 'surfaces' }, + { cssVar: '--foreground', group: 'core', section: 'surfaces' }, + { cssVar: '--surface', group: 'core', section: 'surfaces' }, + { cssVar: '--surface-active', group: 'core', section: 'surfaces', label: 'Hover' }, + { cssVar: '--border', group: 'core', section: 'surfaces' }, + { cssVar: '--input', group: 'core', section: 'surfaces' }, + { cssVar: '--popover', group: 'core', section: 'surfaces' }, + { cssVar: '--popover-foreground', group: 'core', section: 'surfaces' }, + { cssVar: '--card', group: 'core', section: 'surfaces' }, + { cssVar: '--card-foreground', group: 'core', section: 'surfaces' }, + + // Core — text + { cssVar: '--text-primary', group: 'core', section: 'text' }, + { cssVar: '--text-secondary', group: 'core', section: 'text' }, + { cssVar: '--text-tertiary', group: 'core', section: 'text' }, + { cssVar: '--text-bright', group: 'core', section: 'text' }, + { cssVar: '--muted', group: 'core', section: 'text' }, + { cssVar: '--muted-foreground', group: 'core', section: 'text' }, + + // Core — sidebar + { cssVar: '--sidebar', group: 'core', section: 'sidebar' }, + { cssVar: '--sidebar-foreground', group: 'core', section: 'sidebar' }, + { cssVar: '--sidebar-surface', group: 'core', section: 'sidebar' }, + { cssVar: '--sidebar-accent', group: 'core', section: 'sidebar' }, + { cssVar: '--sidebar-border', group: 'core', section: 'sidebar' }, + { cssVar: '--sidebar-muted', group: 'core', section: 'sidebar' }, + { cssVar: '--sidebar-text-folder', group: 'core', section: 'sidebar' }, + { cssVar: '--sidebar-text-child', group: 'core', section: 'sidebar' }, + + // Core — accent + { cssVar: '--user-accent-color', group: 'core', section: 'accent', label: 'Accent' }, + + // Advanced — category dots + { cssVar: '--accent', group: 'advanced', section: 'dots' }, + { cssVar: '--accent-foreground', group: 'advanced', section: 'dots' }, + { cssVar: '--accent-cyan', group: 'advanced', section: 'dots' }, + { cssVar: '--accent-purple', group: 'advanced', section: 'dots' }, + { cssVar: '--accent-green', group: 'advanced', section: 'dots' }, + { cssVar: '--accent-orange', group: 'advanced', section: 'dots' }, + + // Advanced — semantic cards + { cssVar: '--card-sage', group: 'advanced', section: 'cards' }, + { cssVar: '--card-rose', group: 'advanced', section: 'cards' }, + { cssVar: '--card-sand', group: 'advanced', section: 'cards' }, + { cssVar: '--card-lavender', group: 'advanced', section: 'cards' }, + { cssVar: '--card-grey', group: 'advanced', section: 'cards' }, + + // Advanced — states + { cssVar: '--primary', group: 'advanced', section: 'states' }, + { cssVar: '--primary-foreground', group: 'advanced', section: 'states' }, + { cssVar: '--secondary', group: 'advanced', section: 'states' }, + { cssVar: '--secondary-foreground', group: 'advanced', section: 'states' }, + { cssVar: '--destructive', group: 'advanced', section: 'states' }, + { cssVar: '--destructive-foreground', group: 'advanced', section: 'states' }, + { cssVar: '--ring', group: 'advanced', section: 'states' }, + + // Advanced — sidebar details + { cssVar: '--sidebar-primary', group: 'advanced', section: 'sidebarDetails' }, + { cssVar: '--sidebar-primary-foreground', group: 'advanced', section: 'sidebarDetails' }, + { cssVar: '--sidebar-accent-foreground', group: 'advanced', section: 'sidebarDetails' }, + { cssVar: '--sidebar-dot-inactive', group: 'advanced', section: 'sidebarDetails' }, + + // Advanced — graph + { cssVar: '--graph-bg', group: 'advanced', section: 'graph' }, + { cssVar: '--graph-node-note', group: 'advanced', section: 'graph' }, + { cssVar: '--graph-node-journal', group: 'advanced', section: 'graph' }, + { cssVar: '--graph-node-task', group: 'advanced', section: 'graph' }, + { cssVar: '--graph-node-project', group: 'advanced', section: 'graph' }, + { cssVar: '--graph-node-tag', group: 'advanced', section: 'graph' }, + { cssVar: '--graph-edge-default', group: 'advanced', section: 'graph' }, + { cssVar: '--graph-label-color', group: 'advanced', section: 'graph' }, + + // Advanced — tasks + { cssVar: '--task-priority-urgent', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-priority-high', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-priority-medium', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-priority-low', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-priority-none', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-due-overdue', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-due-today', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-due-tomorrow', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-due-upcoming', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-complete', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-checkbox-done', group: 'advanced', section: 'tasks' }, + { cssVar: '--task-star', group: 'advanced', section: 'tasks' }, + + // Advanced — queue + { cssVar: '--queue-bg', group: 'advanced', section: 'queue' }, + { cssVar: '--queue-number-bg', group: 'advanced', section: 'queue' } +] + +export function labelForThemeVariable(def: ThemeVariableDef): string { + if (def.label) return def.label + return def.cssVar + .replace(/^--/, '') + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} diff --git a/apps/desktop/src/renderer/src/pages/settings/appearance-section.tsx b/apps/desktop/src/renderer/src/pages/settings/appearance-section.tsx index 91f45b28f..a1fed634d 100644 --- a/apps/desktop/src/renderer/src/pages/settings/appearance-section.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/appearance-section.tsx @@ -18,6 +18,7 @@ import { SettingRow, COMPACT_SELECT } from '@/components/settings/settings-primitives' +import { CustomThemesSection } from '@/components/settings/custom-themes-section' const ACCENT_PRESETS = [ { value: '#6366f1', labelKey: 'appearance.accent.presets.indigo' }, @@ -112,7 +113,8 @@ export function AppearanceSettings() { async (value: string) => { if (!value) return const theme = value as 'light' | 'dark' | 'white' | 'system' - const success = await updateSettings({ theme }) + // Picking a built-in theme deactivates any custom theme. + const success = await updateSettings({ theme, customThemeId: null }) if (!success) toast.error(t('appearance.theme.error')) }, [t, updateSettings] @@ -191,6 +193,8 @@ export function AppearanceSettings() { + +
diff --git a/apps/desktop/src/renderer/src/pages/settings/settings-sections.test.tsx b/apps/desktop/src/renderer/src/pages/settings/settings-sections.test.tsx index cb1a49f95..2e7e921d6 100644 --- a/apps/desktop/src/renderer/src/pages/settings/settings-sections.test.tsx +++ b/apps/desktop/src/renderer/src/pages/settings/settings-sections.test.tsx @@ -204,6 +204,18 @@ vi.mock('@/hooks/use-general-settings', () => ({ useGeneralSettings: () => mocks.generalSettings })) +vi.mock('@/services/themes-service', () => ({ + themesService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ success: true }), + update: vi.fn().mockResolvedValue({ success: true }), + delete: vi.fn().mockResolvedValue({ success: true }) + }, + onThemeCreated: vi.fn(() => () => {}), + onThemeUpdated: vi.fn(() => () => {}), + onThemeDeleted: vi.fn(() => () => {}) +})) + vi.mock('@/hooks/use-calendar-preferences', () => ({ useCalendarPreferences: () => mocks.calendarPreferences })) diff --git a/apps/desktop/src/renderer/src/services/themes-service.ts b/apps/desktop/src/renderer/src/services/themes-service.ts new file mode 100644 index 000000000..a19c218bc --- /dev/null +++ b/apps/desktop/src/renderer/src/services/themes-service.ts @@ -0,0 +1,29 @@ +/** + * Custom themes service — forwards to window.api.themes (generated RPC). + * + * @module services/themes-service + */ + +import type { + ThemeChangedEvent, + ThemeDeleteResult, + ThemeMutationResult, + ThemesClientAPI +} from '@memry/rpc/themes' +import { createWindowApiForwarder } from './window-api-forwarder' + +export type { ThemeChangedEvent, ThemeDeleteResult, ThemeMutationResult, ThemesClientAPI } + +export const themesService: ThemesClientAPI = createWindowApiForwarder(() => window.api.themes) + +export function onThemeCreated(callback: (event: ThemeChangedEvent) => void): () => void { + return window.api.onThemeCreated(callback) +} + +export function onThemeUpdated(callback: (event: ThemeChangedEvent) => void): () => void { + return window.api.onThemeUpdated(callback) +} + +export function onThemeDeleted(callback: (event: ThemeChangedEvent) => void): () => void { + return window.api.onThemeDeleted(callback) +} diff --git a/apps/docs/src/architecture/local-storage.md b/apps/docs/src/architecture/local-storage.md index e264ed200..728f48a9e 100644 --- a/apps/docs/src/architecture/local-storage.md +++ b/apps/docs/src/architecture/local-storage.md @@ -53,6 +53,7 @@ Drizzle schemas live in `packages/db-schema`. Tables of note: - `notes`, `journals`, `tasks`, `projects`, `inbox_items`, `templates` - `vault_metadata` (stable vault UUID singleton) - `agent_conversations`, `agent_messages` (encrypted agent chat history) +- `custom_themes` (custom UI themes; carries a sync vector clock and mirrors to `.memry/themes/.json` in the vault) - `tags`, `tag_links`, `note_links` (graph) - `properties`, `property_values` - `sync_items`, `sync_pull_cursor`, `sync_outbox` (sync state) diff --git a/apps/docs/src/architecture/sync-handlers.md b/apps/docs/src/architecture/sync-handlers.md index 46b13e845..6eaa819bb 100644 --- a/apps/docs/src/architecture/sync-handlers.md +++ b/apps/docs/src/architecture/sync-handlers.md @@ -30,6 +30,7 @@ apps/desktop/src/main/sync/item-handlers/ ├─ template-handler.ts ├─ agent-conversation-handler.ts ├─ agent-message-handler.ts +├─ theme-handler.ts └─ index.ts # registry: getHandler(type), getAllHandlers() ``` @@ -48,6 +49,10 @@ For tasks, projects, and agent conversations, handlers additionally invoke `merg Agent message sync is append-only. If a message id already exists locally, the handler treats the remote item as idempotent instead of overwriting a terminal message. +Custom themes merge whole-item last-write-wins: each theme is its own sync item, so edits to two +different themes never conflict. Applying a remote theme also writes the theme's JSON file under +`.memry/themes/` in the vault. + ## Atomicity All `applyUpsert` and `applyDelete` paths run inside `db.transaction()`: diff --git a/apps/docs/src/user-guide/settings.md b/apps/docs/src/user-guide/settings.md index 45e5cb963..a973bc154 100644 --- a/apps/docs/src/user-guide/settings.md +++ b/apps/docs/src/user-guide/settings.md @@ -174,7 +174,18 @@ Show or hide journal sidebar panes: ### Theme -Light, White, Dark, or System (follow OS). +Light, White, Dark, or System (follow OS). Picking a built-in theme here deactivates any active custom theme. + +### Custom Themes + +Create named themes that recolor the whole app. Each custom theme forks one built-in base theme (Light, White, or Dark) and stores only the colors you change; everything else inherits from the base. + +- **New theme** creates a theme. The list shows every custom theme with an **Active** badge on the current one, plus **Apply**, **Edit**, **Duplicate**, and **Delete** actions. +- The editor has a name field, a base theme picker, and grouped color rows — Surfaces, Text, Sidebar, and Accent — with a collapsible **Advanced** section for the rest (category dots, cards, states, sidebar details, graph, tasks, queue). Each row takes a 6-digit `#RRGGBB` hex value or a color picker. +- Edits apply live — the app is the preview. A per-row **Reset** clears one color; **Reset all colors** returns the theme to its base. +- Deleting the active theme reverts the app to that theme's base. + +Custom themes sync across devices, and each theme is also written to `.memry/themes/.json` inside the vault. While a custom theme is active the base theme is saved alongside it, so older app versions that don't know about custom themes render the base. ### Accent Color @@ -324,7 +335,7 @@ Create, rename, recolor, and reorder property options. Settings persist via Zod schemas in `packages/contracts/settings-schemas.ts`. Notable keys: -- General: `theme`, `fontSize`, `fontFamily`, `accentColor`, `startOnBoot`, `language`, `clockFormat`, `dateFormat`, `createInSelectedFolder` +- General: `theme`, `customThemeId`, `fontSize`, `fontFamily`, `accentColor`, `startOnBoot`, `language`, `clockFormat`, `dateFormat`, `createInSelectedFolder` - Editor: `width`, `toolbarMode` - Tasks: `defaultProjectId`, `defaultSortOrder`, `staleInboxDays` - Calendar: `dayCellClickBehavior`, `calendarPageClickOverride`, `weekStartDay` diff --git a/apps/docs/src/user-guide/sync/how-sync-works.md b/apps/docs/src/user-guide/sync/how-sync-works.md index 29140ea79..28a4775cd 100644 --- a/apps/docs/src/user-guide/sync/how-sync-works.md +++ b/apps/docs/src/user-guide/sync/how-sync-works.md @@ -77,6 +77,7 @@ Resume sync to push and pull queued changes. Outgoing changes queue locally unti | Attachments (encrypted blobs) | ✓ | | Agent chat conversations and terminal messages (paid accounts) | ✓ | | Folder icons | ✓ | +| Custom themes | ✓ | ## What Does **Not** Get Synced diff --git a/apps/sync-server/src/services/sync-telemetry.ts b/apps/sync-server/src/services/sync-telemetry.ts index 4cadd6065..97a9240de 100644 --- a/apps/sync-server/src/services/sync-telemetry.ts +++ b/apps/sync-server/src/services/sync-telemetry.ts @@ -17,6 +17,7 @@ type SyncDomain = | 'folders' | 'calendar' | 'agent_chat' + | 'themes' const logger = createLogger('SyncTelemetry') @@ -48,6 +49,8 @@ const toSyncDomain = (itemType: SyncItemType): SyncDomain => { return 'tags' case 'folder_config': return 'folders' + case 'theme': + return 'themes' case 'calendar_event': case 'calendar_source': case 'calendar_binding': diff --git a/docs/superpowers/specs/2026-07-09-custom-themes-design.md b/docs/superpowers/specs/2026-07-09-custom-themes-design.md new file mode 100644 index 000000000..104a61555 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-custom-themes-design.md @@ -0,0 +1,156 @@ +# Custom Theme System — Design + +Date: 2026-07-09 +Status: Approved (brainstorm), pending implementation plan + +## Overview + +Users can create named custom themes (e.g. "Tema 1") that override any theme color +variable in the app — background, surface, sidebar tones, hover states, text tones, +accent, and every other color token. Themes are edited from Settings → Appearance with +a hex input plus a color picker per variable, stored as JSON files in the vault, and +synced across devices via a new `theme` sync item type. + +## Decisions (from brainstorm) + +| Decision | Choice | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Editable variable scope | Core set (~25) visible by default + collapsible Advanced section for everything else (colors only) | +| Theme model | Base + override: custom theme forks one built-in (`light`/`white`/`dark`) and stores only changed variables | +| Storage | One JSON file per theme: `.memry/themes/.json` | +| Sync | New `theme` sync item type, one item per theme (folder_config precedent) | +| Accent color | Embedded in the theme as an editable variable when a custom theme is active; built-in themes keep the existing global `accentColor` behavior | +| Preview | Live apply while editing; debounced autosave (~500 ms), no Save button | +| Built-in themes | Immutable; "Customize" creates a custom theme forked from them | + +## Data Model + +```jsonc +// .memry/themes/.json +{ + "id": "uuid", // stable sync identity — never changes + "name": "Tema 1", // user-facing, renameable + "base": "light", // 'light' | 'white' | 'dark' + "variables": { + // ONLY overrides; untouched vars inherit from base + "--background": "#f6f5f0", + "--sidebar": "#111111" + }, + "createdAt": "ISO-8601", + "updatedAt": "ISO-8601" +} +``` + +- Filename slug derived from `name`; rename moves the file, `id` stays stable. + Slug collisions get a `-2` suffix. +- `variables` values are 6-digit hex colors only (`#rrggbb`, matching the existing + `HEX_COLOR_REGEX`). Shadows, radii, durations, and fonts are out of scope. +- Zod schema (`CustomThemeSchema`) lives in `packages/contracts`. +- Main-process module `apps/desktop/src/main/vault/themes.ts`: + `listThemes` / `readTheme` / `writeTheme` / `renameTheme` / `deleteTheme`, + atomic writes (temp file + rename, same as `vault-preferences.ts`). + +## Variable Registry + +- Renderer module `theme-variables.ts`: the single source for which CSS variables are + editable, their group, and i18n label key. +- Groups — **Core** (default open): background, surface, surface-active (hover), + border/input, popover, card, text tones (`--text-primary/secondary/tertiary/bright`), + full sidebar set (`--sidebar*`), accent/tint. **Advanced** (collapsed): category + dots (`--accent-*`), semantic cards (`--card-*`), graph (`--graph-*`), task colors + (`--task-*`), queue (`--queue-*`), destructive/ring, primary/secondary. +- Base values are never duplicated into TS: read via `getComputedStyle` on a probe + element carrying the base theme class (used for current-value display and reset). +- A unit test asserts every registry variable exists in `base.css` (drift guard). + +## Apply Mechanism + +- `general.theme` enum is **unchanged** (backward compatibility). New optional field + `general.customThemeId: string | null`. + - When set, it wins. `general.theme` is simultaneously set to the custom theme's + `base`, so older app versions that don't know the field render the base theme. +- `use-theme-sync.ts` extension: when a custom theme is active → + `setTheme(base)` (next-themes keeps class-based switching and `dark:` variants + correct) + apply each override via + `document.documentElement.style.setProperty(cssVar, value)` — the existing + `--user-accent-color` pattern. Switching themes clears previously applied inline + overrides before applying new ones. +- FOUC: extend the synchronous `GET_STARTUP_THEME` IPC to return + `{ theme, base, overrides }` so the first paint is correct. +- Accent: the registry exposes accent as `--user-accent-color` (the existing source + of the `--tint` chain). With a custom theme active, the theme's override of this + variable wins; with built-ins, the global `accentColor` setting behaves exactly as + today. + +## IPC + +- New `ThemeChannels` in `packages/contracts`: `list`, `get`, `create`, `update`, + `rename`, `delete` invokes + `changed` event broadcast to all windows. +- Follow the `ipc-contract-change` skill; run `pnpm ipc:generate` + `pnpm ipc:check`. +- Errors surface via `extractErrorMessage`; main-process logging via + `createLogger('Themes')`. + +## Sync & Compatibility (PRODUCTION constraints) + +- New sync item type `'theme'` wired with the `adding-sync-item-type` skill: + record type, clock-required, encryptable, `itemId` = theme `id`. +- Payload = the full theme JSON. Whole-theme LWW per item (payloads are tiny); + per-theme granularity means two devices editing different themes never conflict. +- Apply on pull: write `.memry/themes/.json`; if the slug changed (rename), + move the file. Delete operations remove the file; if the deleted theme was active, + fall back to its base and clear `customThemeId`. +- `general.customThemeId` added to `GENERAL_SYNCABLE_FIELDS` and + `SyncedSettingsSchema.general` as optional. Older clients strip unknown fields in + Zod (non-strict) — no breakage. +- DB: additive `custom_themes` table (hand-written migration `0035_custom_themes.sql`) — + the folder_config precedent showed file-backed sync types still need a DB row as the + clock/sync source of truth. Additive CREATE TABLE only; fully backward compatible. +- **Pre-implementation verification points:** + 1. Older desktop clients must gracefully skip unknown item types on pull — verify + the handler-registry (`getHandler(type)`) miss path. + 2. If sync-server validates against `SYNC_ITEM_TYPES`, the server must deploy + **before** the desktop release that ships themes (same ordering rule as the + telemetry rollout). + +## Settings UI + +- `appearance-section.tsx`: below the existing theme segmented control, a + **Custom themes** group — list of user themes (active one marked), actions: + New theme (pick a base or duplicate current), Edit, Rename, Duplicate, Delete. + Built-ins are immutable; a "Customize" action forks them into a new custom theme. +- Editor: Core group open, Advanced collapsible. Each row: + label + current-color swatch + hex `` (validated with `HEX_COLOR_REGEX`) + + a color picker button (native ``, `CustomColorSwatch` + precedent). Picking from the OS picker fills the hex input. Modified variables show + a per-variable reset; a Reset-all action clears every override. +- Live preview: edits apply to the app immediately (the app is the preview); + debounced (~500 ms) write-through to the theme file and sync queue. +- i18n: all labels through the normal i18n flow (ICU, single-brace). +- RTL: logical Tailwind classes only (`ms-*`/`me-*`, `ps-*`/`pe-*`, …). + +## Error Handling + +- Invalid hex rejected at the input (regex) and at the IPC boundary (Zod). +- Reading a theme file: unknown or invalid variables are silently ignored + (forward compatibility when future versions add variables). +- Corrupt/missing theme file → fall back to the base theme, log the error. +- Deleting the active theme → revert to its base, clear `customThemeId` + (propagates through sync). + +## Testing + +- Unit (main): `themes.ts` — atomic write, slug derivation, collision suffix, rename, + delete; Zod schemas; registry↔`base.css` consistency test. +- Unit (renderer): editor component — hex validation, picker→input flow, per-var and + reset-all; `use-theme-sync` — override apply/clear, base class switching. +- Sync: `theme-handler` tests modeled on the folder_config handler tests — + upsert, rename (slug move), delete, active-theme-deleted fallback. +- E2E (light): create theme → change one core variable → assert applied → + restart → assert persisted. + +## Out of Scope + +- Theme export/import/sharing, theme gallery/marketplace. +- Non-color tokens: shadows, radii, durations, fonts. +- Per-window or per-vault-section themes. +- Editing built-in themes in place. diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 73738386b..84367ed43 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -46,6 +46,7 @@ "./sync-api": "./src/sync-api.ts", "./sync-payloads": "./src/sync-payloads.ts", "./tags-api": "./src/tags-api.ts", + "./themes-api": "./src/themes-api.ts", "./tasks-api": "./src/tasks-api.ts", "./telemetry-api": "./src/telemetry-api.ts", "./templates-api": "./src/templates-api.ts", diff --git a/packages/contracts/src/ipc-channels.ts b/packages/contracts/src/ipc-channels.ts index 63fad2437..914b01cb2 100644 --- a/packages/contracts/src/ipc-channels.ts +++ b/packages/contracts/src/ipc-channels.ts @@ -202,6 +202,24 @@ export const SavedFiltersChannels = { } } as const +// ============================================================================ +// Custom Themes Channels +// ============================================================================ + +export const ThemesChannels = { + invoke: { + LIST: 'themes:list', + CREATE: 'themes:create', + UPDATE: 'themes:update', + DELETE: 'themes:delete' + }, + events: { + CREATED: 'themes:created', + UPDATED: 'themes:updated', + DELETED: 'themes:deleted' + } +} as const + // ============================================================================ // Templates Channels // ============================================================================ @@ -266,6 +284,9 @@ export type SavedFiltersInvokeChannel = export type SavedFiltersEventChannel = (typeof SavedFiltersChannels.events)[keyof typeof SavedFiltersChannels.events] +export type ThemesInvokeChannel = (typeof ThemesChannels.invoke)[keyof typeof ThemesChannels.invoke] +export type ThemesEventChannel = (typeof ThemesChannels.events)[keyof typeof ThemesChannels.events] + export type TemplatesInvokeChannel = (typeof TemplatesChannels.invoke)[keyof typeof TemplatesChannels.invoke] export type TemplatesEventChannel = diff --git a/packages/contracts/src/settings-schemas.test.ts b/packages/contracts/src/settings-schemas.test.ts index 28d0e9fd2..6761ba9d3 100644 --- a/packages/contracts/src/settings-schemas.test.ts +++ b/packages/contracts/src/settings-schemas.test.ts @@ -60,6 +60,15 @@ describe('GeneralSettingsSchema', () => { expect(result.success).toBe(true) }) + it('defaults customThemeId to null and accepts a string id', () => { + expect(GENERAL_SETTINGS_DEFAULTS.customThemeId).toBeNull() + const result = GeneralSettingsSchema.safeParse({ + ...GENERAL_SETTINGS_DEFAULTS, + customThemeId: 'theme-1' + }) + expect(result.success).toBe(true) + }) + it('rejects invalid theme enum', () => { const result = GeneralSettingsSchema.safeParse({ ...GENERAL_SETTINGS_DEFAULTS, diff --git a/packages/contracts/src/settings-schemas.ts b/packages/contracts/src/settings-schemas.ts index ade7c910a..e6a85d543 100644 --- a/packages/contracts/src/settings-schemas.ts +++ b/packages/contracts/src/settings-schemas.ts @@ -16,6 +16,9 @@ import { LocaleSchema } from './locale-api' export const GeneralSettingsSchema = z.object({ theme: z.enum(['light', 'dark', 'white', 'system']), + // Active custom theme id; null = built-in theme. Kept separate from `theme` + // so older app versions (unaware of custom themes) still render the base. + customThemeId: z.string().nullable(), fontSize: z.enum(['small', 'medium', 'large']), fontFamily: z.enum(['system', 'serif', 'sans-serif', 'monospace', 'gelasio', 'geist', 'inter']), accentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/), @@ -33,6 +36,7 @@ export const DEFAULT_ACCENT_COLOR = '#f97316' export const GENERAL_SETTINGS_DEFAULTS: GeneralSettings = { theme: 'white', + customThemeId: null, fontSize: 'medium', fontFamily: 'system', accentColor: DEFAULT_ACCENT_COLOR, diff --git a/packages/contracts/src/settings-sync.test.ts b/packages/contracts/src/settings-sync.test.ts index f16247cc5..58008c3a7 100644 --- a/packages/contracts/src/settings-sync.test.ts +++ b/packages/contracts/src/settings-sync.test.ts @@ -149,6 +149,17 @@ describe('SyncedSettingsSchema', () => { } }) + it('accepts general.customThemeId as string or null', () => { + expect(SyncedSettingsSchema.safeParse({ general: { customThemeId: 'theme-1' } }).success).toBe( + true + ) + expect(SyncedSettingsSchema.safeParse({ general: { customThemeId: null } }).success).toBe(true) + }) + + it('rejects non-string general.customThemeId', () => { + expect(SyncedSettingsSchema.safeParse({ general: { customThemeId: 42 } }).success).toBe(false) + }) + it('accepts all groups populated together', () => { const result = SyncedSettingsSchema.safeParse({ general: { theme: 'light', accentColor: '#123456' }, diff --git a/packages/contracts/src/settings-sync.ts b/packages/contracts/src/settings-sync.ts index 8ae860317..1497b3ad1 100644 --- a/packages/contracts/src/settings-sync.ts +++ b/packages/contracts/src/settings-sync.ts @@ -5,6 +5,7 @@ export const SyncedSettingsSchema = z.object({ general: z .object({ theme: z.enum(['light', 'dark', 'white', 'system']).optional(), + customThemeId: z.string().nullable().optional(), fontSize: z.enum(['small', 'medium', 'large']).optional(), fontFamily: z .enum(['system', 'serif', 'sans-serif', 'monospace', 'gelasio', 'geist', 'inter']) diff --git a/packages/contracts/src/sync-api.ts b/packages/contracts/src/sync-api.ts index fabc07381..9ee4e0630 100644 --- a/packages/contracts/src/sync-api.ts +++ b/packages/contracts/src/sync-api.ts @@ -15,6 +15,7 @@ export const SYNC_ITEM_TYPES = [ 'journal', 'tag_definition', 'folder_config', + 'theme', 'calendar_event', 'calendar_source', 'calendar_binding', @@ -33,6 +34,7 @@ export const RECORD_SYNC_ITEM_TYPES = [ 'journal', 'tag_definition', 'folder_config', + 'theme', 'calendar_event', 'calendar_source', 'calendar_binding', @@ -50,6 +52,7 @@ export const RECORD_CLOCK_REQUIRED_ITEM_TYPES = [ 'journal', 'tag_definition', 'folder_config', + 'theme', 'calendar_event', 'calendar_source', 'calendar_binding', @@ -72,6 +75,7 @@ export const ENCRYPTABLE_ITEM_TYPES = [ 'journal', 'tag_definition', 'folder_config', + 'theme', 'calendar_event', 'calendar_source', 'calendar_binding', diff --git a/packages/contracts/src/sync-payloads.test.ts b/packages/contracts/src/sync-payloads.test.ts index eed262b53..81d018733 100644 --- a/packages/contracts/src/sync-payloads.test.ts +++ b/packages/contracts/src/sync-payloads.test.ts @@ -22,7 +22,8 @@ import { ProjectSyncPayloadSchema, StatusSyncSchema, TagDefinitionSyncPayloadSchema, - TaskSyncPayloadSchema + TaskSyncPayloadSchema, + ThemeSyncPayloadSchema } from './sync-payloads' describe('AgentMessageSyncPayloadSchema', () => { @@ -297,9 +298,9 @@ describe('JournalSyncPayloadSchema', () => { describe('TagDefinitionSyncPayloadSchema', () => { it('accepts minimal required fields', () => { - expect( - TagDefinitionSyncPayloadSchema.safeParse({ name: 'work', color: '#abc' }).success - ).toBe(true) + expect(TagDefinitionSyncPayloadSchema.safeParse({ name: 'work', color: '#abc' }).success).toBe( + true + ) }) it('rejects missing color', () => { @@ -353,12 +354,8 @@ describe('CalendarEventSyncPayloadSchema', () => { describe('CalendarSourceSyncPayloadSchema', () => { it('accepts all kind enum values', () => { - expect( - CalendarSourceSyncPayloadSchema.safeParse({ kind: 'account' }).success - ).toBe(true) - expect( - CalendarSourceSyncPayloadSchema.safeParse({ kind: 'calendar' }).success - ).toBe(true) + expect(CalendarSourceSyncPayloadSchema.safeParse({ kind: 'account' }).success).toBe(true) + expect(CalendarSourceSyncPayloadSchema.safeParse({ kind: 'calendar' }).success).toBe(true) }) it('accepts all syncStatus enum values', () => { @@ -413,9 +410,9 @@ describe('CalendarBindingSyncPayloadSchema', () => { expect( CalendarBindingSyncPayloadSchema.safeParse({ lastLocalSnapshot: { a: 1 } }).success ).toBe(true) - expect( - CalendarBindingSyncPayloadSchema.safeParse({ lastLocalSnapshot: null }).success - ).toBe(true) + expect(CalendarBindingSyncPayloadSchema.safeParse({ lastLocalSnapshot: null }).success).toBe( + true + ) }) }) @@ -443,3 +440,38 @@ describe('CalendarExternalEventSyncPayloadSchema', () => { expect(result.success).toBe(true) }) }) + +describe('ThemeSyncPayloadSchema', () => { + it('accepts a full theme payload', () => { + const result = ThemeSyncPayloadSchema.safeParse({ + name: 'Tema 1', + slug: 'tema-1', + base: 'dark', + variables: { '--background': '#101010' }, + clock: { 'device-a': 1 }, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z' + }) + expect(result.success).toBe(true) + }) + + it('tolerates missing optional clock and timestamps', () => { + const result = ThemeSyncPayloadSchema.safeParse({ + name: 'Tema 1', + slug: 'tema-1', + base: 'light', + variables: {} + }) + expect(result.success).toBe(true) + }) + + it('rejects an unknown base', () => { + const result = ThemeSyncPayloadSchema.safeParse({ + name: 'Tema 1', + slug: 'tema-1', + base: 'system', + variables: {} + }) + expect(result.success).toBe(false) + }) +}) diff --git a/packages/contracts/src/sync-payloads.ts b/packages/contracts/src/sync-payloads.ts index c3765f1e6..4017e3dfc 100644 --- a/packages/contracts/src/sync-payloads.ts +++ b/packages/contracts/src/sync-payloads.ts @@ -119,6 +119,16 @@ export const FolderConfigSyncPayloadSchema = z.object({ modifiedAt: z.string().optional() }) +export const ThemeSyncPayloadSchema = z.object({ + name: z.string(), + slug: z.string(), + base: z.enum(['light', 'white', 'dark']), + variables: z.record(z.string(), z.string()), + clock: VectorClockSchema.optional(), + createdAt: z.string().optional(), + modifiedAt: z.string().optional() +}) + export const CalendarEventSyncPayloadSchema = z.object({ title: z.string().optional(), description: z.string().nullable().optional(), @@ -327,6 +337,7 @@ export const AgentMessageSyncPayloadSchema = z.object({ }) export type FolderConfigSyncPayload = z.infer +export type ThemeSyncPayload = z.infer export type CalendarEventSyncPayload = z.infer export type CalendarSourceSyncPayload = z.infer export type CalendarBindingSyncPayload = z.infer diff --git a/packages/contracts/src/themes-api.test.ts b/packages/contracts/src/themes-api.test.ts new file mode 100644 index 000000000..01d0c29e8 --- /dev/null +++ b/packages/contracts/src/themes-api.test.ts @@ -0,0 +1,130 @@ +/** + * Custom Theme Contract Tests + * + * File/DTO schema for custom themes, strict write-side variable validation, + * and read-side sanitization tolerance. + */ + +import { describe, it, expect } from 'vitest' + +import { + CustomThemeSchema, + CreateThemeInputSchema, + UpdateThemeInputSchema, + ThemeBaseSchema, + sanitizeThemeVariables +} from './themes-api' + +const validTheme = { + id: 'a1b2c3d4', + name: 'Tema 1', + base: 'light', + variables: { '--background': '#f6f5f0', '--sidebar': '#111111' }, + createdAt: '2026-07-09T10:00:00.000Z', + modifiedAt: '2026-07-09T10:00:00.000Z' +} + +describe('CustomThemeSchema', () => { + it('accepts a valid theme', () => { + expect(CustomThemeSchema.safeParse(validTheme).success).toBe(true) + }) + + it('rejects an unknown base', () => { + expect(CustomThemeSchema.safeParse({ ...validTheme, base: 'system' }).success).toBe(false) + }) + + it('rejects an empty name', () => { + expect(CustomThemeSchema.safeParse({ ...validTheme, name: '' }).success).toBe(false) + }) + + it('tolerates arbitrary string variable values (read-side leniency)', () => { + const result = CustomThemeSchema.safeParse({ + ...validTheme, + variables: { '--background': 'not-a-color' } + }) + expect(result.success).toBe(true) + }) +}) + +describe('ThemeBaseSchema', () => { + it('accepts the three built-in bases only', () => { + expect(ThemeBaseSchema.safeParse('light').success).toBe(true) + expect(ThemeBaseSchema.safeParse('white').success).toBe(true) + expect(ThemeBaseSchema.safeParse('dark').success).toBe(true) + expect(ThemeBaseSchema.safeParse('warm').success).toBe(false) + }) +}) + +describe('CreateThemeInputSchema', () => { + it('accepts name + base', () => { + expect(CreateThemeInputSchema.safeParse({ name: 'Tema 1', base: 'dark' }).success).toBe(true) + }) + + it('accepts initial variables with valid hex values', () => { + const result = CreateThemeInputSchema.safeParse({ + name: 'Tema 1', + base: 'dark', + variables: { '--background': '#101010' } + }) + expect(result.success).toBe(true) + }) + + it('rejects non-hex variable values (write-side strictness)', () => { + const result = CreateThemeInputSchema.safeParse({ + name: 'Tema 1', + base: 'dark', + variables: { '--background': 'red' } + }) + expect(result.success).toBe(false) + }) + + it('rejects variable keys that are not CSS custom properties', () => { + const result = CreateThemeInputSchema.safeParse({ + name: 'Tema 1', + base: 'dark', + variables: { background: '#101010' } + }) + expect(result.success).toBe(false) + }) +}) + +describe('UpdateThemeInputSchema', () => { + it('accepts partial updates', () => { + expect(UpdateThemeInputSchema.safeParse({ name: 'Renamed' }).success).toBe(true) + expect( + UpdateThemeInputSchema.safeParse({ variables: { '--surface': '#ffffff' } }).success + ).toBe(true) + expect(UpdateThemeInputSchema.safeParse({}).success).toBe(true) + }) + + it('rejects invalid hex in variables', () => { + expect(UpdateThemeInputSchema.safeParse({ variables: { '--surface': '#fff' } }).success).toBe( + false + ) + }) +}) + +describe('sanitizeThemeVariables', () => { + it('keeps valid --var: #rrggbb entries', () => { + expect(sanitizeThemeVariables({ '--background': '#F6F5F0' })).toEqual({ + '--background': '#F6F5F0' + }) + }) + + it('drops invalid hex values and non-custom-property keys', () => { + expect( + sanitizeThemeVariables({ + '--background': 'color-mix(in srgb, red, blue)', + '--surface': '#fff', + background: '#f6f5f0', + '--valid': '#123abc' + }) + ).toEqual({ '--valid': '#123abc' }) + }) + + it('returns empty object for undefined/non-object input', () => { + expect(sanitizeThemeVariables(undefined)).toEqual({}) + expect(sanitizeThemeVariables(null)).toEqual({}) + expect(sanitizeThemeVariables('nope')).toEqual({}) + }) +}) diff --git a/packages/contracts/src/themes-api.ts b/packages/contracts/src/themes-api.ts new file mode 100644 index 000000000..f274e12e1 --- /dev/null +++ b/packages/contracts/src/themes-api.ts @@ -0,0 +1,91 @@ +/** + * Custom Theme Schemas + * + * Custom themes fork a built-in base theme and store only overridden CSS + * color variables. Write-side inputs validate strictly (6-digit hex, custom + * property keys); read-side parsing stays lenient and relies on + * sanitizeThemeVariables so files written by newer app versions never fail + * to load on older ones. + * + * @module contracts/themes-api + */ + +import { z } from 'zod' + +export const THEME_BASES = ['light', 'white', 'dark'] as const + +export const ThemeBaseSchema = z.enum(THEME_BASES) + +export type ThemeBase = z.infer + +export const THEME_HEX_REGEX = /^#[0-9a-fA-F]{6}$/ + +export const ThemeVariablesInputSchema = z.record( + z.string().startsWith('--'), + z.string().regex(THEME_HEX_REGEX) +) + +export const CustomThemeSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1).max(64), + base: ThemeBaseSchema, + variables: z.record(z.string(), z.string()), + createdAt: z.string(), + modifiedAt: z.string() +}) + +export type CustomTheme = z.infer + +export const CreateThemeInputSchema = z.object({ + name: z.string().min(1).max(64), + base: ThemeBaseSchema, + variables: ThemeVariablesInputSchema.optional() +}) + +export type CreateThemeInput = z.infer + +export const UpdateThemeInputSchema = z.object({ + name: z.string().min(1).max(64).optional(), + base: ThemeBaseSchema.optional(), + variables: ThemeVariablesInputSchema.optional() +}) + +export type UpdateThemeInput = z.infer + +export const UpdateThemePayloadSchema = UpdateThemeInputSchema.extend({ + id: z.string().min(1) +}) + +export type UpdateThemePayload = z.infer + +export const DeleteThemePayloadSchema = z.object({ + id: z.string().min(1) +}) + +export type DeleteThemePayload = z.infer + +export interface ThemeMutationResult { + success: boolean + theme?: CustomTheme + error?: string +} + +export interface ThemeDeleteResult { + success: boolean + error?: string +} + +/** + * Read-side tolerance: keep only `--var: #rrggbb` entries, drop everything + * else (unknown shapes, future value syntaxes, non-custom-property keys). + */ +export function sanitizeThemeVariables(input: unknown): Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) return {} + const result: Record = {} + for (const [key, value] of Object.entries(input)) { + if (!key.startsWith('--')) continue + if (typeof value !== 'string' || !THEME_HEX_REGEX.test(value)) continue + result[key] = value + } + return result +} diff --git a/packages/db-schema/src/data-schema.ts b/packages/db-schema/src/data-schema.ts index 5bdb043bc..ae5094d1f 100644 --- a/packages/db-schema/src/data-schema.ts +++ b/packages/db-schema/src/data-schema.ts @@ -28,6 +28,7 @@ export * from './schema/sync-queue.ts' export * from './schema/sync-state.ts' export * from './schema/sync-history.ts' export * from './schema/search-reasons.ts' +export * from './schema/custom-themes.ts' export * from './schema/vault-metadata.ts' export * from './schema/agent-conversations.ts' export * from './schema/agent-messages.ts' diff --git a/packages/db-schema/src/schema/custom-themes.ts b/packages/db-schema/src/schema/custom-themes.ts new file mode 100644 index 000000000..70aeec168 --- /dev/null +++ b/packages/db-schema/src/schema/custom-themes.ts @@ -0,0 +1,20 @@ +import { sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { sql } from 'drizzle-orm' + +export const customThemes = sqliteTable('custom_themes', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull(), + base: text('base').notNull(), + variables: text('variables', { mode: 'json' }).$type>().notNull(), + clock: text('clock', { mode: 'json' }), + createdAt: text('created_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + modifiedAt: text('modified_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`) +}) + +export type CustomThemeRow = typeof customThemes.$inferSelect +export type NewCustomThemeRow = typeof customThemes.$inferInsert diff --git a/packages/db-schema/src/schema/index.ts b/packages/db-schema/src/schema/index.ts index 78d36f989..c2f208b0d 100644 --- a/packages/db-schema/src/schema/index.ts +++ b/packages/db-schema/src/schema/index.ts @@ -21,3 +21,4 @@ export * from './sync-queue.ts' export * from './sync-state.ts' export * from './search-reasons.ts' export * from './folder-configs.ts' +export * from './custom-themes.ts' diff --git a/packages/i18n/src/locales/en/settings.json b/packages/i18n/src/locales/en/settings.json index 4fd65c153..d7396b15b 100644 --- a/packages/i18n/src/locales/en/settings.json +++ b/packages/i18n/src/locales/en/settings.json @@ -206,6 +206,46 @@ }, "error": "Failed to update accent color" }, + "customThemes": { + "group": "Custom Themes", + "empty": "Create a theme to customize every color in the app", + "new": "New theme", + "newNamePrefix": "Theme", + "active": "Active", + "apply": "Apply", + "edit": "Edit", + "duplicate": "Duplicate", + "delete": "Delete", + "copySuffix": "copy", + "editor": { + "name": "Name", + "base": "Base theme", + "advanced": "Advanced", + "reset": "Reset", + "resetAll": "Reset all colors", + "close": "Close editor", + "pickColor": "Pick color", + "sections": { + "surfaces": "Surfaces", + "text": "Text", + "sidebar": "Sidebar", + "accent": "Accent", + "dots": "Category dots", + "cards": "Cards", + "states": "States", + "sidebarDetails": "Sidebar details", + "graph": "Graph", + "tasks": "Tasks", + "queue": "Queue" + } + }, + "errors": { + "create": "Failed to create theme", + "update": "Failed to update theme", + "delete": "Failed to delete theme", + "apply": "Failed to apply theme" + } + }, "typography": { "fontSize": { "label": "Font Size", diff --git a/packages/rpc/package.json b/packages/rpc/package.json index e570cafc4..3cf0c55b9 100644 --- a/packages/rpc/package.json +++ b/packages/rpc/package.json @@ -10,7 +10,8 @@ "./tasks": "./src/tasks.ts", "./notes": "./src/notes.ts", "./inbox": "./src/inbox.ts", - "./settings": "./src/settings.ts" + "./settings": "./src/settings.ts", + "./themes": "./src/themes.ts" }, "types": "./src/index.ts", "scripts": { diff --git a/packages/rpc/src/index.test.ts b/packages/rpc/src/index.test.ts index cde9faea6..3261b84e6 100644 --- a/packages/rpc/src/index.test.ts +++ b/packages/rpc/src/index.test.ts @@ -10,7 +10,8 @@ import { rpcDomains, settingsRpc, tasksRpc, - telemetryRpc + telemetryRpc, + themesRpc } from './index.ts' const DOMAINS_WITHOUT_EVENTS = new Set(['telemetry', 'feedback']) @@ -30,12 +31,13 @@ describe('@memry/rpc public surface', () => { expect(calendarRpc.name).toBe('calendar') expect(telemetryRpc.name).toBe('telemetry') expect(feedbackRpc.name).toBe('feedback') + expect(themesRpc.name).toBe('themes') }) }) describe('rpcDomains aggregate', () => { - it('contains exactly the seven known domains in declaration order', () => { - expect(rpcDomains).toHaveLength(7) + it('contains exactly the eight known domains in declaration order', () => { + expect(rpcDomains).toHaveLength(8) expect(rpcDomains.map((d) => d.name)).toEqual([ 'notes', 'tasks', @@ -43,7 +45,8 @@ describe('rpcDomains aggregate', () => { 'settings', 'calendar', 'telemetry', - 'feedback' + 'feedback', + 'themes' ]) }) diff --git a/packages/rpc/src/index.ts b/packages/rpc/src/index.ts index 02de8fa88..5b656036d 100644 --- a/packages/rpc/src/index.ts +++ b/packages/rpc/src/index.ts @@ -12,8 +12,16 @@ import type { TasksClientAPI, TasksSubscriptions } from './tasks.ts' import { tasksRpc } from './tasks.ts' import type { TelemetryClientAPI } from './telemetry.ts' import { telemetryRpc } from './telemetry.ts' +import type { ThemesClientAPI, ThemesSubscriptions } from './themes.ts' +import { themesRpc } from './themes.ts' -export type { RpcDomainSpec, RpcMethodSpec, RpcEventSpec, RpcClient, RpcSubscriptions } from './schema.ts' +export type { + RpcDomainSpec, + RpcMethodSpec, + RpcEventSpec, + RpcClient, + RpcSubscriptions +} from './schema.ts' export { defineDomain, defineEvent, defineMethod } from './schema.ts' export { notesRpc } from './notes.ts' @@ -23,6 +31,7 @@ export { settingsRpc } from './settings.ts' export { calendarRpc } from './calendar.ts' export { telemetryRpc } from './telemetry.ts' export { feedbackRpc } from './feedback.ts' +export { themesRpc } from './themes.ts' export type { NotesClientAPI, NotesSubscriptions } from './notes.ts' export type { TasksClientAPI, TasksSubscriptions } from './tasks.ts' @@ -31,15 +40,27 @@ export type { SettingsClientAPI, SettingsSubscriptions } from './settings.ts' export type { CalendarClientAPI, CalendarSubscriptions } from './calendar.ts' export type { TelemetryClientAPI, TelemetrySettings } from './telemetry.ts' export type { FeedbackClientAPI } from './feedback.ts' +export type { ThemesClientAPI, ThemesSubscriptions } from './themes.ts' -export const rpcDomains = [notesRpc, tasksRpc, inboxRpc, settingsRpc, calendarRpc, telemetryRpc, feedbackRpc] as const +export const rpcDomains = [ + notesRpc, + tasksRpc, + inboxRpc, + settingsRpc, + calendarRpc, + telemetryRpc, + feedbackRpc, + themesRpc +] as const export interface GeneratedRpcApi - extends NotesSubscriptions, + extends + NotesSubscriptions, TasksSubscriptions, InboxSubscriptions, SettingsSubscriptions, - CalendarSubscriptions { + CalendarSubscriptions, + ThemesSubscriptions { notes: NotesClientAPI tasks: TasksClientAPI inbox: InboxClientAPI @@ -47,4 +68,5 @@ export interface GeneratedRpcApi calendar: CalendarClientAPI telemetry: TelemetryClientAPI feedback: FeedbackClientAPI + themes: ThemesClientAPI } diff --git a/packages/rpc/src/themes.ts b/packages/rpc/src/themes.ts new file mode 100644 index 000000000..60a1f47ed --- /dev/null +++ b/packages/rpc/src/themes.ts @@ -0,0 +1,52 @@ +import type { + CreateThemeInput, + CustomTheme, + DeleteThemePayload, + ThemeDeleteResult, + ThemeMutationResult, + UpdateThemePayload +} from '../../contracts/src/themes-api.ts' +import { ThemesChannels } from '../../contracts/src/ipc-channels.ts' +import { + defineDomain, + defineEvent, + defineMethod, + type RpcClient, + type RpcSubscriptions +} from './schema.ts' + +export type { ThemeDeleteResult, ThemeMutationResult } + +export interface ThemeChangedEvent { + id: string +} + +export const themesRpc = defineDomain({ + name: 'themes', + methods: { + list: defineMethod<() => Promise>({ + channel: ThemesChannels.invoke.LIST, + params: [] + }), + create: defineMethod<(input: CreateThemeInput) => Promise>({ + channel: ThemesChannels.invoke.CREATE, + params: ['input'] + }), + update: defineMethod<(input: UpdateThemePayload) => Promise>({ + channel: ThemesChannels.invoke.UPDATE, + params: ['input'] + }), + delete: defineMethod<(input: DeleteThemePayload) => Promise>({ + channel: ThemesChannels.invoke.DELETE, + params: ['input'] + }) + }, + events: { + onThemeCreated: defineEvent(ThemesChannels.events.CREATED), + onThemeUpdated: defineEvent(ThemesChannels.events.UPDATED), + onThemeDeleted: defineEvent(ThemesChannels.events.DELETED) + } +}) + +export type ThemesClientAPI = RpcClient +export type ThemesSubscriptions = RpcSubscriptions