aboutsummaryrefslogtreecommitdiff
path: root/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'web/src')
-rw-r--r--web/src/App.tsx436
-rw-r--r--web/src/components/EndScreen.tsx686
-rw-r--r--web/src/components/MainMenu.tsx63
-rw-r--r--web/src/components/ThemeToggle.tsx32
-rw-r--r--web/src/components/TypingGame.tsx591
-rw-r--r--web/src/contexts/ThemeContext.tsx63
-rw-r--r--web/src/hooks/useCoreGame.ts80
-rw-r--r--web/src/hooks/useTheme.ts21
-rw-r--r--web/src/main.tsx13
-rw-r--r--web/src/styles.css910
-rw-r--r--web/src/styles/ThemeToggle.css70
-rw-r--r--web/src/styles/TypingGame.css19
-rw-r--r--web/src/types.ts54
-rw-r--r--web/src/types/typerpunk.d.ts27
-rw-r--r--web/src/wasm.d.ts25
15 files changed, 3090 insertions, 0 deletions
diff --git a/web/src/App.tsx b/web/src/App.tsx
new file mode 100644
index 0000000..b34c4b4
--- /dev/null
+++ b/web/src/App.tsx
@@ -0,0 +1,436 @@
+import { useEffect, useState } from 'react';
+import { TypingGame } from './components/TypingGame';
+import MainMenu from './components/MainMenu';
+import { EndScreen } from './components/EndScreen';
+import { GameState, Stats } from './types';
+import { ThemeProvider } from './contexts/ThemeContext';
+import { useCoreGame } from './hooks/useCoreGame';
+import './styles.css';
+import textsData from './data/texts.json';
+
+type TextItem = { category: string; content: string; attribution: string };
+const LOCAL_TEXTS: TextItem[] = Array.isArray(textsData) ? (textsData as TextItem[]) : [];
+function uniqueCategories(items: TextItem[]): string[] {
+ const set = new Set<string>();
+ for (const t of items) if (t.category) set.add(t.category);
+ return Array.from(set).sort();
+}
+function pickRandom<T>(arr: T[]): T {
+ return arr[Math.floor(Math.random() * arr.length)];
+}
+
+function getRandomTextItemFrom(items: TextItem[], category?: string): TextItem {
+ const pool = category && category !== 'random'
+ ? items.filter(t => t.category === category)
+ : items;
+ if (!pool.length) return { category: 'general', content: "The quick brown fox jumps over the lazy dog.", attribution: 'Traditional pangram' };
+ return pickRandom(pool);
+}
+
+function calculateStats(input: string, text: string, elapsedTime: number): Stats {
+ let correct = 0;
+ let incorrect = 0;
+ let currentStreak = 0;
+ let bestStreak = 0;
+ let totalErrors = 0;
+ let hasStartedTyping = input.length > 0;
+
+ // Calculate raw WPM using MonkeyType's approach
+ const windowSize = 0.5; // 500ms window for more granular peak detection
+ let maxWpm = 0;
+ let windowChars = 0;
+ let windowStartTime = 0;
+
+ // Track character timings and errors
+ const charTimings: { time: number; isCorrect: boolean }[] = [];
+ for (let i = 0; i < input.length; i++) {
+ const charTime = (i / input.length) * elapsedTime;
+ const isCorrect = input[i] === text[i];
+ charTimings.push({ time: charTime, isCorrect });
+
+ // Update window
+ while (charTime - windowStartTime > windowSize && charTimings.length > 0) {
+ if (charTimings[0].isCorrect) {
+ windowChars--;
+ }
+ windowStartTime = charTimings[1]?.time ?? charTime;
+ charTimings.shift();
+ }
+
+ if (isCorrect) {
+ windowChars++;
+ const windowTime = charTime - windowStartTime;
+ if (windowTime > 0) {
+ const windowWpm = (windowChars / 5) / (windowTime / 60);
+ maxWpm = Math.max(maxWpm, windowWpm);
+ }
+ }
+ }
+
+ const rawWpm = maxWpm; // Peak typing speed
+
+ // Calculate other stats
+ let totalTyped = 0;
+ for (let i = 0; i < input.length; i++) {
+ totalTyped++;
+ if (i < text.length && input[i] === text[i]) {
+ correct++;
+ currentStreak++;
+ bestStreak = Math.max(bestStreak, currentStreak);
+ } else {
+ incorrect++;
+ totalErrors++;
+ currentStreak = 0;
+ }
+ }
+
+ const totalChars = text.length;
+ // Only show 100% accuracy before typing starts
+ const accuracy = !hasStartedTyping ? 100 : Math.max(0, Math.min(100, (correct / totalTyped) * 100));
+ const wpm = elapsedTime === 0 ? 0 : (correct / 5) / (elapsedTime / 60);
+
+ return {
+ wpm,
+ rawWpm,
+ accuracy,
+ time: elapsedTime,
+ correctChars: correct,
+ incorrectChars: totalErrors,
+ totalChars,
+ currentStreak,
+ bestStreak,
+ };
+}
+
+export type Screen = 'main-menu' | 'typing-game' | 'end-screen';
+
+function App() {
+ const { game, resetGame, cleanupGame } = useCoreGame();
+ const [allTexts, setAllTexts] = useState<TextItem[]>(LOCAL_TEXTS);
+ const [categories, setCategories] = useState<string[]>(uniqueCategories(LOCAL_TEXTS));
+ const [selectedCategory, setSelectedCategory] = useState<string>('random');
+ const [gameState, setGameState] = useState<GameState>({
+ screen: 'main-menu',
+ currentText: '',
+ input: '',
+ startTime: null,
+ isRunning: false,
+ stats: {
+ wpm: 0,
+ rawWpm: 0,
+ accuracy: 100,
+ time: 0,
+ correctChars: 0,
+ incorrectChars: 0,
+ totalChars: 0,
+ currentStreak: 0,
+ bestStreak: 0,
+ },
+ });
+ const [gameKey, setGameKey] = useState<number>(0); // For remounting TypingGame
+ // Track last WPM history only for end-screen payloads (retain local var at finish)
+ const testStats: Stats = {
+ wpm: 85, rawWpm: 90, accuracy: 95, time: 60,
+ correctChars: 425, incorrectChars: 21, totalChars: 446,
+ currentStreak: 50, bestStreak: 100
+ };
+ const testWpmHistory = Array.from({ length: 60 }, (_, i) => ({
+ time: i, wpm: 80 + Math.sin(i / 5) * 10, raw: 85 + Math.sin(i / 5) * 10,
+ isError: Math.random() > 0.95
+ }));
+ const testText = "This is a test sentence for the end screen. It has some text to display and check for errors.";
+ const testUserInput = "This is a test sentance for the end screen. It has sone text to display and check for erors.";
+ const _testCharTimings = testText.split('').map((char, i) => ({
+ time: (i / testText.length) * 60,
+ isCorrect: char === (testUserInput[i] || ''),
+ char: char,
+ index: i
+ }));
+ const [lastTest, setLastTest] = useState<{ stats: Stats; wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>; text: string; userInput: string; charTimings?: Array<{ time: number; isCorrect: boolean; char: string; index: number }>; keypressHistory?: Array<{ time: number; index: number; isCorrect: boolean }> } | null>(null);
+
+ // Removed unused Enter key end-screen toggle handler
+
+ // Optional online dataset fetch (fallback to local). Configure URL via window.TYPERPUNK_TEXTS_URL.
+ useEffect(() => {
+ const url = (window as any).TYPERPUNK_TEXTS_URL as string | undefined;
+ if (!url) return; // no online dataset configured
+ (async () => {
+ try {
+ const res = await fetch(url, { cache: 'no-store' });
+ if (!res.ok) return;
+ const data = await res.json();
+ if (Array.isArray(data)) {
+ setAllTexts(data as TextItem[]);
+ setCategories(uniqueCategories(data as TextItem[]));
+ }
+ } catch {}
+ })();
+ }, []);
+
+ const handleStartGame = async () => {
+ try {
+ // Reset game state first
+ const item = getRandomTextItemFrom(allTexts, selectedCategory);
+ setGameState((prev: GameState) => ({
+ ...prev,
+ screen: 'typing-game',
+ currentText: item.content,
+ currentAttribution: item.attribution,
+ input: '',
+ startTime: null,
+ isRunning: false,
+ stats: {
+ wpm: 0,
+ rawWpm: 0,
+ accuracy: 100,
+ time: 0,
+ correctChars: 0,
+ incorrectChars: 0,
+ totalChars: 0,
+ currentStreak: 0,
+ bestStreak: 0,
+ },
+ }));
+
+ // Then reset WASM game instance
+ await resetGame();
+ setGameKey((k: number) => k + 1); // Force remount
+
+ // Ensure focus after a short delay
+ setTimeout(() => {
+ const inputElement = document.querySelector('.typing-input') as HTMLInputElement;
+ if (inputElement) {
+ inputElement.disabled = false;
+ inputElement.focus();
+ }
+ }, 200);
+ } catch (err) {
+ console.error('Error starting game:', err);
+ // If start fails, stay in main menu
+ setGameState((prev: GameState) => ({
+ ...prev,
+ screen: 'main-menu',
+ }));
+ }
+ };
+
+ const handleResetGame = async () => {
+ try {
+ // Reset game state first
+ const item = getRandomTextItemFrom(allTexts, selectedCategory);
+ setGameState((prev: GameState) => ({
+ ...prev,
+ screen: 'typing-game',
+ currentText: item.content,
+ currentAttribution: item.attribution,
+ input: '',
+ startTime: null,
+ isRunning: false,
+ stats: {
+ wpm: 0,
+ rawWpm: 0,
+ accuracy: 100,
+ time: 0,
+ correctChars: 0,
+ incorrectChars: 0,
+ totalChars: 0,
+ currentStreak: 0,
+ bestStreak: 0,
+ },
+ }));
+
+ // Then reset WASM game instance
+ await resetGame();
+ setGameKey((k: number) => k + 1); // Force remount
+
+ // Ensure focus after a short delay
+ setTimeout(() => {
+ const inputElement = document.querySelector('.typing-input') as HTMLInputElement;
+ if (inputElement) {
+ inputElement.disabled = false;
+ inputElement.focus();
+ }
+ }, 200);
+ } catch (err) {
+ console.error('Error resetting game:', err);
+ // If reset fails, go back to main menu
+ handleMainMenu();
+ }
+ };
+
+ const handleInput = (input: string, accuracy: number, mistakes: number) => {
+ setGameState((prev: GameState) => {
+ // Don't update if input hasn't changed
+ if (prev.input === input) {
+ return prev;
+ }
+
+ const newState = {
+ ...prev,
+ input,
+ stats: {
+ ...prev.stats,
+ accuracy,
+ incorrectChars: mistakes
+ }
+ };
+
+ // Only update running state and start time once
+ if (!prev.isRunning) {
+ newState.isRunning = true;
+ newState.startTime = Date.now();
+ }
+
+ // Check if the game is finished using WASM game's is_finished method
+ if (game && game.is_finished() && prev.screen === 'typing-game') {
+ newState.isRunning = false;
+ newState.screen = 'end-screen';
+ // Calculate final stats but preserve WASM accuracy and mistakes
+ const elapsedTime = (Date.now() - (newState.startTime || Date.now())) / 1000;
+ const stats = calculateStats(input, prev.currentText, elapsedTime);
+ // Preserve WASM accuracy and mistakes instead of recalculating
+ stats.accuracy = accuracy;
+ stats.incorrectChars = mistakes;
+ newState.stats = stats;
+ }
+
+ return newState;
+ });
+ };
+
+ const handleFinish = (
+ finalStats: Stats,
+ wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>,
+ userInput: string,
+ charTimings: Array<{ time: number; isCorrect: boolean; char: string; index: number }>,
+ keypressHistory: Array<{ time: number; index: number; isCorrect: boolean }>
+ ) => {
+ setLastTest({ stats: finalStats, wpmHistory, text: gameState.currentText, userInput, charTimings, keypressHistory });
+ setGameState(prev => ({ ...prev, isRunning: false, screen: 'end-screen' }));
+ };
+
+ const handleMainMenu = async () => {
+ try {
+ // Reset game state first
+ setGameState((prev: GameState) => ({
+ ...prev,
+ screen: 'main-menu',
+ input: '',
+ currentText: '',
+ currentAttribution: undefined,
+ startTime: null,
+ isRunning: false,
+ stats: {
+ wpm: 0,
+ rawWpm: 0,
+ accuracy: 100,
+ time: 0,
+ correctChars: 0,
+ incorrectChars: 0,
+ totalChars: 0,
+ currentStreak: 0,
+ bestStreak: 0,
+ },
+ }));
+
+ // Then cleanup WASM game instance
+ cleanupGame();
+ setGameKey((k: number) => k + 1); // Force remount
+ } catch (err) {
+ console.error('Error going to main menu:', err);
+ // If cleanup fails, still try to go to main menu
+ setGameState((prev: GameState) => ({
+ ...prev,
+ screen: 'main-menu',
+ input: '',
+ currentText: '',
+ startTime: null,
+ isRunning: false,
+ stats: {
+ wpm: 0,
+ rawWpm: 0,
+ accuracy: 100,
+ time: 0,
+ correctChars: 0,
+ incorrectChars: 0,
+ totalChars: 0,
+ currentStreak: 0,
+ bestStreak: 0,
+ },
+ }));
+ }
+ };
+
+ useEffect(() => {
+ let interval: ReturnType<typeof setInterval>;
+ if (gameState.isRunning && gameState.screen === 'typing-game' && gameState.startTime) {
+ interval = setInterval(() => {
+ setGameState((prev: GameState) => {
+ if (!prev.isRunning || !prev.startTime) return prev;
+
+ const elapsedTime = (Date.now() - prev.startTime) / 1000;
+ const stats = calculateStats(prev.input, prev.currentText, elapsedTime);
+
+ // Only update if stats have changed significantly
+ const hasSignificantChange =
+ Math.abs(prev.stats.wpm - stats.wpm) > 0.1 ||
+ Math.abs(prev.stats.rawWpm - stats.rawWpm) > 0.1 ||
+ Math.abs(prev.stats.time - stats.time) > 0.1;
+
+ if (!hasSignificantChange) {
+ return prev;
+ }
+
+ return {
+ ...prev,
+ stats,
+ };
+ });
+ }, 100);
+ }
+ return () => {
+ if (interval) clearInterval(interval);
+ };
+ }, [gameState.isRunning, gameState.screen, gameState.startTime]);
+
+ return (
+ <ThemeProvider>
+ <div className="app">
+ {gameState.screen === 'main-menu' ? (
+ <MainMenu
+ onStartGame={handleStartGame}
+ categories={categories}
+ selectedCategory={selectedCategory}
+ onSelectCategory={setSelectedCategory}
+ />
+ ) : gameState.screen === 'end-screen' ? (
+ <EndScreen
+ stats={lastTest?.stats || testStats}
+ wpmHistory={lastTest?.wpmHistory || testWpmHistory}
+ text={lastTest?.text || testText}
+ userInput={lastTest?.userInput || testUserInput}
+ charTimings={lastTest?.charTimings}
+ keypressHistory={lastTest?.keypressHistory}
+ onPlayAgain={handleResetGame}
+ onMainMenu={handleMainMenu}
+ />
+ ) : (
+ <TypingGame
+ key={gameKey}
+ game={game as any}
+ text={gameState.currentText}
+ input={gameState.input}
+ stats={gameState.stats}
+ attribution={gameState.currentAttribution}
+ onInput={handleInput}
+ onFinish={handleFinish}
+ onReset={handleResetGame}
+ onMainMenu={handleMainMenu}
+ />
+ )}
+ </div>
+ </ThemeProvider>
+ );
+}
+
+export default App; \ No newline at end of file
diff --git a/web/src/components/EndScreen.tsx b/web/src/components/EndScreen.tsx
new file mode 100644
index 0000000..3c70e95
--- /dev/null
+++ b/web/src/components/EndScreen.tsx
@@ -0,0 +1,686 @@
+import { useEffect, useRef, useState, FC } from 'react';
+import { useTheme } from '../contexts/ThemeContext';
+import { Stats, Theme } from '../types';
+import { Line } from 'react-chartjs-2';
+import {
+ Chart as ChartJS,
+ LineElement,
+ PointElement,
+ LinearScale,
+ Title,
+ Tooltip,
+ Legend,
+ CategoryScale,
+} from 'chart.js';
+import type { ChartData } from 'chart.js';
+ChartJS.register(LineElement, PointElement, LinearScale, Title, Tooltip, Legend, CategoryScale);
+
+interface Props {
+ stats: Stats;
+ wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>;
+ onPlayAgain: () => void;
+ onMainMenu: () => void;
+ text: string;
+ userInput: string;
+ charTimings?: Array<{ time: number; isCorrect: boolean; char: string; index: number }>;
+ keypressHistory?: Array<{ time: number; index: number; isCorrect: boolean }>;
+}
+
+export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMenu, text, userInput, charTimings, keypressHistory }) => {
+ // Responsive flag must be declared first
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string } | null>(null);
+ const { theme, toggleTheme } = useTheme();
+ const [isMobileScreen, setIsMobileScreen] = useState(window.innerWidth < 700);
+
+ // Debug log
+ console.log('EndScreen wpmHistory:', wpmHistory);
+ console.log('EndScreen stats:', stats);
+ console.log('EndScreen charTimings:', charTimings);
+ console.log('EndScreen userInput:', userInput);
+ console.log('EndScreen text:', text);
+
+ // --- Monkeytype-style rolling window graph data for WPM and RAW ---
+ const graphInterval = 1.0; // seconds, for 1s intervals
+ const wpmWindow = 2.0; // seconds (WPM window)
+ const rawWindow = 0.5; // seconds (RAW window)
+ let graphPoints: { time: number; wpm: number; raw: number }[] = [];
+ if (charTimings && charTimings.length > 0) {
+ const maxTime = Math.max(charTimings[charTimings.length - 1].time, stats.time);
+ for (let t = 1; t <= Math.ceil(maxTime); t += graphInterval) { // start at 1s, step by 1s
+ // WPM: correct chars in last 2.0s
+ const wpmChars = charTimings.filter(c => c.time > t - wpmWindow && c.time <= t);
+ const wpmCorrect = wpmChars.filter(c => c.isCorrect).length;
+ const wpm = wpmCorrect > 0 ? (wpmCorrect / 5) / (wpmWindow / 60) : 0;
+ // RAW: all chars in last 0.5s
+ const rawChars = charTimings.filter(c => c.time > t - rawWindow && c.time <= t);
+ const raw = rawChars.length > 0 ? (rawChars.length / 5) / (rawWindow / 60) : 0;
+ graphPoints.push({ time: t, wpm, raw });
+ }
+ // Apply moving average smoothing to WPM and RAW
+ const smoothWPM: number[] = [];
+ const smoothRAW: number[] = [];
+ const smoothWindow = 10; // more smoothing
+ for (let i = 0; i < graphPoints.length; i++) {
+ let sumWPM = 0, sumRAW = 0, count = 0;
+ for (let j = Math.max(0, i - smoothWindow + 1); j <= i; j++) {
+ sumWPM += graphPoints[j].wpm;
+ sumRAW += graphPoints[j].raw;
+ count++;
+ }
+ smoothWPM.push(sumWPM / count);
+ smoothRAW.push(sumRAW / count);
+ }
+ graphPoints = graphPoints.map((p, i) => ({ ...p, wpm: smoothWPM[i], raw: smoothRAW[i] }));
+ } else if (stats.time > 0 && text.length > 0) {
+ // fallback: simulate timings
+ const charTimingsSim: { time: number; isCorrect: boolean }[] = [];
+ for (let i = 0; i < stats.correctChars + stats.incorrectChars; i++) {
+ const charTime = (i / (stats.correctChars + stats.incorrectChars)) * stats.time;
+ const isCorrect = i < stats.correctChars;
+ charTimingsSim.push({ time: charTime, isCorrect });
+ }
+ for (let t = 1; t <= Math.ceil(stats.time); t += graphInterval) {
+ const wpmChars = charTimingsSim.filter(c => c.time > t - wpmWindow && c.time <= t);
+ const wpmCorrect = wpmChars.filter(c => c.isCorrect).length;
+ const wpm = wpmCorrect > 0 ? (wpmCorrect / 5) / (wpmWindow / 60) : 0;
+ const rawChars = charTimingsSim.filter(c => c.time > t - rawWindow && c.time <= t);
+ const raw = rawChars.length > 0 ? (rawChars.length / 5) / (rawWindow / 60) : 0;
+ graphPoints.push({ time: t, wpm, raw });
+ }
+ // Apply moving average smoothing to WPM and RAW
+ const smoothWPM: number[] = [];
+ const smoothRAW: number[] = [];
+ const smoothWindow = 10;
+ for (let i = 0; i < graphPoints.length; i++) {
+ let sumWPM = 0, sumRAW = 0, count = 0;
+ for (let j = Math.max(0, i - smoothWindow + 1); j <= i; j++) {
+ sumWPM += graphPoints[j].wpm;
+ sumRAW += graphPoints[j].raw;
+ count++;
+ }
+ smoothWPM.push(sumWPM / count);
+ smoothRAW.push(sumRAW / count);
+ }
+ graphPoints = graphPoints.map((p, i) => ({ ...p, wpm: smoothWPM[i], raw: smoothRAW[i] }));
+ }
+ // --- Chart.js data and options ---
+ // Build labels for every second
+ const xMax = Math.ceil(stats.time);
+ const allLabels = Array.from({length: xMax}, (_, i) => (i+1).toString());
+ // Map graphPoints by time for quick lookup
+ const graphPointsByTime = Object.fromEntries(graphPoints.map(p => [Math.round(p.time), p]));
+ // --- Error points for the graph (Monkeytype style, per-error, at closest WPM value, using keypressHistory) ---
+ let errorPoints: { x: number; y: number }[] = [];
+ if (keypressHistory && keypressHistory.length > 0) {
+ keypressHistory.forEach(({ time, isCorrect }) => {
+ if (!isCorrect) {
+ let p = graphPoints.reduce((prev, curr) =>
+ Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]);
+ if (p) {
+ errorPoints.push({ x: p.time, y: p.wpm });
+ }
+ }
+ });
+ } else if (charTimings && charTimings.length > 0) {
+ charTimings.forEach(({ time, isCorrect }) => {
+ if (!isCorrect) {
+ let p = graphPoints.reduce((prev, curr) =>
+ Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]);
+ if (p) {
+ errorPoints.push({ x: p.time, y: p.wpm });
+ }
+ }
+ });
+ }
+ console.log('EndScreen errorPoints:', errorPoints);
+ console.log('EndScreen graphPoints:', graphPoints);
+ const chartData = {
+ labels: allLabels,
+ datasets: [
+ {
+ label: 'WPM',
+ data: graphPoints.map((p) => ({ x: p.time, y: p.wpm })),
+ borderColor: '#00ff9d',
+ backgroundColor: 'rgba(0,255,157,0.1)',
+ borderWidth: 2,
+ pointRadius: 0,
+ tension: 0.4, // smoother line
+ type: 'line',
+ order: 1,
+ yAxisID: 'y',
+ pointStyle: 'line',
+ },
+ {
+ label: 'RAW',
+ data: graphPoints.map((p) => ({ x: p.time, y: p.raw })),
+ borderColor: '#00cc8f',
+ backgroundColor: 'rgba(0,204,143,0.1)',
+ borderWidth: 1,
+ borderDash: [10, 8],
+ pointRadius: 0,
+ tension: 0.25,
+ type: 'line',
+ order: 2,
+ yAxisID: 'y',
+ pointStyle: 'line',
+ },
+ {
+ label: 'Errors',
+ data: errorPoints,
+ borderColor: '#ff3b3b',
+ borderWidth: 0,
+ backgroundColor: '#ff3b3b',
+ pointRadius: 3,
+ type: 'scatter',
+ showLine: false,
+ order: 3,
+ yAxisID: 'y',
+ pointStyle: 'circle',
+ },
+ ],
+ } as ChartData<'line'>;
+ // --- Dynamic X-axis step size for time (responsive, long time) ---
+ let xStep = 1;
+ let autoSkip = false;
+ let maxTicksLimit = 100;
+ let xMin = 1;
+ if (window.innerWidth < 700) {
+ xStep = 2;
+ autoSkip = true;
+ maxTicksLimit = 10;
+ xMin = 2; // start at 2 for even spacing if skipping by 2
+ }
+ // Calculate max number of x labels that fit in the viewport (assume 40px per label)
+ const maxLabels = Math.floor((isMobileScreen ? window.innerWidth : 600) / 40); // 600px for graph area on desktop
+ if (xMax > maxLabels) {
+ xStep = Math.ceil(xMax / maxLabels);
+ } else if (xMax > 60) xStep = 10;
+ else if (xMax > 30) xStep = 5;
+ const chartOptions: any = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ display: true,
+ position: 'top',
+ align: 'center',
+ labels: {
+ color: '#00ff9d',
+ font: { family: 'JetBrains Mono, monospace', size: 12 },
+ boxWidth: 24,
+ boxHeight: 12,
+ usePointStyle: false,
+ symbol: (ctx: any) => {
+ const {dataset} = ctx;
+ return function customLegendSymbol(ctx2: any, x: any, y: any, width: any, height: any) {
+ ctx2.save();
+ if (dataset.label === 'RAW') {
+ // Dashed line
+ ctx2.strokeStyle = dataset.borderColor || '#00cc8f';
+ ctx2.lineWidth = 3;
+ ctx2.setLineDash([8, 5]);
+ ctx2.beginPath();
+ ctx2.moveTo(x, y + height / 2);
+ ctx2.lineTo(x + width, y + height / 2);
+ ctx2.stroke();
+ ctx2.setLineDash([]);
+ } else if (dataset.label === 'Errors') {
+ // Small dot
+ ctx2.fillStyle = dataset.borderColor || '#ff3b3b';
+ ctx2.beginPath();
+ ctx2.arc(x + width / 2, y + height / 2, 4, 0, 2 * Math.PI);
+ ctx2.fill();
+ } else if (dataset.label === 'WPM') {
+ // Solid line
+ ctx2.strokeStyle = dataset.borderColor || '#00ff9d';
+ ctx2.lineWidth = 3;
+ ctx2.beginPath();
+ ctx2.moveTo(x, y + height / 2);
+ ctx2.lineTo(x + width, y + height / 2);
+ ctx2.stroke();
+ }
+ ctx2.restore();
+ };
+ },
+ },
+ },
+ tooltip: {
+ enabled: true,
+ mode: 'nearest',
+ intersect: false,
+ usePointStyle: true,
+ callbacks: {
+ labelPointStyle: function(context: any) {
+ if (context.dataset.label === 'WPM') {
+ return { pointStyle: 'line', rotation: 0, borderWidth: 2, borderDash: [] };
+ }
+ if (context.dataset.label === 'RAW') {
+ // Chart.js does not support dashed line in tooltip, so use line
+ return { pointStyle: 'line', rotation: 0, borderWidth: 2, borderDash: [] };
+ }
+ if (context.dataset.label === 'Errors') {
+ return { pointStyle: 'circle', rotation: 0, borderWidth: 0, radius: 4 };
+ }
+ return { pointStyle: 'circle', rotation: 0 };
+ },
+ label: function(context: any) {
+ if (context.dataset.label === 'WPM') {
+ return `WPM: ${Math.round(context.parsed.y)}`;
+ }
+ if (context.dataset.label === 'RAW') {
+ return `Raw: ${Math.round(context.parsed.y)}`;
+ }
+ if (context.dataset.label === 'Errors') {
+ if (context.chart.tooltip?._errorShown) return '';
+ context.chart.tooltip._errorShown = true;
+ let errorText = '';
+ if (charTimings && charTimings.length > 0 && text) {
+ const errorPoint = context.raw;
+ const closest = charTimings.reduce((prev, curr) =>
+ Math.abs(curr.time - errorPoint.x) < Math.abs(prev.time - errorPoint.x) ? curr : prev, charTimings[0]);
+ if (!closest.isCorrect) {
+ const idx = closest.index;
+ let start = idx, end = idx;
+ while (start > 0 && text[start-1] !== ' ') start--;
+ while (end < text.length && text[end] !== ' ') end++;
+ const word = text.slice(start, end).trim();
+ if (word.length > 0) {
+ errorText = `Error: "${word}"`;
+ } else {
+ errorText = `Error: '${closest.char}'`;
+ }
+ }
+ }
+ return errorText || 'Error';
+ }
+ return '';
+ },
+ title: function() { return ''; },
+ },
+ backgroundColor: 'rgba(30,30,30,0.97)',
+ titleColor: '#fff',
+ bodyColor: '#fff',
+ borderColor: '#00ff9d',
+ borderWidth: 1,
+ caretSize: 6,
+ padding: 10,
+ external: function(context: any) {
+ if (context && context.tooltip) {
+ context.tooltip._errorShown = false;
+ }
+ },
+ },
+ },
+ scales: {
+ x: {
+ title: {
+ display: true,
+ text: 'Time (s)',
+ color: '#646669',
+ font: { family: 'JetBrains Mono, monospace', size: 13, weight: 'bold' },
+ align: 'center',
+ },
+ min: xMin,
+ max: xMax,
+ type: 'linear',
+ offset: false, // no extra space/lines before 1 or after xMax, even spacing
+ ticks: {
+ color: '#646669',
+ font: { family: 'JetBrains Mono, monospace', size: 12 },
+ stepSize: xStep,
+ autoSkip: autoSkip,
+ maxTicksLimit: maxTicksLimit,
+ callback: function(val: string | number) {
+ const tickNum = Number(val);
+ return Number.isInteger(tickNum) ? tickNum : '';
+ },
+ maxRotation: 0,
+ minRotation: 0,
+ },
+ grid: { color: 'rgba(100,102,105,0.15)' },
+ beginAtZero: false,
+ },
+ y: {
+ title: {
+ display: true,
+ text: 'WPM',
+ color: '#646669',
+ font: { family: 'JetBrains Mono, monospace', size: 13, weight: 'bold' },
+ },
+ beginAtZero: true,
+ ticks: {
+ color: '#646669',
+ font: { family: 'JetBrains Mono, monospace', size: 12 },
+ },
+ grid: { color: 'rgba(100,102,105,0.15)' },
+ position: 'left',
+ },
+ },
+ };
+ // --- Axis scaling and responsive graph width ---
+ const minGraphWidth = 320;
+ const maxGraphWidth = 1200;
+ const pxPerSecond = 60;
+ const graphHeight = isMobileScreen ? 160 : 220;
+ const graphWidth = Math.min(Math.max(minGraphWidth, Math.min((xMax) * pxPerSecond, maxGraphWidth)), window.innerWidth - 32);
+ const margin = 40;
+ const axisFont = '12px JetBrains Mono, monospace';
+ const maxTime = stats.time || (graphPoints.length > 0 ? graphPoints[graphPoints.length - 1].time : 1);
+ const maxWPM = graphPoints.length > 0 ? Math.max(...graphPoints.map(p => p.wpm)) : 0;
+
+ // --- Dynamic Y-axis step size for WPM ---
+ let yStep = xMax > 60 ? 10 : (maxWPM > 50 ? 10 : 5);
+ let yMax = Math.max(yStep, Math.ceil(maxWPM / yStep) * yStep);
+ if (window.innerWidth < 700 && yMax > 60) {
+ yStep = 20;
+ yMax = Math.max(yStep, Math.ceil(maxWPM / yStep) * yStep);
+ }
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container || graphPoints.length === 0) return;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+ // Set canvas size
+ canvas.width = graphWidth;
+ canvas.height = graphHeight;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ // --- Draw axes ---
+ ctx.strokeStyle = '#646669';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ // Y axis
+ ctx.moveTo(margin, margin);
+ ctx.lineTo(margin, canvas.height - margin);
+ // X axis
+ ctx.moveTo(margin, canvas.height - margin);
+ ctx.lineTo(canvas.width - margin, canvas.height - margin);
+ ctx.stroke();
+ // --- Draw Y ticks and labels ---
+ ctx.font = axisFont;
+ ctx.fillStyle = '#646669';
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ for (let yValue = 0; yValue <= yMax; yValue += yStep) {
+ const y = canvas.height - margin - (yValue / yMax) * (canvas.height - 2 * margin);
+ ctx.beginPath();
+ ctx.moveTo(margin - 6, y);
+ ctx.lineTo(margin, y);
+ ctx.stroke();
+ ctx.fillText(Math.round(yValue).toString(), margin - 8, y);
+ }
+ // --- Draw X ticks and labels (every whole second) ---
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ for (let xValue = 0; xValue <= xMax; xValue += 1) {
+ const x = margin + xValue * ((canvas.width - 2 * margin) / (xMax || 1));
+ ctx.beginPath();
+ ctx.moveTo(x, canvas.height - margin);
+ ctx.lineTo(x, canvas.height - margin + 6);
+ ctx.stroke();
+ if (xValue % 5 === 0 || xValue === 0 || xValue === xMax) {
+ ctx.fillText(xValue.toString(), x, canvas.height - margin + 8);
+ }
+ }
+ // --- Draw WPM line ---
+ ctx.strokeStyle = '#00ff9d';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ graphPoints.forEach((point, i) => {
+ const x = margin + point.time * ((canvas.width - 2 * margin) / (xMax || 1));
+ const y = canvas.height - margin - (point.wpm / yMax) * (canvas.height - 2 * margin);
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ });
+ ctx.stroke();
+ // --- Axis labels ---
+ ctx.save();
+ ctx.font = 'bold 13px JetBrains Mono, monospace';
+ ctx.fillStyle = '#646669';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'bottom';
+ ctx.fillText('Time (s)', canvas.width / 2, canvas.height - 2);
+ ctx.save();
+ ctx.translate(10, canvas.height / 2);
+ ctx.rotate(-Math.PI / 2);
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ ctx.fillText('WPM', 0, 0);
+ ctx.restore();
+ ctx.restore();
+ }, [graphPoints]);
+
+ useEffect(() => {
+ const handleResize = () => setIsMobileScreen(window.innerWidth < 700);
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ // --- Text highlighting (per-letter, using charTimings) ---
+ const renderText = () => {
+ if (!text) return null;
+ const inputChars = userInput ? userInput.split('') : [];
+ // Split text into words with trailing spaces (so spaces stay with words)
+ const wordRegex = /[^\s]+\s*/g;
+ const wordMatches = text.match(wordRegex) || [];
+ let charIndex = 0;
+ return wordMatches.map((word, wIdx) => {
+ const chars = [];
+ for (let i = 0; i < word.length; i++) {
+ const char = word[i];
+ const inputChar = inputChars[charIndex];
+ let className = 'neutral';
+ let displayChar = char;
+ if (charIndex < inputChars.length) {
+ if (inputChar === char) {
+ className = 'correct';
+ } else {
+ className = 'incorrect';
+ displayChar = inputChar; // Show the mistyped character
+ }
+ }
+ chars.push(
+ <span key={`char-${charIndex}`} className={className}>{displayChar}</span>
+ );
+ charIndex++;
+ }
+ return <span key={`word-${wIdx}`}>{chars}</span>;
+ });
+ };
+ // --- Layout ---
+ return (
+ <div className="end-screen" style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem', minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}>
+ {/* Logo at top, same as TypingGame */}
+ <div className="logo" onClick={onMainMenu}>TyperPunk</div>
+ {/* Main content area, all in one flex column, no fixed elements */}
+ <div style={{
+ width: '100%',
+ flex: '1 0 auto',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ overflow: 'hidden',
+ maxHeight: 'calc(100vh - 220px)',
+ boxSizing: 'border-box',
+ }}>
+ {/* Text */}
+ <div className="end-screen-text" style={{ margin: '0 auto 1.5rem auto', fontSize: '1.25rem', lineHeight: 1.7, maxWidth: 700, width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative', background: 'rgba(0,0,0,0.04)', borderRadius: 6, padding: '1rem 1.5rem', textAlign: 'left', wordBreak: 'break-word', height: 'auto' }}>
+ <div className="text-display" style={{ whiteSpace: 'pre-wrap', textAlign: 'left', width: '100%' }}>{renderText()}</div>
+ </div>
+ {/* Desktop: WPM | Graph | ACC */}
+ {!isMobileScreen && (
+ <>
+ {/* WPM far left, fixed to viewport edge */}
+ <div style={{ position: 'fixed', left: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center' }}>
+ <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}>
+ <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div>
+ <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'left', width: '100%' }}>RAW</div>
+ <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'left', width: '100%' }}>{Math.round(stats.rawWpm)}</div>
+ </div>
+ </div>
+ {/* ACC far right, fixed to viewport edge */}
+ <div style={{ position: 'fixed', right: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center' }}>
+ <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}>
+ <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(stats.accuracy)}%</div>
+ <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'right', width: '100%' }}>ERR</div>
+ <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'right', width: '100%' }}>{stats.incorrectChars}</div>
+ </div>
+ </div>
+ {/* Graph center, take all available space with margin for stats */}
+ <div style={{ margin: '0 auto 1.5rem auto', width: '100%', maxWidth: 900, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0 }}>
+ {graphPoints.length > 0 && (
+ <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: graphHeight, minHeight: graphHeight, height: graphHeight, margin: '0 auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
+ <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
+ <Line data={chartData} options={chartOptions} style={{ width: '100%', height: graphHeight }} />
+ </div>
+ </div>
+ )}
+ {/* TIME stat below graph */}
+ <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div>
+ </div>
+ </>
+ )}
+ {isMobileScreen && (
+ <>
+ {/* Graph at top, legend centered */}
+ {graphPoints.length > 0 && (
+ <div className="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: graphHeight, minHeight: graphHeight, height: graphHeight, margin: '0 auto 0.5rem auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
+ {/* Center legend above chart by wrapping chart in a flex column */}
+ <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
+ <Line data={chartData} options={chartOptions} style={{ width: '100%', height: graphHeight }} />
+ </div>
+ </div>
+ )}
+ {/* WPM, TIME, ACC in a row below graph */}
+ <div className="end-screen-stats" style={{
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr 1fr',
+ alignItems: 'center',
+ width: '100%',
+ maxWidth: 700,
+ margin: '0.5rem auto 0.2rem auto',
+ gap: '0.3rem',
+ }}>
+ {/* WPM (left) */}
+ <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center' }}>
+ <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div>
+ <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'left', width: '100%' }}>RAW</div>
+ <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'left', width: '100%' }}>{Math.round(stats.rawWpm)}</div>
+ </div>
+ {/* TIME (center, big) */}
+ <div className="end-screen-stat time" style={{ textAlign: 'center', alignItems: 'center', justifyContent: 'center' }}>
+ <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%' }}>TIME</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div>
+ </div>
+ {/* ACC (right) */}
+ <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center' }}>
+ <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(stats.accuracy)}%</div>
+ <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'right', width: '100%' }}>ERR</div>
+ <div className="stat-value" style={{ color: 'var(--primary-color)', fontSize: '1.2rem', textAlign: 'right', width: '100%' }}>{stats.incorrectChars}</div>
+ </div>
+ </div>
+ {/* Buttons closer to stats */}
+ <div className="end-screen-buttons" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem', marginTop: '0.5rem', width: '100%' }}>
+ <button
+ className="end-screen-button"
+ style={{
+ width: '100%',
+ maxWidth: 250,
+ fontSize: '1rem',
+ padding: '0.7rem 1.2rem',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ onClick={() => { setTimeout(onPlayAgain, 0); }}
+ >
+ Play Again
+ </button>
+ <button
+ className="end-screen-button"
+ style={{
+ width: '100%',
+ maxWidth: 250,
+ fontSize: '1rem',
+ padding: '0.7rem 1.2rem',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ onClick={() => { setTimeout(onMainMenu, 0); }}
+ >
+ Main Menu
+ </button>
+ </div>
+ </>
+ )}
+ </div>
+ {/* Desktop: Move the button row outside the main content and make it fixed at the bottom */}
+ {!isMobileScreen && (
+ <div className="end-screen-buttons" style={{ position: 'fixed', bottom: '5rem', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '1.5rem', zIndex: 100, marginBottom: '8rem' }}>
+ <button
+ className="end-screen-button"
+ style={{
+ width: 180,
+ maxWidth: 250,
+ fontSize: '1.2rem',
+ padding: '1rem 2rem',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ onClick={() => { setTimeout(onPlayAgain, 0); }}
+ >
+ Play Again
+ </button>
+ <button
+ className="end-screen-button"
+ style={{
+ width: 180,
+ maxWidth: 250,
+ fontSize: '1.2rem',
+ padding: '1rem 2rem',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ onClick={() => { setTimeout(onMainMenu, 0); }}
+ >
+ Main Menu
+ </button>
+ </div>
+ )}
+ {/* Theme button at the bottom, not fixed, match TypingGame */}
+ <div className="future-modes-placeholder" />
+ <button onClick={toggleTheme} className="theme-toggle">
+ {theme === Theme.Dark ? (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <circle cx="12" cy="12" r="5"/>
+ <line x1="12" y1="1" x2="12" y2="3"/>
+ <line x1="12" y1="21" x2="12" y2="23"/>
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
+ <line x1="1" y1="12" x2="3" y2="12"/>
+ <line x1="21" y1="12" x2="23" y2="12"/>
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
+ </svg>
+ ) : (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
+ </svg>
+ )}
+ </button>
+ </div>
+ );
+};
+
+export default EndScreen;
diff --git a/web/src/components/MainMenu.tsx b/web/src/components/MainMenu.tsx
new file mode 100644
index 0000000..b8daea0
--- /dev/null
+++ b/web/src/components/MainMenu.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { useTheme } from '../contexts/ThemeContext';
+import { Theme } from '../types';
+
+interface Props {
+ onStartGame: () => void;
+ categories?: string[];
+ selectedCategory?: string;
+ onSelectCategory?: (cat: string) => void;
+}
+
+const MainMenu: React.FC<Props> = ({ onStartGame, categories = [], selectedCategory = 'random', onSelectCategory }) => {
+ const { theme, toggleTheme } = useTheme();
+
+ return (
+ <div className="main-menu">
+ <h1>TyperPunk</h1>
+ <div className="menu-options">
+ <div style={{ marginBottom: '0.75rem' }}>
+ <label htmlFor="category" style={{ marginRight: 8 }}>Category:</label>
+ <select
+ id="category"
+ value={selectedCategory}
+ onChange={(e) => onSelectCategory && onSelectCategory(e.target.value)}
+ style={{ padding: '0.4rem 0.6rem' }}
+ >
+ <option value="random">Random</option>
+ {categories.map((c) => (
+ <option key={c} value={c}>{c}</option>
+ ))}
+ </select>
+ </div>
+ <button className="menu-button" onClick={onStartGame}>
+ Start Typing Test
+ </button>
+ <button className="menu-button" onClick={toggleTheme}>
+ Toggle {theme === Theme.Dark ? 'Light' : 'Dark'} Mode
+ </button>
+ </div>
+ <button onClick={toggleTheme} className="theme-toggle">
+ {theme === Theme.Dark ? (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <circle cx="12" cy="12" r="5"/>
+ <line x1="12" y1="1" x2="12" y2="3"/>
+ <line x1="12" y1="21" x2="12" y2="23"/>
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
+ <line x1="1" y1="12" x2="3" y2="12"/>
+ <line x1="21" y1="12" x2="23" y2="12"/>
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
+ </svg>
+ ) : (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
+ </svg>
+ )}
+ </button>
+ </div>
+ );
+};
+
+export default MainMenu; \ No newline at end of file
diff --git a/web/src/components/ThemeToggle.tsx b/web/src/components/ThemeToggle.tsx
new file mode 100644
index 0000000..1d635e9
--- /dev/null
+++ b/web/src/components/ThemeToggle.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import '../styles/ThemeToggle.css';
+
+interface ThemeToggleProps {
+ isDark: boolean;
+ onToggle: () => void;
+}
+
+export const ThemeToggle: React.FC<ThemeToggleProps> = ({ isDark, onToggle }) => {
+ return (
+ <button
+ className={`theme-toggle ${isDark ? 'dark' : 'light'}`}
+ onClick={onToggle}
+ aria-label={`Switch to ${isDark ? 'light' : 'dark'} theme`}
+ >
+ <div className="toggle-track">
+ <div className="toggle-thumb">
+ {isDark ? (
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <circle cx="12" cy="12" r="5" />
+ <path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
+ </svg>
+ ) : (
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+ </svg>
+ )}
+ </div>
+ </div>
+ </button>
+ );
+}; \ No newline at end of file
diff --git a/web/src/components/TypingGame.tsx b/web/src/components/TypingGame.tsx
new file mode 100644
index 0000000..d815420
--- /dev/null
+++ b/web/src/components/TypingGame.tsx
@@ -0,0 +1,591 @@
+import * as React from 'react';
+import { useState, useEffect, useRef } from 'react';
+import { Stats, Theme, TyperPunkGame } from '../types';
+import { useTheme } from '../contexts/ThemeContext';
+import { EndScreen } from './EndScreen';
+
+interface Props {
+ game: TyperPunkGame | null;
+ text: string;
+ input: string;
+ stats: Stats;
+ attribution?: string;
+ onInput: (input: string, accuracy: number, mistakes: number) => void;
+ onFinish: (
+ finalStats: Stats,
+ wpmHistory: Array<{ time: number; wpm: number; raw: number; isError: boolean }>,
+ finalUserInput: string,
+ charTimings: Array<{ time: number; isCorrect: boolean; char: string; index: number }>,
+ keypressHistory: Array<{ time: number; index: number; isCorrect: boolean }>
+ ) => void;
+ onReset: () => void;
+ onMainMenu: () => void;
+}
+
+// Error boundary for TypingGame
+class TypingGameErrorBoundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> {
+ constructor(props: { children: React.ReactNode }) {
+ super(props);
+ this.state = { error: null };
+ }
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+ componentDidCatch(error: Error, errorInfo: any) {
+ console.error('TypingGame error boundary caught:', error, errorInfo);
+ }
+ render() {
+ if (this.state.error) {
+ return <div style={{ color: 'red', padding: 32, background: '#111', minHeight: '100vh' }}>TypingGame Error: {this.state.error.message}</div>;
+ }
+ return this.props.children;
+ }
+}
+
+export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Element => {
+ const { game, text, stats, attribution, onInput, onFinish, onReset, onMainMenu } = props;
+ const { theme, toggleTheme } = useTheme();
+ const [isFinished, setIsFinished] = useState(false);
+ const [wpmHistory, setWpmHistory] = useState<Array<{ time: number; wpm: number; raw: number; isError: boolean }>>([]);
+ const [finalStats, setFinalStats] = useState<Stats | null>(null);
+ const [finalUserInput, setFinalUserInput] = useState<string>('');
+ const [localInput, setLocalInput] = useState<string>('');
+ const inputRef = useRef<HTMLInputElement>(null);
+ const [wasmAccuracy, setWasmAccuracy] = useState<number>(100);
+ const [wasmMistakes, setWasmMistakes] = useState<number>(0);
+ const gameRef = useRef(game);
+ const isInitialized = useRef(false);
+ const lastInputRef = useRef('');
+ const inputQueueRef = useRef<string[]>([]);
+ const isProcessingQueueRef = useRef(false);
+ const [charTimings, setCharTimings] = useState<Array<{ time: number; isCorrect: boolean; char: string; index: number }>>([]);
+ const gameStartTimeRef = useRef<number | null>(null);
+ const [finalCharTimings, setFinalCharTimings] = useState<Array<{ time: number; isCorrect: boolean; char: string; index: number }>>([]);
+ // Persistent mistake tracking
+ const [allMistakes, setAllMistakes] = useState<Array<{ time: number; index: number }>>([]);
+ const [finalAllMistakes, setFinalAllMistakes] = useState<Array<{ time: number; index: number }>>([]);
+ // Persistent keypress history tracking
+ const [keypressHistory, setKeypressHistory] = useState<Array<{ time: number, index: number, isCorrect: boolean }>>([]);
+ const [finalKeypressHistory, setFinalKeypressHistory] = useState<Array<{ time: number, index: number, isCorrect: boolean }>>([]);
+ const [isMobileScreen, setIsMobileScreen] = useState(window.innerWidth < 700);
+
+ // Initialize game only once
+ useEffect(() => {
+ if (game) {
+ gameRef.current = game;
+ isInitialized.current = true;
+ // Reset local state when game changes
+ setIsFinished(false);
+ setFinalStats(null);
+ setFinalUserInput('');
+ setLocalInput('');
+ lastInputRef.current = '';
+ setWpmHistory([]);
+ inputQueueRef.current = [];
+ isProcessingQueueRef.current = false;
+
+ // Ensure input is enabled and focused
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.disabled = false;
+ inputRef.current.focus();
+ }
+ }, 100);
+ }
+ }, [game]);
+
+ // Cleanup processing timeout
+ useEffect(() => {
+ return () => {
+ // No cleanup needed anymore
+ };
+ }, []);
+
+ // Focus input on mount and when component updates
+ useEffect(() => {
+ const focusInput = () => {
+ if (inputRef.current && !isFinished) {
+ inputRef.current.disabled = false;
+ inputRef.current.focus();
+ }
+ };
+
+ const handleVisibilityChange = () => {
+ if (!document.hidden && !isFinished) {
+ // Small delay to ensure the page is fully visible
+ setTimeout(focusInput, 100);
+ }
+ };
+
+ const handleWindowFocus = () => {
+ if (!isFinished) {
+ focusInput();
+ }
+ };
+
+ const handleClick = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ if (target.closest('.typing-game') && !isFinished) {
+ focusInput();
+ }
+ };
+
+ const handleKeyDown = () => {
+ // If user presses any key and input is not focused, focus it
+ if (!isFinished && document.activeElement !== inputRef.current) {
+ focusInput();
+ }
+ };
+
+ // Initial focus with delay to ensure component is mounted
+ setTimeout(focusInput, 50);
+
+ // Add event listeners
+ window.addEventListener('focus', handleWindowFocus);
+ window.addEventListener('visibilitychange', handleVisibilityChange);
+ document.addEventListener('click', handleClick);
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('focus', handleWindowFocus);
+ window.removeEventListener('visibilitychange', handleVisibilityChange);
+ document.removeEventListener('click', handleClick);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isFinished]);
+
+ // Update WPM history on every input/time change
+ useEffect(() => {
+ if (stats.time > 0 && !isFinished && gameRef.current) {
+ const game = gameRef.current;
+ const time = typeof game.get_time_elapsed === 'function' ? game.get_time_elapsed() : 0;
+ const wpm = typeof game.get_wpm === 'function' ? game.get_wpm() : 0;
+ const raw = typeof game.get_raw_wpm === 'function' ? game.get_raw_wpm() : 0;
+ // Track error positions for this input
+ let isError = false;
+ if (typeof game.get_stats_and_input === 'function') {
+ const [wasmInput, accuracy, mistakes] = game.get_stats_and_input();
+ // If mistakes increased, mark as error
+ if (mistakes > 0) isError = true;
+ }
+ setWpmHistory(prev => [
+ ...prev,
+ {
+ time,
+ wpm,
+ raw,
+ isError
+ }
+ ]);
+ }
+ }, [stats.time, isFinished]);
+
+ // Update game text
+ useEffect(() => {
+ if (gameRef.current && text) {
+ try {
+ gameRef.current.set_text(text);
+ } catch (err) {
+ console.error('Error updating game text:', err);
+ }
+ }
+ }, [text]);
+
+ // Process input queue
+ const processInputQueue = React.useCallback(() => {
+ if (isProcessingQueueRef.current || inputQueueRef.current.length === 0) return;
+
+ isProcessingQueueRef.current = true;
+
+ try {
+ const game = gameRef.current;
+ if (!game) return;
+
+ const nextInput = inputQueueRef.current[0];
+
+ // Process input
+ try {
+ game.handle_input(nextInput);
+ const [wasmInput, accuracy, mistakes] = game.get_stats_and_input();
+ setLocalInput(wasmInput);
+ lastInputRef.current = wasmInput;
+ setWasmAccuracy(accuracy);
+ setWasmMistakes(mistakes);
+ onInput(wasmInput, accuracy, mistakes);
+
+ // Check if game is finished using WASM game's is_finished method
+ if (game.is_finished()) {
+ setIsFinished(true);
+ // Get latest stats from WASM
+ let accuracy = 100, mistakes = 0;
+ if (typeof game.get_stats === 'function') {
+ try {
+ [accuracy, mistakes] = game.get_stats();
+ } catch (err) {
+ // fallback to last known
+ }
+ }
+ const finalStats = {
+ ...stats,
+ accuracy,
+ incorrectChars: mistakes
+ };
+ setFinalStats(finalStats);
+ setFinalUserInput(wasmInput);
+ // Rebuild charTimings from final input and text
+ const rebuiltCharTimings = [];
+ for (let i = 0; i < wasmInput.length; i++) {
+ rebuiltCharTimings.push({
+ time: (i / wasmInput.length) * stats.time,
+ isCorrect: wasmInput[i] === text[i],
+ char: wasmInput[i],
+ index: i,
+ });
+ }
+ setFinalCharTimings(rebuiltCharTimings);
+ setFinalAllMistakes([...allMistakes]); // snapshot mistakes
+ if (inputRef.current) inputRef.current.disabled = true;
+ setFinalKeypressHistory(keypressHistory);
+ onFinish(finalStats, [...wpmHistory], wasmInput, rebuiltCharTimings, keypressHistory);
+ return; // Exit early to prevent further processing
+ }
+ } catch (err) {
+ console.error('WASM operation error:', err);
+ setLocalInput(nextInput);
+ lastInputRef.current = nextInput;
+ }
+
+ // Remove processed input from queue
+ inputQueueRef.current.shift();
+ } catch (err) {
+ console.error('WASM operation error:', err);
+ if (inputQueueRef.current.length > 0) {
+ const nextInput = inputQueueRef.current[0];
+ setLocalInput(nextInput);
+ lastInputRef.current = nextInput;
+ inputQueueRef.current.shift();
+ }
+ }
+ isProcessingQueueRef.current = false;
+
+ // Process next input if any
+ if (inputQueueRef.current.length > 0) {
+ requestAnimationFrame(processInputQueue);
+ }
+ }, [onInput, text, stats, onFinish, wpmHistory, allMistakes, keypressHistory]);
+
+ // Handle input changes
+ const handleInput = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+ if (!gameRef.current || isFinished || isProcessingQueueRef.current) return;
+
+ const newInput = e.target.value;
+ if (newInput.length > text.length) return;
+
+ inputQueueRef.current.push(newInput);
+ if (!isProcessingQueueRef.current) {
+ processInputQueue();
+ }
+ }, [isFinished, text.length, processInputQueue]);
+
+ // Handle backspace
+ const handleKeyDown = React.useCallback(async (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (!gameRef.current || isFinished || isProcessingQueueRef.current) return;
+
+ if (e.key === 'Backspace') {
+ e.preventDefault();
+ try {
+ const ctrl = e.ctrlKey || e.metaKey;
+ const success = await gameRef.current.handle_backspace(ctrl);
+ if (success) {
+ const [wasmInput, accuracy, mistakes] = await gameRef.current.get_stats_and_input();
+ setLocalInput(wasmInput);
+ lastInputRef.current = wasmInput;
+ setWasmAccuracy(accuracy);
+ setWasmMistakes(mistakes);
+ onInput(wasmInput, accuracy, mistakes);
+ }
+ } catch (err) {
+ console.error('WASM operation error:', err);
+ setLocalInput(lastInputRef.current);
+ }
+ }
+ }, [isFinished, onInput]);
+
+ // Remove the text-based reset effect since we're handling it in the game effect
+ useEffect(() => {
+ if (!gameRef.current || isFinished) return;
+
+ try {
+ const [accuracy, mistakes] = gameRef.current.get_stats();
+ setWasmAccuracy(accuracy);
+ setWasmMistakes(mistakes);
+ } catch (err) {
+ console.error('WASM stats update error:', err);
+ }
+ }, [isFinished]);
+
+ // Track per-character timing and correctness as user types
+ useEffect(() => {
+ if (!isFinished && localInput.length > 0) {
+ if (gameStartTimeRef.current === null) {
+ gameStartTimeRef.current = Date.now();
+ }
+ const now = Date.now();
+ const elapsed = (now - gameStartTimeRef.current) / 1000;
+ const idx = localInput.length - 1;
+ const char = localInput[idx];
+ const isCorrect = text[idx] === char;
+
+ // Log every keypress event (not just new chars)
+ setKeypressHistory(prev => [...prev, { time: elapsed, index: idx, isCorrect }]);
+
+ setCharTimings(prev => {
+ // If user backspaced, trim timings
+ if (prev.length > localInput.length) {
+ return prev.slice(0, localInput.length);
+ }
+ // If user added a char, append
+ if (prev.length < localInput.length) {
+ return [
+ ...prev,
+ { time: elapsed, isCorrect, char, index: idx }
+ ];
+ }
+ // If user replaced a char, update
+ if (prev.length === localInput.length) {
+ const updated = [...prev];
+ updated[idx] = { time: elapsed, isCorrect, char, index: idx };
+ return updated;
+ }
+ return prev;
+ });
+ } else if (!isFinished && localInput.length === 0) {
+ setCharTimings([]);
+ setKeypressHistory([]);
+ }
+ lastInputRef.current = localInput;
+ }, [localInput, isFinished, text]);
+
+ // Reset charTimings and keypressHistory on new game
+ useEffect(() => {
+ setCharTimings([]);
+ setKeypressHistory([]);
+ gameStartTimeRef.current = null;
+ setAllMistakes([]);
+ }, [game]);
+
+ // On finish, set finalCharTimings and finalKeypressHistory
+ useEffect(() => {
+ if (isFinished && charTimings.length > 0 && finalCharTimings.length === 0) {
+ setFinalCharTimings(charTimings);
+ }
+ if (isFinished && keypressHistory.length > 0 && finalKeypressHistory.length === 0) {
+ setFinalKeypressHistory(keypressHistory);
+ }
+ }, [isFinished, charTimings, finalCharTimings.length, keypressHistory, finalKeypressHistory.length]);
+
+ const handleLogoClick = () => {
+ onMainMenu();
+ };
+
+ const handlePlayAgain = () => {
+ setIsFinished(false);
+ setFinalStats(null);
+ setFinalUserInput('');
+ setWpmHistory([]);
+ setCharTimings([]);
+ setLocalInput('');
+ gameStartTimeRef.current = null;
+ onReset();
+ };
+
+ const renderText = () => {
+ if (!text) return null;
+ const inputChars = localInput ? localInput.split('') : [];
+ // Split text into words with trailing spaces (so spaces stay with words)
+ const wordRegex = /[^\s]+\s*/g;
+ const wordMatches = text.match(wordRegex) || [];
+ let charIndex = 0;
+ return wordMatches.map((word, wIdx) => {
+ const chars = [];
+ for (let i = 0; i < word.length; i++) {
+ const char = word[i];
+ const inputChar = inputChars[charIndex];
+ let className = 'neutral';
+ let displayChar = char;
+ if (charIndex < inputChars.length) {
+ if (inputChar === char) {
+ className = 'correct';
+ } else {
+ className = 'incorrect';
+ displayChar = inputChar; // Show the mistyped character
+ }
+ }
+ if (!isFinished && charIndex === localInput.length && localInput.length <= text.length) {
+ className += ' current';
+ }
+ chars.push(
+ <span key={`char-${charIndex}`} className={className}>{displayChar}</span>
+ );
+ charIndex++;
+ }
+ return <span key={`word-${wIdx}`}>{chars}</span>;
+ });
+ };
+
+ useEffect(() => {
+ const handleResize = () => setIsMobileScreen(window.innerWidth < 700);
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
+ if (isFinished && finalStats) {
+ return (
+ <EndScreen
+ stats={finalStats}
+ wpmHistory={wpmHistory}
+ text={text}
+ charTimings={finalCharTimings}
+ userInput={finalUserInput}
+ onPlayAgain={handlePlayAgain}
+ onMainMenu={handleLogoClick}
+ keypressHistory={finalKeypressHistory}
+ />
+ );
+ }
+
+ return (
+ <TypingGameErrorBoundary>
+ <div className="typing-game" style={{ maxWidth: 900, margin: '0 auto', padding: '2rem 1rem', minHeight: '100vh', position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100vh', boxSizing: 'border-box', overflow: 'hidden' }}>
+ {/* Logo at top */}
+ <div className="logo" onClick={handleLogoClick}>TyperPunk</div>
+ {/* Main content area */}
+ <div style={{
+ width: '100%',
+ flex: '1 0 auto',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ overflow: 'hidden',
+ maxHeight: 'calc(100vh - 220px)',
+ boxSizing: 'border-box',
+ }}>
+ {/* Text */}
+ <div className="end-screen-text" style={{ margin: '0 auto 0.5rem auto', fontSize: '1.25rem', lineHeight: 1.7, maxWidth: 700, width: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative' }}>
+ <div className="text-display" style={{ whiteSpace: 'pre-wrap', textAlign: 'left', width: '100%' }}>{renderText()}</div>
+ <input
+ ref={inputRef}
+ type="text"
+ value={localInput}
+ onChange={handleInput}
+ onKeyDown={handleKeyDown}
+ className="typing-input"
+ autoFocus
+ onBlur={(e) => {
+ if (!isFinished) {
+ setTimeout(() => e.target.focus(), 10);
+ }
+ }}
+ disabled={isFinished}
+ style={{
+ opacity: 0,
+ caretColor: 'transparent',
+ width: '100%',
+ height: '2.5rem',
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ zIndex: 9999,
+ backgroundColor: 'transparent',
+ border: 'none',
+ outline: 'none',
+ pointerEvents: isFinished ? 'none' : 'auto'
+ }}
+ />
+ </div>
+ {attribution && (
+ <div style={{ maxWidth: 700, width: '100%', margin: '0 auto 1.5rem auto', textAlign: 'right', color: 'var(--neutral-color)', fontSize: '0.9rem' }}>
+ — {attribution}
+ </div>
+ )}
+ {/* Desktop: WPM | Graph | ACC */}
+ {!isMobileScreen && (
+ <>
+ {/* WPM far left, fixed to viewport edge */}
+ <div style={{ position: 'fixed', left: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center' }}>
+ <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}>
+ <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div>
+ </div>
+ </div>
+ {/* ACC far right, fixed to viewport edge */}
+ <div style={{ position: 'fixed', right: '2rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10, minWidth: 120, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'center' }}>
+ <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center', display: 'flex', flexDirection: 'column' }}>
+ <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(wasmAccuracy)}%</div>
+ </div>
+ </div>
+ {/* Graph center, take all available space with margin for stats */}
+ <div style={{ margin: '0 auto 1.5rem auto', width: '100%', maxWidth: 900, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0 }}>
+ <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} />
+ {/* TIME stat below graph */}
+ <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 12 }}>TIME</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '2.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div>
+ </div>
+ </>
+ )}
+ {/* Mobile: Graph at top, then WPM & ACC in a row, then TIME below */}
+ {isMobileScreen && (
+ <>
+ <div className="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto 0.5rem auto', position: 'relative', background: 'rgba(0,0,0,0.02)', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }} />
+ <div className="end-screen-stats" style={{
+ display: 'grid',
+ gridTemplateColumns: '1fr 1fr',
+ alignItems: 'center',
+ width: '100%',
+ maxWidth: 700,
+ margin: '0.5rem auto 0.2rem auto',
+ gap: '0.3rem',
+ }}>
+ {/* WPM (left) */}
+ <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center' }}>
+ <div className="stat-label" style={{ textAlign: 'left', width: '100%' }}>WPM</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'left', width: '100%' }}>{Math.round(stats.wpm)}</div>
+ </div>
+ {/* ACC (right) */}
+ <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center' }}>
+ <div className="stat-label" style={{ textAlign: 'right', width: '100%' }}>ACC</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'right', width: '100%' }}>{Math.round(wasmAccuracy)}%</div>
+ </div>
+ </div>
+ {/* TIME stat below WPM/ACC */}
+ <div className="stat-label" style={{ fontSize: '0.8rem', color: 'var(--neutral-color)', textAlign: 'center', width: '100%', marginTop: 8 }}>TIME</div>
+ <div className="stat-value" style={{ color: '#00ff9d', fontSize: '1.5rem', fontWeight: 700, letterSpacing: '0.05em', lineHeight: 1.1, textAlign: 'center', width: '100%' }}>{stats.time.toFixed(1)}</div>
+ </>
+ )}
+ </div>
+ {/* Empty space for future game modes, matches EndScreen */}
+ <div className="future-modes-placeholder" />
+ <button onClick={toggleTheme} className="theme-toggle">
+ {theme === Theme.Dark ? (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <circle cx="12" cy="12" r="5"/>
+ <line x1="12" y1="1" x2="12" y2="3"/>
+ <line x1="12" y1="21" x2="12" y2="23"/>
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
+ <line x1="1" y1="12" x2="3" y2="12"/>
+ <line x1="21" y1="12" x2="23" y2="12"/>
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
+ </svg>
+ ) : (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
+ </svg>
+ )}
+ </button>
+ </div>
+ </TypingGameErrorBoundary>
+ );
+}); \ No newline at end of file
diff --git a/web/src/contexts/ThemeContext.tsx b/web/src/contexts/ThemeContext.tsx
new file mode 100644
index 0000000..f876269
--- /dev/null
+++ b/web/src/contexts/ThemeContext.tsx
@@ -0,0 +1,63 @@
+import React, { createContext, useContext, useState, useEffect } from 'react';
+import { Theme, ThemeColors } from '../types';
+
+interface ThemeContextType {
+ theme: Theme;
+ colors: ThemeColors;
+ toggleTheme: () => void;
+}
+
+const lightColors: ThemeColors = {
+ primary: '#00ff9d',
+ secondary: '#00cc8f',
+ background: '#ffffff',
+ text: '#333333',
+ error: '#ca4754',
+ success: '#2ecc71'
+};
+
+const darkColors: ThemeColors = {
+ primary: '#00ff9d',
+ secondary: '#00cc8f',
+ background: '#000000',
+ text: '#646669',
+ error: '#ef5350',
+ success: '#66bb6a'
+};
+
+const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
+
+export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [theme, setTheme] = useState<Theme>(() => {
+ const savedTheme = localStorage.getItem('theme');
+ return (savedTheme as Theme) || Theme.Dark;
+ });
+
+ const colors = theme === Theme.Light ? lightColors : darkColors;
+
+ useEffect(() => {
+ localStorage.setItem('theme', theme);
+ document.documentElement.setAttribute('data-theme', theme.toLowerCase());
+ if (!window.location.pathname.includes('typing-game')) {
+ document.body.style.backgroundColor = theme === Theme.Light ? '#ffffff' : '#000000';
+ }
+ }, [theme]);
+
+ const toggleTheme = () => {
+ setTheme(prevTheme => prevTheme === Theme.Light ? Theme.Dark : Theme.Light);
+ };
+
+ return (
+ <ThemeContext.Provider value={{ theme, colors, toggleTheme }}>
+ {children}
+ </ThemeContext.Provider>
+ );
+};
+
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (context === undefined) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return context;
+}; \ No newline at end of file
diff --git a/web/src/hooks/useCoreGame.ts b/web/src/hooks/useCoreGame.ts
new file mode 100644
index 0000000..3aa69ec
--- /dev/null
+++ b/web/src/hooks/useCoreGame.ts
@@ -0,0 +1,80 @@
+import { useEffect, useRef, useState } from 'react';
+import init, { TyperPunkGame as Game } from '@typerpunk/wasm';
+
+export function useCoreGame() {
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const gameRef = useRef<Game | null>(null);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const initGame = async () => {
+ try {
+ await init();
+ if (!mounted) return;
+
+ const game = new Game();
+ gameRef.current = game;
+ setIsLoading(false);
+ } catch (err) {
+ console.error('Failed to initialize game:', err);
+ if (mounted) {
+ setError('Failed to initialize game');
+ setIsLoading(false);
+ }
+ }
+ };
+
+ initGame();
+
+ return () => {
+ mounted = false;
+ if (gameRef.current) {
+ try {
+ gameRef.current.free();
+ } catch (err) {
+ console.error('Error cleaning up game:', err);
+ }
+ gameRef.current = null;
+ }
+ };
+ }, []);
+
+ const resetGame = async () => {
+ if (gameRef.current) {
+ try {
+ gameRef.current.free();
+ } catch (err) {
+ console.error('Error freeing old game:', err);
+ }
+ }
+
+ try {
+ const game = new Game();
+ gameRef.current = game;
+ } catch (err) {
+ console.error('Error resetting game:', err);
+ setError('Failed to reset game');
+ }
+ };
+
+ const cleanupGame = () => {
+ if (gameRef.current) {
+ try {
+ gameRef.current.free();
+ } catch (err) {
+ console.error('Error cleaning up game:', err);
+ }
+ gameRef.current = null;
+ }
+ };
+
+ return {
+ game: gameRef.current,
+ isLoading,
+ error,
+ resetGame,
+ cleanupGame
+ };
+} \ No newline at end of file
diff --git a/web/src/hooks/useTheme.ts b/web/src/hooks/useTheme.ts
new file mode 100644
index 0000000..a5457b6
--- /dev/null
+++ b/web/src/hooks/useTheme.ts
@@ -0,0 +1,21 @@
+import { useState, useEffect } from 'react';
+import { Theme } from '../types';
+
+export const useTheme = () => {
+ const [theme, setTheme] = useState<Theme>(() => {
+ const savedTheme = localStorage.getItem('theme');
+ return (savedTheme as Theme) || Theme.Light;
+ });
+
+ useEffect(() => {
+ localStorage.setItem('theme', theme);
+ document.documentElement.classList.remove(Theme.Light, Theme.Dark);
+ document.documentElement.classList.add(theme);
+ }, [theme]);
+
+ const toggleTheme = () => {
+ setTheme(prev => prev === Theme.Light ? Theme.Dark : Theme.Light);
+ };
+
+ return { theme, toggleTheme };
+}; \ No newline at end of file
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..f987afc
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import { ThemeProvider } from './contexts/ThemeContext';
+import './styles.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+ <React.StrictMode>
+ <ThemeProvider>
+ <App />
+ </ThemeProvider>
+ </React.StrictMode>
+); \ No newline at end of file
diff --git a/web/src/styles.css b/web/src/styles.css
new file mode 100644
index 0000000..d9130b4
--- /dev/null
+++ b/web/src/styles.css
@@ -0,0 +1,910 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+:root {
+ --primary-color: #00ff9d;
+ --secondary-color: #00cc8f;
+ --background-color: #000000;
+ --text-color: #646669;
+ --error-color: #ca4754;
+ --correct-color: #00ff9d;
+ --neutral-color: #646669;
+ --caret-color: #00ff9d;
+ --sub-color: #646669;
+}
+
+[data-theme="light"] {
+ --primary-color: #00ff9d;
+ --secondary-color: #00cc8f;
+ --background-color: #ffffff;
+ --text-color: #333333;
+ --error-color: #ca4754;
+ --correct-color: #00ff9d;
+ --neutral-color: #646669;
+ --caret-color: #00ff9d;
+ --sub-color: #646669;
+}
+
+[data-theme="dark"] {
+ --primary-color: #00ff9d;
+ --secondary-color: #00cc8f;
+ --background-color: #000000;
+ --text-color: #646669;
+ --error-color: #ca4754;
+ --correct-color: #00ff9d;
+ --neutral-color: #646669;
+ --caret-color: #00ff9d;
+ --sub-color: #646669;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ font-family: 'JetBrains Mono', monospace;
+ background-color: var(--background-color);
+ color: var(--text-color);
+ height: 100vh;
+ overflow: hidden;
+}
+
+.app {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ padding: 1rem;
+ overflow: hidden;
+}
+
+.logo {
+ position: fixed;
+ top: 1rem;
+ left: 1rem;
+ font-size: 1.2rem;
+ color: var(--primary-color);
+ font-family: 'JetBrains Mono', monospace;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ cursor: pointer;
+ transition: color 0.2s ease;
+}
+
+.logo:hover {
+ color: var(--secondary-color);
+}
+
+/* Main Menu */
+.main-menu {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ width: 100%;
+ padding: 2rem;
+}
+
+.main-menu h1 {
+ font-size: 4rem;
+ color: var(--primary-color);
+ margin-bottom: 2rem;
+ text-transform: uppercase;
+ letter-spacing: 4px;
+}
+
+.menu-options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.menu-button {
+ background: none;
+ border: 2px solid var(--primary-color);
+ color: var(--text-color);
+ padding: 1rem 2rem;
+ font-size: 1.2rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-family: inherit;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ min-width: 250px;
+}
+
+.menu-button:hover {
+ background-color: var(--primary-color);
+ color: var(--background-color);
+}
+
+/* Typing Game */
+.typing-game {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 1rem;
+ position: relative;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.text-container {
+ position: relative;
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 1rem 2rem 0 2rem;
+ margin-top: 2.5rem;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+}
+
+.text-display {
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 1.5rem;
+ line-height: 1.5;
+ min-height: 6rem;
+ color: var(--text-color);
+ position: relative;
+ white-space: pre-wrap;
+ word-break: keep-all;
+ overflow-wrap: break-word;
+ width: fit-content;
+ max-width: 800px;
+ tab-size: 4;
+ hyphens: none;
+ text-align: left;
+ padding: 1rem;
+}
+
+.text-display span {
+ white-space: pre;
+ position: relative;
+ display: inline-block;
+}
+
+.text-display span.correct {
+ color: var(--correct-color);
+}
+
+.text-display span.incorrect {
+ color: var(--error-color);
+}
+
+.text-display span.neutral {
+ color: var(--neutral-color);
+}
+
+.text-display span.current {
+ position: relative;
+}
+
+.text-display span.current::after {
+ content: '▏';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ color: var(--caret-color);
+ animation: blink 1s step-end infinite;
+}
+
+.typing-input {
+ position: relative;
+ width: 100%;
+ opacity: 1 !important;
+ z-index: 1;
+ cursor: text;
+ caret-color: auto !important;
+ background: transparent !important;
+ color: transparent !important;
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+.typing-input:focus {
+ background: transparent !important;
+ color: transparent !important;
+ border: none !important;
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+.theme-toggle {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: rgba(255,255,255,0.08);
+ border: none !important;
+ outline: none !important;
+ box-shadow: 0 2px 12px 0 rgba(0,0,0,0.12), 0 1.5px 4px 0 rgba(0,0,0,0.10);
+ color: var(--primary-color);
+ padding: 0.7rem 1rem;
+ font-size: 1.2rem;
+ cursor: pointer;
+ transition: all 0.2s cubic-bezier(.4,0,.2,1);
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ z-index: 100;
+ border-radius: 1.5rem;
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+}
+
+.theme-toggle:focus,
+.theme-toggle:active {
+ border: none !important;
+ outline: none !important;
+ box-shadow: 0 4px 16px 0 rgba(0,0,0,0.18);
+ background: rgba(255,255,255,0.16);
+}
+
+.theme-toggle:hover {
+ color: var(--secondary-color);
+ background: rgba(255,255,255,0.18);
+ box-shadow: 0 6px 24px 0 rgba(0,0,0,0.18);
+ transform: scale(1.06);
+}
+
+.theme-toggle svg {
+ width: 1.5rem;
+ height: 1.5rem;
+ transition: color 0.2s, filter 0.2s;
+ filter: drop-shadow(0 1px 2px rgba(0,0,0,0.10));
+}
+
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* Mobile adjustments */
+@media (max-width: 768px) {
+ .main-menu h1 {
+ font-size: 2.5rem;
+ margin-bottom: 1.5rem;
+ }
+
+ .menu-button {
+ padding: 0.8rem 1.5rem;
+ font-size: 1rem;
+ min-width: 200px;
+ }
+
+ .typing-game {
+ padding: 1rem;
+ }
+
+ .text-container {
+ padding: 1rem;
+ margin-top: 5rem;
+ }
+
+ .stats-container {
+ padding: 0 1rem;
+ }
+
+ .wpm-stat {
+ left: 1rem;
+ top: 50%;
+ }
+
+ .acc-stat {
+ right: 1rem;
+ top: 50%;
+ }
+
+ .time-stat {
+ bottom: 0.5rem;
+ font-size: 1rem;
+ }
+
+ .text-display {
+ font-size: 1.2rem;
+ }
+
+ .end-screen-stats {
+ gap: 1rem;
+ padding: 0 1rem;
+ }
+
+ .end-screen-stat {
+ padding: 0.5rem;
+ }
+
+ .end-screen-stat-value {
+ font-size: 1.5rem;
+ }
+
+ .graph-container {
+ height: 200px;
+ padding: 1rem;
+ }
+
+ .graph-axis.x {
+ left: 1rem;
+ right: 1rem;
+ }
+
+ .graph-axis.y {
+ bottom: 1rem;
+ }
+
+ .end-screen-buttons {
+ bottom: 1rem;
+ }
+}
+
+@media (max-height: 600px) {
+ .stats-container {
+ top: 0.5rem;
+ }
+
+ .wpm-stat {
+ top: 50%;
+ }
+
+ .acc-stat {
+ top: 50%;
+ }
+
+ .time-stat {
+ bottom: 0.25rem;
+ font-size: 0.9rem;
+ }
+
+ .text-container {
+ margin-top: 4.5rem;
+ padding: 1rem;
+ }
+
+ .text-display {
+ font-size: 1.2rem;
+ min-height: 4.5rem;
+ }
+
+ .end-screen-stats {
+ gap: 0.75rem;
+ margin: 1rem 0;
+ }
+
+ .end-screen-stat {
+ padding: 0.25rem;
+ }
+
+ .end-screen-stat-value {
+ font-size: 1.2rem;
+ }
+
+ .graph-container {
+ height: 200px;
+ }
+}
+
+@media (max-width: 600px) {
+ .typing-game {
+ padding: 0.5rem;
+ height: 100vh;
+ min-width: 0;
+ }
+ .text-container {
+ padding: 0.5rem 0.5rem 0 0.5rem;
+ margin-top: 1.5rem;
+ min-width: 0;
+ }
+ .text-display {
+ font-size: 1rem;
+ padding: 0.5rem;
+ min-height: 3rem;
+ max-width: 100vw;
+ word-break: break-word;
+ }
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ font-size: 1.5rem;
+ color: var(--primary-color);
+ font-family: 'JetBrains Mono', monospace;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+}
+
+#root {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+/* Typing Game */
+.stats-bar {
+ display: none;
+}
+
+.end-screen {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem;
+ padding-top: 6rem;
+ min-height: 100vh;
+ position: relative;
+}
+
+.end-screen-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 2rem;
+ width: 100%;
+ margin-bottom: 3rem;
+}
+
+.end-screen-stat {
+ text-align: center;
+ padding: 1.5rem;
+ transition: transform 0.2s ease;
+}
+
+.end-screen-stat.wpm {
+ grid-column: 1;
+ grid-row: 1;
+ text-align: left;
+}
+
+.end-screen-stat.errors {
+ grid-column: 3;
+ grid-row: 1;
+ text-align: right;
+}
+
+.end-screen-stat.time {
+ grid-column: 2;
+ grid-row: 1;
+ text-align: center;
+}
+
+.end-screen-stat:hover {
+ transform: translateY(-2px);
+}
+
+.end-screen-stat-label {
+ font-size: 0.8rem;
+ color: var(--text-color);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ margin-bottom: 0.5rem;
+}
+
+.end-screen-stat-value {
+ font-size: 2.5rem;
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+.end-screen-buttons {
+ position: fixed;
+ bottom: 2rem;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ gap: 1.5rem;
+}
+
+.end-screen-button {
+ background: none;
+ border: 2px solid var(--primary-color);
+ color: var(--text-color);
+ padding: 1rem 2rem;
+ font-size: 1.2rem;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-family: inherit;
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ min-width: 250px;
+ border-radius: 8px;
+}
+
+.end-screen-button:hover {
+ background-color: var(--primary-color);
+ color: var(--background-color);
+ transform: translateY(-2px);
+}
+
+/* Character styling */
+.correct {
+ color: var(--correct-color);
+}
+
+.incorrect {
+ color: var(--error-color);
+ text-decoration: underline;
+}
+
+.current {
+ background-color: rgba(0, 0, 0, 0.1);
+ border-radius: 2px;
+}
+
+.error-screen {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 2rem;
+ text-align: center;
+}
+
+.error-screen h2 {
+ margin-bottom: 1rem;
+}
+
+.error-screen p {
+ margin-bottom: 2rem;
+}
+
+.stats-container {
+ position: fixed;
+ top: 1rem;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ padding: 0 2rem;
+ z-index: 10;
+}
+
+.wpm-stat {
+ position: fixed;
+ left: 2rem;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ width: 6rem;
+}
+
+.acc-stat {
+ position: fixed;
+ right: 2rem;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ width: 6rem;
+}
+
+.time-stat {
+ position: fixed;
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: 1rem;
+ z-index: 100;
+}
+
+.stat-label {
+ font-size: 0.8rem;
+ color: var(--text-color);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ margin-bottom: 0.5rem;
+}
+
+.stat-value {
+ font-size: 2rem;
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+.graph-container {
+ width: 100%;
+ height: 300px;
+ margin: 2rem 0;
+ position: relative;
+ padding: 2rem;
+ margin-top: 6rem;
+}
+
+.graph-container canvas {
+ width: 100%;
+ height: 100%;
+}
+
+.graph-axis {
+ position: absolute;
+ color: var(--text-color);
+ font-size: 0.8rem;
+ font-family: 'JetBrains Mono', monospace;
+}
+
+.graph-axis.x {
+ bottom: 0;
+ left: 2rem;
+ right: 2rem;
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 0;
+}
+
+.graph-axis.y {
+ top: 0;
+ bottom: 2rem;
+ left: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ width: 3rem;
+ text-align: right;
+}
+
+body, .app, #root {
+ overflow-x: hidden;
+ scrollbar-width: none; /* Firefox */
+}
+body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
+ display: none; /* Chrome, Safari, Opera */
+}
+
+.future-modes-placeholder {
+ width: 100%;
+ min-height: 3rem;
+ background: transparent;
+ margin-top: 1.5rem;
+}
+
+.end-screen-text {
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto 2rem auto;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 1.2rem;
+ color: var(--text-color);
+ background: rgba(0,0,0,0.04);
+ border-radius: 6px;
+ padding: 1rem 1.5rem;
+ text-align: left;
+ word-break: break-word;
+}
+
+.end-screen-graph-row {
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ justify-content: space-between;
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto 1.5rem auto;
+}
+
+.end-screen-stat.wpm {
+ flex: 0 0 120px;
+ text-align: left;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ margin-right: 1.5rem;
+}
+
+.end-screen-stat.errors {
+ flex: 0 0 120px;
+ text-align: right;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-end;
+ margin-left: 1.5rem;
+}
+
+.graph-container {
+ flex: 1 1 0;
+ min-width: 0;
+ margin: 0;
+ padding: 2rem 0;
+ height: 300px;
+ position: relative;
+ background: rgba(0,0,0,0.02);
+ border-radius: 8px;
+}
+
+.end-screen-graph-row.end-screen-time-row {
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ margin: 0 auto 2rem auto;
+}
+
+.end-screen-stat.time {
+ text-align: center;
+ margin: 0 auto;
+ font-size: 1.1rem;
+}
+
+.end-screen-rawwpm {
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto 2rem auto;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ gap: 1.5rem;
+ font-size: 1.3rem;
+}
+.end-screen-rawwpm .stat-label {
+ font-size: 1rem;
+ color: var(--text-color);
+ text-transform: uppercase;
+ letter-spacing: 2px;
+ margin-right: 0.5rem;
+}
+.end-screen-rawwpm .stat-value {
+ font-size: 2rem;
+ color: var(--primary-color);
+ font-weight: bold;
+}
+
+.end-screen-stat.acc {
+ flex: 0 0 120px;
+ text-align: right;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-end;
+ margin-left: 1.5rem;
+}
+
+.end-screen-time {
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto;
+ text-align: center;
+ padding: 1rem 0;
+}
+
+.end-screen-time .stat-label {
+ font-size: 0.9rem;
+ margin-bottom: 0.3rem;
+}
+
+.end-screen-time .stat-value {
+ font-size: 1.5rem;
+}
+
+@media (max-width: 900px) {
+ .end-screen-graph-row {
+ flex-direction: column !important;
+ align-items: stretch !important;
+ max-width: 100vw !important;
+ position: relative !important;
+ }
+ .endscreen-side-stat {
+ position: static !important;
+ left: unset !important;
+ right: unset !important;
+ top: unset !important;
+ transform: none !important;
+ width: 100% !important;
+ margin-bottom: 0.5rem !important;
+ z-index: 10;
+ display: flex !important;
+ flex-direction: row !important;
+ justify-content: space-between !important;
+ align-items: center !important;
+ }
+ .graph-container {
+ max-width: 100vw !important;
+ min-width: 0 !important;
+ width: 100% !important;
+ overflow-x: auto !important;
+ margin: 0 auto 1rem auto !important;
+ height: 160px !important;
+ }
+ .end-screen-buttons {
+ position: static !important;
+ left: unset !important;
+ bottom: unset !important;
+ transform: none !important;
+ width: 100% !important;
+ margin: 1.5rem 0 0 0 !important;
+ flex-direction: column !important;
+ gap: 1rem !important;
+ z-index: 10;
+ }
+ .end-screen-time {
+ position: static !important;
+ left: unset !important;
+ transform: none !important;
+ bottom: unset !important;
+ width: 100% !important;
+ margin-top: 1rem !important;
+ }
+}
+
+@media (max-width: 600px) {
+ .end-screen-graph-row {
+ padding: 0 0.5rem !important;
+ }
+ .graph-container {
+ height: 180px !important;
+ padding: 0.5rem !important;
+ }
+ .end-screen-stat.wpm, .end-screen-stat.acc {
+ font-size: 1rem !important;
+ padding: 0.5rem !important;
+ }
+ .end-screen-time .stat-value {
+ font-size: 1.2rem !important;
+ }
+}
+
+@media (max-width: 900px) {
+ .text-container {
+ padding: 0.5rem !important;
+ min-height: 120px !important;
+ }
+}
+@media (max-width: 600px) {
+ .text-container {
+ min-height: 80px !important;
+ font-size: 1rem !important;
+ }
+}
+
+@media (max-width: 700px) {
+ body, #root, .app, .end-screen {
+ overflow-y: auto !important;
+ overflow-x: hidden !important;
+ }
+ .end-screen {
+ min-height: 0 !important;
+ height: auto !important;
+ padding-top: 1.5rem !important;
+ padding-bottom: 0.5rem !important;
+ display: flex !important;
+ flex-direction: column !important;
+ }
+ .end-screen-buttons {
+ margin-top: auto !important;
+ margin-bottom: 0 !important;
+ }
+ .end-screen-main-content {
+ flex: 1 0 auto !important;
+ width: 100% !important;
+ display: flex !important;
+ flex-direction: column !important;
+ }
+ .end-screen-buttons {
+ margin-top: auto !important;
+ margin-bottom: 0 !important;
+ width: 100% !important;
+ }
+} \ No newline at end of file
diff --git a/web/src/styles/ThemeToggle.css b/web/src/styles/ThemeToggle.css
new file mode 100644
index 0000000..818a1ba
--- /dev/null
+++ b/web/src/styles/ThemeToggle.css
@@ -0,0 +1,70 @@
+.theme-toggle {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ background: none;
+ border: none;
+ padding: 0.5rem;
+ cursor: pointer;
+ z-index: 1000;
+ transition: transform 0.3s ease;
+}
+
+.theme-toggle:hover {
+ transform: scale(1.1);
+}
+
+.toggle-track {
+ width: 3rem;
+ height: 1.5rem;
+ background-color: var(--background-color);
+ border: 2px solid var(--primary-color);
+ border-radius: 1rem;
+ position: relative;
+ transition: background-color 0.3s ease;
+}
+
+.toggle-thumb {
+ width: 1.2rem;
+ height: 1.2rem;
+ background-color: var(--primary-color);
+ border-radius: 50%;
+ position: absolute;
+ top: 0.1rem;
+ left: 0.1rem;
+ transition: transform 0.3s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.theme-toggle.dark .toggle-thumb {
+ transform: translateX(1.5rem);
+}
+
+.theme-toggle svg {
+ width: 1rem;
+ height: 1rem;
+ color: var(--background-color);
+}
+
+@media (max-width: 768px) {
+ .theme-toggle {
+ top: 0.5rem;
+ right: 0.5rem;
+ }
+
+ .toggle-track {
+ width: 2.5rem;
+ height: 1.2rem;
+ }
+
+ .toggle-thumb {
+ width: 1rem;
+ height: 1rem;
+ }
+
+ .theme-toggle.dark .toggle-thumb {
+ transform: translateX(1.2rem);
+ }
+} \ No newline at end of file
diff --git a/web/src/styles/TypingGame.css b/web/src/styles/TypingGame.css
new file mode 100644
index 0000000..e5f5c8e
--- /dev/null
+++ b/web/src/styles/TypingGame.css
@@ -0,0 +1,19 @@
+.space {
+ color: #666;
+ opacity: 0.7;
+}
+
+.space.correct {
+ color: #4CAF50;
+ opacity: 1;
+}
+
+.space.incorrect {
+ color: #f44336;
+ opacity: 1;
+}
+
+.space.current {
+ background-color: rgba(255, 255, 255, 0.1);
+ border-radius: 2px;
+} \ No newline at end of file
diff --git a/web/src/types.ts b/web/src/types.ts
new file mode 100644
index 0000000..07871c2
--- /dev/null
+++ b/web/src/types.ts
@@ -0,0 +1,54 @@
+export type Screen = 'main-menu' | 'typing-game' | 'end-screen';
+
+export interface Stats {
+ wpm: number;
+ rawWpm: number;
+ accuracy: number;
+ time: number;
+ correctChars: number;
+ incorrectChars: number;
+ totalChars: number;
+ currentStreak: number;
+ bestStreak: number;
+}
+
+export interface GameState {
+ screen: Screen;
+ currentText: string;
+ currentAttribution?: string;
+ input: string;
+ startTime: number | null;
+ isRunning: boolean;
+ stats: Stats;
+}
+
+export enum Theme {
+ Light = 'light',
+ Dark = 'dark'
+}
+
+export interface ThemeColors {
+ primary: string;
+ secondary: string;
+ background: string;
+ text: string;
+ error: string;
+ success: string;
+}
+
+export interface TyperPunkGame {
+ handle_input(input: string): void;
+ handle_backspace(ctrl: boolean): boolean;
+ get_stats(): [number, number];
+ get_stats_and_input(): [string, number, number];
+ is_finished(): boolean;
+ get_text(): string;
+ get_input(): string;
+ set_text(text: string): void;
+ start(): void;
+ get_wpm(): number;
+ get_time_elapsed(): number;
+ get_raw_wpm(): number;
+}
+
+export type TyperPunk = TyperPunkGame; \ No newline at end of file
diff --git a/web/src/types/typerpunk.d.ts b/web/src/types/typerpunk.d.ts
new file mode 100644
index 0000000..a21ddd5
--- /dev/null
+++ b/web/src/types/typerpunk.d.ts
@@ -0,0 +1,27 @@
+declare module 'typerpunk' {
+ export class TyperPunkGame {
+ free(): void;
+ set_text(text: string): void;
+ get_text(): string;
+ get_input(): string;
+ start(): void;
+ handle_input(input: string): void;
+ handle_backspace(is_word_deletion: boolean): boolean;
+ is_finished(): boolean;
+ get_error_positions(): Uint32Array;
+ get_current_streak(): number;
+ get_best_streak(): number;
+ get_theme(): string;
+ set_theme(theme: string): void;
+ get_wpm(): number;
+ get_accuracy(): number;
+ get_time_elapsed(): number;
+ get_raw_wpm(): number;
+ can_backspace(): boolean;
+ can_ctrl_backspace(): boolean;
+ handle_backspace(ctrl: boolean): boolean;
+ get_total_mistakes(): number;
+ }
+
+ export default function init(): Promise<void>;
+} \ No newline at end of file
diff --git a/web/src/wasm.d.ts b/web/src/wasm.d.ts
new file mode 100644
index 0000000..6692774
--- /dev/null
+++ b/web/src/wasm.d.ts
@@ -0,0 +1,25 @@
+export class TyperPunkGame {
+ constructor(text: string);
+ handle_input(input: string): Result<void, string>;
+ handle_backspace(ctrl: boolean): Result<boolean, string>;
+ get_stats(): Result<[number, number], string>;
+ get_stats_and_input(): Result<[string, number, number], string>;
+ is_finished(): boolean;
+ can_backspace_to_position(position: number): boolean;
+ get_current_word_start(): number;
+ set_text(text: string): Result<void, string>;
+ get_text(): string;
+ get_input(): string;
+ start(): void;
+ get_wpm(): number;
+ get_time_elapsed(): number;
+ get_raw_wpm(): number;
+ free(): void;
+}
+
+export type Result<T, E> = {
+ isOk(): boolean;
+ isErr(): boolean;
+ unwrap(): T;
+ unwrapErr(): E;
+}; \ No newline at end of file