Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const FractionPizza = lazy(() => import('./routes/arcade/FractionPizza').then((m
const ChessPuzzle = lazy(() => import('./routes/arcade/ChessPuzzle').then((m) => ({ default: m.ChessPuzzle })));
const ChineseCheckers = lazy(() => import('./routes/arcade/ChineseCheckers').then((m) => ({ default: m.ChineseCheckers })));
const GreedyCrawler = lazy(() => import('./routes/arcade/GreedyCrawler').then((m) => ({ default: m.GreedyCrawler })));
const CritterCottage = lazy(() => import('./routes/arcade/CritterCottage').then((m) => ({ default: m.CritterCottage })));
const NotFound = lazy(() => import('./routes/NotFound').then((m) => ({ default: m.NotFound })));

// Warm-up gate wraps every arcade game with a short adaptive quiz.
Expand Down Expand Up @@ -132,6 +133,7 @@ export default function App() {
<Route path="/arcade/chess" element={<ArcadeGate title="Checkmate Lab"><ChessPuzzle /></ArcadeGate>} />
<Route path="/arcade/starhop" element={<ArcadeGate title="Star Hop"><ChineseCheckers /></ArcadeGate>} />
<Route path="/arcade/crawler" element={<ArcadeGate title="Lucky Crawl"><GreedyCrawler /></ArcadeGate>} />
<Route path="/arcade/carpenter" element={<ArcadeGate title="Critter Cottage"><CritterCottage /></ArcadeGate>} />
<Route path="/settings" element={<Settings />} />
<Route path="/shop" element={<Shop />} />
<Route path="/rewards" element={<Rewards />} />
Expand Down
66 changes: 4 additions & 62 deletions src/components/Explanation.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { MathText } from './MathText';
import { useProgress } from '../state/progress';

interface AltExplanation {
title: string;
Expand All @@ -13,68 +12,11 @@ interface Props {
alternatives?: AltExplanation[];
}

// Think-time cover: hides the worked solution for a configurable delay so the
// student tries the problem first, then auto-reveals. Delay is set by the
// grown-ups in Settings (arcadeConfig.answerRevealSeconds; 0 = instant).
function ThinkTimeCover({ total, left }: { total: number; left: number }) {
const pct = total > 0 ? Math.max(0, Math.min(1, left / total)) : 0;
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 bg-amber-50 border-2 border-amber-200 rounded-2xl p-5 text-center"
>
<div className="text-3xl">🧠</div>
<div className="mt-2 font-display font-extrabold text-amber-900">
Give it a try first!
</div>
<p className="text-sm text-amber-800/90 mt-1">
Work it out yourself — the step-by-step answer appears in a moment.
</p>
<div className="mt-4 flex items-center justify-center gap-2">
<span className="text-2xl font-display font-extrabold tabular-nums text-amber-900">
{left}s
</span>
</div>
<div className="mt-2 h-2 w-full rounded-full bg-amber-200 overflow-hidden">
<motion.div
className="h-full bg-amber-500"
animate={{ width: `${pct * 100}%` }}
transition={{ duration: 0.25, ease: 'linear' }}
/>
</div>
</motion.div>
);
}

// Explanations are always available — kids can read the "how & why" at any time.
// The configurable think-time pause lives on the ANSWER reveal instead (see Hint),
// so the worked steps here never sit behind a countdown.
export function Explanation({ steps, alternatives }: Props) {
const [openAlt, setOpenAlt] = useState<number | null>(null);
const delay = useProgress((s) => s.arcadeConfig.answerRevealSeconds) ?? 15;
const [revealed, setRevealed] = useState(delay <= 0);
const [left, setLeft] = useState(delay);

useEffect(() => {
if (delay <= 0) {
setRevealed(true);
return;
}
setRevealed(false);
setLeft(delay);
const start = Date.now();
const id = window.setInterval(() => {
const rem = Math.max(0, delay - Math.round((Date.now() - start) / 1000));
setLeft(rem);
if (rem <= 0) {
window.clearInterval(id);
setRevealed(true);
}
}, 250);
return () => window.clearInterval(id);
}, [delay]);

if (!revealed) {
return <ThinkTimeCover total={delay} left={left} />;
}

return (
<motion.div
Expand Down
56 changes: 42 additions & 14 deletions src/components/Hint.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { MathText } from './MathText';
import { useProgress } from '../state/progress';
import type { HintStep, HintLevel } from '../types/problem';

interface Props {
Expand Down Expand Up @@ -29,9 +30,32 @@ const TIER_BADGE_STYLES: Record<HintLevel, string> = {

export function Hint({ tiers, onReveal, onExplain }: Props) {
const [revealed, setRevealed] = useState(0);
// Think-time pause applies to the ANSWER reveal only: before the final "Reveal"
// tier unlocks, a short countdown nudges the student to try first. Set by the
// grown-ups in Settings (arcadeConfig.answerRevealSeconds; 0 = instant).
const delay = useProgress((s) => s.arcadeConfig.answerRevealSeconds) ?? 15;
const [left, setLeft] = useState(0);

const nextTier = tiers[revealed]; // the tier the button would reveal next
const gateNext = !!nextTier && nextTier.level === 'reveal' && delay > 0;

// run the think-time countdown while the next tier to reveal is the answer
useEffect(() => {
if (!gateNext) { setLeft(0); return; }
setLeft(delay);
const start = Date.now();
const id = window.setInterval(() => {
const rem = Math.max(0, delay - Math.round((Date.now() - start) / 1000));
setLeft(rem);
if (rem <= 0) window.clearInterval(id);
}, 250);
return () => window.clearInterval(id);
}, [gateNext, delay, revealed]);

if (tiers.length === 0) return null;

const locked = gateNext && left > 0; // answer reveal held during think-time

const reveal = () => {
if (revealed >= tiers.length) return;
const next = revealed + 1;
Expand All @@ -40,29 +64,33 @@ export function Hint({ tiers, onReveal, onExplain }: Props) {
};

const more = revealed < tiers.length;
const buttonLabel =
revealed === 0
? 'Show hint'
: more
? 'Need another hint?'
: 'Hide hints';
const buttonLabel = !more
? 'Hide hints'
: locked
? `🧠 Think first… answer in ${left}s`
: nextTier?.level === 'reveal'
? 'Reveal the answer'
: revealed === 0
? 'Show hint'
: 'Need another hint?';

const handleClick = () => {
if (more) {
reveal();
} else {
setRevealed(0);
}
if (!more) { setRevealed(0); return; }
if (locked) return;
reveal();
};

return (
<div className="mt-3">
<button
type="button"
onClick={handleClick}
className="min-h-11 inline-flex items-center gap-2 px-4 py-2 rounded-full bg-amber-100 hover:bg-amber-200 text-amber-900 font-display font-bold text-sm transition-colors"
disabled={locked}
className={`min-h-11 inline-flex items-center gap-2 px-4 py-2 rounded-full font-display font-bold text-sm transition-colors ${
locked ? 'bg-slate-100 text-slate-400 cursor-not-allowed' : 'bg-amber-100 hover:bg-amber-200 text-amber-900'
}`}
>
<span>💡</span>
<span>{locked ? '⏳' : '💡'}</span>
<span>{buttonLabel}</span>
</button>
<AnimatePresence initial={false}>
Expand Down
122 changes: 103 additions & 19 deletions src/components/LessonCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
lessonKey,
lessonAnswerMatches,
type Lesson,
type LessonSlide,
type WorkedExample,
type PracticeQuestion,
} from '../data/lessons';
Expand All @@ -26,16 +27,35 @@ type Page =
| { kind: 'intro' }
| { kind: 'video'; idx: number }
| { kind: 'concept' }
| { kind: 'slide'; idx: number }
| { kind: 'example'; idx: number }
| { kind: 'practice'; idx: number }
| { kind: 'watchout' };

// Video slots are positional: videos[0] = the idea (after intro),
// videos[1] = worked examples (after the example pages),
// videos[2+] = avoid-the-trap (right before the wrap-up).
// Story-style deck: ONE video (the "idea" Manim animation) after the objective,
// then the authored slides (concepts → examples → pro tips), then interactive
// practice, then the trap/summary slides + watch-out. Lessons without an
// authored `slides` deck fall back to the legacy page set (all videos).
function buildPages(lesson: Lesson): Page[] {
const vids = lesson.videos ?? [];
const slides = lesson.slides ?? [];
const pages: Page[] = [{ kind: 'intro' }];

if (slides.length > 0) {
const idxOf = (s: LessonSlide) => slides.indexOf(s);
const objectives = slides.filter((s) => s.kind === 'objective');
const middle = slides.filter((s) => s.kind === 'concept' || s.kind === 'example' || s.kind === 'protip');
const tail = slides.filter((s) => s.kind === 'trap' || s.kind === 'summary');
objectives.forEach((s) => pages.push({ kind: 'slide', idx: idxOf(s) }));
if (vids.length > 0) pages.push({ kind: 'video', idx: 0 }); // the one lesson video
middle.forEach((s) => pages.push({ kind: 'slide', idx: idxOf(s) }));
lesson.practice.forEach((_, i) => pages.push({ kind: 'practice', idx: i }));
tail.forEach((s) => pages.push({ kind: 'slide', idx: idxOf(s) }));
pages.push({ kind: 'watchout' });
return pages;
}

// legacy layout (no slide deck authored)
if (vids.length > 0) pages.push({ kind: 'video', idx: 0 });
pages.push({ kind: 'concept' });
lesson.examples.forEach((_, i) => pages.push({ kind: 'example', idx: i }));
Expand All @@ -50,19 +70,45 @@ function buildPages(lesson: Lesson): Page[] {
const SECTION_ORDER = ['Intro', 'Key idea', 'Examples', 'Try it', 'Wrap-up'] as const;
type SectionName = (typeof SECTION_ORDER)[number];

function sectionOf(page: Page): SectionName {
if (page.kind === 'intro') return 'Intro';
if (page.kind === 'concept') return 'Key idea';
// Video pages roll into the section they support so the breadcrumb
// structure stays at 5 fixed sections.
if (page.kind === 'video') {
if (page.idx === 0) return 'Key idea';
if (page.idx === 1) return 'Examples';
function sectionOfSlide(kind: LessonSlide['kind']): SectionName {
if (kind === 'objective') return 'Intro';
if (kind === 'concept') return 'Key idea';
if (kind === 'example' || kind === 'protip') return 'Examples';
return 'Wrap-up'; // trap | summary
}

function makeSectionOf(lesson: Lesson): (page: Page) => SectionName {
const slides = lesson.slides ?? [];
return (page: Page): SectionName => {
if (page.kind === 'intro') return 'Intro';
if (page.kind === 'concept') return 'Key idea';
if (page.kind === 'slide') return sectionOfSlide(slides[page.idx]?.kind ?? 'concept');
// Video pages roll into the section they support so the breadcrumb
// structure stays at 5 fixed sections.
if (page.kind === 'video') {
if (page.idx === 0) return 'Key idea';
if (page.idx === 1) return 'Examples';
return 'Wrap-up';
}
if (page.kind === 'example') return 'Examples';
if (page.kind === 'practice') return 'Try it';
return 'Wrap-up';
};
}

// Minimum read time per page kind (seconds) before Next unlocks. The deck
// still ONLY advances on a button press — this just stops click-through.
// Scaled by the admin's lessonScreenSeconds (default 6 = 1×; 0 disables).
function baseSecsFor(page: Page, lesson: Lesson): number {
if (page.kind === 'slide') {
const k = (lesson.slides ?? [])[page.idx]?.kind;
if (k === 'example') return 8;
if (k === 'concept') return 5;
return 3; // objective | protip | trap | summary
}
if (page.kind === 'example') return 'Examples';
if (page.kind === 'practice') return 'Try it';
return 'Wrap-up';
if (page.kind === 'example' || page.kind === 'practice') return 8;
if (page.kind === 'concept') return 5;
return 3; // intro | video | watchout
}

interface PracticeState {
Expand All @@ -84,21 +130,23 @@ export function LessonCard({ lesson, onClose, onStart }: Props) {
const [exampleOpen, setExampleOpen] = useState<Record<number, boolean>>({});
const [practiceState, setPracticeState] = useState<Record<number, PracticeState>>({});

// Per-screen minimum read time (anti-click-through). Default 6s, set by the
// parent/admin controls. The primary Next/Finish button is disabled until the
// countdown for the current page elapses. 0 = off (instant advance).
// Per-slide minimum read time (anti-click-through): 8s example slides, 5s
// concept/explanation slides, 3s short slides. The admin's lessonScreenSeconds
// scales the pacing (6 = 1×; 0 = off). The deck still advances only on press.
const screenSecs = useProgress((s) => s.arcadeConfig.lessonScreenSeconds ?? 6);
const [remain, setRemain] = useState(screenSecs);
const [remain, setRemain] = useState(0);
useEffect(() => {
if (phase !== 'learn' || screenSecs <= 0) {
setRemain(0);
return;
}
setRemain(screenSecs);
const secs = Math.max(1, Math.round(baseSecsFor(pages[pageIndex], lesson) * (screenSecs / 6)));
setRemain(secs);
const id = setInterval(() => {
setRemain((r) => (r <= 1 ? 0 : r - 1));
}, 1000);
return () => clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageIndex, screenSecs, phase]);

const finish = () => {
Expand Down Expand Up @@ -127,6 +175,7 @@ export function LessonCard({ lesson, onClose, onStart }: Props) {
const goBack = () => setPageIndex((i) => Math.max(0, i - 1));

const current = pages[pageIndex];
const sectionOf = makeSectionOf(lesson);

return (
<AnimatePresence>
Expand Down Expand Up @@ -161,6 +210,7 @@ export function LessonCard({ lesson, onClose, onStart }: Props) {
<Breadcrumbs
pages={pages}
pageIndex={pageIndex}
sectionOf={sectionOf}
onJump={(idx) => setPageIndex(idx)}
/>

Expand All @@ -181,6 +231,9 @@ export function LessonCard({ lesson, onClose, onStart }: Props) {
/>
)}
{current.kind === 'concept' && <ConceptPage lesson={lesson} />}
{current.kind === 'slide' && lesson.slides?.[current.idx] && (
<SlidePage slide={lesson.slides[current.idx]} />
)}
{current.kind === 'example' && (
<ExamplePage
ex={lesson.examples[current.idx]}
Expand Down Expand Up @@ -247,10 +300,12 @@ export function LessonCard({ lesson, onClose, onStart }: Props) {
function Breadcrumbs({
pages,
pageIndex,
sectionOf,
onJump,
}: {
pages: Page[];
pageIndex: number;
sectionOf: (page: Page) => SectionName;
onJump: (idx: number) => void;
}) {
const activeSection = sectionOf(pages[pageIndex]);
Expand Down Expand Up @@ -384,6 +439,35 @@ function ConceptPage({ lesson }: { lesson: Lesson }) {
);
}

// One story-style slide: a kind badge, a big headline, and ~3 readable
// sentences. Renders lesson `slides` decks (the math-stories format).
const SLIDE_STYLE: Record<LessonSlide['kind'], { badge: string; emoji: string; card: string; badgeCls: string }> = {
objective: { badge: "Today's goal", emoji: '🎯', card: 'bg-sky-50 border-sky-200', badgeCls: 'bg-sky-200 text-sky-900' },
concept: { badge: 'How it works', emoji: '💡', card: 'bg-blue-50 border-blue-200', badgeCls: 'bg-blue-200 text-blue-900' },
example: { badge: 'Worked example', emoji: '✏️', card: 'bg-emerald-50 border-emerald-200', badgeCls: 'bg-emerald-200 text-emerald-900' },
protip: { badge: 'Pro tip', emoji: '⭐', card: 'bg-violet-50 border-violet-200', badgeCls: 'bg-violet-200 text-violet-900' },
trap: { badge: 'Trap to avoid', emoji: '⚠️', card: 'bg-amber-50 border-amber-200', badgeCls: 'bg-amber-200 text-amber-900' },
summary: { badge: 'Summary', emoji: '🏁', card: 'bg-green-50 border-green-200', badgeCls: 'bg-green-200 text-green-900' },
};

function SlidePage({ slide }: { slide: LessonSlide }) {
const st = SLIDE_STYLE[slide.kind];
return (
<div>
<div className="flex items-center justify-between gap-2">
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[11px] font-display font-extrabold uppercase tracking-wider ${st.badgeCls}`}>
<span aria-hidden="true">{st.emoji}</span> {st.badge}
</span>
<ReadAloud text={[slide.head, slide.body]} label="" />
</div>
<div className={`mt-3 rounded-2xl border-2 p-4 ${st.card}`}>
<h3 className="text-xl font-display font-extrabold leading-tight text-slate-900">{slide.head}</h3>
<p className="mt-2 whitespace-pre-line text-[15px] leading-relaxed text-slate-800">{slide.body}</p>
</div>
</div>
);
}

function ExamplePage({
ex,
index,
Expand Down
Loading