From f3dba342626da89da2187b628fbb3cafd56706b2 Mon Sep 17 00:00:00 2001
From: DeAndre Perkins <46944397+nineninenines@users.noreply.github.com>
Date: Sat, 11 Oct 2025 02:08:36 -0500
Subject: [PATCH] Add typing practice example application
---
examples/typing-app/app.js | 246 +++++++++++++++++++++++++++++++++
examples/typing-app/index.html | 50 +++++++
examples/typing-app/style.css | 155 +++++++++++++++++++++
3 files changed, 451 insertions(+)
create mode 100644 examples/typing-app/app.js
create mode 100644 examples/typing-app/index.html
create mode 100644 examples/typing-app/style.css
diff --git a/examples/typing-app/app.js b/examples/typing-app/app.js
new file mode 100644
index 0000000000..7797efa337
--- /dev/null
+++ b/examples/typing-app/app.js
@@ -0,0 +1,246 @@
+const paragraphs = [
+ "Learning to type quickly and accurately takes patience, focus, and practice. Take a steady breath, relax your shoulders, and keep your eyes on the next letter instead of looking down at the keyboard.",
+ "Consistency is more important than speed alone. Smooth and even keystrokes build strong habits, so let your fingers glide across the keys as you follow the flow of the passage.",
+ "Accuracy always matters. Every mistake is a signal to slow down and regain your rhythm. Stay calm, enjoy the process, and celebrate small improvements in each session."
+];
+
+const textDisplay = document.getElementById("text-display");
+const typingArea = document.getElementById("typing-area");
+const startButton = document.getElementById("start-btn");
+const timerSelect = document.getElementById("timer-select");
+const timeRemainingEl = document.getElementById("time-remaining");
+const wpmEl = document.getElementById("wpm");
+const accuracyEl = document.getElementById("accuracy");
+const mistakesEl = document.getElementById("mistakes");
+
+let targetChars = [];
+let spans = [];
+let position = 0;
+let correct = 0;
+let mistakes = 0;
+let totalKeyPresses = 0;
+let timeLeft = 60;
+let timerId = null;
+let startTimestamp = null;
+let testActive = false;
+let audioContext;
+let audioUnlocked = false;
+
+function buildTextDisplay() {
+ textDisplay.innerHTML = "";
+ targetChars = [];
+ spans = [];
+
+ paragraphs.forEach((paragraph, index) => {
+ const paragraphEl = document.createElement("p");
+ [...paragraph].forEach((char) => {
+ const span = document.createElement("span");
+ span.textContent = char;
+ span.className = "char pending";
+ paragraphEl.appendChild(span);
+ spans.push(span);
+ targetChars.push(char);
+ });
+
+ textDisplay.appendChild(paragraphEl);
+
+ if (index < paragraphs.length - 1) {
+ const spaceSpan = document.createElement("span");
+ spaceSpan.textContent = " ";
+ spaceSpan.className = "char pending";
+ textDisplay.appendChild(spaceSpan);
+ spans.push(spaceSpan);
+ targetChars.push(" ");
+ }
+ });
+
+ if (spans.length > 0) {
+ spans[0].classList.add("current");
+ }
+}
+
+function resetStats() {
+ position = 0;
+ correct = 0;
+ mistakes = 0;
+ totalKeyPresses = 0;
+ timeLeft = Number(timerSelect.value);
+ timeRemainingEl.textContent = timeLeft.toString();
+ wpmEl.textContent = "0";
+ accuracyEl.textContent = "100";
+ mistakesEl.textContent = "0";
+}
+
+function updateCurrentHighlight() {
+ spans.forEach((span) => span.classList.remove("current"));
+ if (spans[position]) {
+ spans[position].classList.add("current");
+ }
+}
+
+function updateStatsDisplay() {
+ const elapsedMinutes = startTimestamp
+ ? (performance.now() - startTimestamp) / 60000
+ : 0;
+ const wpm = elapsedMinutes > 0 ? Math.round((correct / 5) / elapsedMinutes) : 0;
+ const accuracy = totalKeyPresses > 0 ? Math.max(0, Math.round((correct / totalKeyPresses) * 100)) : 100;
+
+ wpmEl.textContent = wpm.toString();
+ accuracyEl.textContent = accuracy.toString();
+ mistakesEl.textContent = mistakes.toString();
+}
+
+function unlockAudio() {
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ if (!audioUnlocked) {
+ audioContext.resume().catch(() => {});
+ audioUnlocked = true;
+ }
+}
+
+function playTone(frequency, duration = 120) {
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+
+ if (!audioUnlocked) {
+ return;
+ }
+
+ const oscillator = audioContext.createOscillator();
+ const gainNode = audioContext.createGain();
+ oscillator.type = "sine";
+ oscillator.frequency.value = frequency;
+ gainNode.gain.setValueAtTime(0.001, audioContext.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.15, audioContext.currentTime + 0.02);
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + duration / 1000);
+
+ oscillator.connect(gainNode);
+ gainNode.connect(audioContext.destination);
+ oscillator.start();
+ oscillator.stop(audioContext.currentTime + duration / 1000);
+}
+
+function playCorrectSound() {
+ playTone(880);
+}
+
+function playIncorrectSound() {
+ playTone(220);
+}
+
+function finishTest() {
+ testActive = false;
+ typingArea.blur();
+ if (timerId) {
+ clearInterval(timerId);
+ timerId = null;
+ }
+ updateStatsDisplay();
+ typingArea.textContent = "Session complete. Press Start to try again.";
+}
+
+function handleKeydown(event) {
+ if (!testActive) {
+ return;
+ }
+
+ if (event.key === "Tab") {
+ event.preventDefault();
+ return;
+ }
+
+ unlockAudio();
+
+ if (event.key === "Backspace") {
+ event.preventDefault();
+ if (position > 0) {
+ position -= 1;
+ const span = spans[position];
+ span.classList.remove("correct", "incorrect");
+ span.classList.add("pending");
+ }
+ mistakes += 1;
+ totalKeyPresses += 1;
+ playIncorrectSound();
+ updateCurrentHighlight();
+ updateStatsDisplay();
+ return;
+ }
+
+ const isSingleCharacter = event.key.length === 1;
+ const isEnter = event.key === "Enter";
+
+ if (!isSingleCharacter && !isEnter) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const inputChar = isEnter ? "\n" : event.key;
+ const expectedChar = targetChars[position];
+
+ if (typeof expectedChar === "undefined") {
+ return;
+ }
+
+ totalKeyPresses += 1;
+
+ if (inputChar === expectedChar) {
+ spans[position].classList.remove("pending", "incorrect");
+ spans[position].classList.add("correct");
+ correct += 1;
+ playCorrectSound();
+ } else {
+ spans[position].classList.remove("pending", "correct");
+ spans[position].classList.add("incorrect");
+ mistakes += 1;
+ playIncorrectSound();
+ }
+
+ position += 1;
+ updateCurrentHighlight();
+ updateStatsDisplay();
+
+ if (position >= targetChars.length) {
+ finishTest();
+ }
+}
+
+function tickTimer() {
+ timeLeft -= 1;
+ timeRemainingEl.textContent = timeLeft.toString();
+ updateStatsDisplay();
+ if (timeLeft <= 0) {
+ finishTest();
+ }
+}
+
+function startTest() {
+ buildTextDisplay();
+ resetStats();
+
+ spans.forEach((span) => span.classList.add("pending"));
+
+ testActive = true;
+ startTimestamp = performance.now();
+ typingArea.textContent = "Typing in progress...";
+ typingArea.focus();
+
+ if (timerId) {
+ clearInterval(timerId);
+ }
+ timerId = setInterval(tickTimer, 1000);
+}
+
+startButton.addEventListener("click", () => {
+ startTest();
+});
+
+typingArea.addEventListener("keydown", handleKeydown);
+
+buildTextDisplay();
+typingArea.textContent = "Click here after starting to begin typing.";
diff --git a/examples/typing-app/index.html b/examples/typing-app/index.html
new file mode 100644
index 0000000000..943fa2272d
--- /dev/null
+++ b/examples/typing-app/index.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ Typing Practice Timer
+
+
+
+
+ Typing Practice
+
+ Timer:
+
+ 30 seconds
+ 60 seconds
+ 120 seconds
+
+ Start
+
+
+
+ Time left: 60 s
+ Words per minute: 0
+ Accuracy: 100 %
+ Mistakes: 0
+
+
+
+ Press the Start button, click inside the typing area, and begin typing.
+ Use the dropdown to choose how long you have. Every green character represents a
+ correct key press, while red indicates an error. Backspaces count as mistakes and
+ will remove the last highlighted character.
+
+
+
+
+
+
+
diff --git a/examples/typing-app/style.css b/examples/typing-app/style.css
new file mode 100644
index 0000000000..f69e3c3812
--- /dev/null
+++ b/examples/typing-app/style.css
@@ -0,0 +1,155 @@
+:root {
+ color-scheme: light dark;
+ font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ line-height: 1.6;
+ background-color: #111827;
+ color: #f9fafb;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ min-height: 100vh;
+ padding: 2rem;
+ background: radial-gradient(circle at top, #1f2937, #0f172a 60%);
+}
+
+.app-shell {
+ background: rgba(15, 23, 42, 0.9);
+ border-radius: 16px;
+ padding: 2rem;
+ max-width: 720px;
+ width: 100%;
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(8px);
+}
+
+h1 {
+ margin-top: 0;
+ text-align: center;
+ font-size: 2.4rem;
+ letter-spacing: 0.04em;
+}
+
+.controls {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+.controls select,
+.controls button {
+ padding: 0.5rem 1rem;
+ border-radius: 999px;
+ border: 1px solid rgba(148, 163, 184, 0.6);
+ background: rgba(30, 41, 59, 0.8);
+ color: inherit;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: transform 0.2s ease, background 0.2s ease;
+}
+
+.controls button:hover,
+.controls select:hover {
+ transform: translateY(-2px);
+ background: rgba(59, 130, 246, 0.3);
+}
+
+.status {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 0.75rem;
+ margin: 2rem 0 1.5rem;
+ text-align: center;
+}
+
+.instructions {
+ margin-bottom: 1.5rem;
+ padding: 1rem;
+ border-radius: 12px;
+ background: rgba(30, 41, 59, 0.6);
+ border: 1px solid rgba(148, 163, 184, 0.3);
+}
+
+.text-wrapper {
+ display: grid;
+ gap: 1rem;
+}
+
+.text-display {
+ background: rgba(15, 23, 42, 0.7);
+ border: 1px solid rgba(71, 85, 105, 0.7);
+ border-radius: 12px;
+ padding: 1.5rem;
+ min-height: 200px;
+ font-size: 1.1rem;
+ letter-spacing: 0.02em;
+ line-height: 1.8;
+ white-space: pre-wrap;
+}
+
+.text-display p {
+ margin: 0 0 1rem;
+}
+
+.typing-area {
+ border: 2px dashed rgba(148, 163, 184, 0.4);
+ border-radius: 12px;
+ min-height: 120px;
+ padding: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: rgba(148, 163, 184, 0.7);
+ font-style: italic;
+ text-align: center;
+}
+
+.typing-area:focus {
+ outline: none;
+ border-color: rgba(96, 165, 250, 0.8);
+ color: rgba(226, 232, 240, 0.9);
+ font-style: normal;
+}
+
+.char {
+ padding: 0.1rem 0.05rem;
+ border-radius: 4px;
+ transition: background 0.15s ease, color 0.15s ease;
+}
+
+.char.pending {
+ color: rgba(148, 163, 184, 0.5);
+}
+
+.char.current {
+ border-bottom: 2px solid rgba(129, 140, 248, 0.8);
+}
+
+.char.correct {
+ background: rgba(34, 197, 94, 0.2);
+ color: #bbf7d0;
+}
+
+.char.incorrect {
+ background: rgba(248, 113, 113, 0.2);
+ color: #fecaca;
+}
+
+@media (max-width: 600px) {
+ body {
+ padding: 1.25rem;
+ }
+
+ .app-shell {
+ padding: 1.5rem;
+ }
+
+ h1 {
+ font-size: 2rem;
+ }
+}