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:
@@ -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}`
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
302
frontend/src/services/themeService.ts
Normal file
302
frontend/src/services/themeService.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user