learnings from laptop
This commit is contained in:
parent
1972815bf0
commit
6f3b6c7d37
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Keyboard Learning Game
|
||||||
|
|
||||||
|
Full-screen blue background. Whatever key you press shows up **BIG + BOLD** for kids to learn letters, numbers, and symbols.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
### Option 1: Open directly
|
||||||
|
|
||||||
|
Open `index.html` in your browser.
|
||||||
|
|
||||||
|
### Option 2: Run a small server (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:8080`.
|
||||||
|
|
@ -0,0 +1,224 @@
|
||||||
|
<!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>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "keyboard-learning-game",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A simple keyboard learning game for kids.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import http from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const port = process.env.PORT ? Number(process.env.PORT) : 8080;
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(
|
||||||
|
req.url ?? "/",
|
||||||
|
`http://${req.headers.host ?? "localhost"}`,
|
||||||
|
);
|
||||||
|
const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||||
|
|
||||||
|
// Only serve files from this folder; keep it intentionally simple.
|
||||||
|
const safePath = path.normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||||
|
const filePath = path.join(__dirname, safePath);
|
||||||
|
|
||||||
|
const data = await readFile(filePath);
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
ext === ".html"
|
||||||
|
? "text/html; charset=utf-8"
|
||||||
|
: ext === ".js"
|
||||||
|
? "text/javascript; charset=utf-8"
|
||||||
|
: ext === ".css"
|
||||||
|
? "text/css; charset=utf-8"
|
||||||
|
: "application/octet-stream";
|
||||||
|
|
||||||
|
res.writeHead(200, { "Content-Type": contentType });
|
||||||
|
res.end(data);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||||
|
res.end("Not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, () => {
|
||||||
|
// Intentionally minimal output.
|
||||||
|
console.log(`Keyboard game running on http://localhost:${port}`);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user