img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..014f5aa
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,160 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..986f346
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,83 @@
+"use client"
+
+import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
+
+import { cn } from "@/lib/utils"
+
+function Progress({
+ className,
+ children,
+ value,
+ ...props
+}: ProgressPrimitive.Root.Props) {
+ return (
+
+ {children}
+
+
+
+
+ )
+}
+
+function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
+ return (
+
+ )
+}
+
+function ProgressIndicator({
+ className,
+ ...props
+}: ProgressPrimitive.Indicator.Props) {
+ return (
+
+ )
+}
+
+function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
+ return (
+
+ )
+}
+
+function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+export {
+ Progress,
+ ProgressTrack,
+ ProgressIndicator,
+ ProgressLabel,
+ ProgressValue,
+}
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..84c1e9f
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..6e1369e
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..69e8a82
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delay = 0,
+ ...props
+}: TooltipPrimitive.Provider.Props) {
+ return (
+
+ )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+ return
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+ return
+}
+
+function TooltipContent({
+ className,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ children,
+ ...props
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/src/components/workbench/Scratchpad.tsx b/src/components/workbench/Scratchpad.tsx
new file mode 100644
index 0000000..ddfdcb5
--- /dev/null
+++ b/src/components/workbench/Scratchpad.tsx
@@ -0,0 +1,366 @@
+'use client';
+
+import {
+ useState,
+ useRef,
+ useCallback,
+ useEffect,
+ type PointerEvent as ReactPointerEvent,
+} from 'react';
+import { Pencil, Type, Eraser, Trash2, Minus, Plus } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+type Tool = 'pen' | 'text' | 'eraser';
+
+interface TextBox {
+ id: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ text: string;
+}
+
+const COLORS = ['#ffffff', '#ef4444', '#22c55e', '#3b82f6', '#eab308', '#a855f7'];
+const SIZES = [2, 4, 6];
+
+export function Scratchpad() {
+ const canvasRef = useRef
(null);
+ const containerRef = useRef(null);
+ const [tool, setTool] = useState('pen');
+ const [color, setColor] = useState('#ffffff');
+ const [strokeSize, setStrokeSize] = useState(2);
+ const [isDrawing, setIsDrawing] = useState(false);
+ const [textBoxes, setTextBoxes] = useState([]);
+ const [dragging, setDragging] = useState<{ id: string; offsetX: number; offsetY: number } | null>(null);
+ const [resizing, setResizing] = useState<{ id: string; startW: number; startH: number; startX: number; startY: number } | null>(null);
+ const lastPoint = useRef<{ x: number; y: number } | null>(null);
+
+ // Setup canvas resolution
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container) return;
+
+ const resize = () => {
+ const rect = container.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ // Save current drawing
+ const imageData = canvas.getContext('2d')?.getImageData(0, 0, canvas.width, canvas.height);
+
+ canvas.width = rect.width * dpr;
+ canvas.height = rect.height * dpr;
+ canvas.style.width = `${rect.width}px`;
+ canvas.style.height = `${rect.height}px`;
+
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.scale(dpr, dpr);
+ // Restore drawing if dimensions haven't changed too much
+ if (imageData) {
+ const tempCanvas = document.createElement('canvas');
+ tempCanvas.width = imageData.width;
+ tempCanvas.height = imageData.height;
+ tempCanvas.getContext('2d')?.putImageData(imageData, 0, 0);
+ ctx.drawImage(tempCanvas, 0, 0, imageData.width / dpr, imageData.height / dpr);
+ }
+ }
+ };
+
+ resize();
+ const observer = new ResizeObserver(resize);
+ observer.observe(container);
+ return () => observer.disconnect();
+ }, []);
+
+ const getPos = useCallback((e: ReactPointerEvent | globalThis.PointerEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return { x: 0, y: 0 };
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: e.clientX - rect.left,
+ y: e.clientY - rect.top,
+ };
+ }, []);
+
+ const drawLine = useCallback(
+ (from: { x: number; y: number }, to: { x: number; y: number }) => {
+ const ctx = canvasRef.current?.getContext('2d');
+ if (!ctx) return;
+ ctx.beginPath();
+ ctx.moveTo(from.x, from.y);
+ ctx.lineTo(to.x, to.y);
+ ctx.strokeStyle = tool === 'eraser' ? '#0a0a0a' : color;
+ ctx.lineWidth = tool === 'eraser' ? strokeSize * 6 : strokeSize;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.stroke();
+ },
+ [color, strokeSize, tool]
+ );
+
+ const handlePointerDown = useCallback(
+ (e: ReactPointerEvent) => {
+ if (tool === 'text') {
+ // Place a new text box at click position
+ const pos = getPos(e);
+ const newBox: TextBox = {
+ id: crypto.randomUUID(),
+ x: pos.x,
+ y: pos.y,
+ width: 160,
+ height: 60,
+ text: '',
+ };
+ setTextBoxes((prev) => [...prev, newBox]);
+ setTool('pen'); // Switch back to pen after placing
+ return;
+ }
+
+ setIsDrawing(true);
+ lastPoint.current = getPos(e);
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+ },
+ [tool, getPos]
+ );
+
+ const handlePointerMove = useCallback(
+ (e: ReactPointerEvent) => {
+ if (!isDrawing || !lastPoint.current) return;
+ const pos = getPos(e);
+ drawLine(lastPoint.current, pos);
+ lastPoint.current = pos;
+ },
+ [isDrawing, getPos, drawLine]
+ );
+
+ const handlePointerUp = useCallback(() => {
+ setIsDrawing(false);
+ lastPoint.current = null;
+ }, []);
+
+ const clearCanvas = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ const dpr = window.devicePixelRatio || 1;
+ ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
+ setTextBoxes([]);
+ }, []);
+
+ const deleteTextBox = useCallback((id: string) => {
+ setTextBoxes((prev) => prev.filter((b) => b.id !== id));
+ }, []);
+
+ // Global pointer move/up for dragging text boxes
+ useEffect(() => {
+ if (!dragging && !resizing) return;
+
+ // Prevent text selection while dragging/resizing
+ const preventSelect = (e: Event) => e.preventDefault();
+ document.addEventListener('selectstart', preventSelect);
+
+ const handleMove = (e: globalThis.PointerEvent) => {
+ if (dragging) {
+ setTextBoxes((prev) =>
+ prev.map((b) =>
+ b.id === dragging.id
+ ? { ...b, x: e.clientX - (containerRef.current?.getBoundingClientRect().left ?? 0) - dragging.offsetX, y: e.clientY - (containerRef.current?.getBoundingClientRect().top ?? 0) - dragging.offsetY }
+ : b
+ )
+ );
+ }
+ if (resizing) {
+ const dx = e.clientX - resizing.startX;
+ const dy = e.clientY - resizing.startY;
+ setTextBoxes((prev) =>
+ prev.map((b) =>
+ b.id === resizing.id
+ ? { ...b, width: Math.max(80, resizing.startW + dx), height: Math.max(30, resizing.startH + dy) }
+ : b
+ )
+ );
+ }
+ };
+
+ const handleUp = () => {
+ setDragging(null);
+ setResizing(null);
+ };
+
+ window.addEventListener('pointermove', handleMove);
+ window.addEventListener('pointerup', handleUp);
+ return () => {
+ document.removeEventListener('selectstart', preventSelect);
+ window.removeEventListener('pointermove', handleMove);
+ window.removeEventListener('pointerup', handleUp);
+ };
+ }, [dragging, resizing]);
+
+ return (
+
+ {/* Toolbar */}
+
+
+
+
+
+
+
+ {/* Colors */}
+ {COLORS.map((c) => (
+
+
+ {/* Canvas area */}
+
+
+
+ {/* Text boxes */}
+ {textBoxes.map((box) => (
+
+ {/* Drag handle */}
+
{
+ e.stopPropagation();
+ setDragging({ id: box.id, offsetX: e.nativeEvent.offsetX, offsetY: e.nativeEvent.offsetY + 20 });
+ }}
+ >
+
+
deleteTextBox(box.id)}
+ >
+
+
+
+
+
+ ))}
+
+ {/* Tool indicator */}
+ {tool === 'text' && (
+
+ Haz clic para colocar un cuadro de texto
+
+ )}
+
+
+ );
+}
diff --git a/src/components/workbench/WorkbenchShell.tsx b/src/components/workbench/WorkbenchShell.tsx
new file mode 100644
index 0000000..d1510bf
--- /dev/null
+++ b/src/components/workbench/WorkbenchShell.tsx
@@ -0,0 +1,433 @@
+'use client';
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useRouter } from 'next/navigation';
+import { ArrowLeft, Lightbulb, Clock, CheckCircle2, XCircle, ArrowRight, Eye, PenTool, BookOpen, ChevronDown, ChevronUp } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { Challenge } from '@/types/challenge';
+import { verifyAnswer } from '@/lib/challenge-engine/verifier';
+import { useProgressStore } from '@/stores/useProgressStore';
+import { getChallengesForNode } from '@/data/challenges/math';
+import { MathInput } from './modules/MathInput';
+import { MultipleChoice } from './modules/MultipleChoice';
+import { Scratchpad } from './Scratchpad';
+
+const MAX_ATTEMPTS_BEFORE_REVEAL = 3;
+
+interface WorkbenchShellProps {
+ challenge: Challenge;
+}
+
+function Kbd({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+type Phase = 'answering' | 'wrong-shake' | 'correct' | 'revealed';
+
+export function WorkbenchShell({ challenge }: WorkbenchShellProps) {
+ const router = useRouter();
+ const completeChallenge = useProgressStore((s) => s.completeChallenge);
+ const completedChallenges = useProgressStore((s) => s.completedChallenges);
+
+ const [answer, setAnswer] = useState('');
+ const [phase, setPhase] = useState('answering');
+ const [attempts, setAttempts] = useState(0);
+ const [showHint, setShowHint] = useState(false);
+ const [hintIndex, setHintIndex] = useState(0);
+ const [elapsedTime, setElapsedTime] = useState(0);
+ const [canProceed, setCanProceed] = useState(false);
+ const shakeTimeout = useRef>(null);
+ const [showScratchpad, setShowScratchpad] = useState(true);
+ const [showExplanation, setShowExplanation] = useState(!!challenge.explanation);
+
+ const isAlreadyCompleted = !!completedChallenges[challenge.id];
+ const solved = phase === 'correct';
+ const revealed = phase === 'revealed';
+ const done = solved || revealed;
+
+ const getNextChallenge = useCallback((): Challenge | null => {
+ const nodeChallenges = getChallengesForNode(challenge.nodeId);
+ const currentIndex = nodeChallenges.findIndex((c) => c.id === challenge.id);
+ for (let i = currentIndex + 1; i < nodeChallenges.length; i++) {
+ if (!completedChallenges[nodeChallenges[i].id]) return nodeChallenges[i];
+ }
+ for (let i = 0; i < currentIndex; i++) {
+ if (!completedChallenges[nodeChallenges[i].id]) return nodeChallenges[i];
+ }
+ return null;
+ }, [challenge.id, challenge.nodeId, completedChallenges]);
+
+ // Timer
+ useEffect(() => {
+ if (done) return;
+ const interval = setInterval(() => setElapsedTime((t) => t + 1), 1000);
+ return () => clearInterval(interval);
+ }, [done]);
+
+ // Cleanup shake timeout
+ useEffect(() => {
+ return () => { if (shakeTimeout.current) clearTimeout(shakeTimeout.current); };
+ }, []);
+
+ const formatTime = (seconds: number) => {
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ return `${m}:${s.toString().padStart(2, '0')}`;
+ };
+
+ const handleSubmit = useCallback(() => {
+ if (answer === '' || answer === undefined || done) return;
+ const verification = verifyAnswer(challenge, answer);
+
+ if (verification.correct) {
+ setPhase('correct');
+ setCanProceed(false);
+ setTimeout(() => setCanProceed(true), 800);
+ if (!isAlreadyCompleted) {
+ completeChallenge(challenge.id, elapsedTime);
+ }
+ } else {
+ const newAttempts = attempts + 1;
+ setAttempts(newAttempts);
+ // Brief shake feedback, then clear input to try again
+ setPhase('wrong-shake');
+ shakeTimeout.current = setTimeout(() => {
+ setPhase('answering');
+ setAnswer('');
+ }, 600);
+ }
+ }, [answer, challenge, completeChallenge, elapsedTime, isAlreadyCompleted, attempts, done]);
+
+ const handleRevealSolution = useCallback(() => {
+ setPhase('revealed');
+ setCanProceed(false);
+ setTimeout(() => setCanProceed(true), 800);
+ }, []);
+
+ const handleNextChallenge = useCallback(() => {
+ const next = getNextChallenge();
+ if (next) {
+ router.push(`/workbench/${encodeURIComponent(next.id)}`);
+ } else {
+ router.push('/skill-tree');
+ }
+ }, [getNextChallenge, router]);
+
+ const handleBack = () => {
+ router.push('/skill-tree');
+ };
+
+ const handleNextHint = () => {
+ if (hintIndex < challenge.hints.length - 1) {
+ setHintIndex((i) => i + 1);
+ }
+ setShowHint(true);
+ };
+
+ const getSolutionText = (): string => {
+ if (challenge.content.type === 'math-input') {
+ return String(challenge.content.answer.value);
+ }
+ if (challenge.content.type === 'multiple-choice') {
+ return challenge.content.options[challenge.content.correctIndex];
+ }
+ return '';
+ };
+
+ // Global keyboard handler
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const tag = (e.target as HTMLElement)?.tagName;
+ const isInput = tag === 'INPUT' || tag === 'TEXTAREA';
+
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (!done && phase !== 'wrong-shake') {
+ handleSubmit();
+ } else if (done && canProceed) {
+ handleNextChallenge();
+ }
+ return;
+ }
+
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ handleBack();
+ return;
+ }
+
+ // H for hint
+ if (e.key === 'h' && !isInput && !done && phase !== 'wrong-shake') {
+ e.preventDefault();
+ handleNextHint();
+ return;
+ }
+
+ // S to reveal solution (only when enough attempts)
+ if (e.key === 's' && !isInput && !done && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL) {
+ e.preventDefault();
+ handleRevealSolution();
+ return;
+ }
+
+ // Multiple choice: number keys 1-9
+ if (challenge.content.type === 'multiple-choice' && !isInput && !done && phase !== 'wrong-shake') {
+ const num = parseInt(e.key);
+ if (num >= 1 && num <= challenge.content.options.length) {
+ e.preventDefault();
+ setAnswer(num - 1);
+ }
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [phase, done, canProceed, attempts, handleSubmit, handleNextChallenge, handleRevealSolution, challenge.content]);
+
+ const nextChallenge = getNextChallenge();
+
+ return (
+
+ {/* Top bar */}
+
+
+ {/* Split layout: challenge + scratchpad */}
+
+ {/* Challenge panel */}
+
+
+ {/* Explanation / Theory */}
+ {challenge.explanation && (
+
+ setShowExplanation((v) => !v)}
+ className="w-full px-6 py-4 flex items-center gap-2 text-left hover:bg-blue-500/5 transition-colors"
+ >
+
+ Aprende primero
+ {showExplanation ? (
+
+ ) : (
+
+ )}
+
+ {showExplanation && (
+
+
+ {challenge.explanation}
+
+
+ )}
+
+ )}
+
+ {/* Problem statement */}
+
+ Problema
+ {challenge.description}
+
+
+ {/* Workbench module */}
+
+ Tu respuesta
+ {challenge.content.type === 'math-input' && (
+
+ )}
+ {challenge.content.type === 'multiple-choice' && (
+
+ )}
+ {/* Wrong attempt feedback inline */}
+ {phase === 'wrong-shake' && (
+
+ Incorrecto. Inténtalo de nuevo.
+
+ )}
+
+
+ {/* Hint section */}
+ {challenge.hints.length > 0 && !done && phase !== 'wrong-shake' && (
+
+ {showHint ? (
+
+
+
+
+
+ Pista {hintIndex + 1}/{challenge.hints.length}
+
+
{challenge.hints[hintIndex]}
+ {hintIndex < challenge.hints.length - 1 && (
+
+ Siguiente pista H
+
+ )}
+
+
+
+ ) : (
+
+
+ Mostrar pista H
+
+ )}
+
+ )}
+
+ {/* Reveal solution button — only after enough failed attempts */}
+ {!done && phase !== 'wrong-shake' && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL && (
+
+
+ Ver solución S
+
+ )}
+
+ {/* Result feedback — correct */}
+ {solved && (
+
+
+ {!isAlreadyCompleted && (
+
+ +{challenge.xpReward} XP ganados
+ {attempts === 0 && ' — ¡a la primera!'}
+
+ )}
+
+ )}
+
+ {/* Result feedback — revealed */}
+ {revealed && (
+
+
+ {getSolutionText()}
+
+ No se otorga XP al revelar la solución. Intenta recordarla para la próxima.
+
+
+ )}
+
+ {/* Submit / Continue */}
+
+ {!done && phase !== 'wrong-shake' ? (
+
+ Verificar respuesta Enter
+
+ ) : done ? (
+ <>
+
+ {nextChallenge ? 'Siguiente reto' : 'Volver al árbol'}
+
+ Enter
+
+ {nextChallenge && (
+
+ Árbol Esc
+
+ )}
+ >
+ ) : null}
+
+
+ {/* Keyboard shortcuts legend */}
+
+ Enter Verificar / Siguiente
+ Esc Volver al árbol
+ {challenge.hints.length > 0 && !done && H Pista}
+ {!done && attempts >= MAX_ATTEMPTS_BEFORE_REVEAL && S Ver solución}
+ {challenge.content.type === 'multiple-choice' && !done && 1-{challenge.content.options.length} Seleccionar}
+
+
+
+
+ {/* Scratchpad panel */}
+ {showScratchpad && (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/workbench/modules/MathInput.tsx b/src/components/workbench/modules/MathInput.tsx
new file mode 100644
index 0000000..18ae183
--- /dev/null
+++ b/src/components/workbench/modules/MathInput.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import { useRef, useEffect, useState, useCallback } from 'react';
+
+interface MathInputProps {
+ value: string;
+ onChange: (val: string) => void;
+ onSubmit: () => void;
+ disabled?: boolean;
+}
+
+export function MathInput({ value, onChange, onSubmit, disabled }: MathInputProps) {
+ const inputRef = useRef(null);
+ const [rtl, setRtl] = useState(false);
+
+ useEffect(() => {
+ if (!disabled) inputRef.current?.focus();
+ }, [disabled]);
+
+ // In RTL mode we intercept all key input manually.
+ // Each new digit is prepended (inserted at the left).
+ // Backspace removes the leftmost digit.
+ // The stored `value` is always the final number in normal reading order.
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (disabled) return;
+
+ if (e.key === 'Enter' && value !== '') {
+ e.preventDefault();
+ onSubmit();
+ return;
+ }
+
+ if (!rtl) return; // In LTR mode, let the browser handle it normally
+
+ // Prevent default so we control all input
+ const isDigit = /^[0-9]$/.test(e.key);
+ const isDot = e.key === '.';
+ const isMinus = e.key === '-';
+ const isBackspace = e.key === 'Backspace';
+
+ if (isDigit || isDot || isMinus) {
+ e.preventDefault();
+ // Prepend the character
+ onChange(e.key + value);
+ } else if (isBackspace) {
+ e.preventDefault();
+ // Remove the first character (the last one typed)
+ onChange(value.slice(1));
+ } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
+ // Block any other printable character
+ e.preventDefault();
+ }
+ },
+ [disabled, rtl, value, onChange, onSubmit]
+ );
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ if (disabled || rtl) return; // RTL is handled via keydown
+ onChange(e.target.value);
+ },
+ [disabled, rtl, onChange]
+ );
+
+ const handleToggle = useCallback(() => {
+ setRtl((prev) => !prev);
+ setTimeout(() => inputRef.current?.focus(), 0);
+ }, []);
+
+ return (
+
+
+
+
+ {rtl ? '←' : '→'}
+ {rtl ? 'Derecha a izquierda' : 'Izquierda a derecha'}
+
+
+
+ );
+}
diff --git a/src/components/workbench/modules/MultipleChoice.tsx b/src/components/workbench/modules/MultipleChoice.tsx
new file mode 100644
index 0000000..7caa1ad
--- /dev/null
+++ b/src/components/workbench/modules/MultipleChoice.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+interface MultipleChoiceProps {
+ options: string[];
+ selected: number | string;
+ onChange: (val: number) => void;
+ disabled?: boolean;
+}
+
+export function MultipleChoice({ options, selected, onChange, disabled }: MultipleChoiceProps) {
+ return (
+
+ {options.map((option, index) => {
+ const isSelected = selected === index;
+ return (
+
!disabled && onChange(index)}
+ disabled={disabled}
+ className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
+ isSelected
+ ? 'border-primary bg-primary/10 text-foreground'
+ : 'border-border bg-muted/30 text-foreground hover:border-primary/30 hover:bg-muted/50'
+ } disabled:opacity-60 disabled:cursor-not-allowed`}
+ >
+
+
+ {isSelected ? (
+
+ ) : (
+ index + 1
+ )}
+
+
{option}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/data/challenges/math.ts b/src/data/challenges/math.ts
new file mode 100644
index 0000000..784ef01
--- /dev/null
+++ b/src/data/challenges/math.ts
@@ -0,0 +1,204 @@
+import { Challenge } from '@/types/challenge';
+
+function mathChallenge(
+ id: string,
+ nodeId: string,
+ title: string,
+ problem: string,
+ answer: number,
+ difficulty: 1 | 2 | 3 | 4 | 5 = 1,
+ hints: string[] = [],
+ tolerance = 0,
+ explanation?: string
+): Challenge {
+ return {
+ id: `${nodeId}/${id}`,
+ nodeId,
+ title,
+ description: problem,
+ difficulty,
+ type: 'math-input',
+ hints,
+ xpReward: difficulty * 20,
+ explanation,
+ content: {
+ type: 'math-input',
+ problem,
+ answer: { type: 'numeric', value: answer, tolerance },
+ },
+ };
+}
+
+export const arithmeticChallenges: Challenge[] = [
+ // Addition
+ mathChallenge('add-01', 'arithmetic.addition', 'Suma simple', '¿Cuánto es 7 + 5?', 12, 1, ['Cuenta desde 7 hacia arriba'], 0,
+ 'La suma es la operación más básica. Combina dos cantidades en una sola.\n\nPor ejemplo: si tienes 3 manzanas y te dan 2 más, ahora tienes 3 + 2 = 5 manzanas.\n\nTruco: para sumar mentalmente, empieza por el número más grande y cuenta hacia arriba. Por ejemplo, para 7 + 5: empieza en 7 y cuenta 5 más → 8, 9, 10, 11, 12.'),
+ mathChallenge('add-02', 'arithmetic.addition', 'Suma de dos cifras', '¿Cuánto es 34 + 27?', 61, 1, ['Suma las unidades primero: 4+7=11']),
+ mathChallenge('add-03', 'arithmetic.addition', 'Suma de tres números', '¿Cuánto es 15 + 23 + 42?', 80, 1, ['Suma de dos en dos']),
+ mathChallenge('add-04', 'arithmetic.addition', 'Suma con centenas', '¿Cuánto es 256 + 189?', 445, 1, ['Empieza por las unidades: 6+9=15']),
+
+ // Subtraction
+ mathChallenge('sub-01', 'arithmetic.subtraction', 'Resta simple', '¿Cuánto es 15 - 8?', 7, 1, ['Cuenta hacia atrás desde 15'], 0,
+ 'La resta es la operación inversa de la suma. Quita una cantidad de otra.\n\nPor ejemplo: si tienes 10 galletas y comes 3, te quedan 10 - 3 = 7.\n\nTruco: puedes pensar "¿qué le sumo a 8 para llegar a 15?". Si 8 + 7 = 15, entonces 15 - 8 = 7.'),
+ mathChallenge('sub-02', 'arithmetic.subtraction', 'Resta de dos cifras', '¿Cuánto es 82 - 47?', 35, 1, ['Necesitas "pedir prestado" en las unidades']),
+ mathChallenge('sub-03', 'arithmetic.subtraction', 'Resta con centenas', '¿Cuánto es 500 - 237?', 263, 1),
+
+ // Multiplication
+ mathChallenge('mul-01', 'arithmetic.multiplication', 'Multiplicación básica', '¿Cuánto es 6 × 7?', 42, 1, ['Piensa en 6 grupos de 7'], 0,
+ 'La multiplicación es una suma repetida. 6 × 7 significa "sumar 7 seis veces" (o "sumar 6 siete veces").\n\n6 × 7 = 7 + 7 + 7 + 7 + 7 + 7 = 42\n\nAprender las tablas de multiplicar de memoria es muy útil. Truco: si no recuerdas 6×7, piensa en 6×5=30 y luego suma 6×2=12 → 30+12=42.'),
+ mathChallenge('mul-02', 'arithmetic.multiplication', 'Multiplicación de dos cifras', '¿Cuánto es 12 × 15?', 180, 1, ['12 × 15 = 12 × 10 + 12 × 5']),
+ mathChallenge('mul-03', 'arithmetic.multiplication', 'Multiplicación avanzada', '¿Cuánto es 25 × 32?', 800, 2, ['25 × 32 = 25 × 4 × 8']),
+
+ // Division
+ mathChallenge('div-01', 'arithmetic.division', 'División exacta', '¿Cuánto es 56 ÷ 8?', 7, 1, ['¿Qué número × 8 = 56?'], 0,
+ 'La división es la operación inversa de la multiplicación. Reparte una cantidad en partes iguales.\n\n56 ÷ 8 significa: "¿en cuántos grupos de 8 cabe 56?" o "si reparto 56 entre 8, ¿cuánto toca a cada uno?"\n\nTruco: piensa "¿qué número multiplicado por 8 da 56?". Como 7 × 8 = 56, entonces 56 ÷ 8 = 7.'),
+ mathChallenge('div-02', 'arithmetic.division', 'División de dos cifras', '¿Cuánto es 144 ÷ 12?', 12, 1),
+ mathChallenge('div-03', 'arithmetic.division', 'División con decimales', '¿Cuánto es 7 ÷ 4?', 1.75, 2, ['Divide y continúa con decimales'], 0.01),
+];
+
+export const fractionChallenges: Challenge[] = [
+ mathChallenge('frac-01', 'arithmetic.fractions', 'Suma de fracciones', '¿Cuánto es 1/3 + 1/6?', 0.5, 2, ['Encuentra un denominador común: 6'], 0.01,
+ 'Las fracciones representan partes de un todo. 1/3 = "una de tres partes", 1/6 = "una de seis partes".\n\nPara sumar fracciones necesitas el mismo denominador (la parte de abajo):\n• 1/3 = 2/6 (multiplica arriba y abajo por 2)\n• 2/6 + 1/6 = 3/6 = 1/2 = 0.5\n\nRegla: busca el mínimo común denominador, convierte ambas fracciones, y luego suma los numeradores.'),
+ mathChallenge('frac-02', 'arithmetic.fractions', 'Multiplicación de fracciones', '¿Cuánto es 2/3 × 3/4?', 0.5, 2, ['Multiplica numerador con numerador y denominador con denominador'], 0.01),
+ mathChallenge('frac-03', 'arithmetic.fractions', 'Fracción a decimal', '¿Cuánto es 5/8 en decimal?', 0.625, 2, ['Divide 5 entre 8'], 0.001),
+];
+
+export const decimalChallenges: Challenge[] = [
+ mathChallenge('dec-01', 'arithmetic.decimals', 'Suma de decimales', '¿Cuánto es 3.7 + 2.85?', 6.55, 2, ['Alinea los puntos decimales'], 0.01,
+ 'Los decimales son otra forma de escribir fracciones. 3.7 = 3 + 7/10, y 2.85 = 2 + 85/100.\n\nPara sumar decimales, alinea los puntos decimales y suma columna por columna:\n 3.70\n+ 2.85\n------\n 6.55\n\nTruco: si un número tiene menos decimales, añade ceros al final (3.7 → 3.70) para que sea más fácil alinearlos.'),
+ mathChallenge('dec-02', 'arithmetic.decimals', 'Redondeo', 'Redondea 3.746 a dos decimales', 3.75, 2, ['Mira el tercer decimal: 6 ≥ 5, sube'], 0.001),
+ mathChallenge('dec-03', 'arithmetic.decimals', 'Multiplicación decimal', '¿Cuánto es 2.5 × 0.4?', 1, 2, ['2.5 × 0.4 = 25 × 4 ÷ 100']),
+];
+
+export const percentageChallenges: Challenge[] = [
+ mathChallenge('pct-01', 'arithmetic.percentages', 'Porcentaje básico', '¿Cuánto es el 25% de 200?', 50, 2, ['25% = 1/4'], 0,
+ 'Porcentaje significa "por cada cien". 25% = 25/100 = 0.25\n\nPara calcular un porcentaje de un número, multiplica el número por el porcentaje en decimal:\n• 25% de 200 = 0.25 × 200 = 50\n\nAtajos útiles:\n• 50% = la mitad\n• 25% = un cuarto\n• 10% = mover el punto decimal una posición a la izquierda\n• 1% = mover el punto decimal dos posiciones'),
+ mathChallenge('pct-02', 'arithmetic.percentages', 'Descuento', 'Un artículo cuesta 80€. Con 15% de descuento, ¿cuánto pagas?', 68, 2, ['Calcula el 15% de 80 y réstalo']),
+ mathChallenge('pct-03', 'arithmetic.percentages', 'Porcentaje inverso', 'Si 30 es el 60% de un número, ¿cuál es ese número?', 50, 2, ['30 = 0.6 × x, entonces x = 30/0.6']),
+];
+
+export const primeChallenges: Challenge[] = [
+ mathChallenge('prime-01', 'number-theory.primes', '¿Es primo?', '¿Cuántos números primos hay entre 1 y 20?', 8, 2, ['Los primos son: 2, 3, 5, 7, 11, 13, 17, 19'], 0,
+ 'Un número primo es un número mayor que 1 que solo es divisible por 1 y por sí mismo.\n\nEjemplos:\n• 2 es primo (solo divisible por 1 y 2)\n• 4 NO es primo (divisible por 1, 2 y 4)\n• 7 es primo\n• 9 NO es primo (3 × 3 = 9)\n\nEl 1 no se considera primo. El 2 es el único primo par.\n\nPara verificar si un número es primo, comprueba si es divisible por algún número hasta su raíz cuadrada.'),
+ mathChallenge('prime-02', 'number-theory.primes', 'Factorización', '¿Cuál es el factor primo más grande de 84?', 7, 2, ['84 = 2 × 42 = 2 × 2 × 21 = 2 × 2 × 3 × 7']),
+ mathChallenge('prime-03', 'number-theory.primes', 'Primo siguiente', '¿Cuál es el siguiente número primo después de 23?', 29, 2, ['Comprueba 24, 25, 26, 27, 28, 29...']),
+];
+
+export const gcdLcmChallenges: Challenge[] = [
+ mathChallenge('gcd-01', 'number-theory.gcd-lcm', 'MCD básico', '¿Cuál es el MCD de 24 y 36?', 12, 2, ['Factoriza: 24=2³×3, 36=2²×3²'], 0,
+ 'El MCD (Máximo Común Divisor) es el número más grande que divide a dos números exactamente.\n\nMétodo de factorización:\n1. Descompón cada número en factores primos:\n • 24 = 2 × 2 × 2 × 3 = 2³ × 3\n • 36 = 2 × 2 × 3 × 3 = 2² × 3²\n2. Toma los factores comunes con el menor exponente:\n • Factor 2: mín(3,2) = 2² = 4\n • Factor 3: mín(1,2) = 3¹ = 3\n3. Multiplica: 4 × 3 = 12\n\nEl MCD de 24 y 36 es 12.\n\nMétodo alternativo (Euclides): divide el mayor entre el menor, luego el menor entre el resto, repite hasta que el resto sea 0. El último divisor es el MCD.\n• 36 ÷ 24 = 1 resto 12\n• 24 ÷ 12 = 2 resto 0 → MCD = 12'),
+ mathChallenge('gcd-02', 'number-theory.gcd-lcm', 'MCM básico', '¿Cuál es el MCM de 6 y 8?', 24, 2, ['MCM = (6×8)/MCD(6,8)'], 0,
+ 'El MCM (Mínimo Común Múltiplo) es el número más pequeño que es múltiplo de ambos números.\n\n¡No confundir con el MCD!\n• MCD = el mayor número que DIVIDE a ambos (busca divisores)\n• MCM = el menor número que es MÚLTIPLO de ambos (busca múltiplos)\n\nMétodo 1 — listar múltiplos:\n• Múltiplos de 6: 6, 12, 18, 24, 30...\n• Múltiplos de 8: 8, 16, 24, 32...\n• El primero en común es 24\n\nMétodo 2 — fórmula rápida:\nMCM(a,b) = (a × b) / MCD(a,b)\nMCM(6,8) = (6 × 8) / MCD(6,8) = 48 / 2 = 24\n\nEl MCM se usa mucho para sumar fracciones con distinto denominador.'),
+ mathChallenge('gcd-03', 'number-theory.gcd-lcm', 'MCD de tres números', '¿Cuál es el MCD de 12, 18 y 30?', 6, 2),
+];
+
+export const variableChallenges: Challenge[] = [
+ mathChallenge('var-01', 'algebra.variables', 'Evaluar expresión', 'Si x = 3, ¿cuánto vale 2x + 5?', 11, 2, ['Sustituye x por 3: 2(3) + 5'], 0,
+ 'En álgebra usamos letras (variables) para representar números desconocidos.\n\n"2x" significa "2 multiplicado por x". Si x = 3:\n• 2x = 2 × 3 = 6\n• 2x + 5 = 6 + 5 = 11\n\nEvaluar una expresión es sustituir la variable por su valor y calcular el resultado. Siempre resuelve multiplicaciones antes que sumas (orden de operaciones).'),
+ mathChallenge('var-02', 'algebra.variables', 'Expresión con dos variables', 'Si a = 4 y b = 7, ¿cuánto vale 3a - b + 2?', 7, 2, ['3(4) - 7 + 2 = 12 - 7 + 2']),
+ mathChallenge('var-03', 'algebra.variables', 'Simplificar', 'Simplifica: 3x + 2x - x. ¿Cuántos "x" quedan?', 4, 2, ['Suma los coeficientes: 3+2-1']),
+];
+
+export const equationChallenges: Challenge[] = [
+ mathChallenge('eq-01', 'algebra.equations', 'Ecuación simple', 'Resuelve: x + 7 = 15', 8, 3, ['Resta 7 de ambos lados'], 0,
+ 'Una ecuación es una igualdad con una incógnita (x). Resolverla es encontrar el valor de x.\n\nRegla de oro: lo que hagas a un lado, hazlo al otro.\n\nEjemplo: x + 7 = 15\n• Queremos x sola → restamos 7 de ambos lados\n• x + 7 - 7 = 15 - 7\n• x = 8\n\nComprobación: 8 + 7 = 15 ✓\n\nOperaciones inversas: suma↔resta, multiplicación↔división.'),
+ mathChallenge('eq-02', 'algebra.equations', 'Ecuación con multiplicación', 'Resuelve: 3x = 21', 7, 3, ['Divide ambos lados entre 3']),
+ mathChallenge('eq-03', 'algebra.equations', 'Ecuación de dos pasos', 'Resuelve: 2x + 5 = 17', 6, 3, ['Primero resta 5, luego divide entre 2']),
+ mathChallenge('eq-04', 'algebra.equations', 'Ecuación con paréntesis', 'Resuelve: 3(x - 2) = 15', 7, 3, ['Distribuye: 3x - 6 = 15']),
+];
+
+export const linearSystemChallenges: Challenge[] = [
+ mathChallenge('sys-01', 'algebra.linear-systems', 'Sistema simple', 'Resuelve: x + y = 10, x - y = 2. ¿Cuánto vale x?', 6, 3, ['Suma ambas ecuaciones: 2x = 12'], 0,
+ 'Un sistema de ecuaciones son dos (o más) ecuaciones que deben cumplirse a la vez.\n\nMétodo de eliminación:\n1. Suma o resta las ecuaciones para eliminar una variable\n2. Resuelve la variable que queda\n3. Sustituye para encontrar la otra\n\nEjemplo:\n x + y = 10\n x - y = 2\n\nSumando ambas: (x+y) + (x-y) = 10+2 → 2x = 12 → x = 6\nSustituyendo: 6 + y = 10 → y = 4'),
+ mathChallenge('sys-02', 'algebra.linear-systems', 'Sistema por sustitución', 'Resuelve: y = 2x, x + y = 9. ¿Cuánto vale x?', 3, 3, ['Sustituye y: x + 2x = 9']),
+ mathChallenge('sys-03', 'algebra.linear-systems', 'Sistema avanzado', 'Resuelve: 2x + 3y = 16, x - y = 3. ¿Cuánto vale y?', 2, 3, ['De la segunda: x = y + 3. Sustituye en la primera.']),
+];
+
+export const quadraticChallenges: Challenge[] = [
+ mathChallenge('quad-01', 'algebra.quadratics', 'Cuadrática simple', 'Resuelve: x² = 25. Da la solución positiva.', 5, 3, ['√25 = 5'], 0,
+ 'Una ecuación cuadrática contiene x² (x al cuadrado). La forma general es ax² + bx + c = 0.\n\nEl caso más simple: x² = número\n• Solución: x = ±√número\n• x² = 25 → x = +5 o x = -5 (porque tanto 5×5 como (-5)×(-5) dan 25)\n\nPara ecuaciones más complejas, se usa la fórmula general:\nx = (-b ± √(b²-4ac)) / 2a\n\nO se intenta factorizar: x²-5x+6 = (x-2)(x-3) = 0 → x=2 o x=3'),
+ mathChallenge('quad-02', 'algebra.quadratics', 'Factorización', 'Resuelve: x² - 5x + 6 = 0. Da la solución mayor.', 3, 3, ['Factoriza: (x-2)(x-3) = 0']),
+ mathChallenge('quad-03', 'algebra.quadratics', 'Fórmula general', 'Resuelve: x² + 2x - 8 = 0. Da la solución positiva.', 2, 3, ['Usa: x = (-b ± √(b²-4ac)) / 2a']),
+];
+
+export const booleanChallenges: Challenge[] = [
+ {
+ id: 'logic.boolean/bool-01',
+ nodeId: 'logic.boolean',
+ title: 'AND básico',
+ description: 'En lógica booleana, ¿cuál es el resultado de TRUE AND FALSE?',
+ difficulty: 2,
+ type: 'multiple-choice',
+ hints: ['AND solo es TRUE cuando ambos operandos son TRUE'],
+ xpReward: 40,
+ explanation: 'La lógica booleana trabaja con dos valores: TRUE (verdadero) y FALSE (falso).\n\nOperadores básicos:\n\n• AND (Y): Solo es TRUE si AMBOS son TRUE\n TRUE AND TRUE = TRUE\n TRUE AND FALSE = FALSE\n FALSE AND FALSE = FALSE\n\n• OR (O): Es TRUE si AL MENOS UNO es TRUE\n TRUE OR FALSE = TRUE\n FALSE OR FALSE = FALSE\n\n• NOT (NO): Invierte el valor\n NOT TRUE = FALSE\n NOT FALSE = TRUE\n\nEstos operadores son la base de toda la computación y los circuitos digitales.',
+ content: {
+ type: 'multiple-choice',
+ question: '¿Cuál es el resultado de TRUE AND FALSE?',
+ options: ['TRUE', 'FALSE', 'NULL', 'ERROR'],
+ correctIndex: 1,
+ },
+ },
+ {
+ id: 'logic.boolean/bool-02',
+ nodeId: 'logic.boolean',
+ title: 'OR básico',
+ description: '¿Cuál es el resultado de FALSE OR TRUE?',
+ difficulty: 2,
+ type: 'multiple-choice',
+ hints: ['OR es TRUE cuando al menos uno es TRUE'],
+ xpReward: 40,
+ content: {
+ type: 'multiple-choice',
+ question: '¿Cuál es el resultado de FALSE OR TRUE?',
+ options: ['TRUE', 'FALSE'],
+ correctIndex: 0,
+ },
+ },
+ {
+ id: 'logic.boolean/bool-03',
+ nodeId: 'logic.boolean',
+ title: 'NOT y combinaciones',
+ description: '¿Cuál es el resultado de NOT (TRUE AND FALSE)?',
+ difficulty: 2,
+ type: 'multiple-choice',
+ hints: ['Primero evalúa TRUE AND FALSE, luego aplica NOT'],
+ xpReward: 40,
+ content: {
+ type: 'multiple-choice',
+ question: '¿Cuál es el resultado de NOT (TRUE AND FALSE)?',
+ options: ['TRUE', 'FALSE'],
+ correctIndex: 0,
+ },
+ },
+];
+
+export const binaryChallenges: Challenge[] = [
+ mathChallenge('bin-01', 'logic.binary', 'Decimal a binario', '¿Cuánto es 13 en binario? (escribe el número decimal que forman los dígitos binarios, ej: 1101)', 1101, 2, ['13 = 8+4+1 = 1101₂'], 0,
+ 'El sistema binario usa solo dos dígitos: 0 y 1. Cada posición vale el doble que la anterior (de derecha a izquierda):\n\n... 16 8 4 2 1\n\nPara convertir decimal a binario, descompón en potencias de 2:\n• 13 = 8 + 4 + 1\n• 13 = 1×8 + 1×4 + 0×2 + 1×1\n• 13 en binario = 1101\n\nMétodo alternativo: divide entre 2 repetidamente y lee los restos de abajo arriba:\n• 13÷2 = 6 resto 1\n• 6÷2 = 3 resto 0\n• 3÷2 = 1 resto 1\n• 1÷2 = 0 resto 1\n→ 1101'),
+ mathChallenge('bin-02', 'logic.binary', 'Binario a decimal', '¿Cuánto es 10110 en decimal?', 22, 2, ['1×16 + 0×8 + 1×4 + 1×2 + 0×1']),
+ mathChallenge('bin-03', 'logic.binary', 'Suma binaria', '¿Cuánto es 1010 + 0110 en decimal?', 16, 2, ['1010=10, 0110=6, 10+6=16']),
+];
+
+export const allChallenges: Challenge[] = [
+ ...arithmeticChallenges,
+ ...fractionChallenges,
+ ...decimalChallenges,
+ ...percentageChallenges,
+ ...primeChallenges,
+ ...gcdLcmChallenges,
+ ...variableChallenges,
+ ...equationChallenges,
+ ...linearSystemChallenges,
+ ...quadraticChallenges,
+ ...booleanChallenges,
+ ...binaryChallenges,
+];
+
+export function getChallengeById(id: string): Challenge | undefined {
+ return allChallenges.find((c) => c.id === id);
+}
+
+export function getChallengesForNode(nodeId: string): Challenge[] {
+ return allChallenges.filter((c) => c.nodeId === nodeId);
+}
diff --git a/src/data/skill-tree.ts b/src/data/skill-tree.ts
new file mode 100644
index 0000000..11da117
--- /dev/null
+++ b/src/data/skill-tree.ts
@@ -0,0 +1,256 @@
+import { SkillNode, SkillEdge } from '@/types/skill-tree';
+
+// Layout constants
+const ROW_GAP = 180; // vertical spacing between rows
+const COL_GAP = 250; // horizontal spacing between nodes
+const CENTER = 300; // center x for single nodes
+
+// Row helper: y position for each tier
+const row = (n: number) => n * ROW_GAP;
+
+export const skillNodes: SkillNode[] = [
+ // === ROW 0: Root ===
+ {
+ id: 'arithmetic.addition',
+ discipline: 'mathematics',
+ branch: 'arithmetic',
+ title: 'Suma',
+ description: 'Aprende a sumar números enteros',
+ icon: '➕',
+ position: { x: CENTER, y: row(0) },
+ prerequisites: [],
+ challenges: ['add-01', 'add-02', 'add-03', 'add-04'],
+ difficulty: 1,
+ },
+
+ // === ROW 1: Suma branches into Resta & Multiplicación ===
+ {
+ id: 'arithmetic.subtraction',
+ discipline: 'mathematics',
+ branch: 'arithmetic',
+ title: 'Resta',
+ description: 'Aprende a restar números enteros',
+ icon: '➖',
+ position: { x: CENTER - COL_GAP / 2, y: row(1) },
+ prerequisites: ['arithmetic.addition'],
+ challenges: ['sub-01', 'sub-02', 'sub-03'],
+ difficulty: 1,
+ },
+ {
+ id: 'arithmetic.multiplication',
+ discipline: 'mathematics',
+ branch: 'arithmetic',
+ title: 'Multiplicación',
+ description: 'Domina las tablas de multiplicar y más',
+ icon: '✖️',
+ position: { x: CENTER + COL_GAP / 2, y: row(1) },
+ prerequisites: ['arithmetic.addition'],
+ challenges: ['mul-01', 'mul-02', 'mul-03'],
+ difficulty: 1,
+ },
+
+ // === ROW 2: División (from Multiplicación) ===
+ {
+ id: 'arithmetic.division',
+ discipline: 'mathematics',
+ branch: 'arithmetic',
+ title: 'División',
+ description: 'Divide números enteros con y sin resto',
+ icon: '➗',
+ position: { x: CENTER + COL_GAP / 2, y: row(2) },
+ prerequisites: ['arithmetic.multiplication'],
+ challenges: ['div-01', 'div-02', 'div-03'],
+ difficulty: 1,
+ },
+
+ // === ROW 3: Branches from Resta+División & División ===
+ {
+ id: 'arithmetic.fractions',
+ discipline: 'mathematics',
+ branch: 'arithmetic',
+ title: 'Fracciones',
+ description: 'Opera con fracciones: suma, resta, multiplicación y división',
+ icon: '½',
+ position: { x: CENTER - COL_GAP, y: row(3) },
+ prerequisites: ['arithmetic.subtraction', 'arithmetic.division'],
+ challenges: ['frac-01', 'frac-02', 'frac-03'],
+ difficulty: 2,
+ },
+ {
+ id: 'arithmetic.decimals',
+ discipline: 'mathematics',
+ branch: 'arithmetic',
+ title: 'Decimales',
+ description: 'Trabaja con números decimales y redondeo',
+ icon: '0.5',
+ position: { x: CENTER, y: row(3) },
+ prerequisites: ['arithmetic.division'],
+ challenges: ['dec-01', 'dec-02', 'dec-03'],
+ difficulty: 2,
+ },
+ {
+ id: 'number-theory.gcd-lcm',
+ discipline: 'mathematics',
+ branch: 'number-theory',
+ title: 'MCD y MCM',
+ description: 'Máximo común divisor y mínimo común múltiplo',
+ icon: '🔗',
+ position: { x: CENTER + COL_GAP, y: row(3) },
+ prerequisites: ['arithmetic.division'],
+ challenges: ['gcd-01', 'gcd-02', 'gcd-03'],
+ difficulty: 2,
+ },
+
+ // === ROW 4: Porcentajes & Primos ===
+ {
+ id: 'arithmetic.percentages',
+ discipline: 'mathematics',
+ branch: 'arithmetic',
+ title: 'Porcentajes',
+ description: 'Calcula porcentajes, descuentos y aumentos',
+ icon: '%',
+ position: { x: CENTER - COL_GAP / 2, y: row(4) },
+ prerequisites: ['arithmetic.fractions', 'arithmetic.decimals'],
+ challenges: ['pct-01', 'pct-02', 'pct-03'],
+ difficulty: 2,
+ },
+ {
+ id: 'number-theory.primes',
+ discipline: 'mathematics',
+ branch: 'number-theory',
+ title: 'Números Primos',
+ description: 'Identifica primos y comprende la factorización',
+ icon: '🔢',
+ position: { x: CENTER + COL_GAP, y: row(4) },
+ prerequisites: ['arithmetic.division'],
+ challenges: ['prime-01', 'prime-02', 'prime-03'],
+ difficulty: 2,
+ },
+
+ // === ROW 5: Algebra starts & Logic starts ===
+ {
+ id: 'algebra.variables',
+ discipline: 'mathematics',
+ branch: 'algebra',
+ title: 'Variables y Expresiones',
+ description: 'Introduce variables y evalúa expresiones algebraicas',
+ icon: '𝑥',
+ position: { x: CENTER - COL_GAP / 2, y: row(5) },
+ prerequisites: ['arithmetic.percentages'],
+ challenges: ['var-01', 'var-02', 'var-03'],
+ difficulty: 2,
+ },
+ {
+ id: 'logic.boolean',
+ discipline: 'logic',
+ branch: 'logic',
+ title: 'Lógica Booleana',
+ description: 'AND, OR, NOT — las bases del pensamiento lógico',
+ icon: '🧠',
+ position: { x: CENTER + COL_GAP, y: row(5) },
+ prerequisites: ['number-theory.primes'],
+ challenges: ['bool-01', 'bool-02', 'bool-03'],
+ difficulty: 2,
+ },
+
+ // === ROW 6: Ecuaciones & Binario ===
+ {
+ id: 'algebra.equations',
+ discipline: 'mathematics',
+ branch: 'algebra',
+ title: 'Ecuaciones Lineales',
+ description: 'Resuelve ecuaciones de primer grado',
+ icon: '⚖️',
+ position: { x: CENTER - COL_GAP / 2, y: row(6) },
+ prerequisites: ['algebra.variables'],
+ challenges: ['eq-01', 'eq-02', 'eq-03', 'eq-04'],
+ difficulty: 3,
+ },
+ {
+ id: 'logic.binary',
+ discipline: 'logic',
+ branch: 'logic',
+ title: 'Sistema Binario',
+ description: 'Convierte entre decimal, binario y hexadecimal',
+ icon: '0️⃣1️⃣',
+ position: { x: CENTER + COL_GAP, y: row(6) },
+ prerequisites: ['logic.boolean'],
+ challenges: ['bin-01', 'bin-02', 'bin-03'],
+ difficulty: 2,
+ },
+
+ // === ROW 7: Algebra branches ===
+ {
+ id: 'algebra.linear-systems',
+ discipline: 'mathematics',
+ branch: 'algebra',
+ title: 'Sistemas Lineales',
+ description: 'Resuelve sistemas de dos ecuaciones con dos incógnitas',
+ icon: '📐',
+ position: { x: CENTER - COL_GAP, y: row(7) },
+ prerequisites: ['algebra.equations'],
+ challenges: ['sys-01', 'sys-02', 'sys-03'],
+ difficulty: 3,
+ },
+ {
+ id: 'algebra.quadratics',
+ discipline: 'mathematics',
+ branch: 'algebra',
+ title: 'Ecuaciones Cuadráticas',
+ description: 'Resuelve ecuaciones de segundo grado con la fórmula general',
+ icon: '📈',
+ position: { x: CENTER, y: row(7) },
+ prerequisites: ['algebra.equations'],
+ challenges: ['quad-01', 'quad-02', 'quad-03'],
+ difficulty: 3,
+ },
+];
+
+export const skillEdges: SkillEdge[] = skillNodes
+ .flatMap((node) =>
+ node.prerequisites.map((prereq) => ({
+ from: prereq,
+ to: node.id,
+ type: 'hard' as const,
+ }))
+ );
+
+export function getNodeById(id: string): SkillNode | undefined {
+ return skillNodes.find((n) => n.id === id);
+}
+
+export function getAvailableNodes(completedNodeIds: string[]): string[] {
+ return skillNodes
+ .filter(
+ (node) =>
+ !completedNodeIds.includes(node.id) &&
+ node.prerequisites.every((prereq) => completedNodeIds.includes(prereq))
+ )
+ .map((n) => n.id);
+}
+
+export function getNodeStatus(
+ nodeId: string,
+ completedNodeIds: string[],
+ completedChallengeIds: string[]
+): 'locked' | 'available' | 'in-progress' | 'completed' {
+ const node = getNodeById(nodeId);
+ if (!node) return 'locked';
+
+ const allChallengesCompleted = node.challenges.every((c) =>
+ completedChallengeIds.includes(`${nodeId}/${c}`)
+ );
+ if (allChallengesCompleted) return 'completed';
+
+ const someChallengesCompleted = node.challenges.some((c) =>
+ completedChallengeIds.includes(`${nodeId}/${c}`)
+ );
+ if (someChallengesCompleted) return 'in-progress';
+
+ const prerequisitesMet = node.prerequisites.every((prereq) =>
+ completedNodeIds.includes(prereq)
+ );
+ if (prerequisitesMet || node.prerequisites.length === 0) return 'available';
+
+ return 'locked';
+}
diff --git a/src/lib/challenge-engine/verifier.ts b/src/lib/challenge-engine/verifier.ts
new file mode 100644
index 0000000..1c86172
--- /dev/null
+++ b/src/lib/challenge-engine/verifier.ts
@@ -0,0 +1,55 @@
+import { Challenge, VerificationResult } from '@/types/challenge';
+
+export function verifyAnswer(
+ challenge: Challenge,
+ userAnswer: string | number
+): VerificationResult {
+ const { content } = challenge;
+
+ switch (content.type) {
+ case 'math-input':
+ return verifyMathInput(content, userAnswer, challenge.xpReward);
+ case 'multiple-choice':
+ return verifyMultipleChoice(content, userAnswer, challenge.xpReward);
+ default:
+ return { correct: false, message: 'Tipo de reto no soportado', xpEarned: 0 };
+ }
+}
+
+function verifyMathInput(
+ content: { answer: { type: string; value: number | string; tolerance?: number } },
+ userAnswer: string | number,
+ xpReward: number
+): VerificationResult {
+ const numericAnswer = typeof userAnswer === 'string' ? parseFloat(userAnswer) : userAnswer;
+
+ if (isNaN(numericAnswer)) {
+ return { correct: false, message: 'Por favor ingresa un número válido', xpEarned: 0 };
+ }
+
+ const expected = typeof content.answer.value === 'string'
+ ? parseFloat(content.answer.value)
+ : content.answer.value;
+
+ const tolerance = content.answer.tolerance ?? 0;
+
+ if (Math.abs(numericAnswer - expected) <= tolerance) {
+ return { correct: true, message: '¡Correcto! 🎉', xpEarned: xpReward };
+ }
+
+ return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 };
+}
+
+function verifyMultipleChoice(
+ content: { correctIndex: number },
+ userAnswer: string | number,
+ xpReward: number
+): VerificationResult {
+ const selectedIndex = typeof userAnswer === 'string' ? parseInt(userAnswer) : userAnswer;
+
+ if (selectedIndex === content.correctIndex) {
+ return { correct: true, message: '¡Correcto! 🎉', xpEarned: xpReward };
+ }
+
+ return { correct: false, message: 'Incorrecto. Inténtalo de nuevo.', xpEarned: 0 };
+}
diff --git a/src/stores/useProgressStore.ts b/src/stores/useProgressStore.ts
new file mode 100644
index 0000000..4b828b2
--- /dev/null
+++ b/src/stores/useProgressStore.ts
@@ -0,0 +1,182 @@
+'use client';
+
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { UserProgress, levelFromXP } from '@/types/user';
+import { getAvailableNodes, skillNodes } from '@/data/skill-tree';
+import { getChallengesForNode } from '@/data/challenges/math';
+
+interface ProgressStore extends UserProgress {
+ completeChallenge: (challengeId: string, timeSpent: number) => void;
+ getNodeStatus: (nodeId: string) => 'locked' | 'available' | 'in-progress' | 'completed';
+ getCompletedNodeIds: () => string[];
+ updateStreak: () => void;
+ reset: () => void;
+}
+
+const initialState: UserProgress = {
+ completedChallenges: {},
+ unlockedNodes: [],
+ totalXP: 0,
+ level: 1,
+ currentStreak: 0,
+ longestStreak: 0,
+ lastActiveDate: '',
+ achievements: [],
+};
+
+export const useProgressStore = create()(
+ persist(
+ (set, get) => ({
+ ...initialState,
+
+ completeChallenge: (challengeId: string, timeSpent: number) => {
+ const state = get();
+ if (state.completedChallenges[challengeId]) return;
+
+ // Find challenge xp
+ const allChallenges = skillNodes.flatMap((n) =>
+ getChallengesForNode(n.id)
+ );
+ const challenge = allChallenges.find((c) => c.id === challengeId);
+ const xpReward = challenge?.xpReward ?? 20;
+
+ const newCompleted = {
+ ...state.completedChallenges,
+ [challengeId]: {
+ bestScore: 100,
+ completedAt: new Date().toISOString(),
+ timeSpent,
+ attempts: 1,
+ },
+ };
+
+ const newXP = state.totalXP + xpReward;
+
+ // Check which nodes are now completed
+ const completedNodeIds: string[] = [];
+ for (const node of skillNodes) {
+ const allDone = node.challenges.every(
+ (cId) => newCompleted[`${node.id}/${cId}`]
+ );
+ if (allDone) completedNodeIds.push(node.id);
+ }
+
+ const today = new Date().toISOString().split('T')[0];
+ let streak = state.currentStreak;
+ let longest = state.longestStreak;
+
+ if (state.lastActiveDate !== today) {
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ const yesterdayStr = yesterday.toISOString().split('T')[0];
+
+ if (state.lastActiveDate === yesterdayStr) {
+ streak += 1;
+ } else if (state.lastActiveDate !== today) {
+ streak = 1;
+ }
+ longest = Math.max(longest, streak);
+ }
+
+ // Check achievements
+ const newAchievements = [...state.achievements];
+ const completedCount = Object.keys(newCompleted).length;
+ if (completedCount >= 1 && !newAchievements.includes('first-step')) {
+ newAchievements.push('first-step');
+ }
+ if (completedCount >= 10 && !newAchievements.includes('getting-started')) {
+ newAchievements.push('getting-started');
+ }
+ if (completedNodeIds.length >= 3 && !newAchievements.includes('explorer')) {
+ newAchievements.push('explorer');
+ }
+ if (completedNodeIds.length >= 5 && !newAchievements.includes('scholar')) {
+ newAchievements.push('scholar');
+ }
+ if (streak >= 3 && !newAchievements.includes('consistent')) {
+ newAchievements.push('consistent');
+ }
+ if (streak >= 7 && !newAchievements.includes('dedicated')) {
+ newAchievements.push('dedicated');
+ }
+ // Cross-branch: check if completed nodes span multiple branches
+ const branches = new Set(
+ completedNodeIds
+ .map((id) => skillNodes.find((n) => n.id === id)?.branch)
+ .filter(Boolean)
+ );
+ if (branches.size >= 3 && !newAchievements.includes('polymath')) {
+ newAchievements.push('polymath');
+ }
+
+ set({
+ completedChallenges: newCompleted,
+ unlockedNodes: completedNodeIds,
+ totalXP: newXP,
+ level: levelFromXP(newXP),
+ currentStreak: streak,
+ longestStreak: longest,
+ lastActiveDate: today,
+ achievements: newAchievements,
+ });
+ },
+
+ getCompletedNodeIds: () => {
+ const state = get();
+ const completedNodeIds: string[] = [];
+ for (const node of skillNodes) {
+ const allDone = node.challenges.every(
+ (cId) => state.completedChallenges[`${node.id}/${cId}`]
+ );
+ if (allDone) completedNodeIds.push(node.id);
+ }
+ return completedNodeIds;
+ },
+
+ getNodeStatus: (nodeId: string) => {
+ const state = get();
+ const node = skillNodes.find((n) => n.id === nodeId);
+ if (!node) return 'locked';
+
+ const completedNodeIds = get().getCompletedNodeIds();
+
+ const allDone = node.challenges.every(
+ (cId) => state.completedChallenges[`${node.id}/${cId}`]
+ );
+ if (allDone) return 'completed';
+
+ const someDone = node.challenges.some(
+ (cId) => state.completedChallenges[`${node.id}/${cId}`]
+ );
+ if (someDone) return 'in-progress';
+
+ const prereqsMet = node.prerequisites.every((p) =>
+ completedNodeIds.includes(p)
+ );
+ if (prereqsMet || node.prerequisites.length === 0) return 'available';
+
+ return 'locked';
+ },
+
+ updateStreak: () => {
+ const state = get();
+ const today = new Date().toISOString().split('T')[0];
+ if (state.lastActiveDate === today) return;
+
+ const yesterday = new Date();
+ yesterday.setDate(yesterday.getDate() - 1);
+ const yesterdayStr = yesterday.toISOString().split('T')[0];
+
+ if (state.lastActiveDate !== yesterdayStr) {
+ set({ currentStreak: 0 });
+ }
+ },
+
+ reset: () => set(initialState),
+ }),
+ {
+ name: 'project-math-progress',
+ }
+ )
+);
diff --git a/src/types/challenge.ts b/src/types/challenge.ts
new file mode 100644
index 0000000..f6e678d
--- /dev/null
+++ b/src/types/challenge.ts
@@ -0,0 +1,47 @@
+export type WorkbenchType =
+ | 'math-input'
+ | 'multiple-choice'
+ | 'code-editor'
+ | 'circuit-builder'
+ | 'physics-sim'
+ | 'signal-playground'
+ | 'graph-plotter';
+
+export interface MathInputContent {
+ type: 'math-input';
+ problem: string;
+ answer: {
+ type: 'numeric' | 'expression';
+ value: number | string;
+ tolerance?: number;
+ };
+}
+
+export interface MultipleChoiceContent {
+ type: 'multiple-choice';
+ question: string;
+ options: string[];
+ correctIndex: number;
+}
+
+export type ChallengeContent = MathInputContent | MultipleChoiceContent;
+
+export interface Challenge {
+ id: string;
+ nodeId: string;
+ title: string;
+ description: string;
+ difficulty: 1 | 2 | 3 | 4 | 5;
+ type: WorkbenchType;
+ hints: string[];
+ xpReward: number;
+ content: ChallengeContent;
+ /** Explanation shown before the challenge to teach the concept */
+ explanation?: string;
+}
+
+export interface VerificationResult {
+ correct: boolean;
+ message: string;
+ xpEarned: number;
+}
diff --git a/src/types/skill-tree.ts b/src/types/skill-tree.ts
new file mode 100644
index 0000000..7a0357a
--- /dev/null
+++ b/src/types/skill-tree.ts
@@ -0,0 +1,30 @@
+export type Discipline =
+ | 'mathematics'
+ | 'logic'
+ | 'programming'
+ | 'physics'
+ | 'electronics'
+ | 'cryptography'
+ | 'signal-processing'
+ | 'statistics';
+
+export interface SkillNode {
+ id: string;
+ discipline: Discipline;
+ branch: string;
+ title: string;
+ description: string;
+ icon: string;
+ position: { x: number; y: number };
+ prerequisites: string[];
+ challenges: string[];
+ difficulty: 1 | 2 | 3 | 4 | 5;
+}
+
+export interface SkillEdge {
+ from: string;
+ to: string;
+ type: 'hard' | 'soft';
+}
+
+export type NodeStatus = 'locked' | 'available' | 'in-progress' | 'completed' | 'mastered';
diff --git a/src/types/user.ts b/src/types/user.ts
new file mode 100644
index 0000000..0485b86
--- /dev/null
+++ b/src/types/user.ts
@@ -0,0 +1,53 @@
+export interface ChallengeCompletion {
+ bestScore: number;
+ completedAt: string;
+ timeSpent: number;
+ attempts: number;
+}
+
+export interface UserProgress {
+ completedChallenges: Record;
+ unlockedNodes: string[];
+ totalXP: number;
+ level: number;
+ currentStreak: number;
+ longestStreak: number;
+ lastActiveDate: string;
+ achievements: string[];
+}
+
+export interface Achievement {
+ id: string;
+ title: string;
+ description: string;
+ icon: string;
+ condition: (progress: UserProgress) => boolean;
+}
+
+export function xpForLevel(level: number): number {
+ return Math.floor(100 * Math.pow(1.5, level - 1));
+}
+
+export function levelFromXP(totalXP: number): number {
+ let level = 1;
+ let xpNeeded = 100;
+ let accumulated = 0;
+ while (accumulated + xpNeeded <= totalXP) {
+ accumulated += xpNeeded;
+ level++;
+ xpNeeded = Math.floor(100 * Math.pow(1.5, level - 1));
+ }
+ return level;
+}
+
+export function xpProgressInLevel(totalXP: number): { current: number; needed: number } {
+ let level = 1;
+ let xpNeeded = 100;
+ let accumulated = 0;
+ while (accumulated + xpNeeded <= totalXP) {
+ accumulated += xpNeeded;
+ level++;
+ xpNeeded = Math.floor(100 * Math.pow(1.5, level - 1));
+ }
+ return { current: totalXP - accumulated, needed: xpNeeded };
+}