feat: expose all theme.json properties in theme editor
Add ThemePropertyEditor component that surfaces all customizable properties from theme.json — typography, headings, lists, code blocks, blockquote, table, and horizontal rule — organized into collapsible sections with appropriate input types (number, color, select, text). - themeSchema.ts: derives flat property list from theme.json with auto-detected input types, units, and select options - ThemePropertyEditor.tsx: sectioned editor with collapsible sections, keyboard-accessible toggles, debounced live updates - ThemeManager: add updateThemeProperty() and activeThemeContent - SettingsPanel: show property editor below theme list when active Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
179
src/utils/themeSchema.test.ts
Normal file
179
src/utils/themeSchema.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
193
src/utils/themeSchema.ts
Normal file
193
src/utils/themeSchema.ts
Normal file
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string[]> = {
|
||||
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<string, unknown>
|
||||
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<string, unknown>)) {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user