feat: Add theme system with visual editor

- Backend: themes table and API endpoints (CRUD, export, design-tokens)
- Theme store with preview, apply, and persistence
- ThemesPage with collapsible variables editor and live preview
- Components: ColorPicker (HSL), VariableEditor, ThemePreview, ThemeListItem
- Integration: $theme helper for dynamic components, get_design_tokens MCP tool
- Navigation: /themes route with palette icon in toolbar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 05:10:18 -06:00
parent d1c0f62fc3
commit b880038b07
19 changed files with 3358 additions and 11 deletions

View File

@@ -1,4 +1,5 @@
import { useCanvasStore } from '../stores/canvas'
import { useThemeStore } from '../stores/theme'
import { registerTool } from './webmcp'
import {
renderInlineComponent,
@@ -254,4 +255,62 @@ export function registerCanvasTools() {
}
}
)
// get_design_tokens
registerTool(
'get_design_tokens',
'Obtiene los design tokens y guía de estilos del tema activo. Usa esto para crear componentes con estilos consistentes.',
{
type: 'object',
properties: {
category: {
type: 'string',
enum: ['all', 'colors', 'text', 'accent', 'semantic', 'spacing', 'typography', 'effects'],
description: 'Categoría específica de tokens. Por defecto "all" retorna todos.'
}
}
},
async (args: { category?: string }) => {
try {
const themeStore = useThemeStore()
const theme = themeStore.activeTheme
if (!theme) {
return 'No hay tema activo. Usa las variables CSS por defecto.'
}
const category = args.category || 'all'
const variables = theme.variables
if (category !== 'all' && variables[category as keyof typeof variables]) {
const categoryVars = variables[category as keyof typeof variables]
const tokenList = Object.entries(categoryVars)
.map(([name, value]) => `--${name}: ${value}`)
.join('\n')
return `Design Tokens - ${category.toUpperCase()}:\n\n${tokenList}\n\nUsa estas variables CSS en tus estilos para mantener consistencia con el tema.`
}
// Return all tokens organized by category
const allTokens = Object.entries(variables)
.map(([cat, vars]) => {
const tokenList = Object.entries(vars as Record<string, string>)
.map(([name, value]) => ` --${name}: ${value}`)
.join('\n')
return `[${cat.toUpperCase()}]\n${tokenList}`
})
.join('\n\n')
return `Design Tokens del tema "${theme.name}":\n\n${allTokens}\n\n` +
`GUÍA DE USO:\n` +
`- Usa var(--nombre-variable) en CSS\n` +
`- Los componentes dinámicos tienen acceso a $theme.getVariable('nombre')\n` +
`- Puedes modificar temporalmente con $theme.setVariable('nombre', 'valor')\n` +
`- Colores semánticos: success, warning, error, info (con -bg para fondos)\n` +
`- Radius: radius-sm (4px), radius-md (8px), radius-lg (12px), radius-full (9999px)`
} catch (e: any) {
return `Error: ${e.message}`
}
}
)
}

View File

@@ -18,6 +18,7 @@ import {
} from 'vue'
import { setActivePinia, type Pinia } from 'pinia'
import { useCanvasStore } from '../stores/canvas'
import { useThemeStore } from '../stores/theme'
const API_URL = 'http://localhost:4101'
@@ -171,6 +172,12 @@ function getCanvasStore() {
return useCanvasStore()
}
function getThemeStore() {
const globalPinia = (window as any).__pinia as Pinia | undefined
if (globalPinia) setActivePinia(globalPinia)
return useThemeStore()
}
const dynamicHelpers = {
$emit: (event: string, ...args: any[]) => eventBus.emit(event, ...args),
$on: (event: string, cb: EventCallback) => eventBus.on(event, cb),
@@ -182,7 +189,15 @@ const dynamicHelpers = {
list: () => componentsApi.getAll(),
save: (comp: VueComponentDefinition) => componentsApi.save(comp),
},
$theme: {
getVariable: (name: string) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim(),
setVariable: (name: string, value: string) => document.documentElement.style.setProperty(`--${name}`, value),
getTokens: () => getThemeStore().designTokens,
getActiveTheme: () => getThemeStore().activeTheme,
getVariables: () => getThemeStore().currentVariables,
},
useCanvasStore: getCanvasStore,
useThemeStore: getThemeStore,
$nextTick: nextTick,
}

View File

