diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd6429..3edcaad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Replacement-workflow reliability and file navigation. - Added renderer-only file-library exclusion globs for Files, Articles and Open Quickly. Patterns are stored as preference text only, never as filesystem authority; the active/revealed document remains visible and Search in Folder continues to search the full authorized folder. - Added a built-in export appearance preference that can match the editor or force Light/Dark for TypeOpen HTML, PDF, PNG and print rendering while keeping custom editor CSS/templates out of the export pipeline. - Added an opt-in Page Setup rule that starts direct top-level H1 headings after the first on new printed pages. The rule is print-only for PDF/Print and browser-print of styled HTML; PNG and style-free HTML remain unchanged. +- Split custom editor CSS into independent Light and Dark slots. Existing single custom themes migrate to both slots, System appearance switches between them live, and custom editor CSS remains excluded from export rendering. ## 0.1.6 — 2026-10-10 diff --git a/README.md b/README.md index 2356b0a..3634886 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ This project is an independent clean-room implementation. It does not contain Ty - Literal/regex find and replace with case/whole-word controls, recursive folder search, spell checking, word/character stats and optional autosave. - Native macOS **Edit → Substitutions** controls for smart quotes, smart dashes and text replacement, delegated to WKWebView rather than implemented as Markdown-rewriting transforms. - Private unsaved-buffer recovery plus native external-file conflict detection and **Reload from Disk**; recovered buffers reopen without filesystem write authority and require an explicit Save As, stale recovery is cleared immediately once all documents are clean, and clean saved tabs can be restored only through an explicit identity-revalidated session action. -- Light, dark and system appearance settings plus explicitly selected local custom CSS themes with external-resource/executable CSS blocked. +- Light, dark and system appearance settings plus independently selected local custom CSS for Light/Dark editor appearances; existing single custom themes migrate to both slots and System follows macOS appearance changes. External-resource/executable CSS remains blocked. - Built-in export appearance can match the editor or force Light/Dark for TypeOpen HTML, PDF, PNG and print rendering without injecting custom editor CSS. - Page Setup for Letter/A4/Legal/A5/Tabloid, portrait/landscape orientation, per-side margins and optional new-page starts for top-level H1 headings after the first. The heading rule is print-only, so PNG and onscreen HTML stay continuous. - Compact right-click TypeOpen commands plus a searchable `/` command palette for common Markdown blocks. diff --git a/docs/PARITY_MATRIX.md b/docs/PARITY_MATRIX.md index fc75343..a785b91 100644 --- a/docs/PARITY_MATRIX.md +++ b/docs/PARITY_MATRIX.md @@ -62,7 +62,7 @@ Reference inspected locally with REA: Typora 1.14.10 build 7786 on macOS. Primar | External file changes / Reload from Disk | static File/resources messages | Implemented in native runtime | Native file watchers mark conflicts, block autosave/normal Save, and offer Reload from Disk or Save As. Existing-file saves use macOS file coordination plus descriptor-bound revision checks; the retained Electron migration fallback does not provide live external-change watching | | Tabs / multi-document model | static New Tab / document switching | Implemented | Renderer-owned tabs preserve per-document dirty state, Save All, close prompts and next/previous switching | | Native window behavior | shipped macOS window/menu surfaces | Implemented | AppKit traffic lights/full-size titlebar, draggable inert titlebar region, persisted frame size/position, standard Window menu, fullscreen/minimize/zoom and dirty-close lifecycle | -| Custom themes | static theme preferences | Implemented | Built-in light/dark/system remain available; users can explicitly choose a local CSS file that is size-limited and rejected if it contains external-resource/executable CSS constructs. Reference themes/assets are intentionally not copied | +| Custom themes | static theme preferences | Implemented | Built-in light/dark/system remain available; users can choose independent local CSS for Light and Dark editor appearances. Existing single-theme preferences migrate to both slots, System switches live with macOS appearance, and selected CSS remains size-limited with external-resource/executable constructs rejected. Reference themes/assets are intentionally not copied | | Page Setup | static File menu | Implemented | TypeOpen-owned dialog configures paper size, portrait/landscape orientation, and margins; settings map to `NSPrintInfo` for native PDF export and printing | | Pixel-identical native UI | runtime capture blocked | Reference unknown | REA live native UI observation was denied Screen Recording and Accessibility permissions; TypeOpen uses an original UI | @@ -78,7 +78,7 @@ These are intentionally tracked even when they are not part of the current imple | Later | Advanced file library: pinned/recent locations and grouping | Native management, name/modified/creation-date sorting, a host-enforced hidden-file toggle and renderer-only relative exclusion globs exist. Persisted folder locations remain deferred because they must not imply restored filesystem authority | | Later | Advanced image management: configurable asset roots/copy rules, copy/move/rename/delete image assets, syntax switching, zoom/open actions | Safe local image insert/preview and adjacent `assets/` storage exist. Folder-tree image assets are kept out of the editable-document grant path; Electron preview/export reads bind validated image identity to the opened descriptor rather than reopening a pathname. Configurable write roots/copy rules remain deferred pending stronger destination-directory semantics; cloud/PicGo/PicList integration is intentionally deferred | | Later | Export profiles: headers/footers, richer heading-level page-break rules, custom export CSS/templates, destination/post-export actions and richer Pandoc arguments | Core native/Pandoc export plus **Export with Previous**, built-in Match Editor/Light/Dark appearance selection and an opt-in print-only rule that starts top-level H1 headings after the first on new pages exist. Previous export history is per-document/session-only and destinations are never silently reused; custom CSS/templates and overwrite/destination reuse remain deferred pending explicit safety/authority models | -| Later | Custom shortcuts, richer spellcheck language controls, separate light/dark custom themes and launch behavior preferences | Not required for core Markdown replacement but retained as parity backlog | +| Later | Custom shortcuts, richer spellcheck language controls and launch behavior preferences | Separate Light/Dark custom editor themes are implemented with legacy single-theme migration and live System appearance switching; the remaining preferences are not required for core Markdown replacement but stay in the backlog | | Deliberately different | Typora licensing/activation/server/update mechanisms | Not a parity target. TypeOpen remains open source and uses GitHub/Homebrew distribution instead | ## Evidence boundary diff --git a/src/App.tsx b/src/App.tsx index 3ae16bb..0340333 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { normalizeOutlineMaxLevel, normalizeOutlineMode } from './lib/outlinePre import { isExportFormat, rememberSuccessfulExport, type ExportFormat } from './lib/exportHistory' import { normalizeExportAppearance, resolveExportTheme } from './lib/exportPreferences' import { filterFileTreeByGlobs } from './lib/fileTreeFilters' +import { normalizeCustomThemePreferences, observeSystemTheme, resolveCustomTheme, type CustomThemeAppearance } from './lib/customThemePreferences' const EMPTY_DOCUMENT = '' const PREF_KEY = 'typeopen.preferences.v1' @@ -29,8 +30,10 @@ function navigationScrollBehavior(): ScrollBehavior { const DEFAULT_PREFS: TypeOpenPreferences = { theme: 'system', - customThemeName: null, - customThemeCss: '', + customLightThemeName: null, + customLightThemeCss: '', + customDarkThemeName: null, + customDarkThemeCss: '', exportAppearance: 'editor', fontSize: 17, contentWidth: 780, @@ -68,9 +71,19 @@ function loadPreferences(): TypeOpenPreferences { const raw = localStorage.getItem(PREF_KEY) if (!raw) return DEFAULT_PREFS const parsed = JSON.parse(raw) + const { + customThemeName: _legacyCustomThemeName, + customThemeCss: _legacyCustomThemeCss, + customLightThemeName: _customLightThemeName, + customLightThemeCss: _customLightThemeCss, + customDarkThemeName: _customDarkThemeName, + customDarkThemeCss: _customDarkThemeCss, + ...rest + } = parsed && typeof parsed === 'object' ? parsed : {} return { ...DEFAULT_PREFS, - ...parsed, + ...rest, + ...normalizeCustomThemePreferences(parsed), exportAppearance: normalizeExportAppearance(parsed?.exportAppearance), defaultCodeLanguage: normalizeCodeBlockLanguage(parsed?.defaultCodeLanguage), outlineMode: normalizeOutlineMode(parsed?.outlineMode), @@ -185,6 +198,7 @@ export default function App() { const [preferencesVisible, setPreferencesVisible] = useState(false) const [aboutVisible, setAboutVisible] = useState(false) const [preferences, setPreferences] = useState(() => loadPreferences()) + const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false) const [lastCodeLanguage, setLastCodeLanguage] = useState(null) const [pageSetupVisible, setPageSetupVisible] = useState(false) const [pageSetup, setPageSetup] = useState(() => loadPageSetup()) @@ -781,10 +795,13 @@ export default function App() { } }, [createOutputHtml, pageSetup, showToast]) - const chooseCustomTheme = useCallback(async () => { + const chooseCustomTheme = useCallback(async (appearance: CustomThemeAppearance) => { try { const result = await window.typeopen.invoke<{ name: string; css: string } | null>('typeopen:choose-theme-css') - if (result) setPreferences((current) => ({ ...current, customThemeName: result.name, customThemeCss: result.css })) + if (!result) return + setPreferences((current) => appearance === 'dark' + ? { ...current, customDarkThemeName: result.name, customDarkThemeCss: result.css } + : { ...current, customLightThemeName: result.name, customLightThemeCss: result.css }) } catch (error) { showToast(error instanceof Error ? error.message : String(error)) } @@ -984,8 +1001,14 @@ export default function App() { }, [pageSetup]) useEffect(() => { + const query = window.matchMedia?.('(prefers-color-scheme: dark)') + return observeSystemTheme(query, setSystemPrefersDark) + }, []) + + useEffect(() => { + const activeCustomTheme = resolveCustomTheme(preferences, preferences.theme, systemPrefersDark) let style = document.getElementById('typeopen-custom-theme') as HTMLStyleElement | null - if (!preferences.customThemeCss) { + if (!activeCustomTheme.css) { style?.remove() return } @@ -994,9 +1017,10 @@ export default function App() { style.id = 'typeopen-custom-theme' document.head.append(style) } - style.textContent = preferences.customThemeCss + style.dataset.appearance = activeCustomTheme.appearance + style.textContent = activeCustomTheme.css return () => { style?.remove() } - }, [preferences.customThemeCss]) + }, [preferences.customDarkThemeCss, preferences.customLightThemeCss, preferences.theme, systemPrefersDark]) useEffect(() => { if (!recoveryLoaded) return @@ -1233,7 +1257,7 @@ export default function App() { onOpenLabs={() => void window.typeopen.invoke('typeopen:open-external', 'https://github.com/TriHerm')} onOpenProject={() => void window.typeopen.invoke('typeopen:open-external', 'https://github.com/TriHerm/TypeOpen')} /> - setPreferencesVisible(false)} onChooseCustomTheme={() => void chooseCustomTheme()} /> + setPreferencesVisible(false)} onChooseCustomTheme={(appearance) => void chooseCustomTheme(appearance)} /> setFileEntryDialog(null)} onSubmit={(name) => void submitFolderEntryDialog(name)} /> setPageSetupVisible(false)} /> {toast &&
{toast}
} diff --git a/src/components/PreferencesDialog.tsx b/src/components/PreferencesDialog.tsx index 8adccb3..3d138bf 100644 --- a/src/components/PreferencesDialog.tsx +++ b/src/components/PreferencesDialog.tsx @@ -1,13 +1,12 @@ import { useState } from 'react' import { X } from 'lucide-react' import { CODE_BLOCK_LANGUAGES, type CodeBlockLanguage } from '../lib/codeLanguages' +import type { CustomThemeAppearance, CustomThemePreferences, EditorTheme } from '../lib/customThemePreferences' import type { ExportAppearance } from '../lib/exportPreferences' import type { OutlineMaxLevel, OutlineMode } from '../lib/outlinePreferences' -export interface TypeOpenPreferences { - theme: 'system' | 'light' | 'dark' - customThemeName: string | null - customThemeCss: string +export interface TypeOpenPreferences extends CustomThemePreferences { + theme: EditorTheme exportAppearance: ExportAppearance fontSize: number contentWidth: number @@ -29,7 +28,7 @@ interface Props { preferences: TypeOpenPreferences onChange: (next: TypeOpenPreferences) => void onClose: () => void - onChooseCustomTheme: () => void + onChooseCustomTheme: (appearance: CustomThemeAppearance) => void } export function PreferencesDialog({ visible, preferences, onChange, onClose, onChooseCustomTheme }: Props) { @@ -61,11 +60,17 @@ export function PreferencesDialog({ visible, preferences, onChange, onClose, onC
- - {preferences.customThemeName && <>{preferences.customThemeName}} + Light custom theme + + {preferences.customLightThemeName && <>{preferences.customLightThemeName}} +
+
+ Dark custom theme + + {preferences.customDarkThemeName && <>{preferences.customDarkThemeName}}

Export appearance applies to TypeOpen's HTML, PDF, PNG and print rendering. Custom editor CSS is not injected into exports.

-

Custom CSS is loaded only from a file you explicitly choose. External-resource CSS is rejected.

+

Light and Dark custom CSS are loaded only from files you explicitly choose. System appearance switches between the two automatically. External-resource CSS is rejected.

} {tab === 'editor' && <> diff --git a/src/lib/customThemePreferences.ts b/src/lib/customThemePreferences.ts new file mode 100644 index 0000000..06aac2e --- /dev/null +++ b/src/lib/customThemePreferences.ts @@ -0,0 +1,70 @@ +export type EditorTheme = 'system' | 'light' | 'dark' +export type CustomThemeAppearance = 'light' | 'dark' + +export interface CustomThemePreferences { + customLightThemeName: string | null + customLightThemeCss: string + customDarkThemeName: string | null + customDarkThemeCss: string +} + +export interface SystemThemeQuery { + matches: boolean + addEventListener?: (type: 'change', listener: () => void) => void + removeEventListener?: (type: 'change', listener: () => void) => void +} + +function normalizeName(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.slice(0, 512) : null +} + +function normalizeCss(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +export function normalizeCustomThemePreferences(value: unknown): CustomThemePreferences { + const raw = value && typeof value === 'object' ? value as Record : {} + const hasNewSchema = [ + 'customLightThemeName', + 'customLightThemeCss', + 'customDarkThemeName', + 'customDarkThemeCss' + ].some((key) => Object.prototype.hasOwnProperty.call(raw, key)) + + if (hasNewSchema) { + return { + customLightThemeName: normalizeName(raw.customLightThemeName), + customLightThemeCss: normalizeCss(raw.customLightThemeCss), + customDarkThemeName: normalizeName(raw.customDarkThemeName), + customDarkThemeCss: normalizeCss(raw.customDarkThemeCss) + } + } + + const legacyName = normalizeName(raw.customThemeName) + const legacyCss = normalizeCss(raw.customThemeCss) + return { + customLightThemeName: legacyName, + customLightThemeCss: legacyCss, + customDarkThemeName: legacyName, + customDarkThemeCss: legacyCss + } +} + +export function resolveCustomTheme( + preferences: CustomThemePreferences, + theme: EditorTheme, + systemPrefersDark: boolean +): { appearance: CustomThemeAppearance; name: string | null; css: string } { + const appearance: CustomThemeAppearance = theme === 'dark' || (theme === 'system' && systemPrefersDark) ? 'dark' : 'light' + return appearance === 'dark' + ? { appearance, name: preferences.customDarkThemeName, css: preferences.customDarkThemeCss } + : { appearance, name: preferences.customLightThemeName, css: preferences.customLightThemeCss } +} + +export function observeSystemTheme(query: SystemThemeQuery | null | undefined, onChange: (prefersDark: boolean) => void): () => void { + if (!query) return () => undefined + const update = () => onChange(query.matches) + update() + query.addEventListener?.('change', update) + return () => query.removeEventListener?.('change', update) +} diff --git a/tests/custom-theme-preferences.test.ts b/tests/custom-theme-preferences.test.ts new file mode 100644 index 0000000..3103caa --- /dev/null +++ b/tests/custom-theme-preferences.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { normalizeCustomThemePreferences, observeSystemTheme, resolveCustomTheme, type SystemThemeQuery } from '../src/lib/customThemePreferences' + +describe('custom theme preferences', () => { + it('migrates one legacy custom theme to both Light and Dark slots', () => { + expect(normalizeCustomThemePreferences({ customThemeName: 'legacy.css', customThemeCss: ':root{--x:1}' })).toEqual({ + customLightThemeName: 'legacy.css', + customLightThemeCss: ':root{--x:1}', + customDarkThemeName: 'legacy.css', + customDarkThemeCss: ':root{--x:1}' + }) + }) + + it('treats the new schema as authoritative instead of falling back to legacy values', () => { + expect(normalizeCustomThemePreferences({ + customThemeName: 'legacy.css', + customThemeCss: 'legacy', + customLightThemeName: 'light.css', + customLightThemeCss: 'light' + })).toEqual({ + customLightThemeName: 'light.css', + customLightThemeCss: 'light', + customDarkThemeName: null, + customDarkThemeCss: '' + }) + }) + + const slots = { + customLightThemeName: 'light.css', + customLightThemeCss: 'light', + customDarkThemeName: 'dark.css', + customDarkThemeCss: 'dark' + } + + it('resolves explicit editor themes independently of system appearance', () => { + expect(resolveCustomTheme(slots, 'light', true)).toEqual({ appearance: 'light', name: 'light.css', css: 'light' }) + expect(resolveCustomTheme(slots, 'dark', false)).toEqual({ appearance: 'dark', name: 'dark.css', css: 'dark' }) + }) + + it('switches the effective slot with system appearance', () => { + expect(resolveCustomTheme(slots, 'system', false).css).toBe('light') + expect(resolveCustomTheme(slots, 'system', true).css).toBe('dark') + }) + + it('observes live system appearance changes and removes the listener on cleanup', () => { + const holder: { listener?: () => void } = {} + const query: SystemThemeQuery = { + matches: false, + addEventListener: (_type, next) => { holder.listener = next }, + removeEventListener: (_type, next) => { if (holder.listener === next) delete holder.listener } + } + const values: boolean[] = [] + const cleanup = observeSystemTheme(query, (value) => values.push(value)) + expect(values).toEqual([false]) + + query.matches = true + holder.listener?.() + expect(values).toEqual([false, true]) + + cleanup() + expect(holder.listener).toBeUndefined() + }) +}) diff --git a/tests/preferences-dialog.test.ts b/tests/preferences-dialog.test.ts index 17c99d8..20ffd43 100644 --- a/tests/preferences-dialog.test.ts +++ b/tests/preferences-dialog.test.ts @@ -7,8 +7,10 @@ import { PreferencesDialog, type TypeOpenPreferences } from '../src/components/P const preferences: TypeOpenPreferences = { theme: 'system', - customThemeName: null, - customThemeCss: '', + customLightThemeName: null, + customLightThemeCss: '', + customDarkThemeName: null, + customDarkThemeCss: '', exportAppearance: 'editor', fontSize: 17, contentWidth: 780, @@ -124,4 +126,52 @@ describe('Preferences dialog', () => { await act(async () => root.unmount()) host.remove() }) + + it('chooses and removes custom Light/Dark themes independently', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + const onChange = vi.fn() + const onChooseCustomTheme = vi.fn() + const configured: TypeOpenPreferences = { + ...preferences, + customLightThemeName: 'paper.css', + customLightThemeCss: ':root{--bg:#fff}', + customDarkThemeName: 'ink.css', + customDarkThemeCss: ':root{--bg:#111}' + } + + await act(async () => { + root.render(createElement(PreferencesDialog, { + visible: true, + preferences: configured, + onChange, + onClose: () => undefined, + onChooseCustomTheme + })) + }) + const appearanceTab = Array.from(host.querySelectorAll('.preferences-body nav button')) + .find((button) => button.textContent === 'Appearance') + if (!appearanceTab) throw new Error('Appearance preferences tab not found') + await act(async () => appearanceTab.click()) + + const rows = Array.from(host.querySelectorAll('.custom-theme-row')) + const lightRow = rows.find((row) => row.textContent?.includes('Light custom theme')) + const darkRow = rows.find((row) => row.textContent?.includes('Dark custom theme')) + if (!lightRow || !darkRow) throw new Error('Custom theme rows not found') + const lightButtons = lightRow.querySelectorAll('button') + const darkButtons = darkRow.querySelectorAll('button') + await act(async () => lightButtons[0].click()) + await act(async () => darkButtons[0].click()) + expect(onChooseCustomTheme).toHaveBeenNthCalledWith(1, 'light') + expect(onChooseCustomTheme).toHaveBeenNthCalledWith(2, 'dark') + + await act(async () => lightButtons[1].click()) + expect(onChange).toHaveBeenCalledWith({ ...configured, customLightThemeName: null, customLightThemeCss: '' }) + await act(async () => darkButtons[1].click()) + expect(onChange).toHaveBeenCalledWith({ ...configured, customDarkThemeName: null, customDarkThemeCss: '' }) + + await act(async () => root.unmount()) + host.remove() + }) })