Generated by Spark: Theme editor allows custom colours (not part of standard spec) and multiple themes like dark and light mode.

This commit is contained in:
2026-01-16 00:53:06 +00:00
committed by GitHub
parent 77bacf0ad4
commit d92cf3de30
5 changed files with 518 additions and 137 deletions

8
PRD.md
View File

@@ -41,11 +41,11 @@ This is a full-featured low-code IDE with multiple integrated tools (code editor
- **Success criteria**: Can add/remove/reorder components; AI-generated components are well-structured; props are editable; generates valid React code
### Style Designer
- **Functionality**: Visual interface for Material UI theming and component styling with AI theme generation from descriptions
- **Purpose**: Configure colors, typography, spacing without manual theme object creation, with AI design assistance
- **Functionality**: Visual interface for Material UI theming with support for multiple theme variants (light/dark/custom), custom color management, and AI theme generation from descriptions
- **Purpose**: Configure colors, typography, spacing with support for unlimited theme variants and custom color palettes beyond standard specifications, with AI design assistance
- **Trigger**: Opening the Styling tab
- **Progression**: Select theme property → Adjust values with controls or describe style to AI → Preview updates live → Export theme configuration
- **Success criteria**: Color pickers work; typography scales properly; AI themes match descriptions and have good contrast; generates valid MUI theme code
- **Progression**: Select or create theme variant → Adjust standard colors or add custom colors → Configure typography and spacing → Preview updates live across all variants → Switch between variants → Export theme configuration with all variants
- **Success criteria**: Color pickers work; custom colors can be added/removed; multiple theme variants persist; AI themes match descriptions and have good contrast; generates valid MUI theme code for all variants including light and dark modes
### Project Generator
- **Functionality**: Exports complete Next.js project with all configurations

View File

@@ -25,11 +25,43 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
const DEFAULT_THEME: ThemeConfig = {
primaryColor: '#1976d2',
secondaryColor: '#dc004e',
errorColor: '#f44336',
warningColor: '#ff9800',
successColor: '#4caf50',
variants: [
{
id: 'light',
name: 'Light',
colors: {
primaryColor: '#1976d2',
secondaryColor: '#dc004e',
errorColor: '#f44336',
warningColor: '#ff9800',
successColor: '#4caf50',
background: '#ffffff',
surface: '#f5f5f5',
text: '#000000',
textSecondary: '#666666',
border: '#e0e0e0',
customColors: {},
},
},
{
id: 'dark',
name: 'Dark',
colors: {
primaryColor: '#90caf9',
secondaryColor: '#f48fb1',
errorColor: '#f44336',
warningColor: '#ffa726',
successColor: '#66bb6a',
background: '#121212',
surface: '#1e1e1e',
text: '#ffffff',
textSecondary: '#b0b0b0',
border: '#333333',
customColors: {},
},
},
],
activeVariantId: 'light',
fontFamily: 'Roboto, Arial, sans-serif',
fontSize: { small: 12, medium: 14, large: 20 },
spacing: 8,

View File

@@ -1,21 +1,136 @@
import { ThemeConfig } from '@/types/project'
import { useState } from 'react'
import { ThemeConfig, ThemeVariant } from '@/types/project'
import { Card } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Slider } from '@/components/ui/slider'
import { Button } from '@/components/ui/button'
import { PaintBrush, Sparkle } from '@phosphor-icons/react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PaintBrush, Sparkle, Plus, Trash, Moon, Sun, Palette } from '@phosphor-icons/react'
import { AIService } from '@/lib/ai-service'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
interface StyleDesignerProps {
theme: ThemeConfig
onThemeChange: (theme: ThemeConfig) => void
onThemeChange: (theme: ThemeConfig | ((current: ThemeConfig) => ThemeConfig)) => void
}
export function StyleDesigner({ theme, onThemeChange }: StyleDesignerProps) {
const [newColorName, setNewColorName] = useState('')
const [newColorValue, setNewColorValue] = useState('#000000')
const [customColorDialogOpen, setCustomColorDialogOpen] = useState(false)
const [newVariantDialogOpen, setNewVariantDialogOpen] = useState(false)
const [newVariantName, setNewVariantName] = useState('')
const activeVariant = theme.variants.find((v) => v.id === theme.activeVariantId) || theme.variants[0]
const updateTheme = (updates: Partial<ThemeConfig>) => {
onThemeChange({ ...theme, ...updates })
onThemeChange((current) => ({ ...current, ...updates }))
}
const updateActiveVariantColors = (colorUpdates: Partial<typeof activeVariant.colors>) => {
onThemeChange((current) => ({
...current,
variants: current.variants.map((v) =>
v.id === current.activeVariantId
? { ...v, colors: { ...v.colors, ...colorUpdates } }
: v
),
}))
}
const addCustomColor = () => {
if (!newColorName.trim()) {
toast.error('Please enter a color name')
return
}
updateActiveVariantColors({
customColors: {
...activeVariant.colors.customColors,
[newColorName]: newColorValue,
},
})
setNewColorName('')
setNewColorValue('#000000')
setCustomColorDialogOpen(false)
toast.success(`Added custom color: ${newColorName}`)
}
const removeCustomColor = (colorName: string) => {
const { [colorName]: _, ...remainingColors } = activeVariant.colors.customColors
updateActiveVariantColors({
customColors: remainingColors,
})
toast.success(`Removed custom color: ${colorName}`)
}
const addVariant = () => {
if (!newVariantName.trim()) {
toast.error('Please enter a variant name')
return
}
const newVariant: ThemeVariant = {
id: `variant-${Date.now()}`,
name: newVariantName,
colors: { ...activeVariant.colors, customColors: {} },
}
onThemeChange((current) => ({
...current,
variants: [...current.variants, newVariant],
activeVariantId: newVariant.id,
}))
setNewVariantName('')
setNewVariantDialogOpen(false)
toast.success(`Added theme variant: ${newVariantName}`)
}
const deleteVariant = (variantId: string) => {
if (theme.variants.length <= 1) {
toast.error('Cannot delete the last theme variant')
return
}
onThemeChange((current) => {
const remainingVariants = current.variants.filter((v) => v.id !== variantId)
return {
...current,
variants: remainingVariants,
activeVariantId: current.activeVariantId === variantId ? remainingVariants[0].id : current.activeVariantId,
}
})
toast.success('Theme variant deleted')
}
const duplicateVariant = (variantId: string) => {
const variantToDuplicate = theme.variants.find((v) => v.id === variantId)
if (!variantToDuplicate) return
const newVariant: ThemeVariant = {
id: `variant-${Date.now()}`,
name: `${variantToDuplicate.name} Copy`,
colors: { ...variantToDuplicate.colors, customColors: { ...variantToDuplicate.colors.customColors } },
}
onThemeChange((current) => ({
...current,
variants: [...current.variants, newVariant],
}))
toast.success('Theme variant duplicated')
}
const generateThemeWithAI = async () => {
@@ -25,9 +140,9 @@ export function StyleDesigner({ theme, onThemeChange }: StyleDesignerProps) {
try {
toast.info('Generating theme with AI...')
const generatedTheme = await AIService.generateThemeFromDescription(description)
if (generatedTheme) {
onThemeChange({ ...theme, ...generatedTheme })
onThemeChange((current) => ({ ...current, ...generatedTheme }))
toast.success('Theme generated successfully!')
} else {
toast.error('AI generation failed. Please try again.')
@@ -38,14 +153,39 @@ export function StyleDesigner({ theme, onThemeChange }: StyleDesignerProps) {
}
}
const renderColorInput = (label: string, colorKey: keyof typeof activeVariant.colors, excludeCustom = true) => {
if (excludeCustom && colorKey === 'customColors') return null
const value = activeVariant.colors[colorKey] as string
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="flex gap-2">
<Input
type="color"
value={value}
onChange={(e) => updateActiveVariantColors({ [colorKey]: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={value}
onChange={(e) => updateActiveVariantColors({ [colorKey]: e.target.value })}
className="flex-1 font-mono"
/>
</div>
</div>
)
}
return (
<div className="h-full overflow-auto p-6">
<div className="max-w-4xl mx-auto space-y-6">
<div className="max-w-5xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold mb-2">Material UI Theme Designer</h2>
<h2 className="text-2xl font-bold mb-2">Theme Designer</h2>
<p className="text-muted-foreground">
Customize your application's visual theme
Create and customize multiple theme variants with custom colors
</p>
</div>
<Button onClick={generateThemeWithAI} variant="outline">
@@ -55,100 +195,146 @@ export function StyleDesigner({ theme, onThemeChange }: StyleDesignerProps) {
</div>
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<PaintBrush size={20} weight="duotone" />
Color Palette
</h3>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<Label>Primary Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.primaryColor}
onChange={(e) => updateTheme({ primaryColor: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.primaryColor}
onChange={(e) => updateTheme({ primaryColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Secondary Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.secondaryColor}
onChange={(e) =>
updateTheme({ secondaryColor: e.target.value })
}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.secondaryColor}
onChange={(e) =>
updateTheme({ secondaryColor: e.target.value })
}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Error Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.errorColor}
onChange={(e) => updateTheme({ errorColor: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.errorColor}
onChange={(e) => updateTheme({ errorColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Warning Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.warningColor}
onChange={(e) => updateTheme({ warningColor: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.warningColor}
onChange={(e) => updateTheme({ warningColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="space-y-2">
<Label>Success Color</Label>
<div className="flex gap-2">
<Input
type="color"
value={theme.successColor}
onChange={(e) => updateTheme({ successColor: e.target.value })}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={theme.successColor}
onChange={(e) => updateTheme({ successColor: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Palette size={20} weight="duotone" />
Theme Variants
</h3>
<Button onClick={() => setNewVariantDialogOpen(true)} size="sm">
<Plus size={16} className="mr-2" />
Add Variant
</Button>
</div>
<div className="flex flex-wrap gap-2 mb-6">
{theme.variants.map((variant) => (
<div key={variant.id} className="flex items-center gap-2 group">
<Button
variant={theme.activeVariantId === variant.id ? 'default' : 'outline'}
onClick={() => updateTheme({ activeVariantId: variant.id })}
className="gap-2"
>
{variant.name === 'Light' && <Sun size={16} weight="duotone" />}
{variant.name === 'Dark' && <Moon size={16} weight="duotone" />}
{variant.name}
</Button>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => duplicateVariant(variant.id)}
title="Duplicate"
>
<Plus size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteVariant(variant.id)}
disabled={theme.variants.length <= 1}
title="Delete"
>
<Trash size={14} />
</Button>
</div>
</div>
))}
</div>
<Tabs defaultValue="standard" className="w-full">
<TabsList>
<TabsTrigger value="standard">Standard Colors</TabsTrigger>
<TabsTrigger value="extended">Extended Colors</TabsTrigger>
<TabsTrigger value="custom">Custom Colors ({Object.keys(activeVariant.colors.customColors).length})</TabsTrigger>
</TabsList>
<TabsContent value="standard" className="space-y-6 mt-6">
<div className="grid grid-cols-2 gap-6">
{renderColorInput('Primary Color', 'primaryColor')}
{renderColorInput('Secondary Color', 'secondaryColor')}
{renderColorInput('Error Color', 'errorColor')}
{renderColorInput('Warning Color', 'warningColor')}
{renderColorInput('Success Color', 'successColor')}
</div>
</TabsContent>
<TabsContent value="extended" className="space-y-6 mt-6">
<div className="grid grid-cols-2 gap-6">
{renderColorInput('Background', 'background')}
{renderColorInput('Surface', 'surface')}
{renderColorInput('Text', 'text')}
{renderColorInput('Text Secondary', 'textSecondary')}
{renderColorInput('Border', 'border')}
</div>
</TabsContent>
<TabsContent value="custom" className="space-y-6 mt-6">
<div className="flex justify-between items-center mb-4">
<p className="text-sm text-muted-foreground">
Add custom colors for your specific needs
</p>
<Button onClick={() => setCustomColorDialogOpen(true)} size="sm">
<Plus size={16} className="mr-2" />
Add Custom Color
</Button>
</div>
{Object.keys(activeVariant.colors.customColors).length === 0 ? (
<div className="text-center py-12 border-2 border-dashed border-border rounded-lg">
<Palette size={48} className="mx-auto mb-4 text-muted-foreground" weight="duotone" />
<p className="text-muted-foreground mb-4">No custom colors yet</p>
<Button onClick={() => setCustomColorDialogOpen(true)} variant="outline">
<Plus size={16} className="mr-2" />
Add Your First Custom Color
</Button>
</div>
) : (
<div className="grid grid-cols-2 gap-6">
{Object.entries(activeVariant.colors.customColors).map(([name, value]) => (
<div key={name} className="space-y-2">
<div className="flex items-center justify-between">
<Label className="capitalize">{name}</Label>
<Button
size="sm"
variant="ghost"
onClick={() => removeCustomColor(name)}
>
<Trash size={14} />
</Button>
</div>
<div className="flex gap-2">
<Input
type="color"
value={value}
onChange={(e) =>
updateActiveVariantColors({
customColors: {
...activeVariant.colors.customColors,
[name]: e.target.value,
},
})
}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={value}
onChange={(e) =>
updateActiveVariantColors({
customColors: {
...activeVariant.colors.customColors,
[name]: e.target.value,
},
})
}
className="flex-1 font-mono"
/>
</div>
</div>
))}
</div>
)}
</TabsContent>
</Tabs>
</Card>
<Card className="p-6">
@@ -269,76 +455,163 @@ export function StyleDesigner({ theme, onThemeChange }: StyleDesignerProps) {
</div>
</Card>
<Card className="p-6 bg-gradient-to-br from-card to-muted">
<h3 className="text-lg font-semibold mb-4">Preview</h3>
<Card className="p-6" style={{ backgroundColor: activeVariant.colors.background }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: activeVariant.colors.text }}>
Preview - {activeVariant.name} Mode
</h3>
<div className="space-y-4">
<div className="flex gap-2 flex-wrap">
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
style={{
backgroundColor: theme.primaryColor,
backgroundColor: activeVariant.colors.primaryColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Primary
</div>
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
style={{
backgroundColor: theme.secondaryColor,
backgroundColor: activeVariant.colors.secondaryColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Secondary
</div>
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
style={{
backgroundColor: theme.errorColor,
backgroundColor: activeVariant.colors.errorColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Error
</div>
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
style={{
backgroundColor: theme.warningColor,
backgroundColor: activeVariant.colors.warningColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Warning
</div>
<div
className="w-20 h-20 rounded flex items-center justify-center text-white font-semibold"
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm"
style={{
backgroundColor: theme.successColor,
backgroundColor: activeVariant.colors.successColor,
borderRadius: `${theme.borderRadius}px`,
}}
>
Success
</div>
{Object.entries(activeVariant.colors.customColors).map(([name, color]) => (
<div
key={name}
className="w-24 h-24 rounded flex flex-col items-center justify-center text-white font-semibold text-sm capitalize"
style={{
backgroundColor: color,
borderRadius: `${theme.borderRadius}px`,
}}
>
{name}
</div>
))}
</div>
<div
className="p-4 border"
className="p-6"
style={{
fontFamily: theme.fontFamily,
borderRadius: `${theme.borderRadius}px`,
backgroundColor: activeVariant.colors.surface,
color: activeVariant.colors.text,
border: `1px solid ${activeVariant.colors.border}`,
}}
>
<p style={{ fontSize: `${theme.fontSize.large}px` }}>
<p style={{ fontSize: `${theme.fontSize.large}px`, marginBottom: '8px' }}>
Large Text Sample
</p>
<p style={{ fontSize: `${theme.fontSize.medium}px` }}>
<p style={{ fontSize: `${theme.fontSize.medium}px`, marginBottom: '8px' }}>
Medium Text Sample
</p>
<p style={{ fontSize: `${theme.fontSize.small}px` }}>
Small Text Sample
<p style={{ fontSize: `${theme.fontSize.small}px`, color: activeVariant.colors.textSecondary }}>
Small Text Sample (Secondary)
</p>
</div>
</div>
</Card>
</div>
<Dialog open={customColorDialogOpen} onOpenChange={setCustomColorDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Custom Color</DialogTitle>
<DialogDescription>
Create a custom color for your theme variant
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Color Name</Label>
<Input
value={newColorName}
onChange={(e) => setNewColorName(e.target.value)}
placeholder="e.g., accent, highlight, brand"
/>
</div>
<div className="space-y-2">
<Label>Color Value</Label>
<div className="flex gap-2">
<Input
type="color"
value={newColorValue}
onChange={(e) => setNewColorValue(e.target.value)}
className="w-20 h-10 cursor-pointer"
/>
<Input
value={newColorValue}
onChange={(e) => setNewColorValue(e.target.value)}
className="flex-1 font-mono"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCustomColorDialogOpen(false)}>
Cancel
</Button>
<Button onClick={addCustomColor}>Add Color</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={newVariantDialogOpen} onOpenChange={setNewVariantDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Theme Variant</DialogTitle>
<DialogDescription>
Create a new theme variant based on the current one
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Variant Name</Label>
<Input
value={newVariantName}
onChange={(e) => setNewVariantName(e.target.value)}
placeholder="e.g., High Contrast, Colorblind Friendly"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewVariantDialogOpen(false)}>
Cancel
</Button>
<Button onClick={addVariant}>Add Variant</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -46,24 +46,36 @@ export function generateComponentCode(node: ComponentNode, indent: number = 0):
}
export function generateMUITheme(theme: ThemeConfig): string {
return `import { createTheme } from '@mui/material/styles';
const lightVariant = theme.variants.find((v) => v.id === 'light') || theme.variants[0]
const darkVariant = theme.variants.find((v) => v.id === 'dark')
export const theme = createTheme({
let themeCode = `import { createTheme } from '@mui/material/styles';
export const lightTheme = createTheme({
palette: {
mode: 'light',
primary: {
main: '${theme.primaryColor}',
main: '${lightVariant.colors.primaryColor}',
},
secondary: {
main: '${theme.secondaryColor}',
main: '${lightVariant.colors.secondaryColor}',
},
error: {
main: '${theme.errorColor}',
main: '${lightVariant.colors.errorColor}',
},
warning: {
main: '${theme.warningColor}',
main: '${lightVariant.colors.warningColor}',
},
success: {
main: '${theme.successColor}',
main: '${lightVariant.colors.successColor}',
},
background: {
default: '${lightVariant.colors.background}',
paper: '${lightVariant.colors.surface}',
},
text: {
primary: '${lightVariant.colors.text}',
secondary: '${lightVariant.colors.textSecondary}',
},
},
typography: {
@@ -74,7 +86,54 @@ export const theme = createTheme({
shape: {
borderRadius: ${theme.borderRadius},
},
});`
});
`
if (darkVariant) {
themeCode += `
export const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '${darkVariant.colors.primaryColor}',
},
secondary: {
main: '${darkVariant.colors.secondaryColor}',
},
error: {
main: '${darkVariant.colors.errorColor}',
},
warning: {
main: '${darkVariant.colors.warningColor}',
},
success: {
main: '${darkVariant.colors.successColor}',
},
background: {
default: '${darkVariant.colors.background}',
paper: '${darkVariant.colors.surface}',
},
text: {
primary: '${darkVariant.colors.text}',
secondary: '${darkVariant.colors.textSecondary}',
},
},
typography: {
fontFamily: '${theme.fontFamily}',
fontSize: ${theme.fontSize.medium},
},
spacing: ${theme.spacing},
shape: {
borderRadius: ${theme.borderRadius},
},
});
export const theme = lightTheme;`
} else {
themeCode += `\nexport const theme = lightTheme;`
}
return themeCode
}
export function generateNextJSProject(

View File

@@ -31,12 +31,29 @@ export interface ComponentNode {
name: string
}
export interface ThemeConfig {
export interface ColorPalette {
primaryColor: string
secondaryColor: string
errorColor: string
warningColor: string
successColor: string
background: string
surface: string
text: string
textSecondary: string
border: string
customColors: Record<string, string>
}
export interface ThemeVariant {
id: string
name: string
colors: ColorPalette
}
export interface ThemeConfig {
variants: ThemeVariant[]
activeVariantId: string
fontFamily: string
fontSize: {
small: number