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

+
+ + + +
+ +
+
Time left: 60s
+
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; + } +}