diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3e7381b..c03a70b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,7 +11,8 @@ "@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git", "pinia": "^3.0.4", "vite-plugin-pwa": "^1.2.0", - "vue": "^3.5.25" + "vue": "^3.5.25", + "vue-router": "^4.6.4" }, "devDependencies": { "@types/node": "^24.10.1", @@ -5330,6 +5331,27 @@ } } }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/vue-tsc": { "version": "3.2.4", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 3d5538c..6e27de4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git", "pinia": "^3.0.4", "vite-plugin-pwa": "^1.2.0", - "vue": "^3.5.25" + "vue": "^3.5.25", + "vue-router": "^4.6.4" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cded8ec..a3080ff 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@ + + + + diff --git a/frontend/src/components/themes/ThemeListItem.vue b/frontend/src/components/themes/ThemeListItem.vue new file mode 100644 index 0000000..3b8a3df --- /dev/null +++ b/frontend/src/components/themes/ThemeListItem.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/frontend/src/components/themes/ThemePreview.vue b/frontend/src/components/themes/ThemePreview.vue new file mode 100644 index 0000000..0988aa6 --- /dev/null +++ b/frontend/src/components/themes/ThemePreview.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/frontend/src/components/themes/VariableEditor.vue b/frontend/src/components/themes/VariableEditor.vue new file mode 100644 index 0000000..90fd940 --- /dev/null +++ b/frontend/src/components/themes/VariableEditor.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e7b9b07..c7c3501 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,15 +1,22 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' +import router from './router' import './styles/main.css' const pinia = createPinia() const app = createApp(App) app.use(pinia) +app.use(router) // Exponer contexto global para componentes dinámicos ;(window as any).__vueApp = app ;(window as any).__pinia = pinia -app.mount('#app') +// Inicializar tema antes de montar la app +import { useThemeStore } from './stores/theme' +const themeStore = useThemeStore(pinia) +themeStore.fetchThemes().then(() => { + app.mount('#app') +}) diff --git a/frontend/src/pages/CanvasPage.vue b/frontend/src/pages/CanvasPage.vue new file mode 100644 index 0000000..ff5aba2 --- /dev/null +++ b/frontend/src/pages/CanvasPage.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/pages/ComponentsPage.vue b/frontend/src/pages/ComponentsPage.vue new file mode 100644 index 0000000..3fe59d1 --- /dev/null +++ b/frontend/src/pages/ComponentsPage.vue @@ -0,0 +1,716 @@ + + + + + diff --git a/frontend/src/pages/ThemesPage.vue b/frontend/src/pages/ThemesPage.vue new file mode 100644 index 0000000..5454421 --- /dev/null +++ b/frontend/src/pages/ThemesPage.vue @@ -0,0 +1,662 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..b186eb7 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,24 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'canvas', + component: () => import('../pages/CanvasPage.vue') + }, + { + path: '/components', + name: 'components', + component: () => import('../pages/ComponentsPage.vue') + }, + { + path: '/themes', + name: 'themes', + component: () => import('../pages/ThemesPage.vue') + } + ] +}) + +export default router diff --git a/frontend/src/services/canvasTools.ts b/frontend/src/services/canvasTools.ts index 6492e3e..45aa018 100644 --- a/frontend/src/services/canvasTools.ts +++ b/frontend/src/services/canvasTools.ts @@ -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) + .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}` + } + } + ) } diff --git a/frontend/src/services/dynamicComponents.ts b/frontend/src/services/dynamicComponents.ts index a9d0baf..5805504 100644 --- a/frontend/src/services/dynamicComponents.ts +++ b/frontend/src/services/dynamicComponents.ts @@ -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, } diff --git a/frontend/src/services/themeService.ts b/frontend/src/services/themeService.ts new file mode 100644 index 0000000..cf3dc29 --- /dev/null +++ b/frontend/src/services/themeService.ts @@ -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 { + const res = await fetch(`${API_URL}/api/themes`) + return res.json() + }, + + async getById(id: string): Promise { + const res = await fetch(`${API_URL}/api/themes/${id}`) + if (!res.ok) return null + return res.json() + }, + + async getActive(): Promise { + const res = await fetch(`${API_URL}/api/themes/active`) + if (!res.ok) return null + return res.json() + }, + + async save(theme: Partial): 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 { + 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)) { + 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): { 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> { + 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) + }) +} diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts new file mode 100644 index 0000000..9e462a9 --- /dev/null +++ b/frontend/src/stores/theme.ts @@ -0,0 +1,296 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +// ===================== +// Types +// ===================== + +export interface ThemeVariables { + colors: Record + text: Record + accent: Record + semantic: Record + spacing: Record + typography: Record + effects: Record +} + +export interface ThemeMetadata { + author?: string + version?: string + tags?: string[] + base?: string | null + exported_at?: string | null +} + +export interface Theme { + id: string + name: string + description?: string + is_default: boolean + is_system: boolean + variables: ThemeVariables + metadata?: ThemeMetadata + created_at?: string + updated_at?: string +} + +export interface DesignTokens { + version: string + description: string + usage: string + tokens: ThemeVariables + guidelines: Record + examples: Record +} + +const API_URL = 'http://localhost:4101' + +// ===================== +// Store +// ===================== + +export const useThemeStore = defineStore('theme', () => { + // State + const themes = ref([]) + const activeTheme = ref(null) + const previewTheme = ref(null) + const designTokens = ref(null) + const loading = ref(false) + const saving = ref(false) + const error = ref(null) + + // Getters + const currentVariables = computed(() => { + if (previewTheme.value) return previewTheme.value + return activeTheme.value?.variables || null + }) + + const systemThemes = computed(() => + themes.value.filter(t => t.is_system) + ) + + const userThemes = computed(() => + themes.value.filter(t => !t.is_system) + ) + + const hasUnsavedChanges = computed(() => previewTheme.value !== null) + + const flattenedVariables = computed(() => { + const vars = currentVariables.value + if (!vars) return {} + + const flat: Record = {} + for (const category of Object.keys(vars)) { + for (const [key, value] of Object.entries(vars[category as keyof ThemeVariables])) { + flat[`--${key}`] = value + } + } + return flat + }) + + // Actions + async function fetchThemes() { + loading.value = true + error.value = null + try { + const res = await fetch(`${API_URL}/api/themes`) + themes.value = await res.json() + + const defaultTheme = themes.value.find(t => t.is_default) + if (defaultTheme) { + activeTheme.value = defaultTheme + applyTheme(defaultTheme.variables) + } + } catch (e) { + error.value = 'Error loading themes' + console.error(e) + } finally { + loading.value = false + } + } + + async function fetchDesignTokens() { + try { + const res = await fetch(`${API_URL}/api/design-tokens`) + designTokens.value = await res.json() + } catch (e) { + console.error('Error fetching design tokens:', e) + } + } + + async function saveTheme(theme: Partial & { name: string; variables: ThemeVariables }) { + saving.value = true + try { + const res = await fetch(`${API_URL}/api/themes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(theme) + }) + const result = await res.json() + await fetchThemes() + return result + } catch (e) { + error.value = 'Error saving theme' + throw e + } finally { + saving.value = false + } + } + + async function deleteTheme(id: string) { + try { + const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' }) + const result = await res.json() + if (result.error) { + error.value = result.error + return false + } + await fetchThemes() + return true + } catch (e) { + error.value = 'Error deleting theme' + return false + } + } + + async function setDefaultTheme(id: string) { + try { + await fetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' }) + await fetchThemes() + } catch (e) { + error.value = 'Error setting default theme' + } + } + + async function cloneTheme(id: string, newName: string) { + const original = themes.value.find(t => t.id === id) + if (!original) return null + + return saveTheme({ + name: newName, + description: `Cloned from ${original.name}`, + variables: JSON.parse(JSON.stringify(original.variables)), + metadata: { base: id } + }) + } + + function applyTheme(variables: ThemeVariables) { + const root = document.documentElement + + for (const category of Object.keys(variables)) { + for (const [key, value] of Object.entries(variables[category as keyof ThemeVariables])) { + root.style.setProperty(`--${key}`, value) + } + } + } + + function selectTheme(theme: Theme) { + activeTheme.value = theme + previewTheme.value = null + applyTheme(theme.variables) + } + + function setPreview(variables: ThemeVariables | null) { + previewTheme.value = variables + if (variables) { + applyTheme(variables) + } else if (activeTheme.value) { + applyTheme(activeTheme.value.variables) + } + } + + function updateVariable(category: keyof ThemeVariables, key: string, value: string) { + if (!previewTheme.value && activeTheme.value) { + previewTheme.value = JSON.parse(JSON.stringify(activeTheme.value.variables)) + } + + if (previewTheme.value && previewTheme.value[category]) { + previewTheme.value[category][key] = value + document.documentElement.style.setProperty(`--${key}`, value) + } + } + + function exportTheme(theme: Theme): string { + return JSON.stringify({ + name: theme.name, + description: theme.description, + variables: theme.variables, + metadata: { + ...theme.metadata, + exported_at: new Date().toISOString() + } + }, null, 2) + } + + async function importTheme(jsonString: string) { + try { + const data = JSON.parse(jsonString) + if (!data.name || !data.variables) { + throw new Error('Invalid theme format') + } + return saveTheme({ + name: data.name, + description: data.description, + variables: data.variables, + metadata: data.metadata + }) + } catch (e) { + error.value = 'Invalid theme file' + throw e + } + } + + function resetPreview() { + previewTheme.value = null + if (activeTheme.value) { + applyTheme(activeTheme.value.variables) + } + } + + async function commitPreview(name?: string) { + if (!previewTheme.value) return + + const themeName = name || activeTheme.value?.name || 'Custom Theme' + const themeId = activeTheme.value?.is_system ? undefined : activeTheme.value?.id + + await saveTheme({ + id: themeId, + name: themeName, + variables: previewTheme.value + }) + previewTheme.value = null + } + + return { + // State + themes, + activeTheme, + previewTheme, + designTokens, + loading, + saving, + error, + // Getters + currentVariables, + systemThemes, + userThemes, + hasUnsavedChanges, + flattenedVariables, + // Actions + fetchThemes, + fetchDesignTokens, + saveTheme, + deleteTheme, + setDefaultTheme, + cloneTheme, + applyTheme, + selectTheme, + setPreview, + updateVariable, + exportTheme, + importTheme, + resetPreview, + commitPreview + } +}) diff --git a/server/agent-ui.db b/server/agent-ui.db index 1066ca8..05539a5 100644 Binary files a/server/agent-ui.db and b/server/agent-ui.db differ diff --git a/server/index.ts b/server/index.ts index 0e24ac1..ef34f99 100644 --- a/server/index.ts +++ b/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 }) } })