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 +}