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

@@ -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,

View File

@@ -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",

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import Canvas from './components/Canvas.vue'
import { RouterView } from 'vue-router'
import StatusBar from './components/StatusBar.vue'
import Toolbar from './components/Toolbar.vue'
import ComponentsDropdown from './components/ComponentsDropdown.vue'
@@ -9,14 +9,18 @@ import ComponentsDropdown from './components/ComponentsDropdown.vue'
<div class="app-container">
<header class="app-header">
<div class="header-left">
<h1>Agent UI</h1>
<h1 class="logo">Agent UI</h1>
<ComponentsDropdown />
</div>
<StatusBar />
</header>
<main class="app-main">
<Toolbar />
<Canvas />
<RouterView v-slot="{ Component }">
<Transition name="page" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
</div>
</template>
@@ -44,7 +48,7 @@ import ComponentsDropdown from './components/ComponentsDropdown.vue'
gap: 1.5rem;
}
.app-header h1 {
.logo {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
@@ -56,4 +60,20 @@ import ComponentsDropdown from './components/ComponentsDropdown.vue'
flex: 1;
overflow: hidden;
}
/* Page transitions */
.page-enter-active,
.page-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.page-enter-from {
opacity: 0;
transform: translateX(20px);
}
.page-leave-to {
opacity: 0;
transform: translateX(-20px);
}
</style>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { RouterLink, useRoute } from 'vue-router'
import { useCanvasStore } from '../stores/canvas'
const route = useRoute()
const canvasStore = useCanvasStore()
function clearCanvas() {
@@ -8,13 +10,13 @@ function clearCanvas() {
if (container) {
container.innerHTML = `
<div class="canvas-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
<p>Canvas listo</p>
<span>Claude Code puede renderizar contenido aquí usando las herramientas MCP</span>
<span>Claude Code puede renderizar contenido aquí</span>
</div>
`
}
@@ -27,9 +29,42 @@ function toggleHistory() {
<template>
<aside class="toolbar">
<!-- Navegación -->
<div class="toolbar-section nav-section">
<RouterLink to="/" class="toolbar-btn" :class="{ active: route.path === '/' }" title="Canvas">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
</RouterLink>
<RouterLink to="/components" class="toolbar-btn" :class="{ active: route.path === '/components' }" title="Componentes">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" y1="22" x2="12" y2="12"/>
</svg>
</RouterLink>
<RouterLink to="/themes" class="toolbar-btn" :class="{ active: route.path === '/themes' }" title="Temas">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="13.5" cy="6.5" r="2.5"/>
<circle cx="19" cy="11.5" r="2.5"/>
<circle cx="17" cy="18.5" r="2.5"/>
<circle cx="8.5" cy="17.5" r="2.5"/>
<circle cx="5" cy="10.5" r="2.5"/>
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.82-.13 2.67-.36"/>
</svg>
</RouterLink>
</div>
<div class="toolbar-divider"></div>
<!-- Acciones -->
<div class="toolbar-section">
<button class="toolbar-btn" @click="clearCanvas" title="Limpiar canvas">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
@@ -37,7 +72,7 @@ function toggleHistory() {
</button>
<button class="toolbar-btn" @click="toggleHistory" title="Historial">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 8v4l3 3"/>
<circle cx="12" cy="12" r="10"/>
</svg>
@@ -54,6 +89,7 @@ function toggleHistory() {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toolbar-section {
@@ -62,6 +98,12 @@ function toggleHistory() {
gap: 0.5rem;
}
.toolbar-divider {
height: 1px;
background: var(--border-color);
margin: 0.25rem 0;
}
.toolbar-btn {
width: 40px;
height: 40px;
@@ -74,6 +116,7 @@ function toggleHistory() {
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
text-decoration: none;
}
.toolbar-btn:hover {
@@ -84,4 +127,9 @@ function toggleHistory() {
.toolbar-btn:active {
transform: scale(0.95);
}
.toolbar-btn.active {
background: rgba(99, 102, 241, 0.15);
color: #6366f1;
}
</style>

View File

@@ -0,0 +1,280 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { hexToHSL, hslToHex } from '../../services/themeService'
const props = defineProps<{
modelValue: string
label?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const isOpen = ref(false)
const hexInput = ref(props.modelValue)
const hsl = computed(() => {
try {
return hexToHSL(props.modelValue)
} catch {
return { h: 0, s: 50, l: 50 }
}
})
const hue = ref(hsl.value.h)
const saturation = ref(hsl.value.s)
const lightness = ref(hsl.value.l)
watch(() => props.modelValue, (newVal) => {
hexInput.value = newVal
const newHsl = hexToHSL(newVal)
hue.value = newHsl.h
saturation.value = newHsl.s
lightness.value = newHsl.l
})
function updateFromHSL() {
const hex = hslToHex(hue.value, saturation.value, lightness.value)
hexInput.value = hex
emit('update:modelValue', hex)
}
function updateFromHex() {
if (/^#[0-9A-Fa-f]{6}$/.test(hexInput.value)) {
emit('update:modelValue', hexInput.value)
const newHsl = hexToHSL(hexInput.value)
hue.value = newHsl.h
saturation.value = newHsl.s
lightness.value = newHsl.l
}
}
// Close picker on outside click
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.color-picker')) {
isOpen.value = false
}
}
// Register click outside listener
watch(isOpen, (open) => {
if (open) {
setTimeout(() => document.addEventListener('click', handleClickOutside), 0)
} else {
document.removeEventListener('click', handleClickOutside)
}
})
</script>
<template>
<div class="color-picker" @click.stop>
<div class="color-preview" @click="isOpen = !isOpen">
<div class="color-swatch" :style="{ background: modelValue }"></div>
<span class="color-value">{{ modelValue }}</span>
</div>
<div v-if="isOpen" class="color-dropdown" @click.stop>
<div class="color-sliders">
<div class="slider-group">
<label>Hue</label>
<input
type="range"
v-model.number="hue"
min="0"
max="360"
class="hue-slider"
@input="updateFromHSL"
/>
<span>{{ hue }}°</span>
</div>
<div class="slider-group">
<label>Saturation</label>
<input
type="range"
v-model.number="saturation"
min="0"
max="100"
class="saturation-slider"
:style="{
'--sat-left': hslToHex(hue, 0, lightness),
'--sat-right': hslToHex(hue, 100, lightness)
}"
@input="updateFromHSL"
/>
<span>{{ saturation }}%</span>
</div>
<div class="slider-group">
<label>Lightness</label>
<input
type="range"
v-model.number="lightness"
min="0"
max="100"
class="lightness-slider"
:style="{
'--light-mid': hslToHex(hue, saturation, 50)
}"
@input="updateFromHSL"
/>
<span>{{ lightness }}%</span>
</div>
</div>
<div class="hex-input-group">
<label>Hex</label>
<input
type="text"
v-model="hexInput"
@blur="updateFromHex"
@keyup.enter="updateFromHex"
maxlength="7"
/>
</div>
<div class="large-preview" :style="{ background: modelValue }"></div>
</div>
</div>
</template>
<style scoped>
.color-picker {
position: relative;
}
.color-preview {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s;
}
.color-preview:hover {
border-color: var(--accent);
}
.color-swatch {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.color-value {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.color-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
width: 240px;
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 100;
}
.color-sliders {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.slider-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.slider-group label {
width: 70px;
font-size: 0.75rem;
color: var(--text-secondary);
}
.slider-group input[type="range"] {
flex: 1;
height: 8px;
border-radius: 4px;
appearance: none;
cursor: pointer;
}
.slider-group span {
width: 40px;
font-size: 0.7rem;
color: var(--text-muted);
text-align: right;
}
.hue-slider {
background: linear-gradient(
to right,
#ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000
);
}
.saturation-slider {
background: linear-gradient(to right, var(--sat-left), var(--sat-right));
}
.lightness-slider {
background: linear-gradient(to right, #000000, var(--light-mid), #ffffff);
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
border: 2px solid var(--bg-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
cursor: pointer;
}
.hex-input-group {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.hex-input-group label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.hex-input-group input {
flex: 1;
padding: 0.375rem 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.8rem;
text-transform: uppercase;
}
.large-preview {
height: 40px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import type { Theme } from '../../stores/theme'
const props = defineProps<{
theme: Theme
active: boolean
}>()
const emit = defineEmits<{
select: [theme: Theme]
delete: [id: string]
clone: [id: string]
setDefault: [id: string]
}>()
function getAccentColor(): string {
return props.theme.variables?.accent?.accent || '#6366f1'
}
</script>
<template>
<div
class="theme-item"
:class="{ active, 'is-default': theme.is_default }"
@click="emit('select', theme)"
>
<div class="theme-color" :style="{ background: getAccentColor() }"></div>
<div class="theme-info">
<span class="theme-name">
{{ theme.name }}
<span v-if="theme.is_default" class="default-badge">Default</span>
</span>
<span class="theme-meta">
{{ theme.is_system ? 'System' : 'Custom' }}
</span>
</div>
<div class="theme-actions" @click.stop>
<button
v-if="!theme.is_default"
class="action-btn"
@click="emit('setDefault', theme.id)"
title="Set as default"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</button>
<button
class="action-btn"
@click="emit('clone', theme.id)"
title="Clone theme"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button
v-if="!theme.is_system"
class="action-btn delete"
@click="emit('delete', theme.id)"
title="Delete theme"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</template>
<style scoped>
.theme-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.theme-item:hover {
border-color: var(--border-hover);
background: var(--bg-hover);
}
.theme-item.active {
border-color: var(--accent);
background: var(--accent-muted);
}
.theme-color {
width: 32px;
height: 32px;
border-radius: 6px;
flex-shrink: 0;
}
.theme-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.theme-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.default-badge {
font-size: 0.65rem;
padding: 0.125rem 0.375rem;
background: var(--accent);
color: white;
border-radius: 9999px;
font-weight: 600;
}
.theme-meta {
font-size: 0.7rem;
color: var(--text-muted);
}
.theme-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.15s;
}
.theme-item:hover .theme-actions {
opacity: 1;
}
.action-btn {
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.action-btn.delete:hover {
background: var(--error-bg);
color: var(--error);
}
</style>

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import type { ThemeVariables } from '../../stores/theme'
defineProps<{
variables: ThemeVariables | null
}>()
</script>
<template>
<div class="theme-preview">
<div class="preview-section">
<h4>Colors</h4>
<div class="color-swatches">
<div class="swatch" style="background: var(--bg-primary)">
<span>Primary</span>
</div>
<div class="swatch" style="background: var(--bg-secondary)">
<span>Secondary</span>
</div>
<div class="swatch" style="background: var(--bg-hover)">
<span>Hover</span>
</div>
<div class="swatch" style="background: var(--bg-tertiary)">
<span>Tertiary</span>
</div>
</div>
</div>
<div class="preview-section">
<h4>Text</h4>
<div class="text-samples">
<p style="color: var(--text-primary)">Primary Text</p>
<p style="color: var(--text-secondary)">Secondary Text</p>
<p style="color: var(--text-muted)">Muted Text</p>
</div>
</div>
<div class="preview-section">
<h4>Components</h4>
<div class="component-samples">
<button class="sample-btn primary">Primary Button</button>
<button class="sample-btn secondary">Secondary</button>
<button class="sample-btn danger">Danger</button>
</div>
<div class="sample-card">
<h5>Sample Card</h5>
<p>This is a preview of how cards will look with the current theme.</p>
<div class="card-actions">
<button class="sample-btn primary small">Action</button>
</div>
</div>
<div class="sample-input-group">
<input type="text" placeholder="Sample input..." class="sample-input" />
</div>
</div>
<div class="preview-section">
<h4>Status</h4>
<div class="status-badges">
<span class="badge success">Success</span>
<span class="badge warning">Warning</span>
<span class="badge error">Error</span>
<span class="badge info">Info</span>
</div>
</div>
</div>
</template>
<style scoped>
.theme-preview {
padding: 1rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
}
.preview-section {
margin-bottom: 1.5rem;
}
.preview-section:last-child {
margin-bottom: 0;
}
.preview-section h4 {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0 0 0.75rem 0;
}
.color-swatches {
display: flex;
gap: 0.5rem;
}
.swatch {
flex: 1;
height: 48px;
border-radius: 8px;
border: 1px solid var(--border-color);
display: flex;
align-items: flex-end;
padding: 0.25rem 0.5rem;
}
.swatch span {
font-size: 0.65rem;
color: var(--text-secondary);
mix-blend-mode: difference;
}
.text-samples {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.text-samples p {
margin: 0;
font-size: 0.875rem;
}
.component-samples {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.sample-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition-fast);
}
.sample-btn.primary {
background: var(--accent);
color: var(--accent-text);
}
.sample-btn.primary:hover {
background: var(--accent-hover);
}
.sample-btn.secondary {
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.sample-btn.danger {
background: var(--error-bg);
color: var(--error);
border: 1px solid var(--error);
}
.sample-btn.small {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
}
.sample-card {
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
margin-bottom: 1rem;
}
.sample-card h5 {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
color: var(--text-primary);
}
.sample-card p {
margin: 0 0 0.75rem 0;
font-size: 0.85rem;
color: var(--text-secondary);
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.sample-input-group {
margin-top: 0.75rem;
}
.sample-input {
width: 100%;
padding: 0.625rem 0.875rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: 0.875rem;
transition: var(--transition-fast);
}
.sample-input:focus {
outline: none;
border-color: var(--accent);
}
.sample-input::placeholder {
color: var(--text-muted);
}
.status-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
padding: 0.25rem 0.625rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
}
.badge.success {
background: var(--success-bg);
color: var(--success);
}
.badge.warning {
background: var(--warning-bg);
color: var(--warning);
}
.badge.error {
background: var(--error-bg);
color: var(--error);
}
.badge.info {
background: var(--info-bg);
color: var(--info);
}
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed } from 'vue'
import ColorPicker from './ColorPicker.vue'
import { isColorVariable, isSizeVariable, isShadowVariable, isTransitionVariable, isFontVariable } from '../../services/themeService'
const props = defineProps<{
name: string
value: string
}>()
const emit = defineEmits<{
update: [value: string]
}>()
const variableType = computed(() => {
if (isColorVariable(props.name)) return 'color'
if (isSizeVariable(props.name)) return 'size'
if (isShadowVariable(props.name)) return 'shadow'
if (isTransitionVariable(props.name)) return 'transition'
if (isFontVariable(props.name)) return 'font'
return 'text'
})
const isRadiusVariable = computed(() => props.name.toLowerCase().includes('radius'))
const displayName = computed(() => {
return props.name
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
})
function handleColorChange(newValue: string) {
emit('update', newValue)
}
function handleInputChange(e: Event) {
emit('update', (e.target as HTMLInputElement).value)
}
</script>
<template>
<div class="variable-editor" :class="variableType">
<div class="variable-header">
<span class="variable-name">{{ displayName }}</span>
<code class="variable-key">--{{ name }}</code>
</div>
<div class="variable-input">
<!-- Color input -->
<ColorPicker
v-if="variableType === 'color'"
:modelValue="value"
@update:modelValue="handleColorChange"
/>
<!-- Size input -->
<div v-else-if="variableType === 'size'" class="size-input">
<input
type="text"
:value="value"
@input="handleInputChange"
placeholder="8px"
/>
<div
v-if="isRadiusVariable"
class="radius-preview"
:style="{ borderRadius: value }"
></div>
<div
v-else
class="size-preview"
:style="{ width: value, height: value }"
></div>
</div>
<!-- Text/Other input -->
<input
v-else
type="text"
:value="value"
@input="handleInputChange"
class="text-input"
/>
</div>
</div>
</template>
<style scoped>
.variable-editor {
padding: 0.875rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
transition: border-color 0.15s;
}
.variable-editor:hover {
border-color: var(--border-hover);
}
.variable-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.variable-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.variable-key {
font-size: 0.7rem;
color: var(--text-muted);
font-family: var(--font-mono);
}
.variable-input {
width: 100%;
}
.size-input {
display: flex;
align-items: center;
gap: 0.5rem;
}
.size-input input {
flex: 1;
min-width: 0;
padding: 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.875rem;
}
.size-preview {
width: 28px;
height: 28px;
background: var(--accent);
border-radius: 4px;
flex-shrink: 0;
}
.radius-preview {
width: 28px;
height: 28px;
background: var(--accent);
flex-shrink: 0;
}
.text-input {
width: 100%;
padding: 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.875rem;
}
.text-input:focus {
outline: none;
border-color: var(--accent);
}
</style>

View File

@@ -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')
})

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import Canvas from '../components/Canvas.vue'
</script>
<template>
<Canvas />
</template>

View File

@@ -0,0 +1,716 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useComponentsStore, type ComponentState } from '../stores/components'
import { useRouter } from 'vue-router'
const store = useComponentsStore()
const router = useRouter()
const searchQuery = ref('')
const selectedComponent = ref<ComponentState | null>(null)
const viewMode = ref<'grid' | 'list'>('grid')
const showDeleteConfirm = ref(false)
const componentToDelete = ref<string | null>(null)
const filteredComponents = computed(() => {
if (!searchQuery.value) return store.savedComponents
const query = searchQuery.value.toLowerCase()
return store.savedComponents.filter(c =>
c.name.toLowerCase().includes(query) ||
c.id.toLowerCase().includes(query)
)
})
function selectComponent(comp: ComponentState) {
selectedComponent.value = comp
}
function loadInCanvas(comp: ComponentState) {
window.dispatchEvent(new CustomEvent('load-vue-component', {
detail: {
id: comp.id,
name: comp.name,
template: comp.template,
setup: comp.setup,
style: comp.style,
props: typeof comp.props === 'string' ? JSON.parse(comp.props) : comp.props || [],
imports: typeof comp.imports === 'string' ? JSON.parse(comp.imports) : comp.imports || []
}
}))
router.push('/')
}
function confirmDelete(id: string) {
componentToDelete.value = id
showDeleteConfirm.value = true
}
async function deleteComponent() {
if (componentToDelete.value) {
await store.deleteComponent(componentToDelete.value)
if (selectedComponent.value?.id === componentToDelete.value) {
selectedComponent.value = null
}
}
showDeleteConfirm.value = false
componentToDelete.value = null
}
async function deleteAll() {
if (confirm('¿Eliminar TODOS los componentes? Esta acción no se puede deshacer.')) {
await store.deleteAllComponents()
selectedComponent.value = null
}
}
function formatDate(dateStr?: string) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text)
}
onMounted(() => {
store.fetchComponents()
})
</script>
<template>
<div class="components-page">
<!-- Sidebar con lista -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>Componentes</h2>
<span class="count">{{ store.savedCount }}</span>
</div>
<div class="search-box">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
<input v-model="searchQuery" placeholder="Buscar componentes..." />
</div>
<div class="view-toggle">
<button :class="{ active: viewMode === 'grid' }" @click="viewMode = 'grid'" title="Vista grid">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>
</svg>
</button>
<button :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'" title="Vista lista">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<div class="components-list" :class="viewMode">
<div v-if="store.loading" class="loading">Cargando...</div>
<div v-else-if="filteredComponents.length === 0" class="empty">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
<p>No hay componentes</p>
</div>
<template v-else>
<div
v-for="comp in filteredComponents"
:key="comp.id"
class="component-card"
:class="{ selected: selectedComponent?.id === comp.id }"
@click="selectComponent(comp)"
>
<div class="card-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
</div>
<div class="card-info">
<span class="card-name">{{ comp.name }}</span>
<span class="card-id">{{ comp.id }}</span>
</div>
<div class="card-actions">
<button class="action-btn load" @click.stop="loadInCanvas(comp)" title="Cargar en canvas">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
</button>
<button class="action-btn delete" @click.stop="confirmDelete(comp.id)" title="Eliminar">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</template>
</div>
<div class="sidebar-footer">
<button class="btn-danger" @click="deleteAll" :disabled="store.savedCount === 0">
Eliminar todos
</button>
</div>
</aside>
<!-- Panel de detalles -->
<main class="details-panel">
<div v-if="!selectedComponent" class="no-selection">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.29 7 12 12 20.71 7"/>
<line x1="12" y1="22" x2="12" y2="12"/>
</svg>
<h3>Selecciona un componente</h3>
<p>Haz clic en un componente de la lista para ver sus detalles</p>
</div>
<template v-else>
<div class="details-header">
<div class="details-title">
<h2>{{ selectedComponent.name }}</h2>
<code>{{ selectedComponent.id }}</code>
</div>
<div class="details-actions">
<button class="btn-primary" @click="loadInCanvas(selectedComponent)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Cargar en Canvas
</button>
<button class="btn-danger" @click="confirmDelete(selectedComponent.id)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
</svg>
Eliminar
</button>
</div>
</div>
<div class="details-meta">
<div class="meta-item">
<span class="meta-label">Creado</span>
<span class="meta-value">{{ formatDate(selectedComponent.created_at) }}</span>
</div>
<div class="meta-item">
<span class="meta-label">Actualizado</span>
<span class="meta-value">{{ formatDate(selectedComponent.updated_at) }}</span>
</div>
<div class="meta-item" v-if="selectedComponent.imports">
<span class="meta-label">Imports</span>
<span class="meta-value">{{ Array.isArray(selectedComponent.imports) ? selectedComponent.imports.join(', ') : selectedComponent.imports }}</span>
</div>
</div>
<div class="code-sections">
<div class="code-section">
<div class="code-header">
<span>Template</span>
<button @click="copyToClipboard(selectedComponent.template)" title="Copiar">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<pre><code>{{ selectedComponent.template }}</code></pre>
</div>
<div class="code-section" v-if="selectedComponent.setup">
<div class="code-header">
<span>Setup</span>
<button @click="copyToClipboard(selectedComponent.setup)" title="Copiar">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<pre><code>{{ selectedComponent.setup }}</code></pre>
</div>
<div class="code-section" v-if="selectedComponent.style">
<div class="code-header">
<span>Style</span>
<button @click="copyToClipboard(selectedComponent.style)" title="Copiar">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
</div>
<pre><code>{{ selectedComponent.style }}</code></pre>
</div>
</div>
</template>
</main>
<!-- Modal de confirmación -->
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
<div class="modal" @click.stop>
<h3>¿Eliminar componente?</h3>
<p>Esta acción no se puede deshacer.</p>
<div class="modal-actions">
<button class="btn-secondary" @click="showDeleteConfirm = false">Cancelar</button>
<button class="btn-danger" @click="deleteComponent">Eliminar</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.components-page {
display: flex;
flex: 1;
overflow: hidden;
background: var(--bg-primary);
}
/* Sidebar */
.sidebar {
width: 320px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
margin: 0;
font-size: 1.125rem;
color: var(--text-primary);
}
.count {
background: var(--bg-hover);
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
color: var(--text-secondary);
}
.search-box {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem;
padding: 0.625rem 0.875rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.search-box svg {
color: var(--text-secondary);
flex-shrink: 0;
}
.search-box input {
flex: 1;
background: transparent;
border: none;
color: var(--text-primary);
font-size: 0.875rem;
outline: none;
}
.search-box input::placeholder {
color: var(--text-muted);
}
.view-toggle {
display: flex;
gap: 0.25rem;
padding: 0 1rem;
margin-bottom: 0.5rem;
}
.view-toggle button {
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.view-toggle button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.view-toggle button.active {
background: var(--bg-hover);
color: #6366f1;
}
.components-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem 1rem;
}
.components-list.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.components-list.grid .component-card {
flex-direction: column;
text-align: center;
padding: 1rem;
}
.components-list.grid .card-icon {
margin-bottom: 0.5rem;
}
.components-list.grid .card-actions {
margin-top: 0.75rem;
justify-content: center;
}
.loading, .empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-muted);
text-align: center;
grid-column: 1 / -1;
}
.empty svg {
opacity: 0.3;
margin-bottom: 1rem;
}
.component-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
}
.component-card:hover {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.05);
}
.component-card.selected {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.1);
}
.card-icon {
color: #6366f1;
}
.card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.card-name {
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-id {
font-size: 0.7rem;
color: var(--text-secondary);
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-actions {
display: flex;
gap: 0.25rem;
}
.action-btn {
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
}
.action-btn:hover {
transform: scale(1.1);
}
.action-btn.load:hover {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.action-btn.delete:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border-color);
}
/* Details Panel */
.details-panel {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.no-selection {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
text-align: center;
}
.no-selection svg {
opacity: 0.3;
margin-bottom: 1rem;
}
.no-selection h3 {
margin: 0 0 0.5rem 0;
color: var(--text-secondary);
}
.details-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.details-title h2 {
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.details-title code {
font-size: 0.8rem;
color: var(--text-secondary);
background: var(--bg-hover);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.details-actions {
display: flex;
gap: 0.75rem;
}
.details-meta {
display: flex;
gap: 2rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.meta-label {
font-size: 0.7rem;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.05em;
}
.meta-value {
font-size: 0.875rem;
color: var(--text-secondary);
}
.code-sections {
display: flex;
flex-direction: column;
gap: 1rem;
}
.code-section {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 1rem;
background: var(--bg-hover);
border-bottom: 1px solid var(--border-color);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
}
.code-header button {
padding: 0.25rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.code-header button:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.code-section pre {
margin: 0;
padding: 1rem;
overflow-x: auto;
max-height: 300px;
}
.code-section code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8rem;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
/* Buttons */
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary:hover {
background: #4f46e5;
}
.btn-secondary {
padding: 0.625rem 1rem;
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-secondary:hover {
background: var(--bg-tertiary);
}
.btn-danger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.2);
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
min-width: 300px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal h3 {
margin: 0 0 0.5rem 0;
color: var(--text-primary);
}
.modal p {
margin: 0 0 1.5rem 0;
color: var(--text-secondary);
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,662 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useThemeStore, type ThemeVariables } from '../stores/theme'
import { THEME_CATEGORIES, downloadTheme, readThemeFile, type ThemeCategory } from '../services/themeService'
import ThemeListItem from '../components/themes/ThemeListItem.vue'
import VariableEditor from '../components/themes/VariableEditor.vue'
import ThemePreview from '../components/themes/ThemePreview.vue'
const store = useThemeStore()
const activeCategory = ref<ThemeCategory>('colors')
const variablesCollapsed = ref(false)
const newThemeName = ref('')
const showNewThemeModal = ref(false)
const showCloneModal = ref(false)
const cloneSourceId = ref<string | null>(null)
const cloneName = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const currentCategoryVariables = computed(() => {
const vars = store.currentVariables
if (!vars) return []
const categoryVars = vars[activeCategory.value as keyof ThemeVariables]
if (!categoryVars) return []
return Object.entries(categoryVars)
})
const editingThemeName = computed(() => {
return store.activeTheme?.name || 'No theme selected'
})
function handleSelectTheme(theme: any) {
store.selectTheme(theme)
}
function handleDeleteTheme(id: string) {
if (confirm('Delete this theme?')) {
store.deleteTheme(id)
}
}
function handleCloneTheme(id: string) {
const theme = store.themes.find(t => t.id === id)
if (theme) {
cloneSourceId.value = id
cloneName.value = `${theme.name} Copy`
showCloneModal.value = true
}
}
async function confirmClone() {
if (cloneSourceId.value && cloneName.value.trim()) {
await store.cloneTheme(cloneSourceId.value, cloneName.value.trim())
showCloneModal.value = false
cloneSourceId.value = null
cloneName.value = ''
}
}
function handleSetDefault(id: string) {
store.setDefaultTheme(id)
}
function handleUpdateVariable(key: string, value: string) {
store.updateVariable(activeCategory.value as keyof ThemeVariables, key, value)
}
async function handleSave() {
if (store.activeTheme?.is_system) {
newThemeName.value = `${store.activeTheme.name} Custom`
showNewThemeModal.value = true
} else {
await store.commitPreview()
}
}
async function handleSaveAsNew() {
if (newThemeName.value.trim()) {
await store.commitPreview(newThemeName.value.trim())
showNewThemeModal.value = false
newThemeName.value = ''
}
}
function handleReset() {
store.resetPreview()
}
function handleExport() {
if (store.activeTheme) {
downloadTheme(store.activeTheme)
}
}
function handleImportClick() {
fileInput.value?.click()
}
async function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
try {
const themeData = await readThemeFile(file)
await store.saveTheme({
name: themeData.name || 'Imported Theme',
description: themeData.description,
variables: themeData.variables!,
metadata: themeData.metadata
})
} catch (err) {
alert('Failed to import theme: ' + (err as Error).message)
}
}
input.value = ''
}
function createNewTheme() {
newThemeName.value = 'New Theme'
showNewThemeModal.value = true
}
async function confirmCreateTheme() {
if (newThemeName.value.trim() && store.activeTheme) {
await store.saveTheme({
name: newThemeName.value.trim(),
variables: JSON.parse(JSON.stringify(store.activeTheme.variables))
})
showNewThemeModal.value = false
newThemeName.value = ''
}
}
onMounted(() => {
store.fetchThemes()
})
</script>
<template>
<div class="themes-page">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h2>Themes</h2>
<button class="btn-icon" @click="createNewTheme" title="New theme">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</button>
</div>
<div class="theme-groups">
<section class="theme-group">
<h3>System</h3>
<ThemeListItem
v-for="theme in store.systemThemes"
:key="theme.id"
:theme="theme"
:active="store.activeTheme?.id === theme.id"
@select="handleSelectTheme"
@delete="handleDeleteTheme"
@clone="handleCloneTheme"
@setDefault="handleSetDefault"
/>
</section>
<section v-if="store.userThemes.length > 0" class="theme-group">
<h3>Custom</h3>
<ThemeListItem
v-for="theme in store.userThemes"
:key="theme.id"
:theme="theme"
:active="store.activeTheme?.id === theme.id"
@select="handleSelectTheme"
@delete="handleDeleteTheme"
@clone="handleCloneTheme"
@setDefault="handleSetDefault"
/>
</section>
</div>
<div class="sidebar-footer">
<button class="btn-secondary" @click="handleImportClick">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import
</button>
<input
ref="fileInput"
type="file"
accept=".json"
@change="handleFileChange"
hidden
/>
</div>
</aside>
<!-- Editor -->
<main class="editor">
<header class="editor-header">
<div class="editor-title">
<h2>{{ editingThemeName }}</h2>
<span v-if="store.hasUnsavedChanges" class="unsaved-badge">Unsaved changes</span>
</div>
<div class="editor-actions">
<button
class="btn-secondary"
@click="handleReset"
:disabled="!store.hasUnsavedChanges"
>
Reset
</button>
<button
class="btn-primary"
@click="handleSave"
:disabled="!store.hasUnsavedChanges"
>
Save
</button>
<button class="btn-secondary" @click="handleExport">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Export
</button>
</div>
</header>
<!-- Category Tabs -->
<nav class="category-tabs">
<button
v-for="cat in THEME_CATEGORIES"
:key="cat.key"
:class="{ active: activeCategory === cat.key }"
@click="activeCategory = cat.key"
>
{{ cat.label }}
</button>
</nav>
<!-- Variables Grid -->
<section class="variables-section" :class="{ collapsed: variablesCollapsed }">
<div class="variables-header" @click="variablesCollapsed = !variablesCollapsed">
<span>Variables</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="collapse-icon"
:class="{ rotated: variablesCollapsed }"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
<div class="variables-content">
<div class="variables-grid">
<VariableEditor
v-for="[key, value] in currentCategoryVariables"
:key="key"
:name="key"
:value="value"
@update="handleUpdateVariable(key, $event)"
/>
</div>
</div>
</section>
<!-- Preview -->
<section class="preview-section">
<h3>Preview</h3>
<ThemePreview :variables="store.currentVariables" />
</section>
</main>
<!-- New Theme Modal -->
<div v-if="showNewThemeModal" class="modal-overlay" @click="showNewThemeModal = false">
<div class="modal" @click.stop>
<h3>{{ store.activeTheme?.is_system ? 'Save as New Theme' : 'Create Theme' }}</h3>
<input
v-model="newThemeName"
type="text"
placeholder="Theme name"
@keyup.enter="store.activeTheme?.is_system ? handleSaveAsNew() : confirmCreateTheme()"
/>
<div class="modal-actions">
<button class="btn-secondary" @click="showNewThemeModal = false">Cancel</button>
<button
class="btn-primary"
@click="store.activeTheme?.is_system ? handleSaveAsNew() : confirmCreateTheme()"
>
Create
</button>
</div>
</div>
</div>
<!-- Clone Modal -->
<div v-if="showCloneModal" class="modal-overlay" @click="showCloneModal = false">
<div class="modal" @click.stop>
<h3>Clone Theme</h3>
<input
v-model="cloneName"
type="text"
placeholder="New theme name"
@keyup.enter="confirmClone"
/>
<div class="modal-actions">
<button class="btn-secondary" @click="showCloneModal = false">Cancel</button>
<button class="btn-primary" @click="confirmClone">Clone</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.themes-page {
display: flex;
flex: 1;
overflow: hidden;
background: var(--bg-primary);
}
/* Sidebar */
.sidebar {
width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h2 {
margin: 0;
font-size: 1rem;
color: var(--text-primary);
}
.btn-icon {
padding: 0.375rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--accent);
}
.theme-groups {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.theme-group {
margin-bottom: 1.5rem;
}
.theme-group h3 {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0 0 0.75rem 0;
}
.theme-group > :deep(.theme-item) {
margin-bottom: 0.5rem;
}
.sidebar-footer {
padding: 1rem;
border-top: 1px solid var(--border-color);
}
/* Editor */
.editor {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.editor-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.editor-title h2 {
margin: 0;
font-size: 1.125rem;
color: var(--text-primary);
}
.unsaved-badge {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
background: var(--warning-bg);
color: var(--warning);
border-radius: 9999px;
}
.editor-actions {
display: flex;
gap: 0.5rem;
}
/* Category Tabs */
.category-tabs {
display: flex;
gap: 0.25rem;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
}
.category-tabs button {
padding: 0.5rem 1rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.category-tabs button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.category-tabs button.active {
background: var(--accent-muted);
color: var(--accent);
}
/* Variables */
.variables-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
transition: flex 0.3s ease;
}
.variables-section.collapsed {
flex: 0 0 auto;
}
.variables-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--bg-tertiary);
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--border-color);
}
.variables-header:hover {
background: var(--bg-hover);
}
.variables-header span {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.collapse-icon {
color: var(--text-muted);
transition: transform 0.3s ease;
}
.collapse-icon.rotated {
transform: rotate(-180deg);
}
.variables-content {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
transition: max-height 0.3s ease, opacity 0.3s ease;
}
.variables-section.collapsed .variables-content {
max-height: 0;
padding: 0 1.5rem;
opacity: 0;
overflow: hidden;
}
.variables-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
padding-bottom: 1rem;
}
/* Preview */
.preview-section {
flex-shrink: 0;
max-height: 400px;
overflow-y: auto;
padding: 2rem;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.variables-section.collapsed ~ .preview-section {
max-height: none;
flex: 1;
}
.preview-section h3 {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin: 0 0 1rem 0;
}
/* Buttons */
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
width: 100%;
max-width: 360px;
padding: 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal h3 {
margin: 0 0 1rem 0;
font-size: 1.125rem;
color: var(--text-primary);
}
.modal input {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.95rem;
margin-bottom: 1rem;
}
.modal input:focus {
outline: none;
border-color: var(--accent);
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
</style>

View File

@@ -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

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)
})
}

View File

@@ -0,0 +1,296 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// =====================
// Types
// =====================
export interface ThemeVariables {
colors: Record<string, string>
text: Record<string, string>
accent: Record<string, string>
semantic: Record<string, string>
spacing: Record<string, string>
typography: Record<string, string>
effects: Record<string, string>
}
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<string, string>
examples: Record<string, string>
}
const API_URL = 'http://localhost:4101'
// =====================
// Store
// =====================
export const useThemeStore = defineStore('theme', () => {
// State
const themes = ref<Theme[]>([])
const activeTheme = ref<Theme | null>(null)
const previewTheme = ref<ThemeVariables | null>(null)
const designTokens = ref<DesignTokens | null>(null)
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(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<string, string> = {}
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<Theme> & { 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
}
})