diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx
index a27d5ebb..2485cefb 100644
--- a/src/components/Editor.tsx
+++ b/src/components/Editor.tsx
@@ -5,7 +5,9 @@ import { createReactInlineContentSpec, useCreateBlockNote, SuggestionMenuControl
import { BlockNoteView } from '@blocknote/mantine'
import '@blocknote/mantine/style.css'
import type { VaultEntry } from '../types'
+import { useEditorTheme } from '../hooks/useTheme'
import './Editor.css'
+import './EditorTheme.css'
interface Tab {
entry: VaultEntry
@@ -163,6 +165,7 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
const [, body] = useMemo(() => splitFrontmatter(content), [content])
const navigateRef = useRef(onNavigateWikilink)
navigateRef.current = onNavigateWikilink
+ const { cssVars } = useEditorTheme()
const editor = useCreateBlockNote({ schema })
@@ -217,7 +220,7 @@ function BlockNoteTab({ content, entries, onNavigateWikilink }: { content: strin
const isDark = typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') !== 'light'
return (
-
+
| unknown[]
+
+/** Convert a nested theme config object into a flat map of CSS custom properties */
+function flattenTheme(
+ obj: Record,
+ prefix = '--'
+): Record {
+ const result: Record = {}
+
+ for (const [key, value] of Object.entries(obj)) {
+ const cssKey = `${prefix}${camelToKebab(key)}`
+
+ if (value === null || value === undefined) continue
+ if (Array.isArray(value)) continue // skip arrays (e.g. nestedBulletSymbols)
+
+ if (typeof value === 'object') {
+ Object.assign(result, flattenTheme(value as Record, `${cssKey}-`))
+ } else if (typeof value === 'number') {
+ // Numbers that look like px values get 'px' suffix; ratios/weights don't
+ // These are unitless values; everything else gets 'px'
+ const isUnitless = /weight|lineHeight|opacity/i.test(key) ||
+ cssKey.includes('line-height') ||
+ cssKey.includes('font-weight')
+ const needsPx = !isUnitless
+ result[cssKey] = needsPx ? `${value}px` : String(value)
+ } else {
+ result[cssKey] = String(value)
+ }
+ }
+
+ return result
+}
+
+function camelToKebab(str: string): string {
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
+}
+
+export function useEditorTheme() {
+ const { cssVars, styleString } = useMemo(() => {
+ const vars = flattenTheme(themeConfig as Record)
+ const str = Object.entries(vars)
+ .map(([k, v]) => `${k}: ${v};`)
+ .join('\n')
+ return { cssVars: vars, styleString: str }
+ }, [])
+
+ return { themeConfig, cssVars, styleString }
+}
diff --git a/src/theme.json b/src/theme.json
new file mode 100644
index 00000000..ec37c55b
--- /dev/null
+++ b/src/theme.json
@@ -0,0 +1,137 @@
+{
+ "editor": {
+ "fontFamily": "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
+ "fontSize": 16,
+ "lineHeight": 1.7,
+ "maxWidth": 720,
+ "paddingHorizontal": 40,
+ "paddingVertical": 20,
+ "paragraphSpacing": 8
+ },
+ "headings": {
+ "h1": {
+ "fontSize": 32,
+ "fontWeight": 700,
+ "lineHeight": 1.2,
+ "marginTop": 32,
+ "marginBottom": 12,
+ "color": "var(--text-heading)",
+ "letterSpacing": -0.5
+ },
+ "h2": {
+ "fontSize": 24,
+ "fontWeight": 600,
+ "lineHeight": 1.3,
+ "marginTop": 28,
+ "marginBottom": 10,
+ "color": "var(--text-heading)",
+ "letterSpacing": -0.3
+ },
+ "h3": {
+ "fontSize": 20,
+ "fontWeight": 600,
+ "lineHeight": 1.4,
+ "marginTop": 24,
+ "marginBottom": 8,
+ "color": "var(--text-heading)",
+ "letterSpacing": 0
+ },
+ "h4": {
+ "fontSize": 17,
+ "fontWeight": 600,
+ "lineHeight": 1.4,
+ "marginTop": 20,
+ "marginBottom": 6,
+ "color": "var(--text-heading)",
+ "letterSpacing": 0
+ }
+ },
+ "lists": {
+ "bulletSymbol": "•",
+ "bulletSize": 14,
+ "bulletColor": "var(--text-tertiary)",
+ "indentSize": 24,
+ "itemSpacing": 4,
+ "paddingLeft": 8,
+ "nestedBulletSymbols": ["•", "◦", "▪"]
+ },
+ "checkboxes": {
+ "size": 16,
+ "borderRadius": 3,
+ "checkedColor": "var(--accent-blue)",
+ "uncheckedBorderColor": "var(--text-muted)"
+ },
+ "inlineStyles": {
+ "bold": {
+ "fontWeight": 700,
+ "color": "var(--text-primary)"
+ },
+ "italic": {
+ "fontStyle": "italic",
+ "color": "var(--text-primary)"
+ },
+ "strikethrough": {
+ "color": "var(--text-tertiary)",
+ "textDecoration": "line-through"
+ },
+ "code": {
+ "fontFamily": "'SF Mono', 'Fira Code', monospace",
+ "fontSize": 14,
+ "backgroundColor": "var(--bg-hover-subtle)",
+ "paddingHorizontal": 4,
+ "paddingVertical": 2,
+ "borderRadius": 3,
+ "color": "var(--text-secondary)"
+ },
+ "link": {
+ "color": "var(--accent-blue)",
+ "textDecoration": "underline"
+ },
+ "wikilink": {
+ "color": "var(--accent-blue)",
+ "textDecoration": "none",
+ "borderBottom": "1px dotted var(--accent-blue)",
+ "cursor": "pointer"
+ }
+ },
+ "codeBlocks": {
+ "fontFamily": "'SF Mono', 'Fira Code', monospace",
+ "fontSize": 13,
+ "lineHeight": 1.5,
+ "backgroundColor": "var(--bg-card)",
+ "paddingHorizontal": 16,
+ "paddingVertical": 12,
+ "borderRadius": 6,
+ "marginVertical": 12
+ },
+ "blockquote": {
+ "borderLeftWidth": 3,
+ "borderLeftColor": "var(--accent-blue)",
+ "paddingLeft": 16,
+ "marginVertical": 12,
+ "color": "var(--text-secondary)",
+ "fontStyle": "italic"
+ },
+ "table": {
+ "borderColor": "var(--border-primary)",
+ "headerBackground": "var(--bg-card)",
+ "cellPaddingHorizontal": 12,
+ "cellPaddingVertical": 8,
+ "fontSize": 14
+ },
+ "horizontalRule": {
+ "color": "var(--border-primary)",
+ "marginVertical": 24,
+ "thickness": 1
+ },
+ "colors": {
+ "background": "var(--bg-primary)",
+ "text": "var(--text-primary)",
+ "textSecondary": "var(--text-secondary)",
+ "textMuted": "var(--text-muted)",
+ "heading": "var(--text-heading)",
+ "accent": "var(--accent-blue)",
+ "selection": "var(--bg-selected)",
+ "cursor": "var(--text-primary)"
+ }
+}