diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 2aeb04c..c5ae155 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -2,15 +2,15 @@ import { test, expect } from '@playwright/test'; test('create lesson, add card, practice once', async ({ page }) => { await page.goto('/admin'); - await page.getByPlaceholder('Nieuwe wortel-les...').fill('E2E les'); - await page.getByRole('button', { name: 'Toevoegen' }).click(); - await page.getByRole('link', { name: /E2E les/ }).click(); + await page.getByPlaceholder(/Nieuwe wortel-les/).fill('E2E les'); + await page.getByRole('button', { name: /Toevoegen/ }).click(); + await page.getByRole('link', { name: /E2E les/ }).first().click(); await page.getByPlaceholder('Nieuwe vraag').fill('q1'); await page.getByPlaceholder('Antwoord').fill('a1'); - await page.getByRole('button', { name: '+' }).click(); + await page.getByRole('button', { name: 'Kaart toevoegen' }).click(); await page.getByRole('link', { name: /Start oefenen/ }).click(); - await page.getByRole('button', { name: 'Start' }).click(); + await page.getByRole('button', { name: /Start sessie/ }).click(); await page.getByRole('button', { name: 'Toon antwoord' }).click(); - await page.getByRole('button', { name: 'Goed' }).click(); - await expect(page.getByText(/Sessie klaar!/)).toBeVisible(); + await page.getByRole('button', { name: /Goed/ }).click(); + await expect(page.getByText(/Sessie klaar/)).toBeVisible({ timeout: 8000 }); }); diff --git a/packages/frontend/index.html b/packages/frontend/index.html index 44ae4e3..7875aa4 100644 --- a/packages/frontend/index.html +++ b/packages/frontend/index.html @@ -4,8 +4,14 @@ Flashcards + + + - +
diff --git a/packages/frontend/playwright.config.ts b/packages/frontend/playwright.config.ts index 888bf7a..7ff7e22 100644 --- a/packages/frontend/playwright.config.ts +++ b/packages/frontend/playwright.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ webServer: [ { command: - 'rm -f data/e2e.db && DB_PATH=./data/e2e.db npm -w @flashcard/backend run db:migrate && DB_PATH=./data/e2e.db npm -w @flashcard/backend run dev', + 'rm -f packages/backend/data/e2e.db data/e2e.db && DB_PATH=./data/e2e.db npm -w @flashcard/backend run db:migrate && DB_PATH=./data/e2e.db npm -w @flashcard/backend run dev', cwd: '../..', port: 3000, reuseExistingServer: false, diff --git a/packages/frontend/src/components/CardTable.tsx b/packages/frontend/src/components/CardTable.tsx index 432fa52..4b3a373 100644 --- a/packages/frontend/src/components/CardTable.tsx +++ b/packages/frontend/src/components/CardTable.tsx @@ -22,26 +22,102 @@ export function CardTable({ lessonId, cards, onChange }: { lessonId: number; car } return ( - - - - - - {cards.map((c) => ( - - - - - +
+
VraagAntwoordHint
update(c, 'question', e.target.value)} /> update(c, 'answer', e.target.value)} /> update(c, 'hint', e.target.value)} />
+ + + + + + - ))} - - - - - - - -
VraagAntwoordHint
setDraft({ ...draft, question: e.target.value })} /> setDraft({ ...draft, answer: e.target.value })} /> setDraft({ ...draft, hint: e.target.value })} />
+ + + {cards.length === 0 && ( + + + Nog geen kaarten — voeg er hieronder een toe. + + + )} + {cards.map((c) => ( + + + update(c, 'question', e.target.value)} + /> + + + update(c, 'answer', e.target.value)} + /> + + + update(c, 'hint', e.target.value)} + /> + + + + + + ))} + + + setDraft({ ...draft, question: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + + + setDraft({ ...draft, answer: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + + + setDraft({ ...draft, hint: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && add()} + /> + + + + + + + + ); } diff --git a/packages/frontend/src/components/Flashcard.tsx b/packages/frontend/src/components/Flashcard.tsx index 370760b..760186e 100644 --- a/packages/frontend/src/components/Flashcard.tsx +++ b/packages/frontend/src/components/Flashcard.tsx @@ -1,4 +1,5 @@ import { AnimatePresence, motion } from 'framer-motion'; +import { useState } from 'react'; export function Flashcard({ question, answer, hint, showAnswer, onReveal, onAnswer, @@ -8,41 +9,114 @@ export function Flashcard({ onReveal: () => void; onAnswer: (r: 'correct' | 'incorrect') => void; }) { + const [flash, setFlash] = useState<'correct' | 'incorrect' | null>(null); + + function handleAnswer(r: 'correct' | 'incorrect') { + setFlash(r); + setTimeout(() => { + setFlash(null); + onAnswer(r); + }, 280); + } + return ( -
- - -
- {showAnswer ? answer : question} +
+ + + + + + + + +
+ handleAnswer('incorrect')} + aria-label="Fout antwoord" + > + + Fout + + handleAnswer('correct')} + aria-label="Goed antwoord" + > + + Goed +
- {!showAnswer && hint && ( -
💡 {hint}
- )} -
- {!showAnswer ? ( - - ) : ( - <> - onAnswer('incorrect')}> - Fout - - onAnswer('correct')}> - Goed - - - )} -
-
+ + + + + {flash && ( + +
+ {flash === 'correct' ? '✓' : '✕'} +
+
+ )}
); } + +function CardFace({ side, children }: { side: 'front' | 'back'; children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Content({ text, hint, side }: { text: string; hint: string | null; side: 'question' | 'answer' }) { + return ( +
+ + {side === 'question' ? 'Vraag' : 'Antwoord'} + +
+ {text} +
+ {hint && ( +
+ 💡 + {hint} +
+ )} +
+ ); +} diff --git a/packages/frontend/src/components/ImportDialog.tsx b/packages/frontend/src/components/ImportDialog.tsx index cc8242e..40b924b 100644 --- a/packages/frontend/src/components/ImportDialog.tsx +++ b/packages/frontend/src/components/ImportDialog.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { motion } from 'framer-motion'; import { cardsApi, type ImportResult } from '../api/cards.js'; export function ImportDialog({ lessonId, onClose, onDone }: { lessonId: number; onClose: () => void; onDone: () => void }) { @@ -19,31 +20,88 @@ export function ImportDialog({ lessonId, onClose, onDone }: { lessonId: number; } return ( -
-
-

Excel import

-

Kolommen: question, answer, hint (optioneel), lesson_path (optioneel, bv. "Spaans/Begroetingen").

- setFile(e.target.files?.[0] ?? null)} /> - - +
+ +

Excel import

+

+ Verwachte kolommen: question,{' '} + answer,{' '} + hint,{' '} + lesson_path. +

+ + + +
+ + +
+ {result && ( -
-
Toegevoegd: {result.inserted}
-
Bijgewerkt: {result.updated}
-
Overgeslagen: {result.skipped}
+
+
+
Toegevoegd
+
{result.inserted}
+
+
+
Bijgewerkt
+
{result.updated}
+
+
+
Overgeslagen
+
{result.skipped}
+
{result.errors.length > 0 && ( -
- Fouten: {result.errors.length} -
    {result.errors.map((e, i) =>
  • rij {e.row}: {e.message}
  • )}
+
+
Fouten ({result.errors.length})
+
    + {result.errors.slice(0, 5).map((e, i) =>
  • rij {e.row}: {e.message}
  • )} + {result.errors.length > 5 &&
  • … en {result.errors.length - 5} meer
  • } +
)}
)} -
- - + +
+ +
-
+
); } diff --git a/packages/frontend/src/components/Layout.tsx b/packages/frontend/src/components/Layout.tsx index 2092933..48a3af9 100644 --- a/packages/frontend/src/components/Layout.tsx +++ b/packages/frontend/src/components/Layout.tsx @@ -1,22 +1,84 @@ -import { Link, Outlet } from 'react-router-dom'; +import { NavLink, Outlet } from 'react-router-dom'; import { useSettings } from '../stores/settingsStore.js'; +const navItems = [ + { to: '/', label: 'Dashboard', end: true }, + { to: '/admin', label: 'Admin' }, + { to: '/stats', label: 'Stats' }, +]; + export function Layout() { const { theme, toggleTheme } = useSettings(); return (
-
-
); } diff --git a/packages/frontend/src/components/LessonTree.tsx b/packages/frontend/src/components/LessonTree.tsx index b465beb..df4fd38 100644 --- a/packages/frontend/src/components/LessonTree.tsx +++ b/packages/frontend/src/components/LessonTree.tsx @@ -33,20 +33,51 @@ export function LessonTree({ nodes, depth = 0 }: { nodes: LessonTreeNode[]; dept return (
    {nodes.map((n) => ( -
  • -
    - - {n.name} ({n.cardCount}) +
  • +
    + + + {n.name} + + {n.cardCount} + - - - +
    + + + +
    {addingTo === n.id && ( -
    - setName(e.target.value)} placeholder="Naam" /> - - +
    + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') addChild(n.id); + if (e.key === 'Escape') { setAddingTo(null); setName(''); } + }} + placeholder="Naam van subles" + /> + +
    )} {n.children.length > 0 && } diff --git a/packages/frontend/src/pages/Admin.tsx b/packages/frontend/src/pages/Admin.tsx index c1af58f..7b09a2c 100644 --- a/packages/frontend/src/pages/Admin.tsx +++ b/packages/frontend/src/pages/Admin.tsx @@ -17,13 +17,38 @@ export function AdminPage() { } return ( -
    -

    Lessen beheer

    -
    - setNewRoot(e.target.value)} /> - +
    +
    +

    Lessen beheren

    +

    + Maak een hiërarchie van lessen en sublessen. Klik op een les voor de kaarten. +

    +
    + +
    + setNewRoot(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addRoot()} + /> + +
    + +
    + {loading ? ( +

    Laden…

    + ) : tree.length === 0 ? ( +
    + Nog geen lessen. Voeg er hierboven een toe. +
    + ) : ( + + )}
    - {loading ?

    Laden...

    : }
    ); } diff --git a/packages/frontend/src/pages/AdminLesson.tsx b/packages/frontend/src/pages/AdminLesson.tsx index daa6c90..e4f82bf 100644 --- a/packages/frontend/src/pages/AdminLesson.tsx +++ b/packages/frontend/src/pages/AdminLesson.tsx @@ -15,16 +15,36 @@ export function AdminLessonPage() { useEffect(() => { refresh(); }, [lessonId]); return ( -
    - ← Lessen -

    Kaarten

    -
    - - Exporteer Excel - Exporteer + sublessen - Start oefenen → +
    + + ← Terug naar lessen + + +
    +
    +

    Kaartenbeheer

    +

    {cards.length} {cards.length === 1 ? 'kaart' : 'kaarten'} in deze les

    +
    +
    + + + 📤 Exporteer + + + 📤 + sublessen + + + Start oefenen → + +
    +
    + +
    +
    - + {showImport && setShowImport(false)} onDone={refresh} />}
    ); diff --git a/packages/frontend/src/pages/Dashboard.tsx b/packages/frontend/src/pages/Dashboard.tsx index d19d60b..9fbacd2 100644 --- a/packages/frontend/src/pages/Dashboard.tsx +++ b/packages/frontend/src/pages/Dashboard.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; +import { motion } from 'framer-motion'; import { statsApi, type Overview } from '../api/stats.js'; import { sessionsApi } from '../api/sessions.js'; import type { SessionRow } from '@flashcard/shared'; @@ -22,50 +23,153 @@ export function DashboardPage() { }, []); return ( -
    -

    Dashboard

    +
    +
    +

    Welkom terug 👋

    +

    + Houd je streak vast — een paar minuten oefenen per dag werkt het beste. +

    +
    + {active && ( -
    - Je hebt een open sessie ({active.cardsShown} kaarten behandeld). + +
    +
    + Open sessie wacht op je +
    +
    + {active.cardsShown} kaarten al behandeld +
    +
    - - + + +
    +
    + )} + +
    + + + +
    + +
    +

    Lessen

    + {tree.length === 0 ? ( +
    + 📚 +

    + Nog geen lessen. Maak er een via admin om te beginnen. +

    + Naar admin +
    + ) : ( +
      + {tree.map((n, i) => ( + +
      +
      {n.name}
      +
      + {n.cardCount} {n.cardCount === 1 ? 'kaart' : 'kaarten'} +
      +
      + + Oefenen → + +
      + ))} +
    + )} +
    + + {ov?.recentSessions && ov.recentSessions.length > 0 && ( +
    +

    Recente sessies

    +
      + {ov.recentSessions.map((s) => { + const pct = s.cardsShown > 0 ? Math.round((s.cardsCorrect / s.cardsShown) * 100) : 0; + return ( +
    • + {formatDate(s.startedAt)} +
      + + {pct}% + + + {s.cardsCorrect}/{s.cardsShown} · {formatDuration(s.durationSeconds ?? 0)} + +
      +
    • + ); + })} +
    +
    + )} +
    + ); +} + +function StatCard({ + tone, label, value, unit, icon, +}: { + tone: 'brand' | 'success' | 'muted'; + label: string; value: string; unit: string; icon: string; +}) { + const toneClass = + tone === 'brand' + ? 'bg-brand-gradient text-white shadow-glow' + : tone === 'success' + ? 'bg-success-gradient text-white shadow-success-glow' + : 'bg-white/80 text-slate-900 shadow-soft dark:bg-slate-900/70 dark:text-slate-100'; + + return ( +
    +
    +
    +
    {label}
    +
    + {value} + {unit && {unit}}
    - )} -
    - - - + {icon}
    - -

    Lessen

    -
      - {tree.map((n) => ( -
    • - {n.name} ({n.cardCount} kaarten) - Oefenen -
    • - ))} -
    - -

    Recente sessies

    -
      - {ov?.recentSessions.map((s) => ( -
    • - {formatDate(s.startedAt)} — {s.cardsCorrect}/{s.cardsShown} goed · {formatDuration(s.durationSeconds ?? 0)} -
    • - ))} -
    -
    - ); -} - -function Stat({ label, value }: { label: string; value: string }) { - return ( -
    -
    {label}
    -
    {value}
    ); } diff --git a/packages/frontend/src/pages/Practice.tsx b/packages/frontend/src/pages/Practice.tsx index 05585b1..ce99379 100644 --- a/packages/frontend/src/pages/Practice.tsx +++ b/packages/frontend/src/pages/Practice.tsx @@ -54,22 +54,55 @@ export function PracticePage() { if (done && session) { (async () => { await end(); navigate(`/practice/${lessonId}/done`); })(); } }, [done, session, end, navigate, lessonId]); - if (!current || !card) return
    Laden...
    ; + if (!current || !card) { + return ( +
    +
    +

    Laden…

    +
    + ); + } const isReverse = current.direction === 'backward'; + const shown = session?.cardsShown ?? 0; + const correct = session?.cardsCorrect ?? 0; + const incorrect = session?.cardsIncorrect ?? 0; + const accuracy = shown > 0 ? Math.round((correct / shown) * 100) : 0; + // Heuristic progress: we don't know queue length here, so show steady fill on the answered count + const progress = Math.min(100, shown * 4); + return ( -
    -
    -
    {session?.cardsShown ?? 0} kaarten behandeld
    - answer(r)} - /> +
    +
    +
    + + Sessie + + {accuracy}% goed +
    +
    +
    +
    +
    + {shown} behandeld + + ✓ {correct} + ✕ {incorrect} + +
    + + answer(r)} + />
    ); } diff --git a/packages/frontend/src/pages/PracticeDone.tsx b/packages/frontend/src/pages/PracticeDone.tsx index e168120..0d1eb9a 100644 --- a/packages/frontend/src/pages/PracticeDone.tsx +++ b/packages/frontend/src/pages/PracticeDone.tsx @@ -1,25 +1,100 @@ import { Link, useParams } from 'react-router-dom'; +import { motion } from 'framer-motion'; import { useSession } from '../stores/sessionStore.js'; import { Confetti } from '../components/Confetti.js'; +import { formatDuration } from '../lib/format.js'; export function PracticeDonePage() { const { lessonId } = useParams(); const session = useSession((s) => s.session); const reset = useSession((s) => s.reset); - if (!session) return
    Geen sessie gegevens. Terug
    ; + + if (!session) { + return ( +
    + Geen sessie gegevens.{' '} + Terug +
    + ); + } + const total = session.cardsShown || 1; const pct = Math.round((session.cardsCorrect / total) * 100); + const great = pct >= 80; + const ok = pct >= 60 && pct < 80; + const ringColor = great ? 'stroke-success-500' : ok ? 'stroke-brand-500' : 'stroke-danger-500'; + const message = great + ? 'Geweldig! Je beheerst dit goed.' + : ok + ? 'Goed bezig — nog wat herhalen.' + : 'Niet getreurd, oefenen baart kunst.'; + return ( -
    - = 80} /> -

    Sessie klaar!

    -

    {pct}%

    -

    {session.cardsCorrect} goed, {session.cardsIncorrect} fout van {session.cardsShown}

    -

    Duur: {session.durationSeconds ?? 0}s

    -
    - Opnieuw - Dashboard -
    +
    + + +
    + Sessie klaar +
    +

    {great ? '🎉 ' : ''}{message}

    + +
    + + + + +
    +
    {pct}%
    +
    goed
    +
    +
    + +
    + + + +
    + +
    + + Opnieuw oefenen + + + Naar dashboard + +
    +
    +
    + ); +} + +function Stat({ label, value, tone }: { label: string; value: string; tone: 'success' | 'danger' | 'muted' }) { + const toneClass = + tone === 'success' + ? 'bg-success-50 text-success-700 dark:bg-success-700/20 dark:text-success-400' + : tone === 'danger' + ? 'bg-danger-50 text-danger-700 dark:bg-danger-400/20 dark:text-danger-400' + : 'bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200'; + return ( +
    +
    {label}
    +
    {value}
    ); } diff --git a/packages/frontend/src/pages/PracticeSetup.tsx b/packages/frontend/src/pages/PracticeSetup.tsx index 6a388af..e47d85b 100644 --- a/packages/frontend/src/pages/PracticeSetup.tsx +++ b/packages/frontend/src/pages/PracticeSetup.tsx @@ -1,8 +1,16 @@ import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { motion } from 'framer-motion'; import { useSettings } from '../stores/settingsStore.js'; import { useSession } from '../stores/sessionStore.js'; +const sizeOptions: (number | 'all')[] = [10, 20, 30, 50, 'all']; +const directions: { value: 'forward' | 'backward' | 'both'; label: string; hint: string }[] = [ + { value: 'forward', label: 'Vraag → antwoord', hint: 'Standaard' }, + { value: 'backward', label: 'Antwoord → vraag', hint: 'Voor bidirectionele lessen' }, + { value: 'both', label: 'Beide richtingen', hint: 'Dubbele oefening' }, +]; + export function PracticeSetupPage() { const { lessonId } = useParams(); const id = Number(lessonId); @@ -22,25 +30,110 @@ export function PracticeSetupPage() { } return ( -
    -

    Sessie starten

    - - - - + +
    +
    +

    Klaar om te oefenen?

    +

    + Configureer je sessie en begin. +

    +
    + +
    +
    + {sizeOptions.map((n) => ( + setMaxCards(n)} + > + {n === 'all' ? 'Alle' : n} + + ))} +
    +
    + +
    +
    + {directions.map((d) => ( + + ))} +
    +
    + + + + +
    +
    + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
    +
    + {title} +
    + {children}
    ); } + +function Pill({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { + return ( + + ); +} diff --git a/packages/frontend/src/styles.css b/packages/frontend/src/styles.css index a84d2b0..954e72f 100644 --- a/packages/frontend/src/styles.css +++ b/packages/frontend/src/styles.css @@ -3,5 +3,62 @@ @tailwind utilities; html, body, #root { height: 100%; } -.card-perspective { perspective: 1000px; } -.card-face { backface-visibility: hidden; } + +body { + font-feature-settings: 'cv11', 'ss01', 'ss03'; +} + +@layer base { + body { + background-image: + radial-gradient(at 0% 0%, theme('colors.brand.100') 0px, transparent 50%), + radial-gradient(at 100% 0%, theme('colors.success.50') 0px, transparent 50%), + radial-gradient(at 50% 100%, theme('colors.brand.50') 0px, transparent 60%); + background-attachment: fixed; + } + .dark body { + background-image: + radial-gradient(at 0% 0%, rgba(124, 58, 237, 0.18) 0px, transparent 50%), + radial-gradient(at 100% 0%, rgba(16, 185, 129, 0.10) 0px, transparent 50%), + radial-gradient(at 50% 100%, rgba(76, 29, 149, 0.20) 0px, transparent 60%); + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center gap-2 rounded-2xl px-5 py-2.5 text-sm font-semibold transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; + } + .btn-primary { + @apply btn bg-brand-gradient text-white shadow-glow hover:brightness-110 active:scale-[0.97]; + } + .btn-success { + @apply btn bg-success-gradient text-white shadow-success-glow hover:brightness-110 active:scale-[0.97]; + } + .btn-danger { + @apply btn bg-danger-gradient text-white shadow-danger-glow hover:brightness-110 active:scale-[0.97]; + } + .btn-ghost { + @apply btn bg-white/70 text-slate-700 backdrop-blur hover:bg-white dark:bg-slate-900/60 dark:text-slate-200 dark:hover:bg-slate-900; + } + .surface { + @apply rounded-3xl border border-white/60 bg-white/70 shadow-soft backdrop-blur dark:border-slate-800/60 dark:bg-slate-900/60; + } + .input-field { + @apply w-full rounded-2xl border border-brand-100 bg-white/80 px-4 py-2.5 text-sm shadow-sm transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-200 dark:border-slate-800 dark:bg-slate-900/80 dark:focus:border-brand-500 dark:focus:ring-brand-900; + } +} + +.card-perspective { perspective: 1500px; } +.card-face { + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transform-style: preserve-3d; +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/packages/frontend/tailwind.config.ts b/packages/frontend/tailwind.config.ts index eb668c6..f296385 100644 --- a/packages/frontend/tailwind.config.ts +++ b/packages/frontend/tailwind.config.ts @@ -1,15 +1,78 @@ import type { Config } from 'tailwindcss'; + export default { content: ['./index.html', './src/**/*.{ts,tsx}'], darkMode: 'class', theme: { extend: { - animation: { 'flip': 'flip 0.4s ease-out forwards' }, + colors: { + brand: { + 50: '#FAF5FF', + 100: '#F3E8FF', + 200: '#E9D5FF', + 300: '#D8B4FE', + 400: '#A78BFA', + 500: '#8B5CF6', + 600: '#7C3AED', + 700: '#6D28D9', + 800: '#5B21B6', + 900: '#4C1D95', + }, + success: { + 50: '#ECFDF5', + 100: '#D1FAE5', + 400: '#34D399', + 500: '#10B981', + 600: '#059669', + 700: '#047857', + }, + danger: { + 50: '#FEF2F2', + 400: '#F87171', + 500: '#EF4444', + 600: '#DC2626', + 700: '#B91C1C', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + display: ['"Plus Jakarta Sans"', 'Inter', 'system-ui', 'sans-serif'], + }, + boxShadow: { + soft: '0 6px 24px -8px rgba(124, 58, 237, 0.18), 0 2px 8px -2px rgba(15, 23, 42, 0.06)', + glow: '0 10px 40px -12px rgba(124, 58, 237, 0.4)', + 'success-glow': '0 10px 40px -12px rgba(16, 185, 129, 0.45)', + 'danger-glow': '0 10px 40px -12px rgba(239, 68, 68, 0.45)', + }, + backgroundImage: { + 'brand-gradient': 'linear-gradient(135deg, #A78BFA 0%, #7C3AED 100%)', + 'success-gradient': 'linear-gradient(135deg, #34D399 0%, #059669 100%)', + 'danger-gradient': 'linear-gradient(135deg, #F87171 0%, #DC2626 100%)', + 'mesh-light': 'radial-gradient(at 0% 0%, #F3E8FF 0px, transparent 50%), radial-gradient(at 100% 0%, #ECFDF5 0px, transparent 50%), radial-gradient(at 50% 100%, #FAF5FF 0px, transparent 60%)', + }, + animation: { + 'flip': 'flip 0.4s ease-out forwards', + 'pulse-correct': 'pulseCorrect 0.6s ease-out', + 'pulse-wrong': 'pulseWrong 0.6s ease-out', + 'shimmer': 'shimmer 2s linear infinite', + }, keyframes: { flip: { '0%': { transform: 'rotateY(0)' }, '100%': { transform: 'rotateY(180deg)' }, }, + pulseCorrect: { + '0%, 100%': { boxShadow: '0 0 0 0 rgba(16, 185, 129, 0)' }, + '50%': { boxShadow: '0 0 0 16px rgba(16, 185, 129, 0.2)' }, + }, + pulseWrong: { + '0%, 100%': { boxShadow: '0 0 0 0 rgba(239, 68, 68, 0)' }, + '50%': { boxShadow: '0 0 0 16px rgba(239, 68, 68, 0.2)' }, + }, + shimmer: { + '0%': { backgroundPosition: '-1000px 0' }, + '100%': { backgroundPosition: '1000px 0' }, + }, }, }, },