diff --git a/db.js b/db.js index 14d8ea1..5d2692c 100644 --- a/db.js +++ b/db.js @@ -228,6 +228,7 @@ tryAlter(`ALTER TABLE students ADD COLUMN theme TEXT NOT NULL DEFAULT 'blue'`); tryAlter(`ALTER TABLE students ADD COLUMN streak_date TEXT`); tryAlter(`ALTER TABLE students ADD COLUMN streak_count INTEGER NOT NULL DEFAULT 0`); tryAlter(`ALTER TABLE students ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0`); +tryAlter(`ALTER TABLE students ADD COLUMN bg_pattern TEXT NOT NULL DEFAULT 'none'`); tryAlter(`ALTER TABLE lists ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`); tryAlter(`ALTER TABLE decks ADD COLUMN group_name TEXT NOT NULL DEFAULT ''`); diff --git a/public/app.js b/public/app.js index 8cbeb44..2929c7b 100644 --- a/public/app.js +++ b/public/app.js @@ -211,7 +211,19 @@ function applyTheme(theme) { const vars = THEMES[theme] || {}; for (const [k, v] of Object.entries(DEFAULT_VARS)) document.documentElement.style.setProperty(k, vars[k] || v); + const bgClass = [...document.body.classList].find(c => c.startsWith('bg-')); document.body.className = theme && theme !== 'blue' ? `theme-${theme}` : ''; + if (bgClass) document.body.classList.add(bgClass); +} + +const BG_PATTERNS = ['none','dots','stripes','grid','stars','bubbles']; + +function applyBgPattern(pattern) { + BG_PATTERNS.forEach(p => document.body.classList.remove(`bg-${p}`)); + if (pattern && pattern !== 'none') document.body.classList.add(`bg-${pattern}`); + document.querySelectorAll('.bg-pattern-btn').forEach(btn => + btn.classList.toggle('active', btn.dataset.pattern === (pattern || 'none')) + ); } function resetTheme() { @@ -225,16 +237,22 @@ let currentStudent = null; async function loadHome() { resetTheme(); + const hour = new Date().getHours(); + const greeting = hour < 12 ? 'Good morning! ☀️' + : hour < 17 ? 'Good afternoon! 🌤️' + : 'Good evening! 🌙'; + document.querySelector('.subtitle').textContent = greeting; + const students = await api('/api/students'); $('#no-kids').hidden = students.length > 0; $('#kid-cards').innerHTML = students .map( - (s) => `` ) .join(''); document.querySelectorAll('.kid-card').forEach((card) => - card.addEventListener('click', () => openKid(Number(card.dataset.id))) + card.addEventListener('click', () => openKid(Number(card.dataset.id), 'today', true)) ); } @@ -244,12 +262,26 @@ document.querySelectorAll('.nav-pill').forEach((pill) => pill.addEventListener('click', () => switchTab(pill.dataset.tab)) ); +const TAB_ORDER = ['today', 'courses', 'spelling']; +let currentTab = 'today'; + function switchTab(name) { + const animate = name !== currentTab; + const dir = TAB_ORDER.indexOf(name) > TAB_ORDER.indexOf(currentTab) ? 'right' : 'left'; + currentTab = name; document.querySelectorAll('.nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.tab === name)); - document.querySelectorAll('.tab-panel').forEach((panel) => (panel.hidden = panel.id !== `tab-${name}`)); + document.querySelectorAll('.tab-panel').forEach((panel) => { + const isTarget = panel.id === `tab-${name}`; + panel.hidden = !isTarget; + if (isTarget && animate) { + panel.classList.remove('sliding-right', 'sliding-left'); + void panel.offsetWidth; + panel.classList.add(dir === 'right' ? 'sliding-right' : 'sliding-left'); + } + }); } -async function openKid(id, tab = 'today') { +async function openKid(id, tab = 'today', welcome = false) { const [state, agenda, courses, { tests: recentTests }] = await Promise.all([ api(`/api/state/${id}`), api(`/api/schedule/${id}?date=${todayStr()}`), @@ -258,6 +290,7 @@ async function openKid(id, tab = 'today') { ]); currentStudent = state.student; applyTheme(currentStudent.theme || 'blue'); + applyBgPattern(currentStudent.bg_pattern || 'none'); updateThemeDots(currentStudent.theme || 'blue'); $('#kid-greeting').textContent = `${state.student.emoji} Hi, ${state.student.name}!`; @@ -277,7 +310,9 @@ async function openKid(id, tab = 'today') { renderSpellingTab(state, recentTests); show('kid'); + currentTab = tab; switchTab(tab); + if (welcome) showWelcomeToast(currentStudent.emoji, currentStudent.name); // Celebrate if all today's tasks are done if (agenda.tasks.length > 0) { @@ -298,6 +333,7 @@ function updateThemeDots(active) { } function showCelebration({ streak, best }) { + playFanfare(); const isRecord = streak > 1 && streak === best; let streakHtml = ''; if (streak >= 2) { @@ -314,12 +350,226 @@ $('#celebration-close').addEventListener('click', () => { $('#celebration-overlay').hidden = true; }); +// ---------- mini celebration (confetti + toast) ---------- + +let pendingMiniCelebration = false; + +function popConfetti() { + const colors = ['#4f86f7','#e84040','#2e9e5b','#e8802a','#7c5cbf','#d45d8a','#c8960c','#20a8a0','#f7c948','#ff6b6b','#48dbfb']; + const container = document.createElement('div'); + container.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:999;overflow:hidden'; + document.body.appendChild(container); + for (let i = 0; i < 160; i++) { + const piece = document.createElement('div'); + const color = colors[Math.floor(Math.random() * colors.length)]; + const x = Math.random() * 110 - 5; + const delay = Math.random() * 1.2; + const dur = 1.8 + Math.random() * 1.4; + const size = 10 + Math.random() * 18; + const shape = Math.random(); + let borderRadius = '2px'; + if (shape > 0.66) borderRadius = '50%'; + else if (shape > 0.33) borderRadius = '0'; + piece.style.cssText = `position:absolute;left:${x}%;top:-30px;width:${size}px;height:${size * (0.4 + Math.random() * 0.8)}px;` + + `background:${color};border-radius:${borderRadius};` + + `animation:confetti-fall ${dur}s ${delay}s ease-in forwards;transform:rotate(${Math.random()*360}deg)`; + container.appendChild(piece); + } + setTimeout(() => container.remove(), 4000); +} + +function playFlip() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const t = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); gain.connect(ctx.destination); + osc.type = 'sine'; + osc.frequency.setValueAtTime(320, t); + osc.frequency.exponentialRampToValueAtTime(680, t + 0.05); + osc.frequency.exponentialRampToValueAtTime(220, t + 0.14); + gain.gain.setValueAtTime(0.16, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.16); + osc.start(t); osc.stop(t + 0.16); + setTimeout(() => ctx.close(), 400); + } catch(e) {} +} + +function playDing() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const t = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); gain.connect(ctx.destination); + osc.type = 'sine'; osc.frequency.setValueAtTime(1400, t); + osc.frequency.exponentialRampToValueAtTime(900, t + 0.12); + gain.gain.setValueAtTime(0.28, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35); + osc.start(t); osc.stop(t + 0.35); + setTimeout(() => ctx.close(), 600); + } catch(e) {} +} + +function playBuzz() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const t = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); gain.connect(ctx.destination); + osc.type = 'sawtooth'; osc.frequency.setValueAtTime(220, t); + osc.frequency.exponentialRampToValueAtTime(80, t + 0.18); + gain.gain.setValueAtTime(0.22, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.22); + osc.start(t); osc.stop(t + 0.22); + setTimeout(() => ctx.close(), 400); + } catch(e) {} +} + +function playFanfare() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const t = ctx.currentTime; + // Rising arpeggio + [[523, 0], [659, 0.13], [784, 0.26], [1047, 0.39]].forEach(([freq, delay]) => { + const osc = ctx.createOscillator(); + const g = ctx.createGain(); + osc.connect(g); g.connect(ctx.destination); + osc.frequency.value = freq; + const s = t + delay; + g.gain.setValueAtTime(0.38, s); + g.gain.exponentialRampToValueAtTime(0.001, s + 0.28); + osc.start(s); osc.stop(s + 0.28); + }); + // Final sustained chord + [523, 659, 784, 1047].forEach((freq) => { + const osc = ctx.createOscillator(); + const g = ctx.createGain(); + osc.connect(g); g.connect(ctx.destination); + osc.frequency.value = freq; + const s = t + 0.55; + g.gain.setValueAtTime(0.22, s); + g.gain.exponentialRampToValueAtTime(0.001, s + 1.1); + osc.start(s); osc.stop(s + 1.15); + }); + setTimeout(() => ctx.close(), 2500); + } catch(e) {} +} + +function animateScore(el, score, total) { + if (!el) return; + const duration = 650; + const start = performance.now(); + const tick = (now) => { + const p = Math.min((now - start) / duration, 1); + const eased = 1 - Math.pow(1 - p, 3); + el.textContent = `${Math.round(eased * score)} / ${total}`; + if (p < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} + +function playPopSound() { + try { + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + const t = ctx.currentTime; + + // Thump: short pitch-sweep down like a party popper + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); gain.connect(ctx.destination); + osc.frequency.setValueAtTime(900, t); + osc.frequency.exponentialRampToValueAtTime(80, t + 0.13); + gain.gain.setValueAtTime(0.55, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 0.13); + osc.start(t); osc.stop(t + 0.13); + + // Sparkle: quick ascending arpeggio C-E-G-C + [523, 659, 784, 1047].forEach((freq, i) => { + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.connect(g); g.connect(ctx.destination); + o.type = 'sine'; o.frequency.value = freq; + const s = t + 0.06 + i * 0.075; + g.gain.setValueAtTime(0, s); + g.gain.linearRampToValueAtTime(0.28, s + 0.02); + g.gain.exponentialRampToValueAtTime(0.001, s + 0.13); + o.start(s); o.stop(s + 0.15); + }); + + setTimeout(() => ctx.close(), 1200); + } catch (e) {} +} + +const CHEERS = [ + '🎉 Nailed it!', '⭐ Star student!', '💪 You\'re crushing it!', + '🔥 On fire!', '🎯 Spot on!', '✨ Brilliant!', + '🚀 Killing it!', '🏅 Awesome work!', '💫 You rock!', '🦾 Unstoppable!', +]; +function randomCheer() { return CHEERS[Math.floor(Math.random() * CHEERS.length)]; } + +function showMiniCelebration(msg = '🎉 Great job!') { + playPopSound(); + popConfetti(); + const toast = document.createElement('div'); + toast.className = 'mini-celebration-toast'; + toast.textContent = msg; + document.body.appendChild(toast); + setTimeout(() => { toast.style.animation = 'mini-toast-out 0.5s ease forwards'; }, 1800); + setTimeout(() => toast.remove(), 2400); +} + +function showWelcomeToast(emoji, name) { + const toast = document.createElement('div'); + toast.className = 'welcome-toast'; + toast.innerHTML = `${emoji}
Hi, ${esc(name)}! 👋
`; + document.body.appendChild(toast); + setTimeout(() => toast.classList.add('welcome-toast-out'), 1700); + setTimeout(() => toast.remove(), 2150); +} + +function showDoneStamp() { + return new Promise((resolve) => { + const wrap = document.createElement('div'); + wrap.className = 'done-stamp-wrap'; + const stamp = document.createElement('div'); + stamp.className = 'done-stamp'; + stamp.textContent = '✅'; + wrap.appendChild(stamp); + document.body.appendChild(wrap); + setTimeout(() => { + stamp.classList.add('done-stamp-out'); + setTimeout(() => { wrap.remove(); resolve(); }, 380); + }, 750); + }); +} + // Theme picker toggle $('#theme-picker-btn').addEventListener('click', () => { $('#theme-dots').hidden = !$('#theme-dots').hidden; $('#icon-grid').hidden = true; + $('#bg-pattern-panel').hidden = true; }); +// BG pattern picker toggle +$('#bg-picker-btn').addEventListener('click', () => { + $('#bg-pattern-panel').hidden = !$('#bg-pattern-panel').hidden; + $('#theme-dots').hidden = true; + $('#icon-grid').hidden = true; +}); + +document.querySelectorAll('.bg-pattern-btn').forEach(btn => + btn.addEventListener('click', async () => { + const pattern = btn.dataset.pattern; + applyBgPattern(pattern); + currentStudent.bg_pattern = pattern; + await api(`/api/students/${currentStudent.id}/bg-pattern`, { method: 'PATCH', body: { pattern } }); + $('#bg-pattern-panel').hidden = true; + }) +); + document.querySelectorAll('.theme-dot').forEach((btn) => btn.addEventListener('click', async () => { const theme = btn.dataset.theme; @@ -352,6 +602,7 @@ $('#icon-picker-btn').addEventListener('click', () => { } grid.hidden = !grid.hidden; $('#theme-dots').hidden = true; + $('#bg-pattern-panel').hidden = true; }); const OFFLINE_STATUS_ICON = { not_started: '⬜', in_progress: '🔄', done: '✅' }; @@ -744,6 +995,8 @@ async function openLesson(itemId) { btn.textContent = item.submission ? '✅ Read' : 'Done reading ✓'; btn.onclick = async () => { await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } }); + showMiniCelebration('📖 Lesson complete!'); + await showDoneStamp(); backTarget(); }; } @@ -813,6 +1066,8 @@ async function openAssignment(itemId) { const body = { studentId: currentStudent.id, date: todayStr() }; if (needsEvidence) { body.evidenceNotes = $('#assignment-evidence-notes').value || null; body.evidencePhoto = evidencePhotoBase64; body.studentNote = $('#assignment-student-note').value || null; } await api(`/api/items/${itemId}/complete`, { method: 'POST', body }); + showMiniCelebration('📝 Turned in!'); + await showDoneStamp(); openAssignment(itemId); }; } @@ -974,6 +1229,7 @@ async function openWorksheet(itemId) { result.className = 'status-banner'; result.textContent = '⏳ Turned in — waiting for a parent to grade it.'; submitBtn.hidden = true; + if (pendingMiniCelebration) { pendingMiniCelebration = false; showMiniCelebration('📋 Turned in!'); } } else if (graded) { result.hidden = false; result.className = 'status-banner good'; @@ -981,6 +1237,11 @@ async function openWorksheet(itemId) { result.textContent = hasShort ? `🌟 Score: ${sub.score} / ${sub.points_possible} (short answers parent-graded)` : `🌟 Score: ${sub.score} / ${sub.points_possible}`; + if (pendingMiniCelebration) { + pendingMiniCelebration = false; + const wsPct = sub.points_possible ? Math.round((sub.score / sub.points_possible) * 100) : 100; + showMiniCelebration(wsPct >= 70 ? `🌟 ${wsPct}% — Great!` : randomCheer()); + } if (item.allow_retakes) { submitBtn.hidden = false; submitBtn.textContent = '🔁 Retake'; @@ -1002,6 +1263,7 @@ async function openWorksheet(itemId) { method: 'POST', body: { studentId: currentStudent.id, answers, date: todayStr() }, }); + pendingMiniCelebration = true; openWorksheet(itemId); } catch (err) { result.hidden = false; @@ -1108,6 +1370,11 @@ async function openQuiz(itemId, forceRetake = false) { const pct = item.submission.points_possible ? Math.round((item.submission.score / item.submission.points_possible) * 100) : 0; result.innerHTML = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible} (${pct}%)`; + if (pendingMiniCelebration) { + pendingMiniCelebration = false; + showMiniCelebration(pct >= 70 ? `🌟 ${pct}% — Nice!` : randomCheer()); + } + // Attempt history const { history } = await api(`/api/items/${itemId}/history?studentId=${currentStudent.id}`); let historyHtml = ''; @@ -1179,6 +1446,7 @@ async function openQuiz(itemId, forceRetake = false) { method: 'POST', body: { studentId: currentStudent.id, answers, date: todayStr() }, }); + pendingMiniCelebration = true; openQuiz(itemId, false); }; } @@ -1214,6 +1482,11 @@ async function openMatching(itemId, forceRetake = false) { ? Math.round((item.submission.score / item.submission.points_possible) * 100) : 0; result.innerHTML = `🌟 Score: ${item.submission.score} / ${item.submission.points_possible} (${pct}%)`; + if (pendingMiniCelebration) { + pendingMiniCelebration = false; + showMiniCelebration(pct >= 70 ? `🌟 ${pct}% — Nice!` : randomCheer()); + } + let retakeBtn = ''; if (item.allow_retakes) retakeBtn = ``; @@ -1269,6 +1542,7 @@ async function openMatching(itemId, forceRetake = false) { method: 'POST', body: { studentId: currentStudent.id, answers, date: todayStr() }, }); + pendingMiniCelebration = true; openMatching(itemId, false); }; } @@ -1601,9 +1875,18 @@ function presentFlashcard() { } $('#flash-reveal').addEventListener('click', () => { - $('#flash-back').hidden = false; - $('#flash-reveal').hidden = true; - $('#flash-grade-buttons').hidden = false; + playFlip(); + const card = $('#flash-card'); + card.classList.remove('flip-in'); + card.classList.add('flip-out'); + setTimeout(() => { + card.classList.remove('flip-out'); + $('#flash-back').hidden = false; + $('#flash-reveal').hidden = true; + $('#flash-grade-buttons').hidden = false; + card.classList.add('flip-in'); + setTimeout(() => card.classList.remove('flip-in'), 200); + }, 180); }); let flashGrading = false; @@ -1611,6 +1894,7 @@ let flashGrading = false; async function gradeFlashcard(gotIt) { if (flashGrading) return; // guard against a rapid double-tap misgrading two cards flashGrading = true; + if (gotIt) playDing(); else playBuzz(); const c = flash.cards[flash.i]; await api('/api/flashcards/grade', { method: 'POST', body: { studentId: currentStudent.id, cardId: c.id, gotIt } }); flash.reviewed++; @@ -1686,9 +1970,15 @@ $('#practice-form').addEventListener('submit', async (e) => { }); if (result.correct) { + playDing(); + const card = $('#practice-card'); + card.classList.remove('flash-correct', 'flash-wrong'); + void card.offsetWidth; + card.classList.add('flash-correct'); if (!practice.awaitingRetype) { practice.firstTryCorrect++; practice.streak++; + if ([3, 5, 7, 10].includes(practice.streak)) showMiniCelebration(`🔥 ${practice.streak} in a row!`); } const streakMsg = (!practice.awaitingRetype && practice.streak >= 2) ? ` 🔥 ${practice.streak} in a row!` : ''; @@ -1697,6 +1987,11 @@ $('#practice-form').addEventListener('submit', async (e) => { setTimeout(nextPracticeWord, practice.streak >= 2 ? 1300 : 900); } else { if (!practice.awaitingRetype) { + playBuzz(); + const card = $('#practice-card'); + card.classList.remove('flash-correct', 'flash-wrong'); + void card.offsetWidth; + card.classList.add('flash-wrong'); practice.missed.push(w.word); practice.missedWords.push(w); practice.streak = 0; @@ -1761,6 +2056,9 @@ async function nextPracticeWord() { ${drillBtn} `; $('#practice-done').hidden = false; + animateScore($('#practice-done .big-score'), practice.firstTryCorrect, practice.words.length); + if (practice.missed.length === 0) showMiniCelebration('🏆 Perfect round!'); + else showMiniCelebration(randomCheer()); if (missedWords.length) { $('#practice-drill-missed').addEventListener('click', () => { Object.assign(practice, { words: shuffle(missedWords), i: 0, missed: [], missedWords: [], firstTryCorrect: 0, awaitingRetype: false, streak: 0 }); @@ -1838,6 +2136,7 @@ async function finishTest() { let retakeBtn = ''; if (test.itemId && test.allowRetakes) retakeBtn = ``; + const testPct = result.total ? Math.round((result.score / result.total) * 100) : 100; $('#test-done').innerHTML = `
⭐ Test complete!
${result.score} / ${result.total}
@@ -1845,6 +2144,11 @@ async function finishTest() { ${retakeBtn} `; $('#test-done').hidden = false; + animateScore($('#test-done .big-score'), result.score, result.total); + if (testPct === 100) showMiniCelebration('🏆 Perfect score!'); + else if (testPct >= 80) showMiniCelebration(`⭐ ${result.score}/${result.total} — Amazing!`); + else if (testPct >= 60) showMiniCelebration(`✅ ${result.score}/${result.total} — Nice work!`); + else showMiniCelebration(randomCheer()); $('#test-home').addEventListener('click', () => backTarget()); if (test.itemId && test.allowRetakes) { $('#test-retake-btn').addEventListener('click', () => { diff --git a/public/index.html b/public/index.html index 113f426..db9d3fa 100644 --- a/public/index.html +++ b/public/index.html @@ -24,6 +24,7 @@
+

@@ -39,6 +40,14 @@ +
diff --git a/public/styles.css b/public/styles.css index 87afbfa..aa6fbb4 100644 --- a/public/styles.css +++ b/public/styles.css @@ -55,6 +55,18 @@ body { .error { color: var(--bad); margin: 0.5rem 0; } /* ---------- kid picker ---------- */ +@keyframes card-enter { + from { opacity: 0; transform: translateY(26px) scale(0.93); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes emoji-wobble { + 0% { transform: rotate(0deg) scale(1); } + 20% { transform: rotate(-15deg) scale(1.18); } + 50% { transform: rotate(13deg) scale(1.14); } + 75% { transform: rotate(-7deg) scale(1.07); } + 100% { transform: rotate(0deg) scale(1); } +} + .kid-cards { display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; margin-bottom: 2.5rem; @@ -68,10 +80,16 @@ body { font-weight: 700; cursor: pointer; box-shadow: 0 4px 12px rgba(45, 49, 66, 0.08); - transition: transform 0.1s, border-color 0.1s; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; min-width: 140px; + animation: card-enter 0.42s ease backwards; } -.kid-card:hover { transform: translateY(-3px) scale(1.03); border-color: var(--accent); } +.kid-card:hover { + transform: translateY(-7px) scale(1.07); + border-color: var(--accent); + box-shadow: 0 12px 30px rgba(45, 49, 66, 0.18); +} +.kid-card:hover .avatar { animation: emoji-wobble 0.48s ease; } .kid-card .avatar { display: block; font-size: 3rem; margin-bottom: 0.5rem; } /* ---------- kid home ---------- */ @@ -111,7 +129,7 @@ body { .progress-track { height: 12px; background: #e8e2d0; border-radius: 6px; overflow: hidden; margin-bottom: 1.5rem; } -.progress-fill { height: 100%; width: 0; background: var(--accent); transition: width 0.3s; } +.progress-fill { height: 100%; width: 0; background: var(--accent); transition: width 0.5s ease-out; } .progress-fill.test-fill { background: var(--gold); } .word-card { @@ -140,8 +158,9 @@ body { background: var(--accent); color: #fff; border: none; border-radius: 12px; padding: 0.75rem 2.5rem; cursor: pointer; box-shadow: 0 4px 0 var(--accent-dark); + transition: transform 0.12s; } -.check-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--accent-dark); } +.check-btn:active { transform: scale(0.94) translateY(2px); box-shadow: 0 1px 0 var(--accent-dark); } /* letter-diff feedback */ .feedback { min-height: 1rem; margin-bottom: 1rem; font-size: 1.2rem; } @@ -276,6 +295,16 @@ table.results tr:nth-child(even) td { background: #faf7ee; } } .nav-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); } .tab-panel { text-align: left; } +@keyframes tab-slide-right { + from { transform: translateX(56px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +@keyframes tab-slide-left { + from { transform: translateX(-56px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} +.tab-panel.sliding-right { animation: tab-slide-right 0.26s ease; } +.tab-panel.sliding-left { animation: tab-slide-left 0.26s ease; } /* ---------- today list / item rows (shared by agenda + course pages) ---------- */ .today-row { @@ -441,6 +470,28 @@ table.results tr:nth-child(even) td { background: #faf7ee; } } .grade-btn.again { background: var(--bad); } .grade-btn.got-it { background: var(--good); } +@keyframes card-flash-correct { + 0% { background: var(--card); } + 30% { background: color-mix(in srgb, var(--good) 25%, var(--card)); } + 100% { background: var(--card); } +} +@keyframes card-flash-wrong { + 0% { background: var(--card); } + 30% { background: color-mix(in srgb, var(--bad) 20%, var(--card)); } + 100% { background: var(--card); } +} +.word-card.flash-correct { animation: card-flash-correct 0.55s ease; } +.word-card.flash-wrong { animation: card-flash-wrong 0.45s ease; } +@keyframes flash-flip-out { + from { transform: perspective(800px) rotateY(0deg); } + to { transform: perspective(800px) rotateY(90deg); } +} +@keyframes flash-flip-in { + from { transform: perspective(800px) rotateY(-90deg); } + to { transform: perspective(800px) rotateY(0deg); } +} +#flash-card.flip-out { animation: flash-flip-out 0.18s ease-in forwards; } +#flash-card.flip-in { animation: flash-flip-in 0.18s ease-out forwards; } /* ---------- admin console ---------- */ .admin-header { @@ -800,6 +851,144 @@ body.theme-indigo { --accent: #5b6abf; --accent-dark: #4a57a0; --bg: #f4f5ff; } .icon-choice:hover { transform: scale(1.2); } .icon-choice.active { border-color: var(--accent); background: var(--bg); } +/* ---------- background patterns ---------- */ +body.bg-dots { + background-image: radial-gradient(circle, color-mix(in srgb, var(--accent) 38%, transparent) 2.5px, transparent 2.5px); + background-size: 26px 26px; +} +body.bg-stripes { + background-image: repeating-linear-gradient( + 45deg, transparent, transparent 14px, + color-mix(in srgb, var(--accent) 18%, transparent) 14px, + color-mix(in srgb, var(--accent) 18%, transparent) 28px + ); +} +body.bg-grid { + background-image: + linear-gradient(color-mix(in srgb, var(--accent) 16%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--accent) 16%, transparent) 1px, transparent 1px); + background-size: 32px 32px; +} +body.bg-stars { + background-image: + radial-gradient(circle, color-mix(in srgb, var(--accent) 55%, transparent) 1.5px, transparent 1.5px), + radial-gradient(circle, color-mix(in srgb, var(--accent) 30%, transparent) 1px, transparent 1px), + radial-gradient(circle, color-mix(in srgb, var(--accent) 45%, transparent) 2px, transparent 2px); + background-size: 48px 48px, 28px 28px, 68px 68px; + background-position: 5px 8px, 18px 32px, 46px 18px; +} +body.bg-bubbles { + background-image: radial-gradient(circle, color-mix(in srgb, var(--accent) 18%, transparent) 18px, transparent 18px); + background-size: 64px 64px; +} + +/* ---------- bg pattern picker ---------- */ +.bg-pattern-panel { + display: flex; gap: 0.5rem; flex-wrap: wrap; + padding: 0.6rem 0.75rem; background: var(--card); + border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,0.12); + margin-bottom: 0.75rem; +} +.bg-pattern-btn { + width: 46px; height: 46px; border-radius: 8px; + border: 2.5px solid transparent; cursor: pointer; + background-color: var(--bg); flex-shrink: 0; + transition: border-color 0.15s, transform 0.15s; +} +.bg-pattern-btn:hover { transform: scale(1.12); } +.bg-pattern-btn.active { border-color: var(--accent); } +.bg-pattern-btn[data-pattern="dots"] { + background-image: radial-gradient(circle, color-mix(in srgb, var(--accent) 50%, transparent) 2.5px, transparent 2.5px); + background-size: 9px 9px; +} +.bg-pattern-btn[data-pattern="stripes"] { + background-image: repeating-linear-gradient( + 45deg, transparent, transparent 5px, + color-mix(in srgb, var(--accent) 30%, transparent) 5px, + color-mix(in srgb, var(--accent) 30%, transparent) 10px + ); +} +.bg-pattern-btn[data-pattern="grid"] { + background-image: + linear-gradient(color-mix(in srgb, var(--accent) 25%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--accent) 25%, transparent) 1px, transparent 1px); + background-size: 10px 10px; +} +.bg-pattern-btn[data-pattern="stars"] { + background-image: + radial-gradient(circle, color-mix(in srgb, var(--accent) 60%, transparent) 1.5px, transparent 1.5px), + radial-gradient(circle, color-mix(in srgb, var(--accent) 35%, transparent) 1px, transparent 1px); + background-size: 14px 14px, 8px 8px; + background-position: 2px 3px, 6px 10px; +} +.bg-pattern-btn[data-pattern="bubbles"] { + background-image: radial-gradient(circle, color-mix(in srgb, var(--accent) 25%, transparent) 8px, transparent 8px); + background-size: 22px 22px; +} + +/* ---------- welcome toast ---------- */ +.welcome-toast { + position: fixed; top: 50%; left: 50%; + transform: translate(-50%, -50%); + background: var(--card); border-radius: var(--radius); + padding: 2.2rem 3.5rem; text-align: center; + box-shadow: 0 16px 56px rgba(0,0,0,0.22); z-index: 1000; + pointer-events: none; + animation: welcome-in 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} +.welcome-emoji { display: block; font-size: 4.5rem; margin-bottom: 0.4rem; } +.welcome-name { font-size: 2.2rem; font-weight: 800; } +@keyframes welcome-in { + from { opacity: 0; transform: translate(-50%, -50%) scale(0.7); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} +@keyframes welcome-out { + to { opacity: 0; transform: translate(-50%, -54%) scale(0.88); } +} +.welcome-toast-out { animation: welcome-out 0.4s ease forwards; } + +/* ---------- done stamp ---------- */ +.done-stamp-wrap { + position: fixed; inset: 0; + display: flex; align-items: flex-end; justify-content: center; + padding-bottom: 10vh; + z-index: 1000; pointer-events: none; + background: rgba(0,0,0,0.06); +} +.done-stamp { + background: var(--card); border-radius: 50%; + width: 170px; height: 170px; + display: flex; align-items: center; justify-content: center; + font-size: 5.5rem; + box-shadow: 0 10px 48px rgba(0,0,0,0.22); + animation: stamp-in 0.38s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} +@keyframes stamp-in { + from { transform: scale(0) rotate(-18deg); opacity: 0; } + to { transform: scale(1) rotate(0deg); opacity: 1; } +} +@keyframes stamp-out { + to { transform: scale(0.75) rotate(6deg); opacity: 0; } +} +.done-stamp-out { animation: stamp-out 0.35s ease forwards; } + +/* ---------- mini celebration (confetti + toast) ---------- */ +@keyframes confetti-fall { + to { transform: translateY(105vh) rotate(540deg); opacity: 0; } +} +.mini-celebration-toast { + position: fixed; top: 45%; left: 50%; + transform: translate(-50%, -50%); + background: var(--card); border-radius: var(--radius); + padding: 1.6rem 3.2rem; font-size: 2.4rem; font-weight: 800; + text-align: center; white-space: nowrap; + box-shadow: 0 12px 48px rgba(0,0,0,0.28); z-index: 1000; + animation: pop-in 0.25s ease; pointer-events: none; +} +@keyframes mini-toast-out { + to { opacity: 0; transform: translate(-50%, -65%) scale(0.88); } +} + /* ---------- celebration overlay ---------- */ #celebration-overlay { position: fixed; inset: 0; z-index: 200; diff --git a/server.js b/server.js index 2bd0c40..174eeda 100644 --- a/server.js +++ b/server.js @@ -323,10 +323,11 @@ const GRADABLE_TYPES = ['assignment', 'quiz', 'matching', 'crossword', 'spelling // ---------- kid picker ---------- app.get('/api/students', (req, res) => { - res.json(db.prepare(`SELECT id, name, emoji, theme, streak_count, streak_date FROM students ORDER BY name`).all()); + res.json(db.prepare(`SELECT id, name, emoji, theme, bg_pattern, streak_count, streak_date FROM students ORDER BY name`).all()); }); const VALID_THEMES = ['blue','green','purple','orange','pink','red','teal','yellow','indigo']; +const VALID_BG_PATTERNS = ['none','dots','stripes','grid','stars','bubbles']; app.patch('/api/students/:id/theme', (req, res) => { const theme = VALID_THEMES.includes(req.body.theme) ? req.body.theme : 'blue'; @@ -334,6 +335,12 @@ app.patch('/api/students/:id/theme', (req, res) => { res.json({ ok: true }); }); +app.patch('/api/students/:id/bg-pattern', (req, res) => { + const pattern = VALID_BG_PATTERNS.includes(req.body.pattern) ? req.body.pattern : 'none'; + db.prepare(`UPDATE students SET bg_pattern = ? WHERE id = ?`).run(pattern, req.params.id); + res.json({ ok: true }); +}); + app.patch('/api/students/:id/emoji', (req, res) => { const emoji = req.body.emoji ? String(req.body.emoji).trim() : null; if (!emoji) return res.status(400).json({ error: 'emoji required' }); @@ -361,7 +368,7 @@ app.post('/api/students/:id/complete-day', (req, res) => { // Everything the standalone spelling home screen needs app.get('/api/state/:studentId', (req, res) => { const id = req.params.studentId; - const student = db.prepare(`SELECT id, name, emoji, theme FROM students WHERE id = ?`).get(id); + const student = db.prepare(`SELECT id, name, emoji, theme, bg_pattern FROM students WHERE id = ?`).get(id); if (!student) return res.status(404).json({ error: 'No such student' }); const assignment = db.prepare(`