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:
Binary file not shown.
296
server/index.ts
296
server/index.ts
@@ -37,6 +37,149 @@ db.run(`
|
||||
)
|
||||
`)
|
||||
|
||||
// Tabla para temas/estilos
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS themes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
variables TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
|
||||
// Insertar temas del sistema si no existen
|
||||
const existingThemes = db.query('SELECT COUNT(*) as count FROM themes WHERE is_system = 1').get() as { count: number }
|
||||
if (existingThemes.count === 0) {
|
||||
const darkTheme = {
|
||||
id: 'theme-dark',
|
||||
name: 'Dark',
|
||||
description: 'Default dark theme',
|
||||
is_default: 1,
|
||||
is_system: 1,
|
||||
variables: JSON.stringify({
|
||||
colors: {
|
||||
'bg-primary': '#0f0f14',
|
||||
'bg-secondary': '#16161d',
|
||||
'bg-hover': '#1e1e28',
|
||||
'bg-tertiary': '#252530',
|
||||
'border-color': '#2a2a3a',
|
||||
'border-hover': '#3a3a4a'
|
||||
},
|
||||
text: {
|
||||
'text-primary': '#e4e4e7',
|
||||
'text-secondary': '#a1a1aa',
|
||||
'text-muted': '#52525b',
|
||||
'text-inverse': '#0f0f14'
|
||||
},
|
||||
accent: {
|
||||
'accent': '#6366f1',
|
||||
'accent-hover': '#818cf8',
|
||||
'accent-muted': 'rgba(99, 102, 241, 0.2)',
|
||||
'accent-text': '#ffffff'
|
||||
},
|
||||
semantic: {
|
||||
'success': '#22c55e',
|
||||
'success-bg': 'rgba(34, 197, 94, 0.1)',
|
||||
'warning': '#eab308',
|
||||
'warning-bg': 'rgba(234, 179, 8, 0.1)',
|
||||
'error': '#ef4444',
|
||||
'error-bg': 'rgba(239, 68, 68, 0.1)',
|
||||
'info': '#3b82f6',
|
||||
'info-bg': 'rgba(59, 130, 246, 0.1)'
|
||||
},
|
||||
spacing: {
|
||||
'radius-sm': '4px',
|
||||
'radius-md': '8px',
|
||||
'radius-lg': '12px',
|
||||
'radius-full': '9999px'
|
||||
},
|
||||
typography: {
|
||||
'font-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
'font-mono': "'JetBrains Mono', 'Fira Code', Consolas, monospace"
|
||||
},
|
||||
effects: {
|
||||
'shadow-sm': '0 1px 2px rgba(0,0,0,0.2)',
|
||||
'shadow-md': '0 4px 6px rgba(0,0,0,0.3)',
|
||||
'shadow-lg': '0 10px 15px rgba(0,0,0,0.4)',
|
||||
'transition-fast': '0.15s ease',
|
||||
'transition-normal': '0.2s ease'
|
||||
}
|
||||
}),
|
||||
metadata: JSON.stringify({ author: 'system', version: '1.0.0', tags: ['dark', 'default'] })
|
||||
}
|
||||
|
||||
const lightTheme = {
|
||||
id: 'theme-light',
|
||||
name: 'Light',
|
||||
description: 'Clean light theme',
|
||||
is_default: 0,
|
||||
is_system: 1,
|
||||
variables: JSON.stringify({
|
||||
colors: {
|
||||
'bg-primary': '#ffffff',
|
||||
'bg-secondary': '#f4f4f5',
|
||||
'bg-hover': '#e4e4e7',
|
||||
'bg-tertiary': '#d4d4d8',
|
||||
'border-color': '#d4d4d8',
|
||||
'border-hover': '#a1a1aa'
|
||||
},
|
||||
text: {
|
||||
'text-primary': '#18181b',
|
||||
'text-secondary': '#52525b',
|
||||
'text-muted': '#a1a1aa',
|
||||
'text-inverse': '#ffffff'
|
||||
},
|
||||
accent: {
|
||||
'accent': '#4f46e5',
|
||||
'accent-hover': '#4338ca',
|
||||
'accent-muted': 'rgba(79, 70, 229, 0.1)',
|
||||
'accent-text': '#ffffff'
|
||||
},
|
||||
semantic: {
|
||||
'success': '#16a34a',
|
||||
'success-bg': 'rgba(22, 163, 74, 0.1)',
|
||||
'warning': '#ca8a04',
|
||||
'warning-bg': 'rgba(202, 138, 4, 0.1)',
|
||||
'error': '#dc2626',
|
||||
'error-bg': 'rgba(220, 38, 38, 0.1)',
|
||||
'info': '#2563eb',
|
||||
'info-bg': 'rgba(37, 99, 235, 0.1)'
|
||||
},
|
||||
spacing: {
|
||||
'radius-sm': '4px',
|
||||
'radius-md': '8px',
|
||||
'radius-lg': '12px',
|
||||
'radius-full': '9999px'
|
||||
},
|
||||
typography: {
|
||||
'font-sans': "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
'font-mono': "'JetBrains Mono', 'Fira Code', Consolas, monospace"
|
||||
},
|
||||
effects: {
|
||||
'shadow-sm': '0 1px 2px rgba(0,0,0,0.05)',
|
||||
'shadow-md': '0 4px 6px rgba(0,0,0,0.07)',
|
||||
'shadow-lg': '0 10px 15px rgba(0,0,0,0.1)',
|
||||
'transition-fast': '0.15s ease',
|
||||
'transition-normal': '0.2s ease'
|
||||
}
|
||||
}),
|
||||
metadata: JSON.stringify({ author: 'system', version: '1.0.0', tags: ['light'] })
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO themes (id, name, description, is_default, is_system, variables, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
stmt.run(darkTheme.id, darkTheme.name, darkTheme.description, darkTheme.is_default, darkTheme.is_system, darkTheme.variables, darkTheme.metadata)
|
||||
stmt.run(lightTheme.id, lightTheme.name, lightTheme.description, lightTheme.is_default, lightTheme.is_system, lightTheme.variables, lightTheme.metadata)
|
||||
console.log('[DB] Temas del sistema creados')
|
||||
}
|
||||
|
||||
console.log('[DB] SQLite inicializado: agent-ui.db')
|
||||
|
||||
// API HTTP solamente - WebSocket lo maneja webmcp
|
||||
@@ -151,6 +294,159 @@ Bun.serve({
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// API de Temas
|
||||
// =====================
|
||||
|
||||
// GET /api/themes - Lista todos los temas
|
||||
// POST /api/themes - Crea o actualiza un tema
|
||||
if (url.pathname === '/api/themes') {
|
||||
if (req.method === 'GET') {
|
||||
const rows = db.query('SELECT * FROM themes ORDER BY is_system DESC, is_default DESC, name ASC').all()
|
||||
const themes = (rows as any[]).map(row => ({
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
variables: JSON.parse(row.variables),
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null
|
||||
}))
|
||||
return Response.json(themes, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const body = await req.json()
|
||||
const id = body.id || `theme-${Date.now()}`
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO themes
|
||||
(id, name, description, is_default, is_system, variables, metadata, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`)
|
||||
stmt.run(
|
||||
id,
|
||||
body.name,
|
||||
body.description || '',
|
||||
body.is_default ? 1 : 0,
|
||||
body.is_system ? 1 : 0,
|
||||
JSON.stringify(body.variables),
|
||||
JSON.stringify(body.metadata || {})
|
||||
)
|
||||
return Response.json({ success: true, id }, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/themes/active - Obtiene el tema activo (default)
|
||||
if (url.pathname === '/api/themes/active') {
|
||||
if (req.method === 'GET') {
|
||||
const row = db.query('SELECT * FROM themes WHERE is_default = 1 LIMIT 1').get() as any
|
||||
if (!row) {
|
||||
return Response.json({ error: 'No active theme' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
return Response.json({
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
variables: JSON.parse(row.variables),
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/design-tokens - Guía de design tokens para LLMs
|
||||
if (url.pathname === '/api/design-tokens') {
|
||||
if (req.method === 'GET') {
|
||||
const row = db.query('SELECT variables FROM themes WHERE is_default = 1 LIMIT 1').get() as { variables: string } | null
|
||||
const tokens = row ? JSON.parse(row.variables) : {}
|
||||
|
||||
return Response.json({
|
||||
version: '1.0.0',
|
||||
description: 'Design tokens for Agent UI components. Use these CSS variables for consistent styling.',
|
||||
usage: 'Use var(--token-name) in CSS, e.g., var(--bg-primary)',
|
||||
tokens,
|
||||
guidelines: {
|
||||
backgrounds: 'Use bg-primary for main areas, bg-secondary for cards/panels, bg-tertiary for nested elements',
|
||||
text: 'Use text-primary for headings, text-secondary for body, text-muted for hints',
|
||||
accent: 'Use accent for interactive elements, accent-hover for hover states, accent-muted for backgrounds',
|
||||
semantic: 'Use success/warning/error/info for status indicators with their -bg variants for backgrounds',
|
||||
spacing: 'Use radius-sm (4px) for small elements, radius-md (8px) for cards, radius-lg (12px) for modals',
|
||||
effects: 'Use transition-fast for micro-interactions, shadow-md for elevated elements'
|
||||
},
|
||||
examples: {
|
||||
button: 'background: var(--accent); color: var(--accent-text); border-radius: var(--radius-md); transition: var(--transition-fast);',
|
||||
card: 'background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm);',
|
||||
input: 'background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: var(--radius-md);',
|
||||
badge: 'background: var(--accent-muted); color: var(--accent); padding: 0.25rem 0.5rem; border-radius: var(--radius-full);'
|
||||
}
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// Operaciones sobre un tema específico: /api/themes/:id
|
||||
if (url.pathname.startsWith('/api/themes/') && !url.pathname.includes('/active') && !url.pathname.includes('/export')) {
|
||||
const pathParts = url.pathname.split('/')
|
||||
const id = pathParts[3]
|
||||
const action = pathParts[4] // 'default' si existe
|
||||
|
||||
// POST /api/themes/:id/default - Establecer como default
|
||||
if (action === 'default' && req.method === 'POST') {
|
||||
db.run('UPDATE themes SET is_default = 0')
|
||||
db.run('UPDATE themes SET is_default = 1 WHERE id = ?', [id])
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// GET /api/themes/:id - Obtener un tema
|
||||
if (req.method === 'GET' && !action) {
|
||||
const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
||||
if (!row) {
|
||||
return Response.json({ error: 'Theme not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
return Response.json({
|
||||
...row,
|
||||
is_default: !!row.is_default,
|
||||
is_system: !!row.is_system,
|
||||
variables: JSON.parse(row.variables),
|
||||
metadata: row.metadata ? JSON.parse(row.metadata) : null
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
// DELETE /api/themes/:id - Eliminar un tema
|
||||
if (req.method === 'DELETE' && !action) {
|
||||
// No permitir eliminar temas del sistema
|
||||
const theme = db.query('SELECT is_system FROM themes WHERE id = ?').get(id) as { is_system: number } | null
|
||||
if (theme?.is_system) {
|
||||
return Response.json({ error: 'Cannot delete system theme' }, { status: 403, headers: corsHeaders })
|
||||
}
|
||||
db.run('DELETE FROM themes WHERE id = ?', [id])
|
||||
return Response.json({ success: true }, { headers: corsHeaders })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/themes/export/:id - Exportar tema como JSON
|
||||
if (url.pathname.startsWith('/api/themes/export/')) {
|
||||
const id = url.pathname.split('/').pop()
|
||||
if (req.method === 'GET') {
|
||||
const row = db.query('SELECT * FROM themes WHERE id = ?').get(id) as any
|
||||
if (!row) {
|
||||
return Response.json({ error: 'Theme not found' }, { status: 404, headers: corsHeaders })
|
||||
}
|
||||
const exportData = {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
variables: JSON.parse(row.variables),
|
||||
metadata: {
|
||||
...(row.metadata ? JSON.parse(row.metadata) : {}),
|
||||
exported_at: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(exportData, null, 2), {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Disposition': `attachment; filename="${row.name.toLowerCase().replace(/\s+/g, '-')}-theme.json"`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404, headers: corsHeaders })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user