feat: Replace DB component tools with filesystem-based user-components/

Components are now .vue files in user-components/<folder>/ parsed at runtime.
Replaces 6 DB MCP tools with 2 (list_fs_components, load_fs_component).
Adds vue-parser, fs-components API, and file watcher for live reload.
This commit is contained in:
2026-02-18 10:24:05 -06:00
parent e9451b2a47
commit d27da30494
13 changed files with 597 additions and 19 deletions

View File

@@ -26,6 +26,7 @@ import {
createGitHandlers,
createTorchHandlers,
createSnapshotHandlers,
createFsComponentHandlers,
type ToolConfig
} from './tools/handlers'
import { setRouter } from './tools/handlers/globalHandlers'
@@ -134,7 +135,8 @@ function getToolConfigs(): Map<string, ToolConfig> {
...createResponseHandlers(),
...createGitHandlers(),
...createTorchHandlers(),
...createSnapshotHandlers()
...createSnapshotHandlers(),
...createFsComponentHandlers()
]
for (const config of allHandlers) {
@@ -148,7 +150,7 @@ function getToolConfigs(): Map<string, ToolConfig> {
const categoryTools: Record<ToolCategory, string[]> = {
global: ['get_current_page', 'navigate_to', 'list_available_tools', 'activate_tool', 'deactivate_tool', 'pin_tool', 'page_refresh'],
canvas: ['render_html', 'render_vue_component', 'move_window', 'resize_window', 'close_window', 'list_windows', 'inspect_window', 'get_canvas', 'edit_canvas', 'canvas_css', 'canvas_js', 'get_canvas_css', 'save_canvas_snapshot', 'load_canvas_snapshot', 'list_canvas_snapshots', 'delete_canvas_snapshot'],
component: ['save_vue_component', 'load_vue_component', 'list_vue_components', 'delete_vue_component', 'read_component', 'edit_component'],
component: ['list_fs_components', 'load_fs_component'],
theme: ['get_design_tokens', 'get_active_theme', 'set_theme_variable', 'save_theme', 'list_themes', 'switch_theme', 'reset_theme'],
database: ['list_tables', 'get_table_schema', 'get_table_data', 'get_database_stats', 'execute_query'],
source: ['get_repo_info', 'list_repo_files', 'read_repo_file', 'search_repo_code'],

View File

@@ -3,7 +3,6 @@ import { useCanvasStore } from '../../../stores/canvas'
import { useWindowsStore } from '../../../stores/windows'
import {
renderInlineComponent,
componentsApi,
type VueComponentDefinition
} from '../../dynamicComponents'
@@ -24,7 +23,7 @@ export function clearScriptLog(): void {
// WINDOW DEFINITIONS — tracks component definition per rendered window
// ============================================
export interface WindowDefinitionEntry {
source: 'inline' | 'db'
source: 'inline' | 'db' | 'fs'
componentId?: string
definition: VueComponentDefinition
componentProps: Record<string, any>
@@ -196,13 +195,6 @@ export function createCanvasHandlers(): ToolConfig[] {
emitComponentRendered(args)
// Auto-save component to database
try {
await componentsApi.save(definition)
} catch (e) {
console.warn('[auto-save] Failed to save component:', e)
}
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'render_vue_component', args, timestamp: Date.now() })
return `Componente Vue "${args.name}" renderizado en ventana flotante`

View File

@@ -0,0 +1,113 @@
import type { ToolConfig } from './index'
import { useCanvasStore } from '../../../stores/canvas'
import {
renderInlineComponent,
type VueComponentDefinition
} from '../../dynamicComponents'
import { getWindowDefinitions } from './canvasHandlers'
const API_URL = ''
export const fsComponentsApi = {
async list(): Promise<VueComponentDefinition[]> {
const res = await fetch(`${API_URL}/api/fs-components`)
if (!res.ok) throw new Error(`Failed to list fs-components: ${res.statusText}`)
return res.json()
},
async getByFolder(folder: string): Promise<VueComponentDefinition> {
const res = await fetch(`${API_URL}/api/fs-components/${encodeURIComponent(folder)}`)
if (!res.ok) throw new Error(`Component "${folder}" not found`)
return res.json()
}
}
function getCanvasContainer() {
return document.getElementById('canvas-content')
}
function removePlaceholder(container: HTMLElement) {
const placeholder = container.querySelector('.canvas-placeholder')
if (placeholder) {
placeholder.remove()
window.dispatchEvent(new CustomEvent('canvas-content-rendered'))
}
}
export function createFsComponentHandlers(): ToolConfig[] {
return [
{
name: 'list_fs_components',
description: 'Lista componentes Vue del filesystem (user-components/)',
category: 'component',
schema: {
type: 'object',
properties: {},
required: []
},
handler: async () => {
try {
const components = await fsComponentsApi.list()
if (components.length === 0) {
return 'No hay componentes en user-components/. Crea una carpeta con un archivo .vue para empezar.'
}
const list = components.map(c => {
const tags = c.tags?.length ? ` [${c.tags.join(', ')}]` : ''
return `- ${c.folder}: ${c.name}${tags}`
}).join('\n')
return `Componentes en filesystem (${components.length}):\n${list}`
} catch (e: any) {
return `Error: ${e.message}`
}
}
},
{
name: 'load_fs_component',
description: 'Carga y renderiza un componente desde user-components/',
category: 'component',
schema: {
type: 'object',
properties: {
folder: { type: 'string', description: 'Nombre de la carpeta del componente' },
componentProps: { type: 'object', description: 'Props para el componente' },
mode: { type: 'string', enum: ['replace', 'append'], description: 'Modo de renderizado' },
x: { type: 'number', description: 'Posicion X inicial' },
y: { type: 'number', description: 'Posicion Y inicial' },
width: { type: 'number', description: 'Ancho inicial' },
height: { type: 'number', description: 'Alto inicial' }
},
required: ['folder']
},
handler: async (args: { folder: string; componentProps?: Record<string, any>; mode?: string; x?: number; y?: number; width?: number; height?: number }) => {
try {
const definition = await fsComponentsApi.getByFolder(args.folder)
const container = getCanvasContainer()
if (!container) return 'Error: canvas no encontrado'
removePlaceholder(container)
const isAppend = args.mode === 'append'
const layout = { x: args.x, y: args.y, width: args.width, height: args.height }
const result = renderInlineComponent(definition, container, args.componentProps || {}, isAppend, layout)
// Track definition for snapshot capture
getWindowDefinitions().set(definition.id, {
source: 'fs',
componentId: args.folder,
definition,
componentProps: args.componentProps || {}
})
;(window as any).__vueComponentUnmount = result.unmount
const canvasStore = useCanvasStore()
canvasStore.addToHistory({ tool: 'load_fs_component', args, timestamp: Date.now() })
return `Componente "${definition.name}" cargado desde user-components/${args.folder}/`
} catch (e: any) {
return `Error: ${e.message}`
}
}
}
]
}

View File

@@ -17,6 +17,7 @@ export type { ResponseControls } from './responseHandlers'
export { createGitHandlers } from './gitHandlers'
export { createTorchHandlers } from './torchHandlers'
export { createSnapshotHandlers } from './snapshotHandlers'
export { createFsComponentHandlers } from './fsComponentHandlers'
export type ToolHandler = (args: any) => string | Promise<string>

View File

@@ -35,13 +35,9 @@ export const ALL_TOOL_METAS: ToolMeta[] = [
{ name: 'list_canvas_snapshots', description: 'Lista snapshots del canvas guardados', category: 'canvas' },
{ name: 'delete_canvas_snapshot', description: 'Elimina un snapshot del canvas', category: 'canvas' },
// Component tools
{ name: 'save_vue_component', description: 'Guarda un componente Vue en la base de datos', category: 'component' },
{ name: 'load_vue_component', description: 'Carga y renderiza un componente guardado', category: 'component' },
{ name: 'list_vue_components', description: 'Lista componentes guardados', category: 'component' },
{ name: 'delete_vue_component', description: 'Elimina un componente', category: 'component' },
{ name: 'read_component', description: 'Lee campos especificos de un componente guardado', category: 'component' },
{ name: 'edit_component', description: 'Edita un campo de componente con reemplazo de strings (requiere lectura previa)', category: 'component' },
// Component tools (filesystem-based)
{ name: 'list_fs_components', description: 'Lista componentes Vue del filesystem', category: 'component' },
{ name: 'load_fs_component', description: 'Carga y renderiza un componente desde user-components/', category: 'component' },
// Theme tools
{ name: 'get_design_tokens', description: 'Obtiene los design tokens del tema activo', category: 'theme' },

View File

@@ -15,3 +15,6 @@ export const DB_PATH = 'agent-ui.db'
// Recordings
export const RECORDINGS_DIR = 'recordings'
// User components
export const USER_COMPONENTS_DIR = 'user-components'

View File

@@ -0,0 +1,24 @@
import { join } from 'path'
import { jsonResponse, errorResponse } from '../utils/cors'
import { USER_COMPONENTS_DIR, WORKING_DIR } from '../config'
import { listAllComponents, parseComponentFolder } from '../services/vue-parser'
const baseDir = join(WORKING_DIR, USER_COMPONENTS_DIR)
export function handleFsComponents(req: Request) {
if (req.method !== 'GET') return null
const components = listAllComponents(baseDir)
return jsonResponse(components)
}
export function handleFsComponentByName(req: Request, folderName: string) {
if (req.method !== 'GET') return null
const component = parseComponentFolder(baseDir, folderName)
if (!component) {
return errorResponse(`Component folder "${folderName}" not found or has no .vue file`, 404)
}
return jsonResponse(component)
}

View File

@@ -3,6 +3,7 @@ import { handleHistory } from './history'
import { handleConfig, handleHealth } from './config'
import { handleWebMCPToken, handleWebMCPRequestToken } from './webmcp'
import { handleComponents, handleComponentById, handleComponentUsage } from './components'
import { handleFsComponents, handleFsComponentByName } from './fs-components'
import { handleThemes, handleActiveTheme, handleDesignTokens, handleThemeById, handleThemeExport } from './themes'
import { handleCanvas, handleCanvasById, handleToolbarCanvas, handleDefaultCanvas, handleCanvasComponents, handleCanvasComponentById } from './canvas'
import { handleGiteaRepo, handleGiteaTree, handleGiteaFile } from './gitea'
@@ -107,6 +108,18 @@ export async function handleRequest(req: Request): Promise<Response> {
if (res) return res
}
// Filesystem components
if (path === '/api/fs-components') {
const res = handleFsComponents(req)
if (res) return res
}
if (path.startsWith('/api/fs-components/')) {
const folderName = path.split('/').pop()!
const res = handleFsComponentByName(req, folderName)
if (res) return res
}
// Themes
if (path === '/api/themes') {
const res = await handleThemes(req)

View File

@@ -0,0 +1,67 @@
/**
* Components Handler
* Watches user-components/ for .vue and .json file changes
* and broadcasts notifications via the sync server.
*/
import { watch, mkdirSync, existsSync, type FSWatcher } from 'fs'
import { join } from 'path'
import { USER_COMPONENTS_DIR } from '../../config'
let componentsWatcher: FSWatcher | null = null
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const DEBOUNCE_MS = 400
export function setupComponentsWatcher(workingDir: string, broadcast: (message: string, filter?: (ws: any) => boolean) => void) {
const componentsDir = join(workingDir, USER_COMPONENTS_DIR)
// Auto-create directory if it doesn't exist
if (!existsSync(componentsDir)) {
try {
mkdirSync(componentsDir, { recursive: true })
} catch (e: any) {
console.error(`[Components] Failed to create ${componentsDir}: ${e.message}`)
return
}
}
try {
componentsWatcher = watch(componentsDir, { recursive: true }, (_, filename) => {
if (!filename) return
// Only watch .vue and .json files
if (!filename.endsWith('.vue') && !filename.endsWith('.json')) return
// Debounce
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
// Extract folder name from path (first segment)
const folder = filename.split(/[/\\]/)[0] || ''
const file = filename.split(/[/\\]/).pop() || filename
console.log(`[Components] Change: ${filename}`)
broadcast(JSON.stringify({
type: 'component-change',
folder,
file,
timestamp: Date.now()
}))
}, DEBOUNCE_MS)
})
console.log(`[Components] Watching ${componentsDir}`)
} catch (e: any) {
console.error(`[Components] Watch failed: ${e.message}`)
}
}
export function cleanupComponentsWatcher() {
if (componentsWatcher) {
componentsWatcher.close()
componentsWatcher = null
}
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}

View File

@@ -7,6 +7,7 @@
import { PORT_GIT, WORKING_DIR } from '../config'
import { setupGitWatcher, handleGitClient, cleanupGitWatcher } from './handlers/git-handler'
import { setupComponentsWatcher, cleanupComponentsWatcher } from './handlers/components-handler'
import { handleTorchMessage, handleTorchConnect, handleTorchDisconnect, getTorchStatus, cleanupTorchHandler } from './handlers/torch-handler'
// Connected clients
@@ -96,14 +97,16 @@ export function startSyncServer() {
console.log(`[Sync] WebSocket server on port ${PORT_GIT}`)
// Start git file watcher
// Start file watchers
setupGitWatcher(WORKING_DIR, broadcast)
setupComponentsWatcher(WORKING_DIR, broadcast)
return server
}
export function stopSyncServer() {
cleanupGitWatcher()
cleanupComponentsWatcher()
cleanupTorchHandler()
clients.clear()
}

View File

@@ -0,0 +1,117 @@
/**
* Vue File Parser
* Extracts <template>, <script setup>, <style> from .vue files
* and converts them into VueComponentDefinition objects.
*/
import { join } from 'path'
import { readdirSync, readFileSync, existsSync, statSync } from 'fs'
export interface VueComponentDefinition {
id: string
name: string
template: string
setup?: string
style?: string
props?: string[]
imports?: string[]
tags?: string[]
folder: string
}
export interface ComponentMeta {
name?: string
tags?: string[]
props?: string[]
imports?: string[]
}
/**
* Parse a .vue file content into template, setup, style sections
*/
export function parseVueFile(content: string): { template: string; setup: string; style: string } {
const templateMatch = content.match(/<template>([\s\S]*?)<\/template>/)
const scriptMatch = content.match(/<script\s+setup[^>]*>([\s\S]*?)<\/script>/)
const styleMatch = content.match(/<style[^>]*>([\s\S]*?)<\/style>/)
return {
template: templateMatch?.[1]?.trim() || '',
setup: scriptMatch?.[1]?.trim() || '',
style: styleMatch?.[1]?.trim() || ''
}
}
/**
* Read optional meta.json from a component folder
*/
export function readMetaJson(folderPath: string): ComponentMeta | null {
const metaPath = join(folderPath, 'meta.json')
if (!existsSync(metaPath)) return null
try {
const raw = readFileSync(metaPath, 'utf-8')
return JSON.parse(raw) as ComponentMeta
} catch {
return null
}
}
/**
* Parse a component folder into a VueComponentDefinition
* Convention: user-components/counter-widget/CounterWidget.vue + optional meta.json
*/
export function parseComponentFolder(baseDir: string, folderName: string): VueComponentDefinition | null {
const folderPath = join(baseDir, folderName)
if (!existsSync(folderPath) || !statSync(folderPath).isDirectory()) return null
// Find the .vue file in the folder
const files = readdirSync(folderPath)
const vueFile = files.find(f => f.endsWith('.vue'))
if (!vueFile) return null
const vueContent = readFileSync(join(folderPath, vueFile), 'utf-8')
const { template, setup, style } = parseVueFile(vueContent)
if (!template) return null
const meta = readMetaJson(folderPath)
// Derive name from meta or filename (CounterWidget.vue -> CounterWidget)
const nameFromFile = vueFile.replace('.vue', '')
const name = meta?.name || nameFromFile
return {
id: folderName,
name,
template,
setup: setup || undefined,
style: style || undefined,
props: meta?.props,
imports: meta?.imports,
tags: meta?.tags,
folder: folderName
}
}
/**
* List all component subdirectories and parse them
*/
export function listAllComponents(baseDir: string): VueComponentDefinition[] {
if (!existsSync(baseDir)) return []
const entries = readdirSync(baseDir)
const components: VueComponentDefinition[] = []
for (const entry of entries) {
const fullPath = join(baseDir, entry)
if (!statSync(fullPath).isDirectory()) continue
const component = parseComponentFolder(baseDir, entry)
if (component) {
components.push(component)
}
}
return components
}

0
user-components/.gitkeep Normal file
View File

View File

@@ -0,0 +1,247 @@
<template>
<div class="counter" ref="counterEl">
<div class="fx-layer" ref="fxLayer"></div>
<h2 class="title">{{ title }}</h2>
<p class="count-display" :class="pulseClass" ref="countEl">{{ count }}</p>
<div class="buttons">
<button class="btn btn-minus" @click="decrement">-1</button>
<button class="btn btn-plus" @click="increment">+1</button>
</div>
</div>
</template>
<script setup>
const count = ref(0)
const title = 'Test Counter'
const pulseClass = ref('')
const countEl = ref(null)
const counterEl = ref(null)
const fxLayer = ref(null)
const fxStyles = [
{ pos: 'sparkle', neg: 'ember' },
{ pos: 'bubble', neg: 'drip' },
{ pos: 'star', neg: 'crack' },
{ pos: 'ring', neg: 'smoke' }
]
let styleIdx = 0
// Inject stylesheet once
if (!document.getElementById('counter-fx-css')) {
const s = document.createElement('style')
s.id = 'counter-fx-css'
s.textContent = `
.ctr-fx {
position: absolute;
width: var(--sz);
height: var(--sz);
border-radius: 50%;
pointer-events: none;
animation-delay: var(--del);
animation-duration: var(--dur);
animation-fill-mode: forwards;
animation-timing-function: cubic-bezier(.25,.46,.45,.94);
}
.ctr-fx-sparkle {
background: radial-gradient(circle, #fde047, #f59e0b);
box-shadow: 0 0 8px #fbbf24;
border-radius: 2px;
transform: rotate(45deg);
animation-name: ctr-sparkle;
}
@keyframes ctr-sparkle {
0% { opacity:1; transform: translate(0,0) rotate(45deg) scale(1); }
100% { opacity:0; transform: translate(var(--dx),var(--dy)) rotate(200deg) scale(0); }
}
.ctr-fx-ember {
background: radial-gradient(circle, #fca5a5, #dc2626);
box-shadow: 0 0 10px #ef4444;
animation-name: ctr-ember;
}
@keyframes ctr-ember {
0% { opacity:1; transform: translate(0,0) scale(1); }
100% { opacity:0; transform: translate(var(--dx), calc(var(--dy) + 80px)) scale(0.2); }
}
.ctr-fx-bubble {
background: radial-gradient(circle at 30% 30%, rgba(16,185,129,0.7), rgba(5,150,105,0.2));
border: 1.5px solid rgba(16,185,129,0.5);
animation-name: ctr-bubble;
}
@keyframes ctr-bubble {
0% { opacity:0.9; transform: translate(0,0) scale(0.5); }
50% { opacity:0.7; transform: translate(calc(var(--dx)*0.5), calc(var(--dy)*0.5)) scale(1.3); }
100% { opacity:0; transform: translate(var(--dx), calc(var(--dy) - 40px)) scale(1.6); }
}
.ctr-fx-drip {
background: radial-gradient(circle, #c084fc, #7c3aed);
border-radius: 50% 50% 50% 0;
animation-name: ctr-drip;
}
@keyframes ctr-drip {
0% { opacity:1; transform: translate(0,0) rotate(45deg) scale(1); }
100% { opacity:0; transform: translate(var(--dx), var(--dy)) rotate(45deg) scaleY(1.8) scale(0.3); }
}
.ctr-fx-star {
background: white;
box-shadow: 0 0 10px #22d3ee, 0 0 20px rgba(34,211,238,0.4);
clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
border-radius: 0;
animation-name: ctr-star;
}
@keyframes ctr-star {
0% { opacity:1; transform: translate(0,0) scale(0.3) rotate(0); }
50% { opacity:1; transform: translate(calc(var(--dx)*0.6),calc(var(--dy)*0.6)) scale(1.3) rotate(90deg); }
100% { opacity:0; transform: translate(var(--dx),var(--dy)) scale(0) rotate(180deg); }
}
.ctr-fx-crack {
background: linear-gradient(135deg, #f97316, #dc2626);
clip-path: polygon(20% 0%,80% 0%,100% 60%,60% 100%,0% 80%);
border-radius: 0;
animation-name: ctr-crack;
}
@keyframes ctr-crack {
0% { opacity:1; transform: translate(0,0) scale(1) rotate(0); }
100% { opacity:0; transform: translate(var(--dx),var(--dy)) scale(0.2) rotate(270deg); }
}
.ctr-fx-ring {
background: transparent;
border: 2.5px solid #34d399;
box-shadow: 0 0 8px rgba(52,211,153,0.6);
animation-name: ctr-ring;
}
@keyframes ctr-ring {
0% { opacity:1; transform: translate(0,0) scale(0.3); }
100% { opacity:0; transform: translate(var(--dx),var(--dy)) scale(2.5); }
}
.ctr-fx-smoke {
background: radial-gradient(circle, rgba(120,113,108,0.6), rgba(68,64,60,0.1));
filter: blur(3px);
animation-name: ctr-smoke;
}
@keyframes ctr-smoke {
0% { opacity:0.8; transform: translate(0,0) scale(0.8); }
100% { opacity:0; transform: translate(var(--dx), calc(var(--dy) - 50px)) scale(3); }
}
`
document.head.appendChild(s)
}
function spawnParticles(type) {
const layer = fxLayer.value
const numEl = countEl.value
if (!layer || !numEl) return
const layerRect = layer.getBoundingClientRect()
const numRect = numEl.getBoundingClientRect()
const cx = numRect.left + numRect.width / 2 - layerRect.left
const cy = numRect.top + numRect.height / 2 - layerRect.top
const n = 14 + Math.floor(Math.random() * 8)
for (let i = 0; i < n; i++) {
const el = document.createElement('div')
el.className = 'ctr-fx ctr-fx-' + type
const angle = (Math.PI * 2 * i) / n + (Math.random() - 0.5) * 0.7
const dist = 60 + Math.random() * 120
const dx = Math.cos(angle) * dist
const rawDy = Math.sin(angle) * dist
const dy = type === 'drip' ? Math.abs(rawDy) + 50 : rawDy
const size = 6 + Math.random() * 16
el.style.cssText = [
'left:' + cx + 'px',
'top:' + cy + 'px',
'--dx:' + dx + 'px',
'--dy:' + dy + 'px',
'--sz:' + size + 'px',
'--del:' + (i * 20) + 'ms',
'--dur:' + (500 + Math.random() * 500) + 'ms'
].join(';')
layer.appendChild(el)
setTimeout(() => el.remove(), 1300)
}
}
function triggerFx(isPositive) {
const pair = fxStyles[styleIdx % fxStyles.length]
spawnParticles(isPositive ? pair.pos : pair.neg)
styleIdx++
pulseClass.value = isPositive ? 'pulse-up' : 'pulse-down'
setTimeout(() => { pulseClass.value = '' }, 350)
}
function increment() {
count.value++
triggerFx(true)
}
function decrement() {
count.value--
triggerFx(false)
}
return { count, title, pulseClass, countEl, counterEl, fxLayer, increment, decrement }
</script>
<style>
.counter {
position: relative;
padding: 1.5rem;
text-align: center;
font-family: system-ui, sans-serif;
background: transparent;
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.fx-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: visible;
}
.title {
position: relative;
z-index: 1;
font-size: 1rem;
opacity: 0.7;
margin: 0 0 0.25rem;
font-weight: 400;
}
.count-display {
position: relative;
z-index: 1;
font-size: 3.5rem;
font-weight: 800;
margin: 0.25rem 0;
transition: transform 0.2s cubic-bezier(.34,1.56,.64,1), color 0.3s;
}
.pulse-up { transform: scale(1.15); color: #10b981; }
.pulse-down { transform: scale(0.88); color: #ef4444; }
.buttons {
position: relative;
z-index: 1;
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
}
.btn {
padding: 0.6rem 1.4rem;
font-size: 1.1rem;
font-weight: 600;
border: none;
border-radius: 10px;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.btn:active { transform: scale(0.9); }
.btn:hover { transform: translateY(-2px); box-shadow: 0 4px 14px rgba(0,0,0,0.25); }
.btn-plus { background: #10b981; color: white; }
.btn-plus:hover { background: #059669; }
.btn-minus { background: #ef4444; color: white; }
.btn-minus:hover { background: #dc2626; }
</style>