@@ -0,0 +1,302 @@
import type { ThemeVariables, Theme, DesignTokens } from '../stores/theme'
const API_URL = 'http://localhost:4101'
// =====================
// API Client
// =====================
export const themesApi = {
async getAll(): Promise<Theme[]> {
const res = await fetch(`${API_URL}/api/themes`)
return res.json()
},
async getById(id: string): Promise<Theme | null> {
const res = await fetch(`${API_URL}/api/themes/${id}`)
if (!res.ok) return null
return res.json()
},
async getActive(): Promise<Theme | null> {
const res = await fetch(`${API_URL}/api/themes/active`)
if (!res.ok) return null
return res.json()
},
async save(theme: Partial<Theme>): Promise<{ success: boolean; id: string }> {
const res = await fetch(`${API_URL}/api/themes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(theme)
})
return res.json()
},
async delete(id: string): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' })
return res.json()
},
async setDefault(id: string): Promise<{ success: boolean }> {
const res = await fetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' })
return res.json()
},
async getDesignTokens(): Promise<DesignTokens> {
const res = await fetch(`${API_URL}/api/design-tokens`)
return res.json()
},
getExportUrl(id: string): string {
return `${API_URL}/api/themes/export/${id}`
}
}
// =====================
// CSS Utilities
// =====================
export function variablesToCSS(variables: ThemeVariables): string {
const lines: string[] = [':root {']
for (const [category, vars] of Object.entries(variables)) {
lines.push(` /* ${category} */`)
for (const [key, value] of Object.entries(vars as Record<string, string>)) {
lines.push(` --${key}: ${value};`)
}
lines.push('')
}
lines.push('}')
return lines.join('\n')
}
export function parseColorValue(value: string): { hex: string; alpha: number } {
// Handle rgba
const rgbaMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
if (rgbaMatch) {
const r = rgbaMatch[1] || '0'
const g = rgbaMatch[2] || '0'
const b = rgbaMatch[3] || '0'
const a = rgbaMatch[4]
const hex = `#${[r, g, b].map(x => parseInt(x).toString(16).padStart(2, '0')).join('')}`
return { hex, alpha: a ? parseFloat(a) : 1 }
}
// Handle hex with alpha
if (value.length === 9 && value.startsWith('#')) {
const alpha = parseInt(value.slice(7), 16) / 255
return { hex: value.slice(0, 7), alpha }
}
return { hex: value, alpha: 1 }
}
export function isColorVariable(key: string): boolean {
const colorKeywords = ['bg', 'text', 'color', 'accent', 'success', 'warning', 'error', 'info', 'border']
return colorKeywords.some(kw => key.toLowerCase().includes(kw))
}
export function isSizeVariable(key: string): boolean {
const sizeKeywords = ['radius', 'size', 'spacing', 'width', 'height']
return sizeKeywords.some(kw => key.toLowerCase().includes(kw))
}
export function isTransitionVariable(key: string): boolean {
return key.toLowerCase().includes('transition')
}
export function isShadowVariable(key: string): boolean {
return key.toLowerCase().includes('shadow')
}
export function isFontVariable(key: string): boolean {
return key.toLowerCase().includes('font')
}
// =====================
// Color Manipulation
// =====================
export function hexToRGB(hex: string): { r: number; g: number; b: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
if (!result) return { r: 0, g: 0, b: 0 }
return {
r: parseInt(result[1] || '0', 16),
g: parseInt(result[2] || '0', 16),
b: parseInt(result[3] || '0', 16)
}
}
export function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(x => Math.round(x).toString(16).padStart(2, '0')).join('')
}
export function hexToHSL(hex: string): { h: number; s: number; l: number } {
const { r, g, b } = hexToRGB(hex)
const rNorm = r / 255
const gNorm = g / 255
const bNorm = b / 255
const max = Math.max(rNorm, gNorm, bNorm)
const min = Math.min(rNorm, gNorm, bNorm)
let h = 0
let s = 0
const l = (max + min) / 2
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case rNorm:
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
break
case gNorm:
h = ((bNorm - rNorm) / d + 2) / 6
break
case bNorm:
h = ((rNorm - gNorm) / d + 4) / 6
break
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
}
}
export function hslToHex(h: number, s: number, l: number): string {
s /= 100
l /= 100
const a = s * Math.min(l, 1 - l)
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color)
.toString(16)
.padStart(2, '0')
}
return `#${f(0)}${f(8)}${f(4)}`
}
export function lighten(hex: string, amount: number): string {
const { h, s, l } = hexToHSL(hex)
return hslToHex(h, s, Math.min(100, l + amount))
}
export function darken(hex: string, amount: number): string {
const { h, s, l } = hexToHSL(hex)
return hslToHex(h, s, Math.max(0, l - amount))
}
export function generateColorScale(baseColor: string, steps = 10): string[] {
const { h, s } = hexToHSL(baseColor)
const scale: string[] = []
for (let i = 0; i < steps; i++) {
const l = 95 - i * 9 // From 95% to 5% lightness
scale.push(hslToHex(h, s, Math.max(5, Math.min(95, l))))
}
return scale
}
export function getContrastColor(hex: string): string {
const { r, g, b } = hexToRGB(hex)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.5 ? '#000000' : '#ffffff'
}
// =====================
// Theme Categories
// =====================
export const THEME_CATEGORIES = [
{ key: 'colors', label: 'Backgrounds', icon: 'square' },
{ key: 'text', label: 'Text', icon: 'type' },
{ key: 'accent', label: 'Accent', icon: 'zap' },
{ key: 'semantic', label: 'Semantic', icon: 'alert-circle' },
{ key: 'spacing', label: 'Spacing', icon: 'maximize' },
{ key: 'typography', label: 'Typography', icon: 'text' },
{ key: 'effects', label: 'Effects', icon: 'layers' }
] as const
export type ThemeCategory = (typeof THEME_CATEGORIES)[number]['key']
// =====================
// Validation
// =====================
export function validateTheme(theme: Partial<Theme>): { valid: boolean; errors: string[] } {
const errors: string[] = []
if (!theme.name?.trim()) {
errors.push('Theme name is required')
}
if (!theme.variables) {
errors.push('Theme variables are required')
} else {
const requiredCategories = ['colors', 'text', 'accent']
for (const cat of requiredCategories) {
if (!(cat in theme.variables)) {
errors.push(`Missing required category: ${cat}`)
}
}
}
return { valid: errors.length === 0, errors }
}
// =====================
// File Operations
// =====================
export function downloadTheme(theme: Theme): void {
const data = JSON.stringify(
{
name: theme.name,
description: theme.description,
variables: theme.variables,
metadata: {
...theme.metadata,
exported_at: new Date().toISOString()
}
},
null,
2
)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${theme.name.toLowerCase().replace(/\s+/g, '-')}-theme.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export async function readThemeFile(file: File): Promise<Partial<Theme>> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
try {
const data = JSON.parse(reader.result as string)
if (!data.name || !data.variables) {
reject(new Error('Invalid theme file format'))
}
resolve(data)
} catch {
reject(new Error('Failed to parse theme file'))
}
}
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsText(file)
})
}