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