Initial build: typo, a typing test built from your own code
No backend, no build step -- static HTML/CSS/JS, themed identically to flit/wisp (same CSS vars, same "> " title prefix, same dark/mono/green aesthetic). Snippets are real, shipped code pulled from six repos in this family (flit, wisp, keep, stash, delve-term, goonk) rather than generic pangrams. Stats: WPM = (correct chars / 5) / minutes elapsed, timing from first keystroke not page load. Accuracy = correct keystrokes / total keystrokes tracked at the keydown level (comparing the pressed key against the exact next expected character before it's inserted), so backspaced-over mistakes still count against it rather than only diffing the final text. Keys/sec updates live via a 200ms tick. Tabs in source are rendered as two spaces so players never need to press Tab -- sidesteps a browser default-Tab-moves-focus fight entirely rather than fighting it with preventDefault. Verified via simulated typing sessions (puppeteer), not just static review -- both an artificially fast pass (confirms the math is internally consistent: 308 correct/310 total keystrokes -> 99%, matches displayed) and a realistic ~60wpm-paced pass (confirms no unit-conversion bug that only a slow, deliberate typist would hit tests-at-lightspeed wouldn't surface). That process caught two real bugs before they'd have hit a real player: 1. A stray `tabindex="0"` on the code display let the browser's native click-to-focus behavior steal focus away from the hidden textarea before our own focus() call ran -- typing silently did nothing. Fixed by removing the tabindex and switching to a mousedown+preventDefault handler instead of click. 2. The help modal's CSS set `display: flex` unconditionally, which (author CSS always beats the UA stylesheet) overrode the `hidden` attribute's default `display: none` -- the modal was invisibly covering the entire viewport and intercepting every click, at all times, whether or not it had been opened. Fixed with an explicit `.modal-overlay:not([hidden])` rule. Also verified the actual Docker image builds and serves correctly (nginx, no build stage needed since there's nothing to compile). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
8
.env.example
Normal file
8
.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# Copy to .env to override docker-compose.yml defaults.
|
||||
|
||||
# Image to deploy — set by CI/CD normally; only needed for a manual
|
||||
# docker compose up.
|
||||
IMAGE=repo.explewd.com/explewd/typo:latest
|
||||
|
||||
# Host port to expose (container always listens on 80).
|
||||
HOST_PORT=8850
|
||||
53
.gitea/workflows/docker.yml
Normal file
53
.gitea/workflows/docker.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image name and tags
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
|
||||
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
|
||||
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
|
||||
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
|
||||
echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1
|
||||
fi
|
||||
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
|
||||
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
|
||||
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
|
||||
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.meta.outputs.host }}
|
||||
username: ${{ github.actor }}
|
||||
# Create a Gitea PAT with packages:write scope and add it as a
|
||||
# repository secret named TKNTKN (Settings → Secrets)
|
||||
password: ${{ secrets.TKNTKN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image }}:latest
|
||||
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
3
Dockerfile
Normal file
3
Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
COPY index.html style.css app.js snippets.json /usr/share/nginx/html/
|
||||
EXPOSE 80
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# typo
|
||||
|
||||
A typing test built from your own code. Every snippet is real, shipped
|
||||
code pulled from real repos — flit, wisp, keep, stash, delve-term, goonk
|
||||
itself — not generic pangrams or Lorem Ipsum.
|
||||
|
||||
No backend, no build step — plain HTML/CSS/JS served statically, with
|
||||
snippets baked into `snippets.json`.
|
||||
|
||||
## Stats
|
||||
|
||||
- **WPM** — (correct characters / 5) / minutes elapsed, the standard
|
||||
typing-test formula. Timing starts on the first keystroke, not page load.
|
||||
- **Accuracy** — correct keystrokes / total keystrokes, including ones you
|
||||
backspaced over. Tracked at the keydown level (comparing the pressed key
|
||||
against the exact next expected character), not just diffed after the
|
||||
fact.
|
||||
- **Keys/sec** — total keystrokes / elapsed seconds, live.
|
||||
|
||||
Mistakes are highlighted in place rather than blocking progress — you can
|
||||
backspace to fix them or keep going, same as most real typing tests.
|
||||
Tabs in the original source are shown as two spaces so you never need to
|
||||
press Tab.
|
||||
|
||||
## Adding snippets
|
||||
|
||||
Edit `snippets.json` — each entry is `{ repo, file, lang, code }`. Keep
|
||||
snippets short (10-20 lines), self-contained, and free of exotic
|
||||
Unicode in comments (em dashes etc. are typeable but annoying) if you
|
||||
want them pleasant to type.
|
||||
|
||||
## Local development
|
||||
|
||||
Any static file server works, e.g.:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8850
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
Built and verified via simulated typing sessions (not just static
|
||||
review): focus handling, live character-by-character highlighting,
|
||||
newline handling for multi-line snippets, and the WPM/accuracy/KPS math
|
||||
all confirmed against both artificially fast and realistic human-speed
|
||||
input. Caught and fixed two real bugs in the process — a focus-stealing
|
||||
issue caused by a stray `tabindex` on the code display, and a modal
|
||||
overlay that was invisibly covering the whole page and intercepting
|
||||
every click because its CSS unconditionally overrode the `hidden`
|
||||
attribute.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
159
app.js
Normal file
159
app.js
Normal file
@@ -0,0 +1,159 @@
|
||||
(() => {
|
||||
const sourceLine = document.getElementById('source-line');
|
||||
const codeDisplay = document.getElementById('code-display');
|
||||
const input = document.getElementById('typing-input');
|
||||
const statWpm = document.getElementById('stat-wpm');
|
||||
const statAcc = document.getElementById('stat-acc');
|
||||
const statKps = document.getElementById('stat-kps');
|
||||
const statTime = document.getElementById('stat-time');
|
||||
const restartBtn = document.getElementById('restart-btn');
|
||||
const gameCard = document.getElementById('game-card');
|
||||
const helpBtn = document.getElementById('help-btn');
|
||||
const closeHelp = document.getElementById('close-help');
|
||||
const helpModal = document.getElementById('help-modal');
|
||||
|
||||
let snippets = [];
|
||||
let target = '';
|
||||
let startTime = null;
|
||||
let finished = false;
|
||||
let totalKeystrokes = 0;
|
||||
let correctKeystrokes = 0;
|
||||
let tickHandle = null;
|
||||
let resultBanner = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function pickSnippet() {
|
||||
return snippets[Math.floor(Math.random() * snippets.length)];
|
||||
}
|
||||
|
||||
function loadSnippet() {
|
||||
const s = pickSnippet();
|
||||
// Tabs shown as two spaces so the player never needs to press Tab --
|
||||
// a plain textarea's default Tab behavior is "move focus," and
|
||||
// preventDefault-ing that just to insert a literal tab is more
|
||||
// fragile (IME/mobile edge cases) than sidestepping it entirely.
|
||||
target = s.code.replace(/\t/g, ' ');
|
||||
sourceLine.innerHTML = `typing: <span class="accent">${escapeHtml(s.repo)}/${escapeHtml(s.file)}</span>`;
|
||||
|
||||
startTime = null;
|
||||
finished = false;
|
||||
totalKeystrokes = 0;
|
||||
correctKeystrokes = 0;
|
||||
input.value = '';
|
||||
if (resultBanner) { resultBanner.remove(); resultBanner = null; }
|
||||
render();
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
stopTick();
|
||||
startTick();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const typed = input.value;
|
||||
let html = '';
|
||||
for (let i = 0; i < target.length; i++) {
|
||||
const targetChar = target[i] === '\n' ? '\n' : target[i];
|
||||
let cls = 'char-pending';
|
||||
if (i < typed.length) {
|
||||
cls = typed[i] === target[i] ? 'char-correct' : 'char-incorrect';
|
||||
} else if (i === typed.length) {
|
||||
cls = 'char-pending char-cursor';
|
||||
}
|
||||
html += `<span class="${cls}">${escapeHtml(targetChar)}</span>`;
|
||||
}
|
||||
codeDisplay.innerHTML = html;
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function elapsedSeconds() {
|
||||
if (startTime === null) return 0;
|
||||
return (performance.now() - startTime) / 1000;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const typed = input.value;
|
||||
let correctSoFar = 0;
|
||||
for (let i = 0; i < typed.length && i < target.length; i++) {
|
||||
if (typed[i] === target[i]) correctSoFar++;
|
||||
}
|
||||
const secs = elapsedSeconds();
|
||||
const mins = secs / 60;
|
||||
const wpm = mins > 0 ? Math.round((correctSoFar / 5) / mins) : 0;
|
||||
const acc = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100;
|
||||
const kps = secs > 0 ? (totalKeystrokes / secs) : 0;
|
||||
|
||||
statWpm.textContent = wpm;
|
||||
statAcc.textContent = `${acc}%`;
|
||||
statKps.textContent = kps.toFixed(1);
|
||||
statTime.textContent = `${secs.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function startTick() {
|
||||
tickHandle = setInterval(() => { if (!finished) updateStats(); }, 200);
|
||||
}
|
||||
function stopTick() {
|
||||
if (tickHandle) clearInterval(tickHandle);
|
||||
tickHandle = null;
|
||||
}
|
||||
|
||||
function finish() {
|
||||
finished = true;
|
||||
input.disabled = true;
|
||||
stopTick();
|
||||
updateStats();
|
||||
resultBanner = document.createElement('div');
|
||||
resultBanner.className = 'result-banner';
|
||||
resultBanner.textContent = `Done — ${statWpm.textContent} wpm, ${statAcc.textContent} accuracy, ${statTime.textContent}. Nice.`;
|
||||
gameCard.insertBefore(resultBanner, gameCard.querySelector('.actions'));
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (finished) return;
|
||||
const isBackspace = e.key === 'Backspace';
|
||||
const isNewline = e.key === 'Enter';
|
||||
const isChar = e.key.length === 1;
|
||||
if (!isBackspace && !isNewline && !isChar) return; // ignore Shift/Control/Arrow/Tab/etc
|
||||
|
||||
if (startTime === null && !isBackspace) startTime = performance.now();
|
||||
|
||||
totalKeystrokes++;
|
||||
if (!isBackspace) {
|
||||
const expected = target[input.value.length];
|
||||
const pressed = isNewline ? '\n' : e.key;
|
||||
if (expected !== undefined && pressed === expected) correctKeystrokes++;
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
render();
|
||||
if (input.value === target) finish();
|
||||
});
|
||||
|
||||
// mousedown + preventDefault, not click -- a click handler runs after the
|
||||
// browser's own default-focus behavior has already fired, which was
|
||||
// stealing focus away from the hidden textarea whenever this had a
|
||||
// native tabindex. preventDefault on mousedown stops that entirely, so
|
||||
// our explicit focus() call is the only thing that happens.
|
||||
codeDisplay.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
if (!finished) input.focus();
|
||||
});
|
||||
restartBtn.addEventListener('click', loadSnippet);
|
||||
|
||||
helpBtn.addEventListener('click', () => { helpModal.hidden = false; });
|
||||
closeHelp.addEventListener('click', () => { helpModal.hidden = true; });
|
||||
helpModal.addEventListener('click', (e) => { if (e.target === helpModal) helpModal.hidden = true; });
|
||||
|
||||
fetch('snippets.json')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
snippets = data;
|
||||
loadSnippet();
|
||||
})
|
||||
.catch(() => {
|
||||
sourceLine.textContent = 'Could not load snippets.';
|
||||
});
|
||||
})();
|
||||
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE}
|
||||
container_name: typo
|
||||
restart: unless-stopped
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "${HOST_PORT:-8850}:80"
|
||||
57
index.html
Normal file
57
index.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>typo</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="A typing test built from your own code — real snippets from real repos.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="header-row">
|
||||
<h1>typo</h1>
|
||||
<button class="help-btn" id="help-btn" title="How this works">?</button>
|
||||
</div>
|
||||
<p class="hint">real code, from real repos — type it as fast and cleanly as you can</p>
|
||||
|
||||
<div class="card" id="game-card">
|
||||
<div class="source-line" id="source-line">loading a snippet…</div>
|
||||
<pre class="code-display" id="code-display"></pre>
|
||||
<textarea id="typing-input" class="sr-input" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="Type the code shown above"></textarea>
|
||||
|
||||
<div class="stat-row" id="stat-row">
|
||||
<div class="stat"><span class="stat-value" id="stat-wpm">0</span><span class="stat-label">wpm</span></div>
|
||||
<div class="stat"><span class="stat-value" id="stat-acc">100%</span><span class="stat-label">accuracy</span></div>
|
||||
<div class="stat"><span class="stat-value" id="stat-kps">0.0</span><span class="stat-label">keys/sec</span></div>
|
||||
<div class="stat"><span class="stat-value" id="stat-time">0.0s</span><span class="stat-label">time</span></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" id="restart-btn">new snippet</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="help-modal" hidden>
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<span>how it works</span>
|
||||
<button class="help-btn" id="close-help">×</button>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Every snippet is real code pulled from real, shipped repos — flit, wisp, keep, stash, delve-term, goonk itself.</li>
|
||||
<li>Click the code block and start typing. Timing starts on your first keystroke, not on page load.</li>
|
||||
<li><strong>WPM</strong> = (correct characters / 5) / minutes elapsed — the standard typing-test formula.</li>
|
||||
<li><strong>Accuracy</strong> = correct keystrokes / total keystrokes, including ones you backspaced over.</li>
|
||||
<li>Mistakes are highlighted in place. You can backspace to fix them or keep going — either way, they count against accuracy.</li>
|
||||
<li>Tabs in the original source are shown as two spaces, so you never need to press Tab.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
snippets.json
Normal file
50
snippets.json
Normal file
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"repo": "delve-term",
|
||||
"file": "game/combat.go",
|
||||
"lang": "go",
|
||||
"code": "func withThe(name string) string {\n\tif strings.HasPrefix(name, \"the \") {\n\t\treturn name\n\t}\n\treturn \"the \" + name\n}"
|
||||
},
|
||||
{
|
||||
"repo": "delve-term",
|
||||
"file": "game/graves.go",
|
||||
"lang": "go",
|
||||
"code": "func RandomGraveNear(floor, spread int) *Grave {\n\tgraveMu.Lock()\n\tdefer graveMu.Unlock()\n\tvar candidates []Grave\n\tfor _, g := range graves {\n\t\td := g.Floor - floor\n\t\tif d < 0 {\n\t\t\td = -d\n\t\t}\n\t\tif d <= spread {\n\t\t\tcandidates = append(candidates, g)\n\t\t}\n\t}\n\tif len(candidates) == 0 {\n\t\treturn nil\n\t}\n\treturn &candidates[rand.Intn(len(candidates))]\n}"
|
||||
},
|
||||
{
|
||||
"repo": "flit",
|
||||
"file": "cli/cmd/flit/main.go",
|
||||
"lang": "go",
|
||||
"code": "func decodeInvite(s string) (*invite, error) {\n\tconst prefix = \"flit:\"\n\tif len(s) < len(prefix) || s[:len(prefix)] != prefix {\n\t\treturn nil, fmt.Errorf(\"not a flit invite\")\n\t}\n\traw, err := base64.RawURLEncoding.DecodeString(s[len(prefix):])\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tvar inv invite\n\tif err := json.Unmarshal(raw, &inv); err != nil {\n\t\treturn nil, err\n\t}\n\treturn &inv, nil\n}"
|
||||
},
|
||||
{
|
||||
"repo": "wisp",
|
||||
"file": "client/src/crypto.ts",
|
||||
"lang": "ts",
|
||||
"code": "function concat(...parts: Uint8Array[]): Uint8Array {\n const total = parts.reduce((n, p) => n + p.length, 0)\n const out = new Uint8Array(total)\n let offset = 0\n for (const p of parts) {\n out.set(p, offset)\n offset += p.length\n }\n return out\n}"
|
||||
},
|
||||
{
|
||||
"repo": "stash",
|
||||
"file": "src/server/db.ts",
|
||||
"lang": "ts",
|
||||
"code": "export function deleteArticle(id: number): boolean {\n const result = db.prepare('DELETE FROM articles WHERE id = ?').run(id);\n return result.changes > 0;\n}\n\nexport function isValidToken(token: string): boolean {\n const row = db.prepare('SELECT 1 FROM tokens WHERE token = ?').get(token);\n return !!row;\n}"
|
||||
},
|
||||
{
|
||||
"repo": "keep",
|
||||
"file": "src/server/db.ts",
|
||||
"lang": "ts",
|
||||
"code": "export function hasGrant(vaultKey: string, recipientId: string): boolean {\n return !!getGrant(vaultKey, recipientId);\n}\n\nexport function hasWriteGrant(vaultKey: string, recipientId: string): boolean {\n const grant = getGrant(vaultKey, recipientId);\n return !!grant && grant.canWrite;\n}"
|
||||
},
|
||||
{
|
||||
"repo": "goonk",
|
||||
"file": "api/routes/lastfm.mjs",
|
||||
"lang": "js",
|
||||
"code": "router.get('/top-artists', async (req, res) => {\n const period = req.query.period || '7day';\n const limit = Math.min(Number(req.query.limit) || 10, 50);\n try {\n const data = await cached(`top-artists:${period}:${limit}`, CACHE_TTL_MS, () =>\n lfm('user.gettopartists', { period, limit })\n );\n res.json(data.topartists?.artist ?? []);\n } catch (e) {\n res.status(502).json({ error: e.message });\n }\n});"
|
||||
},
|
||||
{
|
||||
"repo": "goonk",
|
||||
"file": "public/scripts/easter-eggs.js",
|
||||
"lang": "js",
|
||||
"code": "const typedWordAchievements = ['sudo', 'vim', 'emacs', 'gravity'];\nif (typedWordAchievements.includes(achievementKey.toLowerCase()) && !unlocked.includes('console')) {\n setTimeout(() => unlockAchievement('CONSOLE'), 1000);\n}"
|
||||
}
|
||||
]
|
||||
284
style.css
Normal file
284
style.css
Normal file
@@ -0,0 +1,284 @@
|
||||
:root {
|
||||
--bg: #080808;
|
||||
--bg-alt: #0e0e0e;
|
||||
--text: #c8c8c8;
|
||||
--muted: #555;
|
||||
--heading: #f0f0f0;
|
||||
--accent: #00e87a;
|
||||
--accent-dim: rgba(0, 232, 122, 0.12);
|
||||
--accent-border:rgba(0, 232, 122, 0.25);
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
|
||||
--error: #ff6b6b;
|
||||
--error-dim: rgba(255, 107, 107, 0.14);
|
||||
|
||||
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
|
||||
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
|
||||
font: 16px/1.6 var(--sans);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
color-scheme: dark;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body { margin: 0; }
|
||||
|
||||
#app {
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px 64px;
|
||||
}
|
||||
|
||||
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
|
||||
p { margin: 0; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-row h1 {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-row h1::before {
|
||||
content: '> ';
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.header-row .help-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.help-btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.help-btn:hover {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* ── Card ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
background: var(--bg-alt);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.source-line {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.source-line .accent { color: var(--accent); }
|
||||
|
||||
.code-display {
|
||||
font-family: var(--mono);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
cursor: text;
|
||||
outline: none;
|
||||
min-height: 3em;
|
||||
}
|
||||
|
||||
.code-display:focus {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.code-display .char-correct { color: var(--text); }
|
||||
.code-display .char-incorrect { color: var(--error); background: var(--error-dim); border-radius: 2px; }
|
||||
.code-display .char-pending { color: var(--muted); }
|
||||
.code-display .char-cursor {
|
||||
border-left: 2px solid var(--accent);
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes blink { 50% { border-color: transparent; } }
|
||||
|
||||
/* Real input, visually hidden but focusable/typeable — a hidden input is
|
||||
more reliable across mobile keyboards and IME composition than trying
|
||||
to capture raw keydown events on a div. */
|
||||
.sr-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Stats ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--heading);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* ── Actions / buttons ──────────────────────────────────────────────── */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.btn:hover { border-color: var(--accent-border); color: var(--heading); }
|
||||
.btn:active { transform: scale(0.98); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary:hover { background: #00ff88; border-color: #00ff88; color: #000; }
|
||||
|
||||
/* ── Result banner ──────────────────────────────────────────────────── */
|
||||
|
||||
.result-banner {
|
||||
text-align: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* ── Help modal ─────────────────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.88);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Author CSS always wins over the UA stylesheet's [hidden]{display:none},
|
||||
so an unconditional `display: flex` above would keep this covering the
|
||||
whole viewport (and intercepting every click) even while `hidden` is
|
||||
set. Only apply flex layout when it isn't hidden. */
|
||||
.modal-overlay:not([hidden]) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--bg-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.modal-card ul {
|
||||
padding-left: 1.2em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-card li { margin-bottom: 0.8em; font-size: 14px; line-height: 1.5; }
|
||||
.modal-card li:last-child { margin-bottom: 0; }
|
||||
Reference in New Issue
Block a user