1038 lines
30 KiB
HTML
1038 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>TypeBlitz — How Fast Are You?</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Syne+Mono&family=Syne:wght@400;600;800&display=swap" rel="stylesheet" />
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #0d0d0f;
|
|
--surface: #141417;
|
|
--border: #222228;
|
|
--accent: #e8ff47;
|
|
--accent2: #ff6b35;
|
|
--muted: #444450;
|
|
--text: #e8e8f0;
|
|
--text-dim: #6a6a78;
|
|
--correct: #4ade80;
|
|
--incorrect: #f87171;
|
|
--cursor: #e8ff47;
|
|
--font-mono: 'Syne Mono', monospace;
|
|
--font-ui: 'Syne', sans-serif;
|
|
}
|
|
|
|
html, body {
|
|
height: 100%;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--font-ui);
|
|
}
|
|
|
|
body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
padding: 0 1rem;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Noise overlay */
|
|
body::before {
|
|
content: '';
|
|
position: fixed;
|
|
inset: 0;
|
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
opacity: 0.4;
|
|
}
|
|
|
|
/* Top accent line */
|
|
body::after {
|
|
content: '';
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0;
|
|
height: 3px;
|
|
background: linear-gradient(90deg, transparent, var(--accent), var(--accent2), transparent);
|
|
z-index: 100;
|
|
}
|
|
|
|
header {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 100%;
|
|
max-width: 860px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 2rem 0 1.5rem;
|
|
}
|
|
|
|
.logo {
|
|
font-family: var(--font-ui);
|
|
font-weight: 800;
|
|
font-size: 1.4rem;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
.logo span {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.mode-switcher {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 4px;
|
|
}
|
|
|
|
.mode-btn {
|
|
font-family: var(--font-ui);
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.04em;
|
|
padding: 0.35rem 0.8rem;
|
|
border: none;
|
|
border-radius: 5px;
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
transition: all 0.18s;
|
|
}
|
|
|
|
.mode-btn.active {
|
|
background: var(--accent);
|
|
color: #0d0d0f;
|
|
}
|
|
|
|
.mode-btn:hover:not(.active) {
|
|
color: var(--text);
|
|
}
|
|
|
|
/* Stats bar */
|
|
.stats-bar {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 100%;
|
|
max-width: 860px;
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.stat-card {
|
|
flex: 1;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 0.8rem 1.2rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.1em;
|
|
color: var(--text-dim);
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: var(--font-mono);
|
|
font-size: 1.6rem;
|
|
color: var(--text);
|
|
line-height: 1;
|
|
transition: color 0.2s;
|
|
}
|
|
|
|
.stat-value.highlight {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.stat-unit {
|
|
font-size: 0.6rem;
|
|
color: var(--text-dim);
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
|
|
/* Timer ring */
|
|
.timer-wrap {
|
|
position: relative;
|
|
width: 48px;
|
|
height: 48px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.timer-svg {
|
|
transform: rotate(-90deg);
|
|
width: 48px;
|
|
height: 48px;
|
|
}
|
|
|
|
.timer-track {
|
|
fill: none;
|
|
stroke: var(--border);
|
|
stroke-width: 3;
|
|
}
|
|
|
|
.timer-fill {
|
|
fill: none;
|
|
stroke: var(--accent);
|
|
stroke-width: 3;
|
|
stroke-linecap: round;
|
|
stroke-dasharray: 125.66;
|
|
stroke-dashoffset: 0;
|
|
transition: stroke-dashoffset 1s linear, stroke 0.3s;
|
|
}
|
|
|
|
.timer-text {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.9rem;
|
|
color: var(--accent);
|
|
}
|
|
|
|
/* Main typing area */
|
|
.test-container {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 100%;
|
|
max-width: 860px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 16px;
|
|
padding: 2.5rem 2.5rem 2rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.word-display {
|
|
font-family: var(--font-mono);
|
|
font-size: 1.35rem;
|
|
line-height: 1.9;
|
|
color: var(--muted);
|
|
user-select: none;
|
|
max-height: 5.7em; /* 3 lines exactly */
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.word {
|
|
display: inline-block;
|
|
margin-right: 0.55em;
|
|
position: relative;
|
|
}
|
|
|
|
.letter {
|
|
display: inline-block;
|
|
transition: color 0.05s;
|
|
}
|
|
|
|
.letter.correct { color: var(--correct); }
|
|
.letter.incorrect {
|
|
color: var(--incorrect);
|
|
text-decoration: underline;
|
|
text-decoration-color: var(--incorrect);
|
|
}
|
|
.letter.current { color: var(--text); }
|
|
|
|
/* blinking cursor */
|
|
.cursor-char {
|
|
display: inline-block;
|
|
width: 2px;
|
|
height: 1.2em;
|
|
background: var(--cursor);
|
|
vertical-align: middle;
|
|
margin-left: 1px;
|
|
animation: blink 0.9s step-end infinite;
|
|
border-radius: 1px;
|
|
}
|
|
|
|
@keyframes blink {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0; }
|
|
}
|
|
|
|
.hidden-input {
|
|
position: absolute;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
width: 1px;
|
|
height: 1px;
|
|
}
|
|
|
|
/* Click to start overlay */
|
|
.start-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(13,13,15,0.7);
|
|
border-radius: 16px;
|
|
backdrop-filter: blur(2px);
|
|
cursor: pointer;
|
|
z-index: 5;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.start-overlay.hidden { opacity: 0; pointer-events: none; }
|
|
|
|
.start-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.7rem;
|
|
background: var(--accent);
|
|
color: #0d0d0f;
|
|
font-weight: 700;
|
|
font-size: 0.9rem;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.7rem 1.6rem;
|
|
border-radius: 50px;
|
|
box-shadow: 0 0 40px rgba(232,255,71,0.25);
|
|
}
|
|
|
|
.kbd {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.75rem;
|
|
background: rgba(0,0,0,0.2);
|
|
border-radius: 4px;
|
|
padding: 0.15rem 0.4rem;
|
|
}
|
|
|
|
/* Bottom toolbar */
|
|
.toolbar {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 100%;
|
|
max-width: 860px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.restart-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-family: var(--font-ui);
|
|
font-weight: 600;
|
|
font-size: 0.85rem;
|
|
letter-spacing: 0.04em;
|
|
background: var(--surface);
|
|
color: var(--text-dim);
|
|
border: 1px solid var(--border);
|
|
padding: 0.6rem 1.4rem;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.18s;
|
|
}
|
|
|
|
.restart-btn:hover {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.restart-btn svg {
|
|
transition: transform 0.3s;
|
|
}
|
|
|
|
.restart-btn:hover svg {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
/* Results modal */
|
|
.results-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0,0,0,0.7);
|
|
backdrop-filter: blur(6px);
|
|
z-index: 50;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.results-overlay.show { display: flex; }
|
|
|
|
.results-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 20px;
|
|
padding: 3rem;
|
|
width: min(520px, 92vw);
|
|
animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from { transform: translateY(30px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
|
|
.results-title {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--text-dim);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.results-wpm {
|
|
font-family: var(--font-mono);
|
|
font-size: 5rem;
|
|
font-weight: 400;
|
|
color: var(--accent);
|
|
line-height: 1;
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
|
|
.results-wpm-label {
|
|
font-size: 0.8rem;
|
|
color: var(--text-dim);
|
|
letter-spacing: 0.08em;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.results-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.result-metric {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 10px;
|
|
padding: 0.8rem;
|
|
}
|
|
|
|
.result-metric .label {
|
|
font-size: 0.6rem;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--text-dim);
|
|
margin-bottom: 0.2rem;
|
|
}
|
|
|
|
.result-metric .value {
|
|
font-family: var(--font-mono);
|
|
font-size: 1.5rem;
|
|
color: var(--text);
|
|
}
|
|
|
|
.result-metric .value.good { color: var(--correct); }
|
|
.result-metric .value.bad { color: var(--incorrect); }
|
|
|
|
.results-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.btn-primary {
|
|
flex: 1;
|
|
font-family: var(--font-ui);
|
|
font-weight: 700;
|
|
font-size: 0.9rem;
|
|
letter-spacing: 0.04em;
|
|
background: var(--accent);
|
|
color: #0d0d0f;
|
|
border: none;
|
|
padding: 0.85rem 1.5rem;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
transition: transform 0.12s, box-shadow 0.12s;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 8px 24px rgba(232,255,71,0.2);
|
|
}
|
|
|
|
.btn-secondary {
|
|
font-family: var(--font-ui);
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
border: 1px solid var(--border);
|
|
padding: 0.85rem 1.5rem;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
transition: color 0.18s, border-color 0.18s;
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
color: var(--text);
|
|
border-color: var(--muted);
|
|
}
|
|
|
|
/* Accuracy meter */
|
|
.acc-bar-wrap {
|
|
height: 3px;
|
|
background: var(--border);
|
|
border-radius: 2px;
|
|
margin-top: 1.5rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.acc-bar {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--incorrect), var(--accent), var(--correct));
|
|
border-radius: 2px;
|
|
transition: width 0.5s ease;
|
|
}
|
|
|
|
/* Live accuracy */
|
|
#liveAccBar {
|
|
width: 100%;
|
|
height: 3px;
|
|
background: var(--border);
|
|
border-radius: 0 0 16px 16px;
|
|
overflow: hidden;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
#liveAccFill {
|
|
height: 100%;
|
|
background: var(--correct);
|
|
width: 100%;
|
|
transition: width 0.3s, background 0.3s;
|
|
}
|
|
|
|
/* Difficulty picker */
|
|
.difficulty-row {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 100%;
|
|
max-width: 860px;
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.diff-badge {
|
|
font-family: var(--font-ui);
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
padding: 0.3rem 0.75rem;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
background: transparent;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.diff-badge.active, .diff-badge:hover {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.test-container { padding: 1.5rem 1.2rem 1rem; }
|
|
.word-display { font-size: 1.1rem; }
|
|
.results-wpm { font-size: 3.5rem; }
|
|
.stats-bar { gap: 0.5rem; }
|
|
.stat-value { font-size: 1.2rem; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="logo">Type<span>Blitz</span></div>
|
|
<div class="mode-switcher">
|
|
<button class="mode-btn active" data-mode="60">60s</button>
|
|
<button class="mode-btn" data-mode="30">30s</button>
|
|
<button class="mode-btn" data-mode="15">15s</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="stats-bar">
|
|
<div class="stat-card">
|
|
<span class="stat-label">WPM</span>
|
|
<span class="stat-value" id="liveWpm">—</span>
|
|
<span class="stat-unit">words / min</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-label">Accuracy</span>
|
|
<span class="stat-value" id="liveAcc">—</span>
|
|
<span class="stat-unit">percent</span>
|
|
</div>
|
|
<div class="stat-card" style="flex:0; align-items:center; justify-content:center; padding:0.8rem;">
|
|
<div class="timer-wrap">
|
|
<svg class="timer-svg" viewBox="0 0 48 48">
|
|
<circle class="timer-track" cx="24" cy="24" r="20"/>
|
|
<circle class="timer-fill" id="timerArc" cx="24" cy="24" r="20"/>
|
|
</svg>
|
|
<div class="timer-text" id="timerDisplay">60</div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-label">Words</span>
|
|
<span class="stat-value" id="liveWords">0</span>
|
|
<span class="stat-unit">typed</span>
|
|
</div>
|
|
<div class="stat-card">
|
|
<span class="stat-label">Errors</span>
|
|
<span class="stat-value" id="liveErrors">0</span>
|
|
<span class="stat-unit">mistakes</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="difficulty-row">
|
|
<button class="diff-badge active" data-diff="common">Common</button>
|
|
<button class="diff-badge" data-diff="advanced">Advanced</button>
|
|
<button class="diff-badge" data-diff="numbers">Numbers</button>
|
|
<button class="diff-badge" data-diff="punctuation">Punctuation</button>
|
|
<button class="diff-badge" data-diff="code">Code</button>
|
|
</div>
|
|
|
|
<div class="test-container" id="testContainer">
|
|
<div class="start-overlay" id="startOverlay">
|
|
<div class="start-pill">
|
|
Click here or press any key to start <span class="kbd">↵</span>
|
|
</div>
|
|
</div>
|
|
<div class="word-display" id="wordDisplay"></div>
|
|
<div id="liveAccBar"><div id="liveAccFill"></div></div>
|
|
<input type="text" class="hidden-input" id="hiddenInput" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<button class="restart-btn" id="restartBtn">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
|
<path d="M3 3v5h5"/>
|
|
</svg>
|
|
Restart <span style="font-family:var(--font-mono);font-size:0.7rem;margin-left:4px;opacity:0.5;">Tab</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Results Modal -->
|
|
<div class="results-overlay" id="resultsOverlay">
|
|
<div class="results-card">
|
|
<div class="results-title">Test Complete 🎯</div>
|
|
<div class="results-wpm" id="finalWpm">0</div>
|
|
<div class="results-wpm-label">WORDS PER MINUTE</div>
|
|
<div class="results-grid">
|
|
<div class="result-metric">
|
|
<div class="label">Accuracy</div>
|
|
<div class="value" id="finalAcc">0%</div>
|
|
</div>
|
|
<div class="result-metric">
|
|
<div class="label">Correct</div>
|
|
<div class="value good" id="finalCorrect">0</div>
|
|
</div>
|
|
<div class="result-metric">
|
|
<div class="label">Errors</div>
|
|
<div class="value bad" id="finalErrors">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="acc-bar-wrap">
|
|
<div class="acc-bar" id="finalAccBar" style="width:0%"></div>
|
|
</div>
|
|
<br>
|
|
<div class="results-actions">
|
|
<button class="btn-primary" id="tryAgainBtn">Try Again</button>
|
|
<button class="btn-secondary" id="closeResultsBtn">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ── Word banks ──────────────────────────────────────────────────────────
|
|
const BANKS = {
|
|
common: `the be to of and a in that have it for not on with he as you do at this but his by from they we say her she or an will my one all would there their what so up out if about who get which go me when make can like time no just him know take people into year your good some could them see other than then now look only come its over think also back after use two how our work first well way even new want because any these give day most us`.split(' '),
|
|
|
|
advanced: `although consequently nevertheless circumstances approximately fundamental sophisticated simultaneously manifestation infrastructure configuration subsequently preliminary comprehensive representative extraordinary revolutionary philosophical particularly extraordinary administrative specifically consequently deliberate straightforward qualification contemplation establishment approximately infrastructure simultaneously`.split(' '),
|
|
|
|
numbers: `1 2 3 4 5 6 7 8 9 0 10 42 100 256 1024 3.14 99 2048 365 12 60 24 7 30 1000 512 128 64 32 16 8 4 2`.split(' '),
|
|
|
|
punctuation: `Hello, world! How are you? I'm fine, thanks. Let's go! Wait... really? Yes! No. Maybe? Always. Never! Sometimes, though. What's up? I don't know. Oh well. Alright, let's do it! Hmm... interesting. Ready? Set. Go!`.split(' '),
|
|
|
|
code: `const let var function return if else for while true false null undefined import export class async await try catch finally new this typeof instanceof void delete typeof`.split(' ')
|
|
};
|
|
|
|
// ── State ───────────────────────────────────────────────────────────────
|
|
let words = [];
|
|
let currentWordIndex = 0;
|
|
let currentLetterIndex = 0;
|
|
let totalCorrect = 0;
|
|
let totalErrors = 0;
|
|
let typedWords = 0;
|
|
|
|
let duration = 60;
|
|
let timeLeft = 60;
|
|
let timerInterval = null;
|
|
let started = false;
|
|
let finished = false;
|
|
|
|
let difficulty = 'common';
|
|
const INITIAL_WORDS = 60; // words rendered upfront
|
|
const REFILL_AT = 20; // append more when this many words remain ahead
|
|
|
|
// ── DOM ─────────────────────────────────────────────────────────────────
|
|
const wordDisplay = document.getElementById('wordDisplay');
|
|
const hiddenInput = document.getElementById('hiddenInput');
|
|
const startOverlay = document.getElementById('startOverlay');
|
|
const timerDisplay = document.getElementById('timerDisplay');
|
|
const timerArc = document.getElementById('timerArc');
|
|
const liveWpm = document.getElementById('liveWpm');
|
|
const liveAcc = document.getElementById('liveAcc');
|
|
const liveWords = document.getElementById('liveWords');
|
|
const liveErrors = document.getElementById('liveErrors');
|
|
const liveAccFill = document.getElementById('liveAccFill');
|
|
const resultsOverlay = document.getElementById('resultsOverlay');
|
|
const testContainer = document.getElementById('testContainer');
|
|
|
|
const ARC_LEN = 125.66;
|
|
|
|
// ── Word generation ─────────────────────────────────────────────────────
|
|
function pickWords(count = INITIAL_WORDS) {
|
|
const bank = BANKS[difficulty];
|
|
const result = [];
|
|
for (let i = 0; i < count; i++) {
|
|
result.push(bank[Math.floor(Math.random() * bank.length)]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function appendMoreWords(count = 40) {
|
|
const bank = BANKS[difficulty];
|
|
const startIndex = words.length;
|
|
for (let i = 0; i < count; i++) {
|
|
const word = bank[Math.floor(Math.random() * bank.length)];
|
|
words.push(word);
|
|
const wordEl = document.createElement('span');
|
|
wordEl.className = 'word';
|
|
wordEl.dataset.wi = startIndex + i;
|
|
[...word].forEach((ch) => {
|
|
const span = document.createElement('span');
|
|
span.className = 'letter';
|
|
span.textContent = ch;
|
|
wordEl.appendChild(span);
|
|
});
|
|
wordDisplay.appendChild(wordEl);
|
|
}
|
|
}
|
|
|
|
// ── Render ──────────────────────────────────────────────────────────────
|
|
function renderWords() {
|
|
wordDisplay.innerHTML = '';
|
|
words.forEach((word, wi) => {
|
|
const wordEl = document.createElement('span');
|
|
wordEl.className = 'word';
|
|
wordEl.dataset.wi = wi;
|
|
[...word].forEach((ch, li) => {
|
|
const span = document.createElement('span');
|
|
span.className = 'letter';
|
|
span.textContent = ch;
|
|
span.dataset.li = li;
|
|
wordEl.appendChild(span);
|
|
});
|
|
wordDisplay.appendChild(wordEl);
|
|
// Space between words (not rendered as letter, just visual gap via margin-right)
|
|
});
|
|
placeCursor();
|
|
scrollToCurrentLine();
|
|
}
|
|
|
|
function placeCursor() {
|
|
// Remove existing cursor
|
|
document.querySelectorAll('.cursor-char').forEach(e => e.remove());
|
|
|
|
const wordEl = wordDisplay.querySelector(`[data-wi="${currentWordIndex}"]`);
|
|
if (!wordEl) return;
|
|
|
|
const letters = wordEl.querySelectorAll('.letter');
|
|
const cursor = document.createElement('span');
|
|
cursor.className = 'cursor-char';
|
|
|
|
if (currentLetterIndex === 0) {
|
|
wordEl.insertBefore(cursor, letters[0] || null);
|
|
} else if (currentLetterIndex <= letters.length) {
|
|
const ref = letters[currentLetterIndex] || null;
|
|
if (ref) wordEl.insertBefore(cursor, ref);
|
|
else wordEl.appendChild(cursor);
|
|
}
|
|
}
|
|
|
|
function scrollToCurrentLine() {
|
|
const wordEl = wordDisplay.querySelector(`[data-wi="${currentWordIndex}"]`);
|
|
if (!wordEl) return;
|
|
const containerTop = wordDisplay.getBoundingClientRect().top;
|
|
const wordTop = wordEl.getBoundingClientRect().top;
|
|
const lineHeight = parseFloat(getComputedStyle(wordDisplay).lineHeight);
|
|
// Shift display so current word is always on line 1
|
|
const offset = wordTop - containerTop;
|
|
if (offset > lineHeight * 1.5) {
|
|
wordDisplay.scrollTop += offset - lineHeight * 0.1;
|
|
}
|
|
}
|
|
|
|
// ── Timer ───────────────────────────────────────────────────────────────
|
|
function startTimer() {
|
|
timerInterval = setInterval(() => {
|
|
timeLeft--;
|
|
updateTimer();
|
|
updateLiveStats();
|
|
if (timeLeft <= 0) endTest();
|
|
}, 1000);
|
|
}
|
|
|
|
function updateTimer() {
|
|
timerDisplay.textContent = timeLeft;
|
|
const ratio = timeLeft / duration;
|
|
timerArc.style.strokeDashoffset = ARC_LEN * (1 - ratio);
|
|
if (ratio < 0.3) timerArc.style.stroke = 'var(--incorrect)';
|
|
else if (ratio < 0.6) timerArc.style.stroke = 'var(--accent2)';
|
|
else timerArc.style.stroke = 'var(--accent)';
|
|
}
|
|
|
|
// ── Live stats ───────────────────────────────────────────────────────────
|
|
function updateLiveStats() {
|
|
const elapsed = duration - timeLeft;
|
|
if (elapsed === 0) return;
|
|
const wpm = Math.round((typedWords / elapsed) * 60);
|
|
const total = totalCorrect + totalErrors;
|
|
const acc = total === 0 ? 100 : Math.round((totalCorrect / total) * 100);
|
|
|
|
liveWpm.textContent = wpm;
|
|
liveWpm.className = 'stat-value' + (wpm > 60 ? ' highlight' : '');
|
|
liveAcc.textContent = acc + '%';
|
|
liveWords.textContent = typedWords;
|
|
liveErrors.textContent = totalErrors;
|
|
|
|
liveAccFill.style.width = acc + '%';
|
|
liveAccFill.style.background = acc >= 90 ? 'var(--correct)' : acc >= 70 ? 'var(--accent)' : 'var(--incorrect)';
|
|
}
|
|
|
|
// ── Input handling ───────────────────────────────────────────────────────
|
|
hiddenInput.addEventListener('input', onInput);
|
|
hiddenInput.addEventListener('keydown', onKeydown);
|
|
|
|
function onInput(e) {
|
|
if (!started || finished) return;
|
|
const val = hiddenInput.value;
|
|
|
|
// Space = next word
|
|
if (val.endsWith(' ')) {
|
|
commitWord(val.trim());
|
|
hiddenInput.value = '';
|
|
return;
|
|
}
|
|
|
|
// Typing in current word
|
|
const wordEl = wordDisplay.querySelector(`[data-wi="${currentWordIndex}"]`);
|
|
if (!wordEl) return;
|
|
const letters = wordEl.querySelectorAll('.letter');
|
|
|
|
// Reset all letters in current word
|
|
letters.forEach(l => l.className = 'letter');
|
|
|
|
[...val].forEach((ch, i) => {
|
|
if (i < letters.length) {
|
|
letters[i].className = 'letter ' + (ch === words[currentWordIndex][i] ? 'correct' : 'incorrect');
|
|
}
|
|
});
|
|
|
|
currentLetterIndex = val.length;
|
|
placeCursor();
|
|
}
|
|
|
|
function onKeydown(e) {
|
|
if (e.key === 'Backspace' && hiddenInput.value === '' && currentWordIndex > 0) {
|
|
// Go back to previous word
|
|
e.preventDefault();
|
|
currentWordIndex--;
|
|
const prevWord = words[currentWordIndex];
|
|
hiddenInput.value = prevWord; // restore previous typed value (approx)
|
|
|
|
// Reset previous word letters
|
|
const wordEl = wordDisplay.querySelector(`[data-wi="${currentWordIndex}"]`);
|
|
if (wordEl) {
|
|
wordEl.querySelectorAll('.letter').forEach(l => l.className = 'letter');
|
|
}
|
|
|
|
currentLetterIndex = prevWord.length;
|
|
placeCursor();
|
|
scrollToCurrentLine();
|
|
}
|
|
}
|
|
|
|
function commitWord(typed) {
|
|
const correct = words[currentWordIndex];
|
|
const letters = wordDisplay.querySelector(`[data-wi="${currentWordIndex}"]`)?.querySelectorAll('.letter');
|
|
|
|
let wordCorrect = true;
|
|
[...correct].forEach((ch, i) => {
|
|
const isCorrect = typed[i] === ch;
|
|
if (letters && letters[i]) {
|
|
letters[i].className = 'letter ' + (isCorrect ? 'correct' : 'incorrect');
|
|
}
|
|
if (isCorrect) totalCorrect++;
|
|
else { totalErrors++; wordCorrect = false; }
|
|
});
|
|
|
|
// Extra chars typed that don't exist in word
|
|
if (typed.length > correct.length) {
|
|
totalErrors += typed.length - correct.length;
|
|
wordCorrect = false;
|
|
}
|
|
|
|
if (typed.length < correct.length) {
|
|
for (let i = typed.length; i < correct.length; i++) {
|
|
if (letters && letters[i]) letters[i].className = 'letter incorrect';
|
|
totalErrors++;
|
|
wordCorrect = false;
|
|
}
|
|
}
|
|
|
|
typedWords++;
|
|
currentWordIndex++;
|
|
currentLetterIndex = 0;
|
|
|
|
// Refill words ahead so we never run out
|
|
if (words.length - currentWordIndex < REFILL_AT) {
|
|
appendMoreWords(40);
|
|
}
|
|
|
|
placeCursor();
|
|
scrollToCurrentLine();
|
|
updateLiveStats();
|
|
}
|
|
|
|
// ── Start / End ──────────────────────────────────────────────────────────
|
|
function startTest() {
|
|
if (started) return;
|
|
started = true;
|
|
startOverlay.classList.add('hidden');
|
|
hiddenInput.focus();
|
|
startTimer();
|
|
}
|
|
|
|
function endTest() {
|
|
if (finished) return;
|
|
finished = true;
|
|
clearInterval(timerInterval);
|
|
hiddenInput.blur();
|
|
|
|
const elapsed = duration - timeLeft || duration;
|
|
const wpm = Math.round((typedWords / elapsed) * 60);
|
|
const total = totalCorrect + totalErrors;
|
|
const acc = total === 0 ? 100 : Math.round((totalCorrect / total) * 100);
|
|
|
|
document.getElementById('finalWpm').textContent = wpm;
|
|
document.getElementById('finalAcc').textContent = acc + '%';
|
|
document.getElementById('finalCorrect').textContent = totalCorrect;
|
|
document.getElementById('finalErrors').textContent = totalErrors;
|
|
setTimeout(() => {
|
|
document.getElementById('finalAccBar').style.width = acc + '%';
|
|
}, 100);
|
|
|
|
resultsOverlay.classList.add('show');
|
|
}
|
|
|
|
function resetTest() {
|
|
clearInterval(timerInterval);
|
|
started = false;
|
|
finished = false;
|
|
timeLeft = duration;
|
|
currentWordIndex = 0;
|
|
currentLetterIndex = 0;
|
|
totalCorrect = 0;
|
|
totalErrors = 0;
|
|
typedWords = 0;
|
|
hiddenInput.value = '';
|
|
wordDisplay.scrollTop = 0;
|
|
|
|
liveWpm.textContent = '—';
|
|
liveAcc.textContent = '—';
|
|
liveWords.textContent = '0';
|
|
liveErrors.textContent = '0';
|
|
liveAccFill.style.width = '100%';
|
|
liveAccFill.style.background = 'var(--correct)';
|
|
|
|
timerDisplay.textContent = duration;
|
|
timerArc.style.strokeDashoffset = 0;
|
|
timerArc.style.stroke = 'var(--accent)';
|
|
|
|
resultsOverlay.classList.remove('show');
|
|
startOverlay.classList.remove('hidden');
|
|
|
|
words = pickWords(INITIAL_WORDS);
|
|
renderWords();
|
|
}
|
|
|
|
// ── Event listeners ───────────────────────────────────────────────────────
|
|
startOverlay.addEventListener('click', startTest);
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
resetTest();
|
|
return;
|
|
}
|
|
if (!started && !finished && e.key !== 'Tab') {
|
|
startTest();
|
|
}
|
|
});
|
|
|
|
document.getElementById('restartBtn').addEventListener('click', resetTest);
|
|
document.getElementById('tryAgainBtn').addEventListener('click', resetTest);
|
|
document.getElementById('closeResultsBtn').addEventListener('click', () => {
|
|
resultsOverlay.classList.remove('show');
|
|
resetTest();
|
|
});
|
|
|
|
// Mode buttons
|
|
document.querySelectorAll('.mode-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
duration = parseInt(btn.dataset.mode);
|
|
resetTest();
|
|
});
|
|
});
|
|
|
|
// Difficulty buttons
|
|
document.querySelectorAll('.diff-badge').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.diff-badge').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
difficulty = btn.dataset.diff;
|
|
resetTest();
|
|
});
|
|
});
|
|
|
|
// Focus input when clicking test area (not overlay)
|
|
testContainer.addEventListener('click', (e) => {
|
|
if (started && !finished) hiddenInput.focus();
|
|
});
|
|
|
|
// ── Init ─────────────────────────────────────────────────────────────────
|
|
words = pickWords(INITIAL_WORDS);
|
|
renderWords();
|
|
</script>
|
|
</body>
|
|
</html>
|