diff --git a/src/__tests__/card-themes.test.ts b/src/__tests__/card-themes.test.ts new file mode 100644 index 0000000..5f9e203 --- /dev/null +++ b/src/__tests__/card-themes.test.ts @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + CARD_THEMES, + CLASSIC_CARD_THEMES, + DESIGNER_CARD_THEMES, + getCardThemeDefinition, + getThemeRecommendedUpdates, + isDesignerCardTheme, +} from '@/lib/card-themes' + +const css = readFileSync(new URL('../index.css', import.meta.url), 'utf8') + +describe('card themes', () => { + it('keeps the existing classic themes and adds eight designer themes', () => { + expect(CLASSIC_CARD_THEMES.map((theme) => theme.id)).toEqual([ + 'teacher-pro', + 'minimal', + 'classroom-cute', + 'bold-vocabulary', + 'picture-focus', + 'quiz-card', + 'playful-pop', + 'calm-study', + 'ink-saver', + ]) + + expect(DESIGNER_CARD_THEMES.map((theme) => theme.id)).toEqual([ + 'dreamy-classroom', + 'storybook', + 'notebook-doodle', + 'retro-schoolhouse', + 'botanical-study', + 'candy-pop', + 'space-explorer', + 'modern-editorial', + ]) + }) + + it('has unique ids and valid starting palettes', () => { + expect(new Set(CARD_THEMES.map((theme) => theme.id)).size).toBe(CARD_THEMES.length) + + for (const theme of CARD_THEMES) { + expect(theme.name.length).toBeGreaterThan(0) + expect(theme.description.length).toBeGreaterThan(10) + expect(theme.mainColor).toMatch(/^#[0-9a-f]{6}$/i) + expect(theme.accentColor).toMatch(/^#[0-9a-f]{6}$/i) + expect(getCardThemeDefinition(theme.id)).toEqual(theme) + } + }) + + it('marks only the designer collection as designer themes', () => { + for (const theme of DESIGNER_CARD_THEMES) { + expect(isDesignerCardTheme(theme.id)).toBe(true) + } + for (const theme of CLASSIC_CARD_THEMES) { + expect(isDesignerCardTheme(theme.id)).toBe(false) + } + }) + + it('theme application only changes theme and its starting palette', () => { + const updates = getThemeRecommendedUpdates('dreamy-classroom') + expect(Object.keys(updates).sort()).toEqual(['accentColor', 'mainColor', 'theme']) + expect(updates).toEqual({ + theme: 'dreamy-classroom', + mainColor: '#342a4f', + accentColor: '#8b6bbd', + }) + }) + + it('ships print-safe CSS surfaces for every designer theme', () => { + for (const theme of DESIGNER_CARD_THEMES) { + expect(css).toContain(`[data-card-theme='${theme.id}']`) + } + }) +}) diff --git a/src/components/CardThemeDecorations.tsx b/src/components/CardThemeDecorations.tsx new file mode 100644 index 0000000..f8c3851 --- /dev/null +++ b/src/components/CardThemeDecorations.tsx @@ -0,0 +1,175 @@ +import type { CardTheme } from '@/lib/types' +import { isDesignerCardTheme } from '@/lib/card-themes' + +interface CardThemeDecorationsProps { + theme: CardTheme +} + +const commonSvgProps = { + viewBox: '0 0 100 100', + preserveAspectRatio: 'none' as const, + 'aria-hidden': true, + focusable: false, +} + +export function CardThemeDecorations({ theme }: CardThemeDecorationsProps) { + if (!isDesignerCardTheme(theme)) return null + + return ( + + ) +} + +function DreamyClassroom() { + return ( + + + + + + + + + + + + + + + + + ) +} + +function Storybook() { + return ( + + + + + + + + + + + + + + + ) +} + +function NotebookDoodle() { + return ( + + + + + + + + + + + + + ) +} + +function RetroSchoolhouse() { + return ( + + + + + + + + + + + ) +} + +function BotanicalStudy() { + return ( + + + + + + + + + + + + + ) +} + +function CandyPop() { + return ( + + + + + + + + + + + + + + ) +} + +function SpaceExplorer() { + return ( + + + + + + + + + + + + + + + + + + + + ) +} + +function ModernEditorial() { + return ( + + + + + + + + + + ) +} diff --git a/src/components/DesignPanel.tsx b/src/components/DesignPanel.tsx index 5831e69..8440bee 100644 --- a/src/components/DesignPanel.tsx +++ b/src/components/DesignPanel.tsx @@ -1,12 +1,15 @@ +import type { CSSProperties } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Slider } from '@/components/ui/slider' import { Switch } from '@/components/ui/switch' import { Input } from '@/components/ui/input' -import type { PrintSettings, DuplexMode, CardType, CardsPerPage, Orientation, PaperSize } from '@/lib/types' +import type { PrintSettings, CardTheme, DuplexMode, CardType, CardsPerPage, Orientation, PaperSize } from '@/lib/types' import { DESIGN_PRESETS, getActiveDesignPreset } from '@/lib/design-presets' import type { DesignPreset } from '@/lib/design-presets' +import { CLASSIC_CARD_THEMES, DESIGNER_CARD_THEMES, getCardThemeDefinition, getThemeRecommendedUpdates } from '@/lib/card-themes' +import { CardThemeDecorations } from '@/components/CardThemeDecorations' import { Palette, TextAlignCenter, TextAlignLeft, TextAlignRight, ArrowsCounterClockwise, Cards, Check } from '@phosphor-icons/react' import { toast } from 'sonner' @@ -46,6 +49,37 @@ const PREVIEW_BACKGROUNDS: Record = { 'ink-saver': '#ffffff', } +function ThemePreview({ theme }: { theme: CardTheme }) { + const item = getCardThemeDefinition(theme) + + return ( +