wpmx 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ {
2
+ "enabledMcpjsonServers": [
3
+ "shadcn"
4
+ ],
5
+ "enableAllProjectMcpServers": true
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wpmx",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "A minimal, fast typing test for the terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.tsx CHANGED
@@ -21,9 +21,12 @@ export type GameResults = {
21
21
 
22
22
  export function App() {
23
23
  const { exit } = useApp();
24
- const settings = loadSettings();
24
+ const handleQuit = useCallback(() => {
25
+ exit();
26
+ process.exit(0);
27
+ }, [exit]);
25
28
  const [screen, setScreen] = useState<Screen>("menu");
26
- const [duration, setDuration] = useState<Duration>(settings.lastDuration);
29
+ const [duration, setDuration] = useState<Duration>(() => loadSettings().lastDuration);
27
30
  const [results, setResults] = useState<GameResults | null>(null);
28
31
  const [gameKey, setGameKey] = useState(0);
29
32
 
@@ -58,7 +61,7 @@ export function App() {
58
61
  }, []);
59
62
 
60
63
  if (screen === "menu") {
61
- return <Menu onStart={handleStart} onQuit={exit} defaultDuration={duration} />;
64
+ return <Menu onStart={handleStart} onQuit={handleQuit} defaultDuration={duration} />;
62
65
  }
63
66
 
64
67
  if (screen === "game") {
@@ -81,7 +84,7 @@ export function App() {
81
84
  personalBest={pb}
82
85
  onRestart={handleRestart}
83
86
  onMenu={handleMenu}
84
- onQuit={exit}
87
+ onQuit={handleQuit}
85
88
  />
86
89
  );
87
90
  }
@@ -1,33 +1,15 @@
1
- import { Box, Text, useInput } from "ink";
1
+ import { Box, Text, useInput, useStdout } from "ink";
2
+ import type { Key } from "ink";
2
3
  import { useGame, type GameResults } from "../hooks/useGame.ts";
3
- import { memo, useEffect, useMemo } from "react";
4
-
5
- const PastWord = memo(({ word, inputChars }: { word: string; inputChars: string[] }) => (
6
- <Box marginRight={1}>
7
- {word.split("").map((char, charIndex) => {
8
- const inputChar = inputChars[charIndex];
9
- return (
10
- <Text key={charIndex} color={inputChar === char ? "green" : "red"}>
11
- {char}
12
- </Text>
13
- );
14
- })}
15
- {inputChars.length > word.length &&
16
- inputChars
17
- .slice(word.length)
18
- .map((char, i) => (
19
- <Text key={`extra-${i}`} color="red" strikethrough>
20
- {char}
21
- </Text>
22
- ))}
23
- </Box>
24
- ));
25
-
26
- const FutureWord = memo(({ word }: { word: string }) => (
27
- <Box marginRight={1}>
28
- <Text dimColor>{word}</Text>
29
- </Box>
30
- ));
4
+ import { useEffect, useMemo, useCallback, useState } from "react";
5
+
6
+ type StyledChar = {
7
+ char: string;
8
+ color?: string;
9
+ dimColor?: boolean;
10
+ strikethrough?: boolean;
11
+ isCursor?: boolean;
12
+ };
31
13
 
32
14
  type GameProps = {
33
15
  duration: number;
@@ -38,6 +20,7 @@ type GameProps = {
38
20
 
39
21
  export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
40
22
  const game = useGame(duration);
23
+ const { stdout } = useStdout();
41
24
 
42
25
  useEffect(() => {
43
26
  if (game.isFinished) {
@@ -45,50 +28,119 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
45
28
  }
46
29
  }, [game.isFinished]);
47
30
 
48
- useInput((input, key) => {
49
- if (key.escape) {
50
- onExit();
51
- return;
52
- }
53
- if (key.tab) {
54
- onRestart();
55
- return;
56
- }
57
- if (game.isFinished) return;
58
-
59
- if (key.backspace || key.delete) {
60
- game.handleBackspace();
61
- } else if (input && !key.ctrl && !key.meta) {
62
- for (const char of input) {
63
- if (char === " ") {
64
- game.handleSpace();
65
- } else if (char >= "!" && char <= "~") {
66
- game.handleChar(char);
31
+ const handleInput = useCallback(
32
+ (input: string, key: Key) => {
33
+ if (key.escape) {
34
+ onExit();
35
+ return;
36
+ }
37
+ if (key.tab) {
38
+ onRestart();
39
+ return;
40
+ }
41
+ if (game.isFinished) return;
42
+
43
+ if (key.backspace || key.delete) {
44
+ game.handleBackspace();
45
+ } else if (input && !key.ctrl && !key.meta) {
46
+ for (const char of input) {
47
+ if (char === " ") {
48
+ game.handleSpace();
49
+ } else if (char >= "!" && char <= "~") {
50
+ game.handleChar(char);
51
+ }
67
52
  }
68
53
  }
69
- }
70
- });
54
+ },
55
+ [onExit, onRestart, game.isFinished, game.handleBackspace, game.handleSpace, game.handleChar],
56
+ );
57
+
58
+ useInput(handleInput);
59
+
60
+ const [cursorVisible, setCursorVisible] = useState(true);
61
+ useEffect(() => {
62
+ const id = setInterval(() => setCursorVisible((v) => !v), 530);
63
+ return () => clearInterval(id);
64
+ }, []);
71
65
 
72
66
  const liveWpm = useMemo(() => {
73
67
  if (!game.isRunning || game.isFinished) return 0;
74
- let correct = 0;
75
- for (let i = 0; i < game.currentWordIndex; i++) {
76
- const word = game.words[i];
77
- const input = game.charInputs[i].join("");
78
- if (input === word) correct += word.length + 1;
79
- else {
80
- for (let j = 0; j < word.length; j++) {
81
- if (input[j] === word[j]) correct++;
68
+ const elapsed = (duration - game.timeLeft) || 1;
69
+ return Math.round((game.correctCharsAcc / 5) / (elapsed / 60));
70
+ }, [game.correctCharsAcc, game.timeLeft, game.isRunning, game.isFinished, duration]);
71
+
72
+ const termWidth = stdout.columns || 80;
73
+ const availableWidth = termWidth - 4;
74
+ const centerCol = Math.floor(availableWidth / 2);
75
+ const currentWordIdx = game.currentWordIndex;
76
+
77
+ // Build flat styled character array for all words (cursor style applied at render time)
78
+ const { chars, cursorPos } = useMemo(() => {
79
+ const chars: StyledChar[] = [];
80
+ let cursorPos = 0;
81
+
82
+ for (let wi = 0; wi < game.words.length; wi++) {
83
+ const word = game.words[wi];
84
+ const isPast = wi < currentWordIdx;
85
+ const isCurrent = wi === currentWordIdx;
86
+
87
+ if (isPast) {
88
+ const inputs = game.charInputs[wi] || [];
89
+ for (let ci = 0; ci < word.length; ci++) {
90
+ chars.push({ char: word[ci], color: inputs[ci] === word[ci] ? "green" : "red" });
91
+ }
92
+ for (let ci = word.length; ci < inputs.length; ci++) {
93
+ chars.push({ char: inputs[ci], color: "red", strikethrough: true });
82
94
  }
95
+ chars.push({ char: " " });
96
+ } else if (isCurrent) {
97
+ const typedInWord = Math.min(game.currentInput.length, word.length);
98
+ for (let ci = 0; ci < typedInWord; ci++) {
99
+ chars.push({ char: word[ci], color: game.currentInput[ci] === word[ci] ? "green" : "red" });
100
+ }
101
+ if (game.currentInput.length < word.length) {
102
+ // Cursor on next untyped character
103
+ cursorPos = chars.length;
104
+ chars.push({ char: word[game.currentInput.length], isCursor: true });
105
+ for (let ci = game.currentInput.length + 1; ci < word.length; ci++) {
106
+ chars.push({ char: word[ci], dimColor: true });
107
+ }
108
+ chars.push({ char: " " });
109
+ } else {
110
+ // Extra typed chars beyond word length
111
+ for (let ci = word.length; ci < game.currentInput.length; ci++) {
112
+ chars.push({ char: game.currentInput[ci], color: "red" });
113
+ }
114
+ // Cursor at space position
115
+ cursorPos = chars.length;
116
+ chars.push({ char: " ", isCursor: true });
117
+ }
118
+ } else {
119
+ // Future word
120
+ for (let ci = 0; ci < word.length; ci++) {
121
+ chars.push({ char: word[ci], dimColor: true });
122
+ }
123
+ chars.push({ char: " " });
83
124
  }
84
125
  }
85
- const elapsed = (duration - game.timeLeft) || 1;
86
- return Math.round((correct / 5) / (elapsed / 60));
87
- }, [game.currentWordIndex, game.timeLeft, game.isRunning, game.isFinished]);
126
+
127
+ return { chars, cursorPos };
128
+ }, [game.words, game.charInputs, currentWordIdx, game.currentInput]);
129
+
130
+ // Extract visible window centered on cursor
131
+ const windowStart = cursorPos - centerCol;
132
+ const visibleChars: StyledChar[] = [];
133
+ for (let i = windowStart; i < windowStart + availableWidth; i++) {
134
+ if (i >= 0 && i < chars.length) {
135
+ visibleChars.push(chars[i]);
136
+ } else {
137
+ visibleChars.push({ char: " " });
138
+ }
139
+ }
88
140
 
89
141
  return (
90
142
  <Box flexDirection="column" paddingX={2} paddingY={1}>
91
- <Box justifyContent="space-between">
143
+ <Box justifyContent="center" gap={2}>
92
144
  <Text color="yellow" bold>
93
145
  {game.timeLeft}s
94
146
  </Text>
@@ -96,52 +148,32 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
96
148
  {liveWpm} wpm
97
149
  </Text>
98
150
  </Box>
99
- <Box marginTop={1} flexWrap="wrap">
100
- {game.words.slice(0, Math.min(game.words.length, game.currentWordIndex + 30)).map((word, wordIndex) => {
101
- if (wordIndex < game.currentWordIndex) {
102
- return <PastWord key={wordIndex} word={word} inputChars={game.charInputs[wordIndex]} />;
103
- }
104
-
105
- if (wordIndex === game.currentWordIndex) {
151
+ <Box marginTop={1}>
152
+ <Text>
153
+ {visibleChars.map((sc, i) => {
154
+ if (sc.isCursor) {
155
+ return cursorVisible ? (
156
+ <Text key={i} backgroundColor="white" color="black">
157
+ {sc.char}
158
+ </Text>
159
+ ) : (
160
+ <Text key={i} underline>
161
+ {sc.char}
162
+ </Text>
163
+ );
164
+ }
106
165
  return (
107
- <Box key={wordIndex} marginRight={1}>
108
- {word.split("").map((char, charIndex) => {
109
- const inputChar = game.currentInput[charIndex];
110
- if (inputChar === undefined && charIndex === game.currentInput.length) {
111
- return (
112
- <Text key={charIndex} color="white" bold underline>
113
- {char}
114
- </Text>
115
- );
116
- }
117
- if (inputChar === undefined) {
118
- return (
119
- <Text key={charIndex} dimColor>
120
- {char}
121
- </Text>
122
- );
123
- }
124
- return (
125
- <Text key={charIndex} color={inputChar === char ? "green" : "red"}>
126
- {char}
127
- </Text>
128
- );
129
- })}
130
- {game.currentInput.length > word.length &&
131
- game.currentInput
132
- .slice(word.length)
133
- .split("")
134
- .map((char, i) => (
135
- <Text key={`extra-${i}`} color="red">
136
- {char}
137
- </Text>
138
- ))}
139
- </Box>
166
+ <Text
167
+ key={i}
168
+ color={sc.color}
169
+ dimColor={sc.dimColor}
170
+ strikethrough={sc.strikethrough}
171
+ >
172
+ {sc.char}
173
+ </Text>
140
174
  );
141
- }
142
-
143
- return <FutureWord key={wordIndex} word={word} />;
144
- })}
175
+ })}
176
+ </Text>
145
177
  </Box>
146
178
  </Box>
147
179
  );
@@ -8,11 +8,11 @@ export type GameState = {
8
8
  timeLeft: number;
9
9
  isRunning: boolean;
10
10
  isFinished: boolean;
11
- wordResults: Array<"correct" | "incorrect" | "pending">;
12
11
  };
13
12
 
14
13
  export type GameHook = GameState & {
15
14
  charInputs: string[][];
15
+ correctCharsAcc: number;
16
16
  startGame: () => void;
17
17
  handleChar: (char: string) => void;
18
18
  handleBackspace: () => void;
@@ -39,15 +39,27 @@ export function useGame(duration: number) {
39
39
  const charInputsRef = useRef<string[][]>(
40
40
  Array.from({ length: wordCount }, () => [])
41
41
  );
42
- const [state, setState] = useState<GameState>(() => ({
43
- words: generateWords(wordCount),
44
- currentWordIndex: 0,
45
- currentInput: "",
46
- timeLeft: duration,
47
- isRunning: false,
48
- isFinished: false,
49
- wordResults: Array(wordCount).fill("pending"),
50
- }));
42
+ const wordsRef = useRef<string[]>([]);
43
+ const currentWordIndexRef = useRef(0);
44
+ const isFinishedRef = useRef(false);
45
+ const isRunningRef = useRef(false);
46
+ const correctCharsAccRef = useRef(0);
47
+ const totalCharsAccRef = useRef(0);
48
+ const wordCorrectCharsRef = useRef<number[]>([]);
49
+ const wordTotalCharsRef = useRef<number[]>([]);
50
+
51
+ const [state, setState] = useState<GameState>(() => {
52
+ const generatedWords = generateWords(wordCount);
53
+ wordsRef.current = generatedWords;
54
+ return {
55
+ words: generatedWords,
56
+ currentWordIndex: 0,
57
+ currentInput: "",
58
+ timeLeft: duration,
59
+ isRunning: false,
60
+ isFinished: false,
61
+ };
62
+ });
51
63
 
52
64
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
53
65
  const startTimeRef = useRef<number>(0);
@@ -55,10 +67,14 @@ export function useGame(duration: number) {
55
67
  useEffect(() => {
56
68
  if (state.isRunning && !state.isFinished) {
57
69
  timerRef.current = setInterval(() => {
70
+ const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000);
71
+ const newTimeLeft = Math.max(duration - elapsed, 0);
58
72
  setState((prev) => {
59
- const newTimeLeft = prev.timeLeft - 1;
73
+ if (prev.timeLeft === newTimeLeft) return prev;
60
74
  if (newTimeLeft <= 0) {
61
75
  if (timerRef.current) clearInterval(timerRef.current);
76
+ isFinishedRef.current = true;
77
+ isRunningRef.current = false;
62
78
  return { ...prev, timeLeft: 0, isRunning: false, isFinished: true };
63
79
  }
64
80
  return { ...prev, timeLeft: newTimeLeft };
@@ -68,103 +84,106 @@ export function useGame(duration: number) {
68
84
  return () => {
69
85
  if (timerRef.current) clearInterval(timerRef.current);
70
86
  };
71
- }, [state.isRunning, state.isFinished]);
87
+ }, [state.isRunning, state.isFinished, duration]);
72
88
 
73
89
  const startGame = useCallback(() => {
74
90
  startTimeRef.current = Date.now();
91
+ isRunningRef.current = true;
75
92
  setState((prev) => ({ ...prev, isRunning: true }));
76
93
  }, []);
77
94
 
78
95
  const handleChar = useCallback((char: string) => {
79
- setState((prev) => {
80
- if (prev.isFinished) return prev;
81
- charInputsRef.current[prev.currentWordIndex].push(char);
82
- if (!prev.isRunning) {
83
- startTimeRef.current = Date.now();
84
- return {
85
- ...prev,
86
- isRunning: true,
87
- currentInput: prev.currentInput + char,
88
- };
89
- }
90
- return {
91
- ...prev,
92
- currentInput: prev.currentInput + char,
93
- };
94
- });
96
+ if (isFinishedRef.current) return;
97
+ charInputsRef.current[currentWordIndexRef.current].push(char);
98
+ if (!isRunningRef.current) {
99
+ startTimeRef.current = Date.now();
100
+ isRunningRef.current = true;
101
+ }
102
+ setState((prev) => ({
103
+ ...prev,
104
+ isRunning: true,
105
+ currentInput: prev.currentInput + char,
106
+ }));
95
107
  }, []);
96
108
 
97
109
  const handleBackspace = useCallback(() => {
98
- setState((prev) => {
99
- if (prev.isFinished) return prev;
100
-
101
- if (prev.currentInput.length === 0) {
102
- if (prev.currentWordIndex === 0) return prev;
103
- const prevIndex = prev.currentWordIndex - 1;
104
- const prevInput = charInputsRef.current[prevIndex].join("");
105
- const newWordResults = [...prev.wordResults];
106
- newWordResults[prevIndex] = "pending";
107
- return {
108
- ...prev,
109
- currentWordIndex: prevIndex,
110
- currentInput: prevInput,
111
- wordResults: newWordResults,
112
- };
113
- }
114
-
115
- charInputsRef.current[prev.currentWordIndex].pop();
116
- return {
110
+ if (isFinishedRef.current) return;
111
+
112
+ const currentIdx = currentWordIndexRef.current;
113
+ const currentChars = charInputsRef.current[currentIdx];
114
+
115
+ if (currentChars.length === 0) {
116
+ if (currentIdx === 0) return;
117
+ const prevIndex = currentIdx - 1;
118
+ currentWordIndexRef.current = prevIndex;
119
+ const lastCorrect = wordCorrectCharsRef.current.pop() || 0;
120
+ const lastTotal = wordTotalCharsRef.current.pop() || 0;
121
+ correctCharsAccRef.current -= lastCorrect;
122
+ totalCharsAccRef.current -= lastTotal;
123
+ const prevInput = charInputsRef.current[prevIndex].join("");
124
+ setState((prev) => ({
125
+ ...prev,
126
+ currentWordIndex: prevIndex,
127
+ currentInput: prevInput,
128
+ }));
129
+ } else {
130
+ charInputsRef.current[currentIdx].pop();
131
+ setState((prev) => ({
117
132
  ...prev,
118
133
  currentInput: prev.currentInput.slice(0, -1),
119
- };
120
- });
134
+ }));
135
+ }
121
136
  }, []);
122
137
 
123
138
  const handleSpace = useCallback(() => {
124
- setState((prev) => {
125
- if (prev.isFinished || !prev.isRunning) return prev;
126
- const word = prev.words[prev.currentWordIndex];
127
- const isCorrect = prev.currentInput === word;
128
- const newWordResults = [...prev.wordResults];
129
- newWordResults[prev.currentWordIndex] = isCorrect
130
- ? "correct"
131
- : "incorrect";
132
- return {
133
- ...prev,
134
- currentWordIndex: prev.currentWordIndex + 1,
135
- currentInput: "",
136
- wordResults: newWordResults,
137
- };
138
- });
139
- }, []);
140
-
141
- const getResults = useCallback((): GameResults => {
142
- let correctChars = 0;
143
- let totalChars = 0;
144
-
145
- for (let i = 0; i < state.currentWordIndex; i++) {
146
- const word = state.words[i];
147
- const input = charInputsRef.current[i].join("");
148
- totalChars += Math.max(word.length, input.length) + 1;
149
- if (input === word) {
150
- correctChars += word.length + 1;
151
- } else {
152
- for (let j = 0; j < word.length; j++) {
153
- if (input[j] === word[j]) correctChars++;
154
- }
139
+ if (isFinishedRef.current || !isRunningRef.current) return;
140
+
141
+ const currentIdx = currentWordIndexRef.current;
142
+ const word = wordsRef.current[currentIdx];
143
+ const inputChars = charInputsRef.current[currentIdx];
144
+ const input = inputChars.join("");
145
+
146
+ let correct = 0;
147
+ if (input === word) {
148
+ correct = word.length + 1;
149
+ } else {
150
+ for (let j = 0; j < word.length; j++) {
151
+ if (inputChars[j] === word[j]) correct++;
155
152
  }
156
153
  }
154
+ const total = Math.max(word.length, input.length) + 1;
155
+
156
+ wordCorrectCharsRef.current.push(correct);
157
+ wordTotalCharsRef.current.push(total);
158
+ correctCharsAccRef.current += correct;
159
+ totalCharsAccRef.current += total;
160
+ currentWordIndexRef.current = currentIdx + 1;
161
+
162
+ setState((prev) => ({
163
+ ...prev,
164
+ currentWordIndex: currentIdx + 1,
165
+ currentInput: "",
166
+ }));
167
+ }, []);
157
168
 
158
- const timeElapsed = duration;
159
- const wpm = totalChars > 0 ? Math.round((correctChars / 5) / (timeElapsed / 60)) : 0;
160
- const accuracy = totalChars > 0 ? Math.round((correctChars / totalChars) * 1000) / 10 : 0;
161
-
169
+ const getResults = useCallback((): GameResults => {
170
+ const correctChars = correctCharsAccRef.current;
171
+ const totalChars = totalCharsAccRef.current;
172
+ const wpm =
173
+ totalChars > 0
174
+ ? Math.round((correctChars / 5) / (duration / 60))
175
+ : 0;
176
+ const accuracy =
177
+ totalChars > 0
178
+ ? Math.round((correctChars / totalChars) * 1000) / 10
179
+ : 0;
162
180
  return { wpm, accuracy, time: duration };
163
- }, [state.currentWordIndex, state.words, duration]);
181
+ }, [duration]);
164
182
 
165
183
  return {
166
184
  ...state,
167
185
  charInputs: charInputsRef.current,
186
+ correctCharsAcc: correctCharsAccRef.current,
168
187
  startGame,
169
188
  handleChar,
170
189
  handleBackspace,
@@ -1,7 +0,0 @@
1
- <claude-mem-context>
2
- # Recent Activity
3
-
4
- <!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5
-
6
- *No recent activity*
7
- </claude-mem-context>
@@ -1,46 +0,0 @@
1
- import React from "react";
2
- import { TransitionSeries, linearTiming } from "@remotion/transitions";
3
- import { fade } from "@remotion/transitions/fade";
4
- import { InstallScene } from "./scenes/InstallScene";
5
- import { MenuScene } from "./scenes/MenuScene";
6
- import { GameScene } from "./scenes/GameScene";
7
- import { ResultsScene } from "./scenes/ResultsScene";
8
-
9
- const FADE_DURATION = 10;
10
-
11
- export const WpmxDemo: React.FC = () => {
12
- return (
13
- <TransitionSeries>
14
- <TransitionSeries.Sequence durationInFrames={90}>
15
- <InstallScene />
16
- </TransitionSeries.Sequence>
17
-
18
- <TransitionSeries.Transition
19
- presentation={fade()}
20
- timing={linearTiming({ durationInFrames: FADE_DURATION })}
21
- />
22
-
23
- <TransitionSeries.Sequence durationInFrames={60}>
24
- <MenuScene />
25
- </TransitionSeries.Sequence>
26
-
27
- <TransitionSeries.Transition
28
- presentation={fade()}
29
- timing={linearTiming({ durationInFrames: FADE_DURATION })}
30
- />
31
-
32
- <TransitionSeries.Sequence durationInFrames={300}>
33
- <GameScene />
34
- </TransitionSeries.Sequence>
35
-
36
- <TransitionSeries.Transition
37
- presentation={fade()}
38
- timing={linearTiming({ durationInFrames: FADE_DURATION })}
39
- />
40
-
41
- <TransitionSeries.Sequence durationInFrames={90}>
42
- <ResultsScene />
43
- </TransitionSeries.Sequence>
44
- </TransitionSeries>
45
- );
46
- };
@@ -1,7 +0,0 @@
1
- <claude-mem-context>
2
- # Recent Activity
3
-
4
- <!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5
-
6
- *No recent activity*
7
- </claude-mem-context>
@@ -1,22 +0,0 @@
1
- <claude-mem-context>
2
- # Recent Activity
3
-
4
- <!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5
-
6
- ### Feb 20, 2026
7
-
8
- | ID | Time | T | Title | Read |
9
- |----|------|---|-------|------|
10
- | #1722 | 6:31 PM | 🟣 | MenuScene created with frame-driven duration selector animation | ~423 |
11
- | #1723 | " | 🟣 | ResultsScene created with spring-animated WPM counter and cascading fade-in effects | ~496 |
12
- | #1724 | " | 🟣 | GameScene created with character-level color feedback and live WPM calculation | ~569 |
13
- | #1720 | 6:30 PM | 🟣 | MenuScene created with frame-driven duration selection animation | ~440 |
14
- | #1721 | " | 🟣 | ResultsScene created with spring-animated WPM counter and staggered fade-ins | ~508 |
15
- | #1719 | " | 🟣 | MenuScene created with frame-driven duration selector animation | ~525 |
16
- | #1718 | 6:29 PM | 🟣 | GameScene implements sophisticated typing simulation with real-time feedback | ~580 |
17
- | #1717 | " | 🟣 | Results scene implemented with animated score and staggered fade-ins | ~476 |
18
- | #1716 | " | 🟣 | Install scene implements typewriter command entry animation | ~455 |
19
- | #1715 | " | 🟣 | InstallScene created with typewriter effect for terminal command entry | ~446 |
20
- | #1714 | 6:28 PM | 🟣 | MenuScene component displays duration selector with animated selection change | ~497 |
21
- | #1713 | " | 🟣 | InstallScene component implements typewriter command prompt animation | ~494 |
22
- </claude-mem-context>
@@ -1,193 +0,0 @@
1
- import React from "react";
2
- import { useCurrentFrame, useVideoConfig } from "remotion";
3
- import { Terminal } from "../components/Terminal";
4
- import { monoFont } from "../Root";
5
- import { GAME_WORDS, TYPING_EVENTS } from "../data/typing-script";
6
-
7
- interface WordState {
8
- typed: string[];
9
- submitted: boolean;
10
- }
11
-
12
- interface FrameState {
13
- words: WordState[];
14
- currentWordIndex: number;
15
- }
16
-
17
- function computeStateAtFrame(frame: number): FrameState {
18
- const words: WordState[] = GAME_WORDS.map(() => ({
19
- typed: [],
20
- submitted: false,
21
- }));
22
-
23
- let currentWordIndex = 0;
24
-
25
- for (const event of TYPING_EVENTS) {
26
- if (event.frame > frame) break;
27
-
28
- if (event.type === "char" && event.char !== undefined) {
29
- words[currentWordIndex].typed.push(event.char);
30
- } else if (event.type === "space") {
31
- words[currentWordIndex].submitted = true;
32
- currentWordIndex = Math.min(currentWordIndex + 1, GAME_WORDS.length - 1);
33
- } else if (event.type === "backspace") {
34
- if (words[currentWordIndex].typed.length > 0) {
35
- words[currentWordIndex].typed.pop();
36
- }
37
- }
38
- }
39
-
40
- return { words, currentWordIndex };
41
- }
42
-
43
- export const GameScene: React.FC = () => {
44
- const frame = useCurrentFrame();
45
- const { fps } = useVideoConfig();
46
-
47
- const timeLeft = Math.max(0, Math.ceil(15 - frame / fps));
48
-
49
- const { words, currentWordIndex } = computeStateAtFrame(frame);
50
-
51
- // Live WPM calculation
52
- const elapsed = frame / fps;
53
- let correctChars = 0;
54
- for (let wi = 0; wi < currentWordIndex; wi++) {
55
- const target = GAME_WORDS[wi];
56
- const typedStr = words[wi].typed.join("");
57
- if (typedStr === target) {
58
- correctChars += target.length + 1; // +1 for space
59
- } else {
60
- for (let ci = 0; ci < target.length; ci++) {
61
- if (ci < words[wi].typed.length && words[wi].typed[ci] === target[ci]) {
62
- correctChars++;
63
- }
64
- }
65
- }
66
- }
67
- const wpm = elapsed > 0 ? Math.round((correctChars / 5) / (elapsed / 60)) : 0;
68
-
69
- return (
70
- <Terminal title="wpmx">
71
- <div
72
- style={{
73
- fontFamily: monoFont,
74
- color: "#cdd6f4",
75
- display: "flex",
76
- flexDirection: "column",
77
- height: "100%",
78
- }}
79
- >
80
- {/* Top bar */}
81
- <div
82
- style={{
83
- display: "flex",
84
- flexDirection: "row",
85
- justifyContent: "space-between",
86
- marginBottom: 24,
87
- fontSize: 20,
88
- fontWeight: 700,
89
- color: "#f9e2af",
90
- }}
91
- >
92
- <span>{timeLeft}s</span>
93
- <span>{wpm} wpm</span>
94
- </div>
95
-
96
- {/* Word display */}
97
- <div
98
- style={{
99
- display: "flex",
100
- flexDirection: "row",
101
- flexWrap: "wrap",
102
- gap: 8,
103
- fontSize: 22,
104
- lineHeight: 1.8,
105
- }}
106
- >
107
- {GAME_WORDS.map((word, wi) => {
108
- const wordState = words[wi];
109
- const typed = wordState.typed;
110
-
111
- const isFuture = wi > currentWordIndex;
112
- const isCurrent = wi === currentWordIndex;
113
- const isPast = wi < currentWordIndex;
114
-
115
- const chars: React.ReactNode[] = [];
116
-
117
- // Render each target character
118
- for (let ci = 0; ci < word.length; ci++) {
119
- let color: string;
120
- let fontWeight: number | undefined;
121
- let textDecoration: string | undefined;
122
-
123
- if (isFuture) {
124
- color = "#6c7086";
125
- } else if (isCurrent) {
126
- if (ci < typed.length) {
127
- if (typed[ci] === word[ci]) {
128
- color = "#a6e3a1";
129
- } else {
130
- color = "#f38ba8";
131
- }
132
- } else if (ci === typed.length) {
133
- color = "#cdd6f4";
134
- fontWeight = 700;
135
- textDecoration = "underline";
136
- } else {
137
- color = "#6c7086";
138
- }
139
- } else {
140
- // isPast
141
- if (ci < typed.length) {
142
- if (typed[ci] === word[ci]) {
143
- color = "#a6e3a1";
144
- } else {
145
- color = "#f38ba8";
146
- }
147
- } else {
148
- color = "#6c7086";
149
- }
150
- }
151
-
152
- chars.push(
153
- <span
154
- key={`${wi}-${ci}`}
155
- style={{
156
- color,
157
- fontWeight,
158
- textDecoration,
159
- }}
160
- >
161
- {word[ci]}
162
- </span>
163
- );
164
- }
165
-
166
- // Render extra characters beyond word length
167
- if ((isCurrent || isPast) && typed.length > word.length) {
168
- for (let ei = word.length; ei < typed.length; ei++) {
169
- chars.push(
170
- <span
171
- key={`${wi}-extra-${ei}`}
172
- style={{
173
- color: "#f38ba8",
174
- textDecoration: isPast ? "line-through" : undefined,
175
- }}
176
- >
177
- {typed[ei]}
178
- </span>
179
- );
180
- }
181
- }
182
-
183
- return (
184
- <span key={wi}>
185
- {chars}
186
- </span>
187
- );
188
- })}
189
- </div>
190
- </div>
191
- </Terminal>
192
- );
193
- };
@@ -1,48 +0,0 @@
1
- import React from "react";
2
- import { useCurrentFrame } from "remotion";
3
- import { Terminal } from "../components/Terminal";
4
- import { Cursor } from "../components/Cursor";
5
- import { monoFont } from "../Root";
6
-
7
- const COMMAND = "npx wpmx";
8
- const TYPING_START_FRAME = 10;
9
- const FRAMES_PER_CHAR = 4;
10
-
11
- export const InstallScene: React.FC = () => {
12
- const frame = useCurrentFrame();
13
-
14
- const charsTyped = Math.min(
15
- Math.max(0, Math.floor((frame - TYPING_START_FRAME) / FRAMES_PER_CHAR)),
16
- COMMAND.length
17
- );
18
-
19
- const typingComplete = charsTyped >= COMMAND.length;
20
- const typedText = COMMAND.slice(0, charsTyped);
21
-
22
- return (
23
- <Terminal title="Terminal">
24
- <div
25
- style={{
26
- fontFamily: monoFont,
27
- fontSize: 22,
28
- color: "#cdd6f4",
29
- lineHeight: 1.6,
30
- }}
31
- >
32
- <span style={{ color: "#a6e3a1" }}>$ </span>
33
- <span>{typedText}</span>
34
- {!typingComplete && <Cursor />}
35
- {typingComplete && (
36
- <div
37
- style={{
38
- color: "#6c7086",
39
- marginTop: 8,
40
- }}
41
- >
42
- Loading wpmx...
43
- </div>
44
- )}
45
- </div>
46
- </Terminal>
47
- );
48
- };
@@ -1,78 +0,0 @@
1
- import React from "react";
2
- import { useCurrentFrame } from "remotion";
3
- import { Terminal } from "../components/Terminal";
4
- import { monoFont } from "../Root";
5
-
6
- const DURATIONS = ["15s", "30s", "60s"];
7
-
8
- export const MenuScene: React.FC = () => {
9
- const frame = useCurrentFrame();
10
-
11
- // Before frame 25: index 1 ("30s") is selected; at/after frame 25: index 0 ("15s")
12
- const selectedIndex = frame < 25 ? 1 : 0;
13
-
14
- return (
15
- <Terminal title="wpmx">
16
- <div
17
- style={{
18
- display: "flex",
19
- flexDirection: "column",
20
- alignItems: "center",
21
- justifyContent: "center",
22
- flex: 1,
23
- gap: 24,
24
- }}
25
- >
26
- {/* Title */}
27
- <div
28
- style={{
29
- fontFamily: monoFont,
30
- fontWeight: 700,
31
- fontSize: 36,
32
- color: "#f9e2af",
33
- }}
34
- >
35
- wpmx
36
- </div>
37
-
38
- {/* Duration selector */}
39
- <div
40
- style={{
41
- display: "flex",
42
- flexDirection: "row",
43
- gap: 24,
44
- fontSize: 20,
45
- fontFamily: monoFont,
46
- }}
47
- >
48
- {DURATIONS.map((label, i) => {
49
- const isSelected = i === selectedIndex;
50
- return (
51
- <span
52
- key={label}
53
- style={{
54
- color: isSelected ? "#cdd6f4" : "#6c7086",
55
- fontWeight: isSelected ? 700 : 400,
56
- textDecoration: isSelected ? "underline" : "none",
57
- }}
58
- >
59
- {label}
60
- </span>
61
- );
62
- })}
63
- </div>
64
-
65
- {/* Hint text */}
66
- <div
67
- style={{
68
- fontFamily: monoFont,
69
- fontSize: 14,
70
- color: "#6c7086",
71
- }}
72
- >
73
- h / l or ← / → to choose, Enter to start
74
- </div>
75
- </div>
76
- </Terminal>
77
- );
78
- };
@@ -1,112 +0,0 @@
1
- import React from "react";
2
- import {
3
- useCurrentFrame,
4
- useVideoConfig,
5
- spring,
6
- interpolate,
7
- } from "remotion";
8
- import { Terminal } from "../components/Terminal";
9
- import { monoFont } from "../Root";
10
-
11
- export const ResultsScene: React.FC = () => {
12
- const frame = useCurrentFrame();
13
- const { fps } = useVideoConfig();
14
-
15
- const springValue = spring({ frame, fps, config: { damping: 200 } });
16
- const wpm = Math.round(interpolate(springValue, [0, 1], [0, 87]));
17
-
18
- const personalBestOpacity = interpolate(frame, [15, 30], [0, 1], {
19
- extrapolateLeft: "clamp",
20
- extrapolateRight: "clamp",
21
- });
22
-
23
- const statsOpacity = interpolate(frame, [25, 40], [0, 1], {
24
- extrapolateLeft: "clamp",
25
- extrapolateRight: "clamp",
26
- });
27
-
28
- return (
29
- <Terminal title="wpmx">
30
- <div
31
- style={{
32
- display: "flex",
33
- flexDirection: "column",
34
- alignItems: "center",
35
- justifyContent: "center",
36
- flex: 1,
37
- gap: 12,
38
- fontFamily: monoFont,
39
- }}
40
- >
41
- {/* Title */}
42
- <div
43
- style={{
44
- fontSize: 28,
45
- fontWeight: 700,
46
- color: "#f9e2af",
47
- marginBottom: 16,
48
- }}
49
- >
50
- wpmx
51
- </div>
52
-
53
- {/* WPM number */}
54
- <div
55
- style={{
56
- fontSize: 48,
57
- fontWeight: 700,
58
- color: "#cdd6f4",
59
- }}
60
- >
61
- {wpm}
62
- </div>
63
-
64
- {/* New personal best */}
65
- <div
66
- style={{
67
- fontSize: 18,
68
- fontWeight: 700,
69
- color: "#a6e3a1",
70
- opacity: personalBestOpacity,
71
- }}
72
- >
73
- new personal best!
74
- </div>
75
-
76
- {/* Accuracy */}
77
- <div
78
- style={{
79
- fontSize: 20,
80
- color: "#cdd6f4",
81
- opacity: statsOpacity,
82
- }}
83
- >
84
- 96.2% accuracy
85
- </div>
86
-
87
- {/* Duration */}
88
- <div
89
- style={{
90
- fontSize: 16,
91
- color: "#6c7086",
92
- opacity: statsOpacity,
93
- }}
94
- >
95
- 15s
96
- </div>
97
-
98
- {/* Hints */}
99
- <div
100
- style={{
101
- fontSize: 14,
102
- color: "#6c7086",
103
- marginTop: 24,
104
- opacity: statsOpacity,
105
- }}
106
- >
107
- {`Tab → restart Esc → menu q → quit`}
108
- </div>
109
- </div>
110
- </Terminal>
111
- );
112
- };