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:
Fredrik Johansson
2026-07-15 14:12:44 +02:00
commit 4192a05a78
10 changed files with 677 additions and 0 deletions

8
.env.example Normal file
View 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

View 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
View File

@@ -0,0 +1 @@
.env

3
Dockerfile Normal file
View 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
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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
View 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
View 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">&times;</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
View 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
View 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; }