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

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