wpmx 1.0.1 → 1.0.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wpmx",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "A minimal, fast typing test for the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,33 @@
1
1
  import { Box, Text, useInput } from "ink";
2
2
  import { useGame, type GameResults } from "../hooks/useGame.ts";
3
- import { useEffect } from "react";
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
31
 
5
32
  type GameProps = {
6
33
  duration: number;
@@ -31,32 +58,33 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
31
58
 
32
59
  if (key.backspace || key.delete) {
33
60
  game.handleBackspace();
34
- } else if (input === " ") {
35
- game.handleSpace();
36
- } else if (input && !key.ctrl && !key.meta && input.length === 1) {
37
- game.handleChar(input);
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);
67
+ }
68
+ }
38
69
  }
39
70
  });
40
71
 
41
- const liveWpm = game.isRunning && !game.isFinished
42
- ? Math.round(
43
- (() => {
44
- let correct = 0;
45
- for (let i = 0; i < game.currentWordIndex; i++) {
46
- const word = game.words[i];
47
- const input = game.charInputs[i].join("");
48
- if (input === word) correct += word.length + 1;
49
- else {
50
- for (let j = 0; j < word.length; j++) {
51
- if (input[j] === word[j]) correct++;
52
- }
53
- }
54
- }
55
- const elapsed = (duration - game.timeLeft) || 1;
56
- return (correct / 5) / (elapsed / 60);
57
- })()
58
- )
59
- : 0;
72
+ const liveWpm = useMemo(() => {
73
+ 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++;
82
+ }
83
+ }
84
+ }
85
+ const elapsed = (duration - game.timeLeft) || 1;
86
+ return Math.round((correct / 5) / (elapsed / 60));
87
+ }, [game.currentWordIndex, game.timeLeft, game.isRunning, game.isFinished]);
60
88
 
