225 lines
5.9 KiB
HTML
225 lines
5.9 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Keyboard Learning Game</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0b57ff;
|
|
--fg: #ffffff;
|
|
--muted: rgba(255, 255, 255, 0.85);
|
|
--shadow: rgba(0, 0, 0, 0.25);
|
|
}
|
|
|
|
html,
|
|
body {
|
|
height: 100%;
|
|
margin: 0;
|
|
}
|
|
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--fg);
|
|
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
|
|
"Segoe UI Emoji";
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
user-select: none;
|
|
}
|
|
|
|
.wrap {
|
|
width: min(1100px, 92vw);
|
|
text-align: center;
|
|
}
|
|
|
|
.sound {
|
|
position: fixed;
|
|
top: 18px;
|
|
right: 18px;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
padding: 10px 14px;
|
|
background: rgba(255, 255, 255, 0.18);
|
|
color: var(--fg);
|
|
font-weight: 900;
|
|
letter-spacing: 0.02em;
|
|
box-shadow: 0 10px 0 rgba(0, 0, 0, 0.12);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.sound:focus-visible {
|
|
outline: 4px solid rgba(255, 255, 255, 0.55);
|
|
outline-offset: 4px;
|
|
}
|
|
|
|
.big {
|
|
font-weight: 900;
|
|
letter-spacing: 0.03em;
|
|
font-size: clamp(72px, 18vw, 220px);
|
|
line-height: 1;
|
|
text-shadow: 0 12px 0 var(--shadow);
|
|
margin: 0;
|
|
}
|
|
|
|
.hint {
|
|
margin-top: 18px;
|
|
font-size: clamp(16px, 2.5vw, 26px);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.kbd {
|
|
display: inline-block;
|
|
padding: 6px 12px;
|
|
border-radius: 12px;
|
|
background: rgba(255, 255, 255, 0.15);
|
|
box-shadow: 0 10px 0 rgba(0, 0, 0, 0.12);
|
|
font-weight: 800;
|
|
}
|
|
|
|
.pulse {
|
|
animation: pop 120ms ease-out;
|
|
}
|
|
|
|
@keyframes pop {
|
|
from {
|
|
transform: scale(0.94);
|
|
filter: brightness(0.95);
|
|
}
|
|
to {
|
|
transform: scale(1);
|
|
filter: brightness(1);
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<button id="soundBtn" class="sound" type="button" aria-pressed="true">Sound: ON</button>
|
|
<main class="wrap" aria-live="polite" aria-atomic="true">
|
|
<h1 id="big" class="big">Press a key</h1>
|
|
<div class="hint">
|
|
Type any letter, number, or symbol on your keyboard (try <span class="kbd">A</span>, <span class="kbd">1</span>,
|
|
<span class="kbd">!</span>, <span class="kbd">@</span>).
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
const big = document.getElementById("big");
|
|
const soundBtn = document.getElementById("soundBtn");
|
|
let soundOn = true;
|
|
|
|
function setSound(on) {
|
|
soundOn = on;
|
|
soundBtn.textContent = `Sound: ${soundOn ? "ON" : "OFF"}`;
|
|
soundBtn.setAttribute("aria-pressed", String(soundOn));
|
|
}
|
|
|
|
setSound(true);
|
|
|
|
soundBtn.addEventListener("click", () => {
|
|
setSound(!soundOn);
|
|
});
|
|
|
|
function prettyKey(e) {
|
|
const k = e.key;
|
|
|
|
if (k === " ") return "SPACE";
|
|
if (k === "Enter") return "ENTER";
|
|
if (k === "Backspace") return "BACKSPACE";
|
|
if (k === "Tab") return "TAB";
|
|
if (k === "Escape") return "ESC";
|
|
if (k === "Shift" || k === "Control" || k === "Alt" || k === "Meta") return k.toUpperCase();
|
|
if (k.startsWith("Arrow")) return k.replace("Arrow", "ARROW ");
|
|
|
|
// Printable characters come through as length 1 (letters, numbers, symbols).
|
|
if (k.length === 1) return k.toUpperCase();
|
|
|
|
// Fallback for other keys like "Home", "End", "PageUp", etc.
|
|
return k.toUpperCase();
|
|
}
|
|
|
|
const speakMap = {
|
|
" ": "space",
|
|
Enter: "enter",
|
|
Backspace: "backspace",
|
|
Tab: "tab",
|
|
Escape: "escape",
|
|
"!": "exclamation mark",
|
|
"@": "at sign",
|
|
"#": "hash",
|
|
"$": "dollar sign",
|
|
"%": "percent",
|
|
"^": "caret",
|
|
"&": "ampersand",
|
|
"*": "asterisk",
|
|
"(": "left parenthesis",
|
|
")": "right parenthesis",
|
|
"-": "hyphen",
|
|
_: "underscore",
|
|
"+": "plus",
|
|
"=": "equals",
|
|
"[": "left bracket",
|
|
"]": "right bracket",
|
|
"{": "left brace",
|
|
"}": "right brace",
|
|
"\\": "backslash",
|
|
"/": "slash",
|
|
"|": "pipe",
|
|
":": "colon",
|
|
";": "semicolon",
|
|
"'": "apostrophe",
|
|
'"': "quote",
|
|
",": "comma",
|
|
".": "period",
|
|
"<": "less than",
|
|
">": "greater than",
|
|
"?": "question mark",
|
|
"`": "backtick",
|
|
"~": "tilde"
|
|
};
|
|
|
|
function speakForKey(e) {
|
|
if (!soundOn) return;
|
|
if (!("speechSynthesis" in window)) return;
|
|
|
|
const raw = e.key;
|
|
let phrase = speakMap[raw];
|
|
|
|
if (!phrase) {
|
|
if (raw.startsWith("Arrow")) phrase = raw.replace("Arrow", "arrow ").toLowerCase();
|
|
else if (raw.length === 1) phrase = raw.toUpperCase(); // letters/numbers
|
|
else phrase = raw.toLowerCase();
|
|
}
|
|
|
|
try {
|
|
window.speechSynthesis.cancel();
|
|
const u = new SpeechSynthesisUtterance(phrase);
|
|
u.rate = 0.9;
|
|
u.pitch = 1.0;
|
|
u.volume = 1.0;
|
|
window.speechSynthesis.speak(u);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function show(text) {
|
|
big.textContent = text;
|
|
big.classList.remove("pulse");
|
|
// Force reflow so animation restarts reliably
|
|
void big.offsetWidth;
|
|
big.classList.add("pulse");
|
|
}
|
|
|
|
window.addEventListener("keydown", (e) => {
|
|
// Prevent browser shortcuts from stealing focus in some cases
|
|
// (Still allows normal typing, and this is a full-screen toy app.)
|
|
e.preventDefault();
|
|
show(prettyKey(e));
|
|
speakForKey(e);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|