aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/core/src/app.rs96
-rw-r--r--crates/core/src/stats.rs26
-rw-r--r--crates/core/src/ui.rs231
-rw-r--r--web/src/App.tsx17
-rw-r--r--web/src/components/EndScreen.tsx43
-rw-r--r--web/src/components/MainMenu.tsx39
-rw-r--r--web/src/components/TypingGame.tsx29
-rw-r--r--web/src/hooks/useCoreGame.ts17
-rw-r--r--web/src/styles.css65
9 files changed, 366 insertions, 197 deletions
diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs
index b230bc3..5e2da72 100644
--- a/crates/core/src/app.rs
+++ b/crates/core/src/app.rs
@@ -24,6 +24,7 @@ pub struct App {
pub current_text_index: usize,
pub should_exit: bool,
pub state: State,
+ pub wpm_history: Vec<u64>,
}
impl App {
@@ -64,9 +65,59 @@ impl App {
current_text_index,
should_exit,
state,
+ wpm_history: Vec::new(),
})
}
+ fn handle_backspace_with_rules(&mut self, ctrl: bool) {
+ if self.input.is_empty() { return; }
+ let current_text = &self.texts[self.current_text_index].content;
+ if ctrl {
+ // Delete to start of current word
+ let word_start = self.get_current_word_start();
+ if word_start < self.input.len() {
+ self.input.truncate(word_start);
+ self.update_stats();
+ }
+ return;
+ }
+
+ // Deleting one character. Only allow crossing into previous word if there are errors before.
+ let target_pos = self.input.len().saturating_sub(1);
+ let current_word_start = self.get_current_word_start();
+ if target_pos < current_word_start {
+ if !self.has_errors_before_position(current_text, current_word_start) {
+ // No errors before; do not allow moving back into previous words
+ return;
+ }
+ }
+ self.input.pop();
+ self.update_stats();
+ }
+
+ fn get_current_word_start(&self) -> usize {
+ let mut word_start = 0;
+ let mut in_word = false;
+ for (i, c) in self.input.chars().enumerate() {
+ if c.is_whitespace() {
+ if in_word { word_start = i + 1; }
+ in_word = false;
+ } else {
+ in_word = true;
+ }
+ }
+ word_start
+ }
+
+ fn has_errors_before_position(&self, text: &str, position: usize) -> bool {
+ let compare_len = self.input.len().min(text.len());
+ for (i, (ic, tc)) in self.input.chars().zip(text.chars()).take(compare_len).enumerate() {
+ if i >= position { break; }
+ if ic != tc { return true; }
+ }
+ false
+ }
+
pub fn new_with_config(config: Config) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
#[derive(Deserialize)]
struct RawText { category: String, content: String, attribution: String }
@@ -97,6 +148,7 @@ impl App {
current_text_index,
stats: Stats::new(),
config,
+ wpm_history: Vec::new(),
})
}
@@ -104,6 +156,7 @@ impl App {
self.input.clear();
self.stats.reset();
self.current_text_index = self.pick_random_index();
+ self.wpm_history.clear();
}
fn pick_random_index(&self) -> usize {
@@ -162,15 +215,45 @@ impl App {
State::TypingGame => {
match key.code {
crossterm::event::KeyCode::Char(c) => {
- if !self.stats.is_running() {
- self.stats.start();
+ // Handle control-word delete (Ctrl+W)
+ if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
+ && (c == 'w' || c == 'W')
+ {
+ self.handle_backspace_with_rules(true);
+ return;
+ }
+ // Don't insert invisible control chars; only insert when no CTRL/ALT (SHIFT ok)
+ if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) {
+ return;
}
+ if !self.stats.is_running() { self.stats.start(); }
+ // Record keystroke correctness before mutating input
+ let was_correct = {
+ let pos = self.input.len();
+ let current_text = &self.texts[self.current_text_index].content;
+ if pos < current_text.len() {
+ // Compare with target at this position
+ current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false)
+ } else {
+ false // extra chars are considered incorrect
+ }
+ };
+ self.stats.note_keypress(was_correct);
self.input.push(c);
self.update_stats();
}
crossterm::event::KeyCode::Backspace => {
- self.input.pop();
- self.update_stats();
+ // Treat Ctrl or Alt modified Backspace as word delete for tmux/screen/terms
+ let ctrl_or_alt = key.modifiers.intersects(
+ crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT,
+ );
+ self.handle_backspace_with_rules(ctrl_or_alt);
+ }
+ // Some terminals send Ctrl+H instead of Ctrl+Backspace
+ crossterm::event::KeyCode::Char('h') | crossterm::event::KeyCode::Char('H')
+ if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) =>
+ {
+ self.handle_backspace_with_rules(true);
}
crossterm::event::KeyCode::Esc => {
self.state = State::MainMenu;
@@ -270,6 +353,11 @@ impl App {
pub fn update(&mut self) {
if self.state == State::TypingGame {
self.update_stats();
+ // Sample WPM once per elapsed second to build a compact sparkline
+ let secs = self.stats.elapsed_time().as_secs() as usize;
+ while self.wpm_history.len() < secs {
+ self.wpm_history.push(self.stats.wpm().round() as u64);
+ }
}
}
} \ No newline at end of file
diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs
index 59d56c9..264000b 100644
--- a/crates/core/src/stats.rs
+++ b/crates/core/src/stats.rs
@@ -14,6 +14,10 @@ pub struct Stats {
total_words: usize,
correct_words: usize,
errors: usize,
+ // Persistent keystroke-level tracking (CLI):
+ // counts every typed character (excluding control sequences) and how many were incorrect at time of keypress
+ keystrokes_total: usize,
+ keystrokes_incorrect: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -81,6 +85,8 @@ impl Stats {
total_words: 0,
correct_words: 0,
errors: 0,
+ keystrokes_total: 0,
+ keystrokes_incorrect: 0,
}
}
@@ -96,6 +102,8 @@ impl Stats {
self.total_words = 0;
self.correct_words = 0;
self.errors = 0;
+ self.keystrokes_total = 0;
+ self.keystrokes_incorrect = 0;
}
pub fn start(&mut self) {
@@ -147,6 +155,15 @@ impl Stats {
self.best_streak = self.best_streak.max(best_streak_local);
}
+ // Record a single keypress for persistent accuracy tracking (CLI only).
+ // If the typed char at the time of keypress was incorrect, mark it as incorrect permanently.
+ pub fn note_keypress(&mut self, was_correct: bool) {
+ self.keystrokes_total = self.keystrokes_total.saturating_add(1);
+ if !was_correct {
+ self.keystrokes_incorrect = self.keystrokes_incorrect.saturating_add(1);
+ }
+ }
+
pub fn finish(&mut self) {
self.end_time = Some(Instant::now());
}
@@ -178,11 +195,14 @@ impl Stats {
}
pub fn get_accuracy(&self) -> f64 {
+ // Prefer persistent keystroke accuracy for CLI to avoid resetting to 100% after fixes.
+ if self.keystrokes_total > 0 {
+ let correct = (self.keystrokes_total - self.keystrokes_incorrect) as f64;
+ return (correct / self.keystrokes_total as f64) * 100.0;
+ }
if self.total_chars > 0 {
(self.correct_chars as f64 / self.total_chars as f64) * 100.0
- } else {
- 0.0
- }
+ } else { 0.0 }
}
pub fn get_time_elapsed(&self) -> Duration {
diff --git a/crates/core/src/ui.rs b/crates/core/src/ui.rs
index f503474..ac18e41 100644
--- a/crates/core/src/ui.rs
+++ b/crates/core/src/ui.rs
@@ -1,22 +1,13 @@
-#[cfg(feature = "tui")]
-use std::io;
-
-#[cfg(feature = "tui")]
use ratatui::{
- backend::Backend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
- text::{Span},
- widgets::{Block, Borders, Paragraph, Wrap},
+ text::Span,
+ widgets::{Block, Paragraph, Wrap},
Frame,
};
+use ratatui::prelude::{Alignment, Line};
-#[cfg(feature = "tui")]
-use crate::app::App;
-
-#[cfg(feature = "tui")]
-use ratatui::prelude::{Line, Alignment};
-use crate::app::State;
+use crate::app::{App, State};
pub fn draw(f: &mut Frame, app: &App) {
match app.state {
@@ -26,61 +17,58 @@ pub fn draw(f: &mut Frame, app: &App) {
}
}
-pub fn draw_main_menu(f: &mut Frame, _app: &App) {
+pub fn draw_main_menu(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
- .margin(2)
+ .margin(1)
.constraints([Constraint::Min(0)])
.split(f.size());
let category_label = {
- let cat = _app.selected_category.as_ref().map(String::as_str).unwrap_or("Random");
- format!("Category: {} (←/→ to change)", cat)
+ let cat = app
+ .selected_category
+ .as_ref()
+ .map(String::as_str)
+ .unwrap_or("Random");
+ format!("Category: {} (\u{2190}/\u{2192} to change)", cat)
};
- let main_menu = vec![
- Line::from(Span::styled("Welcome to Typerpunk!", Style::default().add_modifier(Modifier::BOLD))),
- Line::from(Span::styled(category_label, Style::default().fg(Color::Cyan))),
- Line::from(Span::styled("Press Enter to Start", Style::default())),
- Line::from(Span::styled("Press Esc to Quit", Style::default())),
+
+ let mut lines: Vec<Line> = vec![
+ Line::from(Span::styled(
+ "TYPERPUNK",
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
+ )),
+ Line::from(Span::from("")),
+ Line::from(Span::styled(
+ category_label,
+ Style::default().fg(Color::Cyan),
+ )),
+ Line::from(Span::from("")),
+ Line::from(Span::styled("Start: Enter", Style::default())),
+ Line::from(Span::styled(
+ "Change Category: \u{2190} / \u{2192}",
+ Style::default(),
+ )),
+ Line::from(Span::styled("Quit: Esc", Style::default())),
];
f.render_widget(
- Paragraph::new(main_menu)
+ Paragraph::new(lines)
.alignment(Alignment::Center)
- .block(Block::default().borders(Borders::ALL)),
+ .block(Block::default()),
chunks[0],
);
}
pub fn draw_typing_game(f: &mut Frame, app: &App) {
+ let area = f.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
- .margin(2)
- .constraints([
- Constraint::Length(3), // Stats
- Constraint::Min(0), // Text
- ])
- .split(f.size());
-
- // Draw stats
- let stats = vec![
- Line::from(vec![
- Span::styled(format!("WPM: {:.1}", app.stats.wpm()), Style::default().fg(Color::Yellow)),
- Span::styled(" | ", Style::default()),
- Span::styled(format!("Time: {:.1}s", app.stats.elapsed_time().as_secs_f64()), Style::default().fg(Color::Cyan)),
- Span::styled(" | ", Style::default()),
- Span::styled(format!("Accuracy: {:.1}%", app.stats.accuracy()), Style::default().fg(Color::Green)),
- ]),
- ];
-
- f.render_widget(
- Paragraph::new(stats)
- .alignment(Alignment::Center)
- .block(Block::default().borders(Borders::ALL)),
- chunks[0],
- );
+ .margin(1)
+ .constraints([Constraint::Min(0)])
+ .split(area);
- // Draw text
+ // Build colored text
let text_chars: Vec<char> = app.current_text().content.chars().collect();
let input_chars: Vec<char> = app.input.chars().collect();
let mut colored_text: Vec<Span> = Vec::new();
@@ -96,7 +84,6 @@ pub fn draw_typing_game(f: &mut Frame, app: &App) {
} else {
Style::default().fg(Color::Gray)
};
-
let span = if i == cursor_pos {
Span::styled(c.to_string(), style.add_modifier(Modifier::REVERSED))
} else {
@@ -105,83 +92,125 @@ pub fn draw_typing_game(f: &mut Frame, app: &App) {
colored_text.push(span);
}
- // Add any remaining incorrect characters
if input_chars.len() > text_chars.len() {
for &c in &input_chars[text_chars.len()..] {
- colored_text.push(Span::styled(
- c.to_string(),
- Style::default().fg(Color::Red),
- ));
+ colored_text.push(Span::styled(c.to_string(), Style::default().fg(Color::Red)));
}
}
- let text = vec![
- Line::from(Span::styled(
- "Type the following text:",
- Style::default().add_modifier(Modifier::BOLD),
- )),
- Line::from(colored_text),
- ];
+ let lines = vec![Line::from(Span::from("")), Line::from(colored_text)];
f.render_widget(
- Paragraph::new(text)
- .alignment(Alignment::Left)
- .block(Block::default().borders(Borders::ALL))
+ Paragraph::new(lines)
+ .alignment(Alignment::Center)
+ .block(Block::default())
.wrap(Wrap { trim: true }),
- chunks[1],
+ chunks[0],
);
- // Render attribution beneath the text box
- let attribution = app.current_text().source.clone();
- if !attribution.is_empty() {
- let area = ratatui::layout::Rect {
- x: chunks[1].x,
- y: chunks[1].y.saturating_add(chunks[1].height.saturating_sub(2)),
- width: chunks[1].width,
+ // Attribution under text
+ if !app.current_text().source.is_empty() {
+ let att_area = ratatui::layout::Rect {
+ x: chunks[0].x,
+ y: chunks[0].y.saturating_add(chunks[0].height.saturating_sub(5)),
+ width: chunks[0].width,
height: 2,
};
let attribution_line = Line::from(Span::styled(
- format!("— {}", attribution),
+ format!("— {}", app.current_text().source),
Style::default().fg(Color::Gray),
));
f.render_widget(
Paragraph::new(vec![attribution_line])
- .alignment(Alignment::Right)
+ .alignment(Alignment::Center)
.wrap(Wrap { trim: true }),
- area,
+ att_area,
);
}
-}
-pub fn draw_end_screen(f: &mut Frame, app: &App) {
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(2)
- .constraints([Constraint::Min(0)])
- .split(f.size());
+ // Anchored stats: WPM (left), ACC (right), TIME (bottom center)
+ let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(3), width: 20, height: 3 };
+ let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(3), width: 20, height: 3 };
+ let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(2), width: 20, height: 2 };
- let end_screen = vec![
- Line::from(Span::styled("Game Over!", Style::default().add_modifier(Modifier::BOLD))),
+ let wpm_widget = Paragraph::new(vec![
+ Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))),
Line::from(Span::styled(
- format!("Words Per Minute: {:.1}", app.stats.wpm()),
- Style::default(),
+ format!("{:.0}", app.stats.wpm()),
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
)),
+ ])
+ .alignment(Alignment::Left);
+
+ let acc_widget = Paragraph::new(vec![
+ Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))),
Line::from(Span::styled(
- format!("Time Taken: {:.1} seconds", app.stats.elapsed_time().as_secs_f64()),
- Style::default(),
+ format!("{:.0}%", app.stats.accuracy()),
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
)),
+ ])
+ .alignment(Alignment::Right);
+
+ let time_widget = Paragraph::new(vec![
+ Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))),
Line::from(Span::styled(
- format!("Accuracy: {:.1}%", app.stats.accuracy()),
- Style::default(),
+ format!("{:.1}", app.stats.elapsed_time().as_secs_f64()),
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
)),
- Line::from(Span::styled("Press Enter to Play Again", Style::default())),
- Line::from(Span::styled("Press Esc to Quit", Style::default())),
- ];
+ ])
+ .alignment(Alignment::Center);
- f.render_widget(
- Paragraph::new(end_screen)
- .alignment(Alignment::Center)
- .block(Block::default().borders(Borders::ALL)),
- chunks[0],
- );
-} \ No newline at end of file
+ f.render_widget(wpm_widget, wpm_rect);
+ f.render_widget(acc_widget, acc_rect);
+ f.render_widget(time_widget, time_rect);
+}
+
+pub fn draw_end_screen(f: &mut Frame, app: &App) {
+ let area = f.size();
+ // We don't render a central RESULTS section to avoid duplication.
+ // We only render anchored stats and bottom buttons.
+
+ // Anchored stats at the edges
+ let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(6), width: 20, height: 3 };
+ let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(6), width: 20, height: 3 };
+ let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(5), width: 20, height: 2 };
+ let buttons_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 20, y: area.y + area.height.saturating_sub(2), width: 40, height: 2 };
+
+ let wpm_widget = Paragraph::new(vec![
+ Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))),
+ Line::from(Span::styled(
+ format!("{:.0}", app.stats.wpm()),
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
+ )),
+ ])
+ .alignment(Alignment::Left);
+
+ let acc_widget = Paragraph::new(vec![
+ Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))),
+ Line::from(Span::styled(
+ format!("{:.0}%", app.stats.accuracy()),
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
+ )),
+ ])
+ .alignment(Alignment::Right);
+
+ let time_widget = Paragraph::new(vec![
+ Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))),
+ Line::from(Span::styled(
+ format!("{:.1}", app.stats.elapsed_time().as_secs_f64()),
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
+ )),
+ ])
+ .alignment(Alignment::Center);
+
+ let buttons = Paragraph::new(vec![
+ Line::from(Span::styled("Enter: Play Again", Style::default())),
+ Line::from(Span::styled("Esc: Main Menu", Style::default())),
+ ])
+ .alignment(Alignment::Center);
+
+ f.render_widget(wpm_widget, wpm_rect);
+ f.render_widget(acc_widget, acc_rect);
+ f.render_widget(time_widget, time_rect);
+ f.render_widget(buttons, buttons_rect);
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index b34c4b4..9b266d9 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -108,7 +108,10 @@ 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 [selectedCategory, setSelectedCategory] = useState<string>(() => {
+ const saved = localStorage.getItem('typerpunk:last_mode');
+ return saved || 'random';
+ });
const [gameState, setGameState] = useState<GameState>({
screen: 'main-menu',
currentText: '',
@@ -140,12 +143,7 @@ function App() {
}));
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
- }));
+ // removed unused _testCharTimings
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
@@ -167,6 +165,10 @@ function App() {
})();
}, []);
+ useEffect(() => {
+ try { localStorage.setItem('typerpunk:last_mode', selectedCategory); } catch {}
+ }, [selectedCategory]);
+
const handleStartGame = async () => {
try {
// Reset game state first
@@ -402,6 +404,7 @@ function App() {
categories={categories}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
+ startLabel={`Start: ${selectedCategory === 'random' ? 'Random' : selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)}`}
/>
) : gameState.screen === 'end-screen' ? (
<EndScreen
diff --git a/web/src/components/EndScreen.tsx b/web/src/components/EndScreen.tsx
index 3c70e95..9b1d7ab 100644
--- a/web/src/components/EndScreen.tsx
+++ b/web/src/components/EndScreen.tsx
@@ -492,7 +492,7 @@ export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMen
};
// --- 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' }}>
+ <div className="end-screen" style={{ maxWidth: 900, margin: '0 auto', 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 */}
@@ -507,7 +507,7 @@ export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMen
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="end-screen-text" style={{ 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 */}
@@ -540,6 +540,45 @@ export const EndScreen: FC<Props> = ({ stats, wpmHistory, onPlayAgain, onMainMen
</div>
</div>
)}
+ {/* Transparent theme toggle (no class to avoid inherited styles) */}
+ <button
+ onClick={toggleTheme}
+ style={{
+ position: 'fixed',
+ top: '1rem',
+ right: '1rem',
+ background: 'transparent',
+ border: 'none',
+ boxShadow: 'none',
+ outline: 'none',
+ backdropFilter: 'none',
+ WebkitBackdropFilter: 'none',
+ appearance: 'none',
+ WebkitAppearance: 'none',
+ MozAppearance: 'none',
+ padding: 0,
+ margin: 0,
+ borderRadius: 0,
+ }}
+ >
+ {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>
{/* 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>
diff --git a/web/src/components/MainMenu.tsx b/web/src/components/MainMenu.tsx
index b8daea0..fc80039 100644
--- a/web/src/components/MainMenu.tsx
+++ b/web/src/components/MainMenu.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { useTheme } from '../contexts/ThemeContext';
import { Theme } from '../types';
@@ -11,33 +11,28 @@ interface Props {
const MainMenu: React.FC<Props> = ({ onStartGame, categories = [], selectedCategory = 'random', onSelectCategory }) => {
const { theme, toggleTheme } = useTheme();
+ const modes = useMemo(() => ['random', ...categories], [categories]);
+ const currentIndex = Math.max(0, modes.findIndex(m => (selectedCategory || 'random') === m));
+ const currentMode = modes[currentIndex] || 'random';
+ const nextMode = modes[(currentIndex + 1) % modes.length] || 'random';
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
+ <h1 style={{ marginTop: '-0.25rem', marginBottom: '0.8rem' }}>TyperPunk</h1>
+ <div className="menu-options" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.6rem' }}>
+ <button className="menu-button" onClick={onStartGame} style={{ width: 220, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>Start</button>
+ <button
+ className="menu-button"
+ onClick={() => onSelectCategory && onSelectCategory(nextMode)}
+ style={{ width: 220, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
+ >
+ {`Mode: ${currentMode === 'random' ? 'Random' : currentMode.charAt(0).toUpperCase() + currentMode.slice(1)}`}
</button>
- <button className="menu-button" onClick={toggleTheme}>
- Toggle {theme === Theme.Dark ? 'Light' : 'Dark'} Mode
+ <button className="menu-button" onClick={toggleTheme} style={{ width: 220, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
+ Toggle Theme
</button>
</div>
- <button onClick={toggleTheme} className="theme-toggle">
+ <button onClick={toggleTheme} className="theme-toggle" style={{ background: 'transparent', border: 'none', boxShadow: 'none', backdropFilter: 'none' }}>
{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"/>
diff --git a/web/src/components/TypingGame.tsx b/web/src/components/TypingGame.tsx
index d815420..7d79c24 100644
--- a/web/src/components/TypingGame.tsx
+++ b/web/src/components/TypingGame.tsx
@@ -52,7 +52,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen
const [localInput, setLocalInput] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);
const [wasmAccuracy, setWasmAccuracy] = useState<number>(100);
- const [wasmMistakes, setWasmMistakes] = useState<number>(0);
+ const [_wasmMistakes, setWasmMistakes] = useState<number>(0);
const gameRef = useRef(game);
const isInitialized = useRef(false);
const lastInputRef = useRef('');
@@ -63,7 +63,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen
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 }>>([]);
+ 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 }>>([]);
@@ -164,7 +164,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen
// 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();
+ const [, , mistakes] = game.get_stats_and_input();
// If mistakes increased, mark as error
if (mistakes > 0) isError = true;
}
@@ -504,7 +504,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen
/>
</div>
{attribution && (
- <div style={{ maxWidth: 700, width: '100%', margin: '0 auto 1.5rem auto', textAlign: 'right', color: 'var(--neutral-color)', fontSize: '0.9rem' }}>
+ <div style={{ maxWidth: 700, width: '100%', margin: '0.5rem auto 1.75rem auto', textAlign: 'center', color: 'var(--neutral-color)', fontSize: '0.95rem' }}>
— {attribution}
</div>
)}
@@ -527,7 +527,7 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen
</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' }} />
+ <div className="graph-container" style={{ flex: '1 1 0', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 160, minHeight: 160, height: 160, 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>
@@ -537,36 +537,37 @@ export const TypingGame: React.FC<Props> = React.memo((props: Props): JSX.Elemen
{/* 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="graph-container" style={{ flex: 'none', minWidth: 0, width: '100%', maxWidth: '100%', maxHeight: 220, minHeight: 220, height: 220, margin: '0 auto 0.35rem 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',
+ margin: '0.35rem auto 0.15rem auto',
+ padding: '0 0.6rem',
+ gap: '0.2rem',
}}>
- {/* WPM (left) */}
- <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center' }}>
+ {/* WPM (left, close to edge) */}
+ <div className="end-screen-stat wpm" style={{ textAlign: 'left', alignItems: 'flex-start', justifyContent: 'center', paddingLeft: '0.2rem' }}>
<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' }}>
+ {/* ACC (right, close to edge) */}
+ <div className="end-screen-stat acc" style={{ textAlign: 'right', alignItems: 'flex-end', justifyContent: 'center', paddingRight: '0.2rem' }}>
<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-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: '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">
+ <button onClick={toggleTheme} className="theme-toggle" style={{ background: 'transparent', border: 'none', boxShadow: 'none', backdropFilter: 'none' }}>
{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"/>
diff --git a/web/src/hooks/useCoreGame.ts b/web/src/hooks/useCoreGame.ts
index 3aa69ec..7e0b287 100644
--- a/web/src/hooks/useCoreGame.ts
+++ b/web/src/hooks/useCoreGame.ts
@@ -42,14 +42,8 @@ export function useCoreGame() {
}, []);
const resetGame = async () => {
- if (gameRef.current) {
- try {
- gameRef.current.free();
- } catch (err) {
- console.error('Error freeing old game:', err);
- }
- }
-
+ // Do NOT free() here to avoid freeing while React components may still reference it.
+ // Create a fresh instance and replace the ref.
try {
const game = new Game();
gameRef.current = game;
@@ -60,14 +54,15 @@ export function useCoreGame() {
};
const cleanupGame = () => {
- if (gameRef.current) {
+ const inst = gameRef.current;
+ if (inst) {
try {
- gameRef.current.free();
+ inst.free();
} catch (err) {
console.error('Error cleaning up game:', err);
}
- gameRef.current = null;
}
+ gameRef.current = null;
};
return {
diff --git a/web/src/styles.css b/web/src/styles.css
index d9130b4..5eb97eb 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -12,6 +12,8 @@
--neutral-color: #646669;
--caret-color: #00ff9d;
--sub-color: #646669;
+ --header-offset: 3.5rem;
+ --app-padding: 1rem;
}
[data-theme="light"] {
@@ -58,9 +60,9 @@ body {
display: flex;
flex-direction: column;
align-items: center;
- justify-content: center;
+ justify-content: flex-start; /* avoid vertical centering that pushes content down */
height: 100vh;
- padding: 1rem;
+ padding: var(--app-padding);
overflow: hidden;
}
@@ -145,7 +147,7 @@ body {
max-width: 800px;
margin: 0 auto;
padding: 1rem 2rem 0 2rem;
- margin-top: 2.5rem;
+ margin-top: var(--header-offset); /* unified: just below header/toggle */
display: flex;
justify-content: center;
align-items: flex-start;
@@ -227,37 +229,40 @@ body {
position: fixed;
top: 1rem;
right: 1rem;
- background: rgba(255,255,255,0.08);
+ background: transparent !important;
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);
+ box-shadow: none !important;
color: var(--primary-color);
- padding: 0.7rem 1rem;
+ padding: 0;
font-size: 1.2rem;
cursor: pointer;
- transition: all 0.2s cubic-bezier(.4,0,.2,1);
+ transition: color 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
z-index: 100;
- border-radius: 1.5rem;
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
+ border-radius: 0;
+ backdrop-filter: none !important;
+ -webkit-backdrop-filter: none !important;
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
}
.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);
+ box-shadow: none !important;
+ background: transparent !important;
}
.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);
+ background: transparent !important;
+ box-shadow: none !important;
+ transform: none;
}
.theme-toggle svg {
@@ -291,7 +296,7 @@ body {
.text-container {
padding: 1rem;
- margin-top: 5rem;
+ margin-top: var(--header-offset);
}
.stats-container {
@@ -403,7 +408,7 @@ body {
}
.text-container {
padding: 0.5rem 0.5rem 0 0.5rem;
- margin-top: 1.5rem;
+ margin-top: var(--header-offset);
min-width: 0;
}
.text-display {
@@ -446,8 +451,8 @@ body {
width: 100%;
max-width: 800px;
margin: 0 auto;
- padding: 2rem;
- padding-top: 6rem;
+ padding: 0.25rem;
+ margin-top: calc(var(--header-offset) - var(--app-padding)); /* match .text-container exactly */
min-height: 100vh;
position: relative;
}
@@ -683,7 +688,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
.end-screen-text {
width: 100%;
max-width: 800px;
- margin: 0 auto 2rem auto;
+ margin: 0 auto 0.5rem auto;
font-family: 'JetBrains Mono', monospace;
font-size: 1.2rem;
color: var(--text-color);
@@ -728,7 +733,7 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
flex: 1 1 0;
min-width: 0;
margin: 0;
- padding: 2rem 0;
+ padding: 1rem 0;
height: 300px;
position: relative;
background: rgba(0,0,0,0.02);
@@ -880,6 +885,9 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
}
@media (max-width: 700px) {
+ :root {
+ --header-offset: 2.5rem;
+ }
body, #root, .app, .end-screen {
overflow-y: auto !important;
overflow-x: hidden !important;
@@ -887,7 +895,8 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
.end-screen {
min-height: 0 !important;
height: auto !important;
- padding-top: 1.5rem !important;
+ padding: 0.5rem 0.75rem !important;
+ padding-top: var(--header-offset) !important; /* align with game text */
padding-bottom: 0.5rem !important;
display: flex !important;
flex-direction: column !important;
@@ -895,16 +904,6 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
.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
+}