diff --git a/src/App.tsx b/src/App.tsx
index 40878a93..aedbbb13 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -103,7 +103,7 @@ function App() {
const resolvedPath = onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath
const vault = useVaultLoader(resolvedPath)
const { settings, saveSettings } = useSettings()
- const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent)
+ const themeManager = useThemeManager(resolvedPath, vault.entries, vault.allContent, vault.updateContent)
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
index df47d7fd..2c3e89e2 100644
--- a/src/components/SettingsPanel.tsx
+++ b/src/components/SettingsPanel.tsx
@@ -1,6 +1,7 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { X, Eye, EyeSlash, GithubLogo, SignOut, Check, Plus } from '@phosphor-icons/react'
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
+import { ThemePropertyEditor } from './ThemePropertyEditor'
import type { Settings, ThemeFile } from '../types'
import type { ThemeManager } from '../hooks/useThemeManager'
@@ -356,6 +357,13 @@ function AppearanceSection({ themeManager }: { themeManager: ThemeManager }) {
New Theme
+
+ {activeThemeId && (
+ <>
+
+
+ >
+ )}
>
)
}
diff --git a/src/components/ThemePropertyEditor.test.tsx b/src/components/ThemePropertyEditor.test.tsx
new file mode 100644
index 00000000..ec67b92c
--- /dev/null
+++ b/src/components/ThemePropertyEditor.test.tsx
@@ -0,0 +1,133 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent, act } from '@testing-library/react'
+import { ThemePropertyEditor } from './ThemePropertyEditor'
+import type { ThemeManager } from '../hooks/useThemeManager'
+
+function makeThemeManager(overrides: Partial = {}): ThemeManager {
+ return {
+ themes: [],
+ activeThemeId: '/vault/_themes/My Theme.md',
+ activeTheme: { id: '/vault/_themes/My Theme.md', name: 'My Theme', description: '', colors: {}, typography: {}, spacing: {} },
+ activeThemeContent: '---\nIs A: Theme\nName: My Theme\neditor-font-size: 18px\nlists-bullet-color: "#ff0000"\n---\n',
+ isDark: false,
+ switchTheme: vi.fn(),
+ createTheme: vi.fn().mockResolvedValue(''),
+ reloadThemes: vi.fn(),
+ updateThemeProperty: vi.fn(),
+ ...overrides,
+ }
+}
+
+describe('ThemePropertyEditor', () => {
+ it('shows message when no theme is active', () => {
+ const tm = makeThemeManager({ activeThemeId: null })
+ render()
+ expect(screen.getByText(/Select a theme/)).toBeInTheDocument()
+ })
+
+ it('renders the editor when a theme is active', () => {
+ const tm = makeThemeManager()
+ render()
+ expect(screen.getByTestId('theme-property-editor')).toBeInTheDocument()
+ })
+
+ it('shows section headers for all theme.json sections', () => {
+ const tm = makeThemeManager()
+ render()
+ expect(screen.getByText('Typography')).toBeInTheDocument()
+ expect(screen.getByText('Headings')).toBeInTheDocument()
+ expect(screen.getByText('Lists')).toBeInTheDocument()
+ expect(screen.getByText('Code Blocks')).toBeInTheDocument()
+ expect(screen.getByText('Blockquote')).toBeInTheDocument()
+ expect(screen.getByText('Table')).toBeInTheDocument()
+ expect(screen.getByText('Horizontal Rule')).toBeInTheDocument()
+ expect(screen.getByText('Colors')).toBeInTheDocument()
+ })
+
+ it('shows active theme name', () => {
+ const tm = makeThemeManager()
+ render()
+ expect(screen.getByText('My Theme')).toBeInTheDocument()
+ })
+
+ it('expands Typography section by default', () => {
+ const tm = makeThemeManager()
+ render()
+ // Typography section should be expanded, showing its properties
+ expect(screen.getByTestId('theme-input-editor-font-size')).toBeInTheDocument()
+ })
+
+ it('shows current theme value for overridden properties', () => {
+ const tm = makeThemeManager()
+ render()
+ const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement
+ expect(fontSizeInput.value).toBe('18')
+ })
+
+ it('shows default value for non-overridden properties', () => {
+ const tm = makeThemeManager()
+ render()
+ // editor-max-width is not in the theme content, so it should show the default (720)
+ const maxWidthInput = screen.getByTestId('theme-input-editor-max-width') as HTMLInputElement
+ expect(maxWidthInput.value).toBe('720')
+ })
+
+ it('calls updateThemeProperty on number input change', async () => {
+ vi.useFakeTimers()
+ const updateFn = vi.fn()
+ const tm = makeThemeManager({ updateThemeProperty: updateFn })
+ render()
+ const fontSizeInput = screen.getByTestId('theme-input-editor-font-size') as HTMLInputElement
+ fireEvent.change(fontSizeInput, { target: { value: '16' } })
+
+ // Debounce fires after 300ms
+ act(() => { vi.advanceTimersByTime(300) })
+ expect(updateFn).toHaveBeenCalledWith('editor-font-size', '16px')
+ vi.useRealTimers()
+ })
+
+ it('expands collapsed sections on click', () => {
+ const tm = makeThemeManager()
+ render()
+ // Lists section should be collapsed by default
+ expect(screen.queryByTestId('theme-input-lists-bullet-size')).not.toBeInTheDocument()
+
+ // Click to expand
+ fireEvent.click(screen.getByTestId('theme-section-lists-toggle'))
+ expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
+ })
+
+ it('toggles section via keyboard Enter', () => {
+ const tm = makeThemeManager()
+ render()
+ const toggle = screen.getByTestId('theme-section-lists-toggle')
+ fireEvent.keyDown(toggle, { key: 'Enter' })
+ expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
+ })
+
+ it('toggles section via keyboard Space', () => {
+ const tm = makeThemeManager()
+ render()
+ const toggle = screen.getByTestId('theme-section-lists-toggle')
+ fireEvent.keyDown(toggle, { key: ' ' })
+ expect(screen.getByTestId('theme-input-lists-bullet-size')).toBeInTheDocument()
+ })
+
+ it('shows heading subsections after expanding Headings', () => {
+ const tm = makeThemeManager()
+ render()
+ fireEvent.click(screen.getByTestId('theme-section-headings-toggle'))
+ expect(screen.getByText('Heading 1')).toBeInTheDocument()
+ expect(screen.getByText('Heading 2')).toBeInTheDocument()
+ expect(screen.getByText('Heading 3')).toBeInTheDocument()
+ expect(screen.getByText('Heading 4')).toBeInTheDocument()
+ })
+
+ it('shows unit label for numeric properties', () => {
+ const tm = makeThemeManager()
+ render()
+ // "px" should appear near Font Size input
+ const container = screen.getByTestId('theme-input-editor-font-size').parentElement!
+ expect(container.textContent).toContain('px')
+ })
+})
diff --git a/src/components/ThemePropertyEditor.tsx b/src/components/ThemePropertyEditor.tsx
new file mode 100644
index 00000000..f9dc2d62
--- /dev/null
+++ b/src/components/ThemePropertyEditor.tsx
@@ -0,0 +1,321 @@
+import { useState, useCallback, useMemo, useRef } from 'react'
+import { CaretRight } from '@phosphor-icons/react'
+import { ColorSwatch } from './ColorInput'
+import { getThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from '../utils/themeSchema'
+import type { ThemeProperty, ThemeSection, ThemeSubsection } from '../utils/themeSchema'
+import type { ThemeManager } from '../hooks/useThemeManager'
+import { parseFrontmatter } from '../utils/frontmatter'
+import { isValidCssColor } from '../utils/colorUtils'
+
+/** Extract current theme property values from frontmatter content. */
+function useThemeValues(content: string | undefined): Record {
+ return useMemo(() => {
+ if (!content) return {}
+ const fm = parseFrontmatter(content)
+ const result: Record = {}
+ for (const [key, value] of Object.entries(fm)) {
+ if (typeof value === 'string') result[key] = value
+ else if (typeof value === 'number') result[key] = String(value)
+ else if (typeof value === 'boolean') result[key] = String(value)
+ }
+ return result
+ }, [content])
+}
+
+// --- Individual input components ---
+
+function NumberInput({ property, value, onChange }: {
+ property: ThemeProperty
+ value: string | number
+ onChange: (val: string) => void
+}) {
+ const numericValue = typeof value === 'number' ? value : parseFloat(String(value)) || 0
+ const debounceRef = useRef>()
+
+ const handleChange = useCallback((e: React.ChangeEvent) => {
+ const raw = e.target.value
+ if (raw === '' || raw === '-') return
+ const num = parseFloat(raw)
+ if (isNaN(num)) return
+ if (property.min !== undefined && num < property.min) return
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => {
+ onChange(formatValueForFrontmatter(num, property))
+ }, 300)
+ }, [onChange, property])
+
+ return (
+
+
+ {property.unit && (
+ {property.unit}
+ )}
+
+ )
+}
+
+function ColorInput({ property, value, onChange }: {
+ property: ThemeProperty
+ value: string
+ onChange: (val: string) => void
+}) {
+ const [localValue, setLocalValue] = useState(value)
+ const debounceRef = useRef>()
+ const showSwatch = isValidCssColor(localValue)
+
+ const handleTextChange = useCallback((e: React.ChangeEvent) => {
+ const newVal = e.target.value
+ setLocalValue(newVal)
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => onChange(newVal), 300)
+ }, [onChange])
+
+ const handlePickerChange = useCallback((hex: string) => {
+ setLocalValue(hex)
+ onChange(hex)
+ }, [onChange])
+
+ return (
+
+ {showSwatch && }
+
+
+ )
+}
+
+function SelectInput({ property, value, onChange }: {
+ property: ThemeProperty
+ value: string
+ onChange: (val: string) => void
+}) {
+ return (
+
+ )
+}
+
+function TextInput({ property, value, onChange }: {
+ property: ThemeProperty
+ value: string
+ onChange: (val: string) => void
+}) {
+ const debounceRef = useRef>()
+
+ const handleChange = useCallback((e: React.ChangeEvent) => {
+ const newVal = e.target.value
+ clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => onChange(newVal), 500)
+ }, [onChange])
+
+ return (
+
+ )
+}
+
+// --- Property row ---
+
+function PropertyRow({ property, currentValue, onUpdate }: {
+ property: ThemeProperty
+ currentValue: string | undefined
+ onUpdate: (cssVar: string, value: string) => void
+}) {
+ const displayValue = currentValue !== undefined
+ ? parseValueFromFrontmatter(currentValue, property)
+ : property.defaultValue
+ const isPlaceholder = currentValue === undefined
+
+ const handleChange = useCallback((val: string) => {
+ onUpdate(property.cssVar, val)
+ }, [property.cssVar, onUpdate])
+
+ return (
+
+
+
+ {property.inputType === 'number' && (
+
+ )}
+ {property.inputType === 'color' && (
+
+ )}
+ {property.inputType === 'select' && (
+
+ )}
+ {property.inputType === 'text' && (
+
+ )}
+
+
+ )
+}
+
+// --- Collapsible section ---
+
+function CollapsibleSection({ label, defaultOpen, children, testId }: {
+ label: string
+ defaultOpen?: boolean
+ children: React.ReactNode
+ testId?: string
+}) {
+ const [open, setOpen] = useState(defaultOpen ?? false)
+
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ setOpen(prev => !prev)
+ }
+ }, [])
+
+ return (
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+ )
+}
+
+// --- Section renderers ---
+
+function SubsectionBlock({ subsection, currentValues, onUpdate }: {
+ subsection: ThemeSubsection
+ currentValues: Record
+ onUpdate: (cssVar: string, value: string) => void
+}) {
+ return (
+
+ {subsection.properties.map(prop => (
+
+ ))}
+
+ )
+}
+
+function SectionBlock({ section, currentValues, onUpdate }: {
+ section: ThemeSection
+ currentValues: Record
+ onUpdate: (cssVar: string, value: string) => void
+}) {
+ return (
+
+ {section.properties.map(prop => (
+
+ ))}
+ {section.subsections.map(sub => (
+
+ ))}
+
+ )
+}
+
+// --- Main component ---
+
+export function ThemePropertyEditor({ themeManager }: { themeManager: ThemeManager }) {
+ const schema = useMemo(() => getThemeSchema(), [])
+ const currentValues = useThemeValues(themeManager.activeThemeContent)
+
+ const handleUpdate = useCallback((cssVar: string, value: string) => {
+ themeManager.updateThemeProperty(cssVar, value)
+ }, [themeManager])
+
+ if (!themeManager.activeThemeId) {
+ return (
+
+ Select a theme to customize its properties.
+
+ )
+ }
+
+ return (
+
+
+ Editing: {themeManager.activeTheme?.name ?? 'Theme'}
+
+ {schema.map(section => (
+
+ ))}
+
+ )
+}
diff --git a/src/hooks/useThemeManager.ts b/src/hooks/useThemeManager.ts
index 73404cc0..1581ac66 100644
--- a/src/hooks/useThemeManager.ts
+++ b/src/hooks/useThemeManager.ts
@@ -117,10 +117,13 @@ export interface ThemeManager {
themes: ThemeFile[]
activeThemeId: string | null
activeTheme: ThemeFile | null
+ activeThemeContent: string | undefined
isDark: boolean
switchTheme: (themeId: string) => Promise
createTheme: (name?: string) => Promise
reloadThemes: () => Promise
+ /** Update a single frontmatter property on the active theme note. */
+ updateThemeProperty: (key: string, value: string) => Promise
}
/** Manages loading and persisting the active theme path from vault settings. */
@@ -195,6 +198,7 @@ export function useThemeManager(
vaultPath: string | null,
entries: VaultEntry[],
allContent: Record,
+ updateContent?: (path: string, content: string) => void,
): ThemeManager {
// Ensure default theme files exist on vault open (creates theme/ dir + defaults if missing)
useEffect(() => {
@@ -268,5 +272,21 @@ export function useThemeManager(
const reloadThemes = useCallback(async () => { await reload() }, [reload])
- return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes }
+ const updateThemeProperty = useCallback(async (key: string, value: string) => {
+ if (!activeThemeId) return
+ try {
+ const newContent = await tauriCall('update_frontmatter', {
+ path: activeThemeId,
+ key,
+ value,
+ })
+ updateContent?.(activeThemeId, newContent)
+ } catch (err) { console.error('Failed to update theme property:', err) }
+ }, [activeThemeId, updateContent])
+
+ return {
+ themes, activeThemeId, activeTheme,
+ activeThemeContent: cachedThemeContent,
+ isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty,
+ }
}
diff --git a/src/utils/themeSchema.test.ts b/src/utils/themeSchema.test.ts
new file mode 100644
index 00000000..2c9b8f79
--- /dev/null
+++ b/src/utils/themeSchema.test.ts
@@ -0,0 +1,179 @@
+import { describe, it, expect } from 'vitest'
+import { buildThemeSchema, formatValueForFrontmatter, parseValueFromFrontmatter } from './themeSchema'
+import type { ThemeProperty } from './themeSchema'
+
+describe('buildThemeSchema', () => {
+ const schema = buildThemeSchema()
+
+ it('returns all top-level sections from theme.json', () => {
+ const ids = schema.map(s => s.id)
+ expect(ids).toContain('editor')
+ expect(ids).toContain('headings')
+ expect(ids).toContain('lists')
+ expect(ids).toContain('checkboxes')
+ expect(ids).toContain('inlineStyles')
+ expect(ids).toContain('codeBlocks')
+ expect(ids).toContain('blockquote')
+ expect(ids).toContain('table')
+ expect(ids).toContain('horizontalRule')
+ expect(ids).toContain('colors')
+ })
+
+ it('assigns human-readable labels to sections', () => {
+ const editor = schema.find(s => s.id === 'editor')!
+ expect(editor.label).toBe('Typography')
+ const headings = schema.find(s => s.id === 'headings')!
+ expect(headings.label).toBe('Headings')
+ })
+
+ it('produces flat CSS variable names from editor section', () => {
+ const editor = schema.find(s => s.id === 'editor')!
+ const vars = editor.properties.map(p => p.cssVar)
+ expect(vars).toContain('editor-font-family')
+ expect(vars).toContain('editor-font-size')
+ expect(vars).toContain('editor-line-height')
+ expect(vars).toContain('editor-max-width')
+ expect(vars).toContain('editor-padding-horizontal')
+ expect(vars).toContain('editor-paragraph-spacing')
+ })
+
+ it('detects numeric input type for number values', () => {
+ const editor = schema.find(s => s.id === 'editor')!
+ const fontSize = editor.properties.find(p => p.cssVar === 'editor-font-size')!
+ expect(fontSize.inputType).toBe('number')
+ expect(fontSize.unit).toBe('px')
+ expect(fontSize.defaultValue).toBe(15)
+ })
+
+ it('detects unitless numbers for lineHeight and fontWeight', () => {
+ const editor = schema.find(s => s.id === 'editor')!
+ const lineHeight = editor.properties.find(p => p.cssVar === 'editor-line-height')!
+ expect(lineHeight.inputType).toBe('number')
+ expect(lineHeight.unit).toBeUndefined()
+ })
+
+ it('detects text input type for font family', () => {
+ const editor = schema.find(s => s.id === 'editor')!
+ const fontFamily = editor.properties.find(p => p.cssVar === 'editor-font-family')!
+ expect(fontFamily.inputType).toBe('text')
+ })
+
+ it('creates subsections for headings h1-h4', () => {
+ const headings = schema.find(s => s.id === 'headings')!
+ const subIds = headings.subsections.map(s => s.id)
+ expect(subIds).toContain('h1')
+ expect(subIds).toContain('h2')
+ expect(subIds).toContain('h3')
+ expect(subIds).toContain('h4')
+ })
+
+ it('produces correct CSS var names for heading subsections', () => {
+ const headings = schema.find(s => s.id === 'headings')!
+ const h1 = headings.subsections.find(s => s.id === 'h1')!
+ const vars = h1.properties.map(p => p.cssVar)
+ expect(vars).toContain('headings-h1-font-size')
+ expect(vars).toContain('headings-h1-font-weight')
+ expect(vars).toContain('headings-h1-line-height')
+ expect(vars).toContain('headings-h1-margin-top')
+ expect(vars).toContain('headings-h1-color')
+ expect(vars).toContain('headings-h1-letter-spacing')
+ })
+
+ it('detects color values from var(--) references', () => {
+ const headings = schema.find(s => s.id === 'headings')!
+ const h1 = headings.subsections.find(s => s.id === 'h1')!
+ const color = h1.properties.find(p => p.cssVar === 'headings-h1-color')!
+ expect(color.inputType).toBe('color')
+ })
+
+ it('detects hex color values', () => {
+ const lists = schema.find(s => s.id === 'lists')!
+ const bulletColor = lists.properties.find(p => p.cssVar === 'lists-bullet-color')!
+ expect(bulletColor.inputType).toBe('color')
+ })
+
+ it('creates subsections for inline styles', () => {
+ const inline = schema.find(s => s.id === 'inlineStyles')!
+ const subIds = inline.subsections.map(s => s.id)
+ expect(subIds).toContain('bold')
+ expect(subIds).toContain('italic')
+ expect(subIds).toContain('code')
+ expect(subIds).toContain('link')
+ expect(subIds).toContain('wikilink')
+ })
+
+ it('produces correct CSS var names for code blocks section', () => {
+ const codeBlocks = schema.find(s => s.id === 'codeBlocks')!
+ const vars = codeBlocks.properties.map(p => p.cssVar)
+ expect(vars).toContain('code-blocks-font-family')
+ expect(vars).toContain('code-blocks-font-size')
+ expect(vars).toContain('code-blocks-background-color')
+ expect(vars).toContain('code-blocks-border-radius')
+ })
+
+ it('skips array values like nestedBulletSymbols', () => {
+ const lists = schema.find(s => s.id === 'lists')!
+ const vars = lists.properties.map(p => p.cssVar)
+ expect(vars).not.toContain('lists-nested-bullet-symbols')
+ })
+
+ it('assigns select input type for fontStyle', () => {
+ const blockquote = schema.find(s => s.id === 'blockquote')!
+ const fontStyle = blockquote.properties.find(p => p.cssVar === 'blockquote-font-style')!
+ expect(fontStyle.inputType).toBe('select')
+ expect(fontStyle.options).toContain('normal')
+ expect(fontStyle.options).toContain('italic')
+ })
+})
+
+describe('formatValueForFrontmatter', () => {
+ it('appends unit to numeric values', () => {
+ const prop: ThemeProperty = {
+ cssVar: 'editor-font-size', label: 'Font Size', defaultValue: 15,
+ inputType: 'number', unit: 'px', min: 0,
+ }
+ expect(formatValueForFrontmatter(15, prop)).toBe('15px')
+ })
+
+ it('does not append unit for unitless values', () => {
+ const prop: ThemeProperty = {
+ cssVar: 'editor-line-height', label: 'Line Height', defaultValue: 1.5,
+ inputType: 'number',
+ }
+ expect(formatValueForFrontmatter(1.5, prop)).toBe('1.5')
+ })
+
+ it('returns string values as-is', () => {
+ const prop: ThemeProperty = {
+ cssVar: 'editor-font-family', label: 'Font Family', defaultValue: 'Inter',
+ inputType: 'text',
+ }
+ expect(formatValueForFrontmatter('Helvetica', prop)).toBe('Helvetica')
+ })
+})
+
+describe('parseValueFromFrontmatter', () => {
+ it('extracts numeric value from string with unit', () => {
+ const prop: ThemeProperty = {
+ cssVar: 'editor-font-size', label: 'Font Size', defaultValue: 15,
+ inputType: 'number', unit: 'px',
+ }
+ expect(parseValueFromFrontmatter('15px', prop)).toBe(15)
+ })
+
+ it('returns string for non-numeric values', () => {
+ const prop: ThemeProperty = {
+ cssVar: 'editor-font-family', label: 'Font Family', defaultValue: 'Inter',
+ inputType: 'text',
+ }
+ expect(parseValueFromFrontmatter('Helvetica', prop)).toBe('Helvetica')
+ })
+
+ it('parses bare numbers', () => {
+ const prop: ThemeProperty = {
+ cssVar: 'editor-line-height', label: 'Line Height', defaultValue: 1.5,
+ inputType: 'number',
+ }
+ expect(parseValueFromFrontmatter('1.6', prop)).toBe(1.6)
+ })
+})
diff --git a/src/utils/themeSchema.ts b/src/utils/themeSchema.ts
new file mode 100644
index 00000000..5c823aab
--- /dev/null
+++ b/src/utils/themeSchema.ts
@@ -0,0 +1,193 @@
+import themeConfig from '../theme.json'
+import { isValidCssColor } from './colorUtils'
+
+export type InputType = 'number' | 'color' | 'text' | 'select'
+
+export interface ThemeProperty {
+ /** Flat kebab-case key used in frontmatter, e.g. "editor-font-size" */
+ cssVar: string
+ /** Human-readable label, e.g. "Font Size" */
+ label: string
+ /** Default value from theme.json */
+ defaultValue: string | number
+ inputType: InputType
+ /** Unit label shown next to numeric inputs (e.g. "px"). Absent for unitless. */
+ unit?: string
+ /** Options for select inputs (e.g. font weights). */
+ options?: string[]
+ /** Minimum allowed value for numeric inputs. */
+ min?: number
+}
+
+export interface ThemeSubsection {
+ id: string
+ label: string
+ properties: ThemeProperty[]
+}
+
+export interface ThemeSection {
+ id: string
+ label: string
+ properties: ThemeProperty[]
+ subsections: ThemeSubsection[]
+}
+
+const SECTION_LABELS: Record = {
+ editor: 'Typography',
+ headings: 'Headings',
+ lists: 'Lists',
+ checkboxes: 'Checkboxes',
+ inlineStyles: 'Inline Styles',
+ codeBlocks: 'Code Blocks',
+ blockquote: 'Blockquote',
+ table: 'Table',
+ horizontalRule: 'Horizontal Rule',
+ colors: 'Colors',
+}
+
+const SUBSECTION_LABELS: Record = {
+ h1: 'Heading 1',
+ h2: 'Heading 2',
+ h3: 'Heading 3',
+ h4: 'Heading 4',
+ bold: 'Bold',
+ italic: 'Italic',
+ strikethrough: 'Strikethrough',
+ code: 'Inline Code',
+ link: 'Link',
+ wikilink: 'Wiki Link',
+}
+
+/** Keys where the numeric value is unitless (ratios, weights). */
+const UNITLESS_KEYS = /weight|lineHeight|opacity/i
+
+/** Keys that should use a select input with predefined options. */
+const SELECT_OPTIONS: Record = {
+ fontWeight: ['400', '500', '600', '700'],
+ fontStyle: ['normal', 'italic'],
+ textDecoration: ['none', 'underline', 'line-through'],
+ cursor: ['default', 'pointer', 'text'],
+}
+
+function camelToKebab(str: string): string {
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
+}
+
+function camelToTitle(str: string): string {
+ return str
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .replace(/^./, c => c.toUpperCase())
+}
+
+function isColorValue(value: unknown, key: string): boolean {
+ if (typeof value !== 'string') return false
+ if (value.startsWith('#') || value.startsWith('var(--')) return isColorKeyHint(key) || isValidCssColor(value)
+ return false
+}
+
+function isColorKeyHint(key: string): boolean {
+ const lower = key.toLowerCase()
+ return lower === 'color' || lower.endsWith('color') || lower === 'background'
+ || lower.endsWith('background') || lower === 'fill' || lower === 'tint'
+}
+
+function deriveInputType(key: string, value: unknown): { inputType: InputType; unit?: string; options?: string[]; min?: number } {
+ // Select options take priority
+ for (const [pattern, opts] of Object.entries(SELECT_OPTIONS)) {
+ if (key === pattern || key.endsWith(pattern.charAt(0).toUpperCase() + pattern.slice(1))) {
+ // Check if current key ends with the select key (e.g. "fontWeight" matches "boldFontWeight")
+ if (key === pattern || key.toLowerCase().endsWith(pattern.toLowerCase())) {
+ return { inputType: 'select', options: opts }
+ }
+ }
+ }
+
+ if (typeof value === 'number') {
+ const isUnitless = UNITLESS_KEYS.test(key)
+ return { inputType: 'number', unit: isUnitless ? undefined : 'px', min: 0 }
+ }
+
+ if (isColorValue(value, key)) {
+ return { inputType: 'color' }
+ }
+
+ return { inputType: 'text' }
+}
+
+function buildProperty(parentPrefix: string, key: string, value: string | number): ThemeProperty {
+ const cssVar = `${parentPrefix}${camelToKebab(key)}`
+ const { inputType, unit, options, min } = deriveInputType(key, value)
+ return { cssVar, label: camelToTitle(key), defaultValue: value, inputType, unit, options, min }
+}
+
+/** Build the full theme schema from theme.json, grouped by section. */
+export function buildThemeSchema(): ThemeSection[] {
+ const sections: ThemeSection[] = []
+
+ for (const [sectionKey, sectionValue] of Object.entries(themeConfig)) {
+ if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) continue
+ const sectionObj = sectionValue as Record
+ const sectionPrefix = `${camelToKebab(sectionKey)}-`
+
+ const section: ThemeSection = {
+ id: sectionKey,
+ label: SECTION_LABELS[sectionKey] ?? camelToTitle(sectionKey),
+ properties: [],
+ subsections: [],
+ }
+
+ for (const [key, value] of Object.entries(sectionObj)) {
+ if (Array.isArray(value)) continue // skip arrays like nestedBulletSymbols
+
+ if (typeof value === 'object' && value !== null) {
+ // Subsection (e.g. headings.h1, inlineStyles.bold)
+ const subPrefix = `${sectionPrefix}${camelToKebab(key)}-`
+ const subProperties: ThemeProperty[] = []
+ for (const [subKey, subValue] of Object.entries(value as Record)) {
+ if (typeof subValue === 'string' || typeof subValue === 'number') {
+ subProperties.push(buildProperty(subPrefix, subKey, subValue))
+ }
+ }
+ if (subProperties.length > 0) {
+ section.subsections.push({
+ id: key,
+ label: SUBSECTION_LABELS[key] ?? camelToTitle(key),
+ properties: subProperties,
+ })
+ }
+ } else if (typeof value === 'string' || typeof value === 'number') {
+ section.properties.push(buildProperty(sectionPrefix, key, value))
+ }
+ }
+
+ if (section.properties.length > 0 || section.subsections.length > 0) {
+ sections.push(section)
+ }
+ }
+
+ return sections
+}
+
+/** Format a value for storage in theme note frontmatter. */
+export function formatValueForFrontmatter(value: string | number, property: ThemeProperty): string {
+ if (property.inputType === 'number' && property.unit && typeof value === 'number') {
+ return `${value}${property.unit}`
+ }
+ return String(value)
+}
+
+/** Parse a frontmatter value back to its editable form (strip unit suffix). */
+export function parseValueFromFrontmatter(raw: string, property: ThemeProperty): string | number {
+ if (property.inputType === 'number') {
+ const numeric = parseFloat(raw)
+ if (!isNaN(numeric)) return numeric
+ }
+ return raw
+}
+
+/** Cached schema — built once from theme.json. */
+let cachedSchema: ThemeSection[] | null = null
+export function getThemeSchema(): ThemeSection[] {
+ if (!cachedSchema) cachedSchema = buildThemeSchema()
+ return cachedSchema
+}