61
89
  return (
62
90
  <Box flexDirection="column" paddingX={2} paddingY={1}>
@@ -70,36 +98,11 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
70
98
  </Box>
71
99
  <Box marginTop={1} flexWrap="wrap">
72
100
  {game.words.slice(0, Math.min(game.words.length, game.currentWordIndex + 30)).map((word, wordIndex) => {
73
- const isCurrentWord = wordIndex === game.currentWordIndex;
74
- const isPast = wordIndex < game.currentWordIndex;
75
- const wordResult = game.wordResults[wordIndex];
76
-
77
- if (isPast) {
78
- const inputChars = game.charInputs[wordIndex];
79
- return (
80
- <Box key={wordIndex} marginRight={1}>
81
- {word.split("").map((char, charIndex) => {
82
- const inputChar = inputChars[charIndex];
83
- const isCorrect = inputChar === char;
84
- return (
85
- <Text key={charIndex} color={isCorrect ? "green" : "red"}>
86
- {char}
87
- </Text>
88
- );
89
- })}
90
- {inputChars.length > word.length &&
91
- inputChars
92
- .slice(word.length)
93
- .map((char, i) => (
94
- <Text key={`extra-${i}`} color="red" strikethrough>
95
- {char}
96
- </Text>
97
- ))}
98
- </Box>
99
- );
101
+ if (wordIndex < game.currentWordIndex) {
102
+ return <PastWord key={wordIndex} word={word} inputChars={game.charInputs[wordIndex]} />;
100
103
  }
101
104
 
102
- if (isCurrentWord) {
105
+ if (wordIndex === game.currentWordIndex) {
103
106
  return (
104
107
  <Box key={wordIndex} marginRight={1}>
105
108
  {word.split("").map((char, charIndex) => {
@@ -118,15 +121,8 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
118
121
  </Text>
119
122
  );
120
123
  }
121
- if (inputChar === char) {
122
- return (
123
- <Text key={charIndex} color="green">
124
- {char}
125
- </Text>
126
- );
127
- }
128
124
  return (
129
- <Text key={charIndex} color="red">
125
+ <Text key={charIndex} color={inputChar === char ? "green" : "red"}>
130
126
  {char}
131
127
  </Text>
132
128
  );
@@ -144,11 +140,7 @@ export function Game({ duration, onFinish, onExit, onRestart }: GameProps) {
144
140
  );
145
141
  }
146
142
 
147
- return (
148
- <Box key={wordIndex} marginRight={1}>
149
- <Text dimColor>{word}</Text>
150
- </Box>
151
- );
143
+ return <FutureWord key={wordIndex} word={word} />;
152
144
  })}
153
145
  </Box>
154
146
  </Box>
@@ -9,7 +9,15 @@ export type GameState = {
9
9
  isRunning: boolean;
10
10
  isFinished: boolean;
11
11
  wordResults: Array<"correct" | "incorrect" | "pending">;
12
+ };
13
+
14
+ export type GameHook = GameState & {
12
15
  charInputs: string[][];
16
+ startGame: () => void;
17
+ handleChar: (char: string) => void;
18
+ handleBackspace: () => void;
19
+ handleSpace: () => void;
20
+ getResults: () => GameResults;
13
21
  };
14
22
 
15
23
  export type GameResults = {
@@ -28,7 +36,10 @@ function generateWords(count: number): string[] {
28
36
 
29
37
  export function useGame(duration: number) {
30
38
  const wordCount = Math.max(duration * 3, 50);
31
- const [state, setState] = useState<GameState>({
39
+ const charInputsRef = useRef<string[][]>(
40
+ Array.from({ length: wordCount }, () => [])
41
+ );
42
+ const [state, setState] = useState<GameState>(() => ({
32
43
  words: generateWords(wordCount),
33
44
  currentWordIndex: 0,
34
45
  currentInput: "",
@@ -36,8 +47,7 @@ export function useGame(duration: number) {
36
47
  isRunning: false,
37
48
  isFinished: false,
38
49
  wordResults: Array(wordCount).fill("pending"),
39
- charInputs: Array(wordCount).fill(null).map(() => []),
40
- });
50
+ }));
41
51
 
42
52
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
43
53
  const startTimeRef = useRef<number>(0);
@@ -68,29 +78,18 @@ export function useGame(duration: number) {
68
78
  const handleChar = useCallback((char: string) => {
69
79
  setState((prev) => {
70
80
  if (prev.isFinished) return prev;
81
+ charInputsRef.current[prev.currentWordIndex].push(char);
71
82
  if (!prev.isRunning) {
72
83
  startTimeRef.current = Date.now();
73
- const newCharInputs = [...prev.charInputs];
74
- newCharInputs[prev.currentWordIndex] = [
75
- ...newCharInputs[prev.currentWordIndex],
76
- char,
77
- ];
78
84
  return {
79
85
  ...prev,
80
86
  isRunning: true,
81
87
  currentInput: prev.currentInput + char,
82
- charInputs: newCharInputs,
83
88
  };
84
89
  }
85
- const newCharInputs = [...prev.charInputs];
86
- newCharInputs[prev.currentWordIndex] = [
87
- ...newCharInputs[prev.currentWordIndex],
88
- char,
89
- ];
90
90
  return {
91
91
  ...prev,
92
92
  currentInput: prev.currentInput + char,
93
- charInputs: newCharInputs,
94
93
  };
95
94
  });
96
95
  }, []);
@@ -99,11 +98,10 @@ export function useGame(duration: number) {
99
98
  setState((prev) => {
100
99
  if (prev.isFinished) return prev;
101
100
 
102
- // If current input is empty, go back to previous word
103
101
  if (prev.currentInput.length === 0) {
104
102
  if (prev.currentWordIndex === 0) return prev;
105
103
  const prevIndex = prev.currentWordIndex - 1;
106
- const prevInput = prev.charInputs[prevIndex].join("");
104
+ const prevInput = charInputsRef.current[prevIndex].join("");
107
105
  const newWordResults = [...prev.wordResults];
108
106
  newWordResults[prevIndex] = "pending";
109
107
  return {
@@ -114,15 +112,10 @@ export function useGame(duration: number) {
114
112
  };
115
113
  }
116
114
 
117
- // Otherwise delete last char of current word
118
- const newCharInputs = [...prev.charInputs];
119
- newCharInputs[prev.currentWordIndex] = newCharInputs[
120
- prev.currentWordIndex
121
- ].slice(0, -1);
115
+ charInputsRef.current[prev.currentWordIndex].pop();
122
116
  return {
123
117
  ...prev,
124
118
  currentInput: prev.currentInput.slice(0, -1),
125
- charInputs: newCharInputs,
126
119
  };
127
120
  });
128
121
  }, []);
@@ -151,7 +144,7 @@ export function useGame(duration: number) {
151
144
 
152
145
  for (let i = 0; i < state.currentWordIndex; i++) {
153
146
  const word = state.words[i];
154
- const input = state.charInputs[i].join("");
147
+ const input = charInputsRef.current[i].join("");
155
148
  totalChars += Math.max(word.length, input.length) + 1;
156
149
  if (input === word) {
157
150
  correctChars += word.length + 1;
@@ -167,10 +160,11 @@ export function useGame(duration: number) {
167
160
  const accuracy = totalChars > 0 ? Math.round((correctChars / totalChars) * 1000) / 10 : 0;
168
161
 
169
162
  return { wpm, accuracy, time: duration };
170
- }, [state.currentWordIndex, state.charInputs, state.words, duration]);
163
+ }, [state.currentWordIndex, state.words, duration]);
171
164
 
172
165
  return {
173
166
  ...state,
167
+ charInputs: charInputsRef.current,
174
168
  startGame,
175
169
  handleChar,
176
170
  handleBackspace,
@@ -0,0 +1,7 @@
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>
@@ -0,0 +1,46 @@
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
+ };
@@ -0,0 +1,7 @@
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>
@@ -0,0 +1,22 @@
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>
@@ -0,0 +1,193 @@
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
+ };
@@ -0,0 +1,48 @@
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
+ };
@@ -0,0 +1,78 @@
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
+ };
@@ -0,0 +1,112 @@
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
+ };