diff --git a/www/html/wordle/board.js b/www/html/wordle/board.js
new file mode 100644
index 0000000..50109ed
--- /dev/null
+++ b/www/html/wordle/board.js
@@ -0,0 +1,150 @@
+import {
+ h, icon, tile, bigBoard, emptyState, skeleton, toast, copyText, confetti, mountShell,
+ profileHeader, parseBoardName, boardPage, historyPage, getBoard, getLastBoard, findGame, today,
+ formatDay, resultText, shareText,
+} from "./wordle.js?v=2";
+
+mountShell();
+const main = document.getElementById("main");
+const target = parseBoardName(new URLSearchParams(location.search).get("board"));
+
+if (!target) {
+ document.title = "Wordle board";
+ main.append(emptyState("Which board?", "The link doesn't say which board to show.",
+ h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find a player")));
+} else {
+ show(target.user, target.day);
+}
+
+async function show(user, day) {
+ const header = profileHeader(user, day == null ? "board" : null, { meta: day == null ? "Latest board" : formatDay(day) });
+ const stage = h("section", { class: "card board-stage", style: { "margin-top": "28px" } },
+ skeleton("26px", "200px"), skeleton("400px", "min(100%, 340px)"));
+ main.append(header, stage);
+ document.title = `${user} · Wordle`;
+
+ let board;
+ try {
+ // Without a day, show today's game, else the most recent one we can find
+ board = day == null
+ ? await findGame(user, today(), -1) ?? await getLastBoard(user)
+ : await getBoard(user, day);
+ } catch {
+ stage.replaceChildren(h("h2", {}, "Couldn't load the board"), h("p", {}, "Check your connection and try again."),
+ h("button", { class: "btn btn-primary", type: "button", onclick: () => location.reload() }, "Retry"));
+ return;
+ }
+
+ if (!board) {
+ const when = day == null ? "any games" : `a game on ${formatDay(day)}`;
+ stage.replaceChildren(
+ h("h2", {}, `No board for ${user}`),
+ h("p", { style: { color: "var(--muted)", margin: "0" } }, `Couldn't find ${when}.`),
+ day == null ? null : navRow(user, day),
+ h("a", { class: "btn", href: historyPage(user) }, icon("calendar"), "See their history"));
+ return;
+ }
+
+ const shownDay = board.day;
+ header.setMeta(shownDay == null ? "Last board played" : formatDay(shownDay));
+ document.title = `${user} · ${shownDay == null ? "latest board" : formatDay(shownDay, "medium")} · Wordle`;
+ if (day == null && shownDay != null) {
+ // Point the address bar at the real board so the link can be shared
+ history.replaceState(null, "", boardPage(user, shownDay));
+ }
+
+ const boardEl = bigBoard(board);
+ const answerEl = board.won || !board.answer ? null
+ : h("div", { class: "answer-row", "data-letters": "hidden" },
+ h("span", {}, "The word was"),
+ [...board.answer].map(letter => tile(letter, "correct")));
+
+ stage.replaceChildren(
+ h("div", { class: "headline" },
+ h("span", { class: board.won ? "badge win" : "badge loss", style: { "font-size": "0.95rem", padding: "6px 14px" } },
+ board.won ? `Solved in ${resultText(board)}` : `Missed it - ${resultText(board)}`)),
+ boardEl,
+ answerEl,
+ revealButton(boardEl, answerEl),
+ h("div", { class: "nav-row" }, shareButtons(user, board, shownDay)),
+ shownDay == null ? null : navRow(user, shownDay),
+ h("p", { class: "kbd-hint", style: { margin: "0" } },
+ "Hold ", h("kbd", {}, "R"), " to peek at the letters",
+ shownDay == null ? "" : [" · ", h("kbd", {}, "←"), " ", h("kbd", {}, "→"), " for other days"]));
+
+ if (board.won) setTimeout(confetti, board.guesses * 420 + 900);
+}
+
+// Letters stay hidden (spoiler free) unless the button or R key is held down
+function revealButton(boardEl, answerEl) {
+ const button = h("button", { class: "btn btn-primary hold-btn", type: "button", "aria-pressed": "false" },
+ icon("eye"), "Hold to reveal letters");
+ const set = visible => {
+ for (const el of [boardEl, answerEl]) el?.setAttribute("data-letters", visible ? "shown" : "hidden");
+ button.setAttribute("aria-pressed", String(visible));
+ };
+
+ button.addEventListener("pointerdown", e => {
+ e.preventDefault();
+ button.setPointerCapture(e.pointerId);
+ set(true);
+ });
+ for (const type of ["pointerup", "pointercancel", "lostpointercapture", "blur"]) {
+ button.addEventListener(type, () => set(false));
+ }
+ button.addEventListener("contextmenu", e => e.preventDefault());
+ button.addEventListener("keydown", e => {
+ if (e.key === " " || e.key === "Enter") {
+ e.preventDefault();
+ set(true);
+ }
+ });
+ button.addEventListener("keyup", e => {
+ if (e.key === " " || e.key === "Enter") set(false);
+ });
+
+ const typing = e => e.target.closest?.("input, textarea, [contenteditable]");
+ document.addEventListener("keydown", e => {
+ if (e.key.toLowerCase() === "r" && !e.ctrlKey && !e.metaKey && !e.altKey && !typing(e)) set(true);
+ });
+ document.addEventListener("keyup", e => {
+ if (e.key.toLowerCase() === "r") set(false);
+ });
+ return button;
+}
+
+function shareButtons(user, board, day) {
+ const share = h("button", { class: "btn", type: "button" }, icon("copy"), "Copy result");
+ share.addEventListener("click", async () => {
+ toast(await copyText(shareText(board, user, day)) ? "Result copied - no spoilers included" : "Couldn't copy, sorry");
+ });
+ const link = h("button", { class: "btn", type: "button" }, icon("share"), "Copy link");
+ link.addEventListener("click", async () => {
+ toast(await copyText(location.origin + boardPage(user, day)) ? "Link copied" : "Couldn't copy, sorry");
+ });
+ return [share, link];
+}
+
+// Previous / next game buttons, enabled once the nearest games are found
+function navRow(user, day) {
+ const make = (dir, label) => {
+ const el = h("a", { class: "btn btn-sm", "aria-disabled": "true", title: "Looking…" },
+ dir < 0 ? icon("left") : null, label, dir > 0 ? icon("right") : null);
+ const key = dir < 0 ? "ArrowLeft" : "ArrowRight";
+ findGame(user, day + dir, dir).then(board => {
+ if (!board) {
+ el.title = dir < 0 ? "No earlier games in the 60 days before" : "No later games";
+ el.style.opacity = "0.45";
+ return;
+ }
+ el.href = boardPage(user, board.day);
+ el.title = formatDay(board.day);
+ el.removeAttribute("aria-disabled");
+ document.addEventListener("keydown", e => {
+ if (e.key === key && !e.altKey && !e.ctrlKey && !e.metaKey) location.href = el.href;
+ });
+ }).catch(() => {});
+ return el;
+ };
+ return h("div", { class: "nav-row" }, make(-1, "Previous game"), make(1, "Next game"));
+}
diff --git a/www/html/wordle/favicon.svg b/www/html/wordle/favicon.svg
new file mode 100644
index 0000000..50e94f1
--- /dev/null
+++ b/www/html/wordle/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/www/html/wordle/game.html b/www/html/wordle/game.html
index 7ae7452..c359f78 100644
--- a/www/html/wordle/game.html
+++ b/www/html/wordle/game.html
@@ -1,407 +1,32 @@
-
+
-Wordle Board Viewer
-
-
-
+
+Wordle board
+
+
+
+
+
+
+
-
-Wordle Board
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
diff --git a/www/html/wordle/games.html b/www/html/wordle/games.html
index c82e95b..7859299 100644
--- a/www/html/wordle/games.html
+++ b/www/html/wordle/games.html
@@ -1,218 +1,32 @@
-
+
-Recent Wordle Games
-
-
+
+Wordle history
+
+
+
+
+
+
+
-
-
-
Recent Games for ...
-
Last 30 days (only days actually played)
-
-
-
-
-
-
No games in the last 30 days.
-
-
-
-
-
+
+
+
-
diff --git a/www/html/wordle/history.js b/www/html/wordle/history.js
new file mode 100644
index 0000000..fea9722
--- /dev/null
+++ b/www/html/wordle/history.js
@@ -0,0 +1,111 @@
+import {
+ h, icon, miniBoard, emptyState, skeleton, mountShell, profileHeader, normalizeUser, boardPage,
+ getHistory, today, formatDay, relativeDay, weekdayIndex, resultText,
+} from "./wordle.js?v=2";
+
+mountShell();
+const main = document.getElementById("main");
+const user = normalizeUser(new URLSearchParams(location.search).get("user"));
+const PAGE_DAYS = 35; // five weeks at a time
+
+if (!user) {
+ document.title = "Wordle history";
+ main.append(emptyState("Whose history?", "No player was given in the link.",
+ h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find a player")));
+} else {
+ document.title = `${user} · Wordle history`;
+ show(user);
+}
+
+async function show(user) {
+ const header = profileHeader(user, "history", { meta: "Loading games…" });
+ const summary = h("div", { class: "stat-grid" }, Array.from({ length: 4 }, () => skeleton("104px")));
+ const calendar = h("div", { class: "card" }, skeleton("150px"));
+ const games = h("div", { class: "game-grid" }, Array.from({ length: 6 }, () => skeleton("190px")));
+ const more = h("button", { class: "btn", type: "button", hidden: true }, icon("calendar"), `Load ${PAGE_DAYS} more days`);
+ main.append(header, h("div", { class: "stack", style: { "margin-top": "28px" } },
+ summary, calendar,
+ h("div", { class: "card-head", style: { "margin-bottom": "0" } }, h("h2", {}, "Games"), h("span", { class: "sub" }, "Newest first")),
+ games,
+ h("div", { style: { "text-align": "center" } }, more)));
+
+ const end = today();
+ let start = end - PAGE_DAYS + 1;
+ let boards = await getHistory(user, end, PAGE_DAYS);
+
+ const render = () => {
+ const wins = boards.filter(b => b.won);
+ const guesses = wins.reduce((sum, b) => sum + b.guesses, 0);
+ const span = end - start + 1;
+ header.setMeta(`${boards.length} games in the last ${span} days`);
+
+ summary.replaceChildren(
+ stat(boards.length, "Games"),
+ stat(wins.length, "Wins"),
+ stat(boards.length ? `${Math.round((wins.length * 100) / boards.length)}%` : "-", "Win rate"),
+ stat(wins.length ? (Math.floor((guesses * 100) / wins.length) / 100).toFixed(2) : "-", "Average guesses"));
+
+ calendar.replaceChildren(
+ h("div", { class: "card-head" },
+ h("h2", {}, "Calendar"),
+ h("span", { class: "sub" }, `${formatDay(start, "medium")} - ${formatDay(end, "medium")} (UTC)`)),
+ heatmap(user, boards, start, end));
+
+ games.replaceChildren(...(boards.length
+ ? boards.map(board => gameCard(user, board))
+ : [h("p", { style: { color: "var(--muted)", "grid-column": "1 / -1" } },
+ `No games in the last ${span} days. Try loading more.`)]));
+ more.hidden = false;
+ };
+ render();
+
+ more.addEventListener("click", async () => {
+ more.disabled = true;
+ more.replaceChildren("Loading…");
+ const older = await getHistory(user, start - 1, PAGE_DAYS);
+ boards = [...boards, ...older];
+ start -= PAGE_DAYS;
+ more.disabled = false;
+ more.replaceChildren(icon("calendar"), `Load ${PAGE_DAYS} more days`);
+ render();
+ });
+}
+
+function stat(value, label) {
+ return h("div", { class: "stat" }, h("div", { class: "value" }, String(value)), h("div", { class: "label" }, label));
+}
+
+// GitHub-style grid: a column per week, Monday at the top
+function heatmap(user, boards, start, end) {
+ const byDay = new Map(boards.map(board => [board.day, board]));
+ const cells = Array.from({ length: weekdayIndex(start) }, () => h("span", { class: "heat none" }));
+ for (let day = start; day <= end; day++) {
+ const board = byDay.get(day);
+ const cls = day === end ? "heat today" : "heat";
+ if (!board) {
+ cells.push(h("span", { class: cls, title: `${formatDay(day, "short")}: not played` }));
+ continue;
+ }
+ const level = !board.won ? "loss" : board.guesses <= 2 ? "1" : board.guesses <= 4 ? "2" : "3";
+ cells.push(h("a", {
+ class: cls,
+ href: boardPage(user, day),
+ "data-level": level,
+ title: `${formatDay(day, "short")}: ${resultText(board)}`,
+ "aria-label": `${formatDay(day, "short")}: ${resultText(board)}`,
+ }));
+ }
+ const key = (level, label) => [h("span", { class: "heat", "data-level": level }), label];
+ return h("div", {},
+ h("div", { class: "heatmap-wrap" }, h("div", { class: "heatmap" }, cells)),
+ h("div", { class: "legend" },
+ key("1", "1-2 guesses"), key("2", "3-4"), key("3", "5-6"), key("loss", "missed"),
+ [h("span", { class: "heat" }), "not played"]));
+}
+
+function gameCard(user, board) {
+ return h("a", { class: "game-card", href: boardPage(user, board.day) },
+ h("span", { class: "date" }, relativeDay(board.day)),
+ miniBoard(board, { large: true }),
+ h("span", { class: board.won ? "badge win" : "badge loss" }, board.won ? `Solved ${resultText(board)}` : "Missed X/6"));
+}
diff --git a/www/html/wordle/home.js b/www/html/wordle/home.js
new file mode 100644
index 0000000..5694251
--- /dev/null
+++ b/www/html/wordle/home.js
@@ -0,0 +1,269 @@
+import {
+ SSH_COMMAND, h, icon, tile, avatar, miniBoard, emptyState, skeleton, toast, copyText, countdown,
+ mountShell, normalizeUser, statsPage, boardPage, following, setFollowing, recentSearches,
+ addRecentSearch, getStats, getBoard, today, resultText, score, sleep, reducedMotion,
+} from "./wordle.js?v=2";
+
+mountShell({ active: "home" });
+const main = document.getElementById("main");
+
+/* ---------- hero ---------- */
+
+const heroTiles = [["W", "correct"], ["O", "present"], ["R", "absent"], ["D", "correct"], ["L", "present"], ["E", "correct"]]
+ .map(([letter, state], i) => tile(letter, state, { style: { "--i": i } }));
+
+const copyCommand = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("copy"), "Copy");
+copyCommand.addEventListener("click", async () => {
+ toast(await copyText(SSH_COMMAND) ? "Copied - paste it into a terminal" : "Couldn't copy, sorry");
+});
+
+const terminalBody = h("div", { class: "terminal-body", "aria-hidden": "true" });
+
+main.append(h("section", { class: "hero" },
+ h("div", {},
+ h("div", { class: "eyebrow" }, "The daily word game for your terminal"),
+ h("h1", { class: "hero-title", "aria-label": "Wordle" }, heroTiles),
+ h("p", { class: "lead" },
+ "Six guesses. One word. ", h("strong", {}, "Everyone gets the same word every day"),
+ " - played over SSH, with your stats and boards collected here."),
+ h("div", { class: "cmd" }, h("span", { class: "prompt" }, "$"), h("code", {}, SSH_COMMAND), copyCommand),
+ h("p", { class: "countdown" }, "New word in ", countdown(h("span", { class: "mono" })), " (midnight UTC)")),
+ h("div", { class: "terminal" },
+ h("div", { class: "terminal-bar" }, h("i"), h("i"), h("i"), h("span", {}, "wordle - ssh")),
+ terminalBody)));
+
+/* ---------- find a player & leaderboard ---------- */
+
+const findHint = h("p", { class: "form-hint" });
+const findInput = h("input", {
+ class: "field", id: "find-input", type: "text", placeholder: "Username, e.g. adam",
+ autocomplete: "off", autocapitalize: "none", spellcheck: "false", "aria-describedby": "find-hint",
+});
+findHint.id = "find-hint";
+
+const findForm = h("form", { class: "input-row", role: "search" },
+ h("label", { class: "sr-only", for: "find-input" }, "Username"),
+ findInput,
+ h("button", { class: "btn btn-primary", type: "submit" }, icon("search"), "View stats"));
+findForm.addEventListener("submit", e => {
+ e.preventDefault();
+ const user = normalizeUser(findInput.value);
+ if (!user) {
+ findHint.textContent = "Type a username to look up.";
+ findHint.classList.add("error");
+ return;
+ }
+ addRecentSearch(user);
+ location.href = statsPage(user);
+});
+
+const recent = recentSearches();
+const leaderboard = h("div", { class: "leaderboard", "aria-live": "polite" });
+
+main.append(h("section", { class: "grid-2", style: { "margin-top": "56px" } },
+ h("div", { class: "card", id: "find" },
+ h("div", { class: "card-head" }, h("h2", {}, "Find a player"), h("span", { class: "sub" }, "Stats, history and boards")),
+ findForm,
+ findHint,
+ recent.length ? h("div", { style: { "margin-top": "12px" } },
+ h("div", { class: "sub", style: { color: "var(--muted)", "font-size": "0.85rem", "margin-bottom": "8px" } }, "Recently viewed"),
+ h("div", { class: "chips" }, recent.map(user =>
+ h("a", { class: "chip", href: statsPage(user) }, avatar(user), user)))) : null),
+ h("div", { class: "card" },
+ h("div", { class: "card-head" }, h("h2", {}, "Your leaderboard"), h("span", { class: "sub" }, "Today's results for players you follow")),
+ leaderboard,
+ followForm())));
+
+function followForm() {
+ const input = h("input", {
+ class: "field", type: "text", placeholder: "Follow a player", autocomplete: "off",
+ autocapitalize: "none", spellcheck: "false", "aria-label": "Username to follow",
+ });
+ const hint = h("p", { class: "form-hint" });
+ const button = h("button", { class: "btn", type: "submit" }, icon("plus"), "Follow");
+ const form = h("form", { class: "input-row", style: { "margin-top": "16px" } }, input, button);
+ form.addEventListener("submit", async e => {
+ e.preventDefault();
+ const user = normalizeUser(input.value);
+ hint.classList.remove("error");
+ if (!user) return;
+ if (following().includes(user)) {
+ hint.textContent = `You already follow ${user}.`;
+ return;
+ }
+ button.disabled = true;
+ const stats = await getStats(user).catch(() => null);
+ button.disabled = false;
+ if (!stats) {
+ hint.textContent = `No stats for "${user}" yet - they need to finish a daily game first.`;
+ hint.classList.add("error");
+ return;
+ }
+ setFollowing(user, true);
+ input.value = "";
+ hint.textContent = "";
+ toast(`Following ${user}`);
+ renderLeaderboard();
+ });
+ return h("div", {}, form, hint);
+}
+
+async function renderLeaderboard() {
+ const users = following();
+ if (!users.length) {
+ leaderboard.replaceChildren(h("div", { class: "empty-state", style: { padding: "20px 8px" } },
+ h("p", { style: { margin: "0" } },
+ "Follow players to race them every day. Add them below, or use the ",
+ h("strong", {}, "Follow"), " button on their stats page.")));
+ return;
+ }
+
+ leaderboard.replaceChildren(...users.map(() => skeleton("64px")));
+ const day = today();
+ const players = await Promise.all(users.map(async user => {
+ const [stats, board] = await Promise.all([
+ getStats(user).catch(() => null),
+ getBoard(user, day).catch(() => null),
+ ]);
+ return { user, stats, board };
+ }));
+
+ // Fewest guesses today first, then losses, then not played yet
+ const rank = p => (p.board ? (p.board.won ? p.board.guesses : 7) : 8);
+ players.sort((a, b) => rank(a) - rank(b)
+ || (b.stats?.streak ?? 0) - (a.stats?.streak ?? 0)
+ || a.user.localeCompare(b.user));
+
+ leaderboard.replaceChildren(...players.map((p, i) => {
+ const remove = h("button", { class: "lb-remove", type: "button", "aria-label": `Unfollow ${p.user}`, title: "Unfollow" }, icon("close"));
+ remove.addEventListener("click", () => {
+ setFollowing(p.user, false);
+ toast(`Unfollowed ${p.user}`);
+ renderLeaderboard();
+ });
+
+ const meta = p.stats
+ ? [
+ h("span", { title: "Current streak" }, "🔥 ", String(p.stats.streak)),
+ h("span", { title: "Win rate" }, `${p.stats.winRate}% wins`),
+ p.stats.average != null ? h("span", { title: "Average guesses" }, `avg ${p.stats.average.toFixed(2)}`) : null,
+ ]
+ : [h("span", {}, "No stats yet")];
+
+ const todayResult = p.board
+ ? h("a", { class: "lb-today", href: boardPage(p.user, day), title: "Today's board" },
+ miniBoard(p.board),
+ h("span", { class: p.board.won ? "badge win" : "badge loss" }, resultText(p.board)))
+ : h("div", { class: "lb-today" }, h("span", { class: "badge" }, "Not played"));
+
+ return h("div", { class: "lb-row", style: { "--i": i } },
+ h("div", { class: "lb-rank" }, p.board ? String(i + 1) : "-"),
+ avatar(p.user),
+ h("div", { style: { "min-width": "0" } },
+ h("a", { class: "lb-name", href: statsPage(p.user) }, p.user),
+ h("div", { class: "lb-meta" }, meta)),
+ h("div", { class: "lb-right" }, todayResult, remove));
+ }));
+}
+
+/* ---------- how to play ---------- */
+
+main.append(h("section", { id: "how", style: { "margin-top": "56px" } },
+ h("div", { class: "card-head" },
+ h("h2", { style: { "font-size": "1.5rem" } }, "How it works"),
+ h("span", { class: "sub" }, "No app, no account, no browser needed to play")),
+ h("div", { class: "steps" },
+ h("div", { class: "step" },
+ h("h3", {}, "Connect"),
+ h("p", {}, "Run ", h("code", {}, SSH_COMMAND), " and pick a username. There's no password - your username is your identity, so choose something unique.")),
+ h("div", { class: "step" },
+ h("h3", {}, "Guess"),
+ h("p", {}, "Find the five letter word in six tries. Green is the right spot, yellow is in the word, grey is out. Hard mode makes you use what you've found.")),
+ h("div", { class: "step" },
+ h("h3", {}, "Compare"),
+ h("p", {}, "Your board and stats appear here straight away. Follow friends to see how everyone did today.")))));
+
+renderLeaderboard();
+
+/* ---------- terminal demo ---------- */
+
+// Made-up games whose answers are never daily answers, so nothing is spoiled
+const DEMOS = [
+ { user: "guest", answer: "BYTES", guesses: ["CRANE", "TYPES", "BYTES"] },
+ { user: "ada", answer: "PIPES", guesses: ["SLATE", "SNIPE", "PIPES"] },
+ { user: "linus", answer: "GREPS", guesses: ["AUDIO", "STARE", "CORES", "GREPS"] },
+];
+
+function line(...children) {
+ const el = h("div", { class: "line" }, children);
+ terminalBody.append(el);
+ return el;
+}
+
+async function type(el, text, speed = 55) {
+ const caret = h("span", { class: "caret" });
+ el.append(caret);
+ for (const ch of text) {
+ caret.before(ch);
+ await sleep(speed + Math.random() * 40);
+ }
+ caret.remove();
+}
+
+function demoBoard(demo, filled) {
+ const rows = [];
+ for (let r = 0; r < 6; r++) {
+ const guess = demo.guesses[r];
+ const states = guess && r < filled ? score(guess, demo.answer) : [];
+ rows.push(h("div", { class: "row" },
+ Array.from({ length: 5 }, (_, c) => tile(r < filled ? guess[c] : "", states[c] ?? "empty"))));
+ }
+ return h("div", { class: "term-board" }, rows);
+}
+
+async function playDemo(demo) {
+ terminalBody.replaceChildren();
+ await type(line(h("span", { class: "prompt" }, "$ ")), SSH_COMMAND);
+ await sleep(500);
+ await type(line("Username: "), demo.user, 90);
+ await sleep(300);
+ line("Do you want to enable hard mode [y/N]? n");
+ await sleep(400);
+
+ const board = demoBoard(demo, 0);
+ terminalBody.append(board);
+ const prompt = line("Enter Guess: ");
+
+ for (const [r, guess] of demo.guesses.entries()) {
+ const tiles = board.children[r].children;
+ for (let c = 0; c < 5; c++) {
+ await sleep(140 + Math.random() * 80);
+ tiles[c].querySelector(".letter").textContent = guess[c];
+ tiles[c].classList.add("pop");
+ }
+ await sleep(350);
+ score(guess, demo.answer).forEach((state, c) => {
+ tiles[c].dataset.state = state;
+ tiles[c].style.setProperty("--c-i", c);
+ tiles[c].classList.add("flip");
+ });
+ await sleep(1100);
+ }
+ prompt.replaceChildren("YOU WIN");
+ line(h("span", { class: "dim" }, `Guess - ${demo.guesses.length}/6`));
+ await sleep(3200);
+}
+
+async function runDemos() {
+ if (reducedMotion()) {
+ const demo = DEMOS[0];
+ line(h("span", { class: "prompt" }, "$ "), SSH_COMMAND);
+ line(`Username: ${demo.user}`);
+ terminalBody.append(demoBoard(demo, demo.guesses.length));
+ line("YOU WIN");
+ return;
+ }
+ for (let i = 0; ; i = (i + 1) % DEMOS.length) await playDemo(DEMOS[i]);
+}
+
+runDemos();
diff --git a/www/html/wordle/index.html b/www/html/wordle/index.html
index 1c0ecc8..88b157e 100644
--- a/www/html/wordle/index.html
+++ b/www/html/wordle/index.html
@@ -1,121 +1,32 @@
-
+
-Wordle Viewer
-
-
+
+Wordle - the daily word game for your terminal
+
+
+
+
+
+
+
-
-
-
Wordle Viewer
-
Enter a username to view their stats and games.
-
-
-
-
- You’ll be taken to stats.html?user=<name>.
-
-
-
-
-
+
+
+
-
diff --git a/www/html/wordle/stats.html b/www/html/wordle/stats.html
index 4c18948..45607a9 100644
--- a/www/html/wordle/stats.html
+++ b/www/html/wordle/stats.html
@@ -1,177 +1,32 @@
-
+
-Wordle Stats
-
-
-
-
-
-
-
-
-
Wordle Stats for ...
-
Daily performance summary
-
-
-
-
-
-
-
+
+Wordle stats
+
+
+
+
+
-
+
+
+
+
+
+
-
diff --git a/www/html/wordle/stats.js b/www/html/wordle/stats.js
new file mode 100644
index 0000000..d5ac2d5
--- /dev/null
+++ b/www/html/wordle/stats.js
@@ -0,0 +1,138 @@
+import {
+ h, icon, miniBoard, emptyState, skeleton, toast, copyText, countUp, countdown, mountShell,
+ profileHeader, normalizeUser, statsPage, boardPage, getStats, getHistory, today, formatDay,
+ relativeDay, resultText, describeBoard,
+} from "./wordle.js?v=2";
+
+mountShell();
+const main = document.getElementById("main");
+const user = normalizeUser(new URLSearchParams(location.search).get("user"));
+const FORM_DAYS = 14;
+
+if (!user) {
+ document.title = "Wordle stats";
+ main.append(emptyState("Whose stats?", "No player was given in the link.",
+ h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find a player")));
+} else {
+ document.title = `${user} · Wordle stats`;
+ show(user);
+}
+
+async function show(user) {
+ const copyLink = h("button", { class: "btn btn-sm", type: "button" }, icon("share"), "Copy link");
+ copyLink.addEventListener("click", async () => {
+ toast(await copyText(location.origin + statsPage(user)) ? "Link copied" : "Couldn't copy, sorry");
+ });
+ const header = profileHeader(user, "stats", { meta: "Loading stats…", actions: [copyLink] });
+ const body = h("div", { class: "stack", style: { "margin-top": "28px" } },
+ h("div", { class: "stat-grid" }, Array.from({ length: 5 }, () => skeleton("104px"))),
+ h("div", { class: "grid-2" }, skeleton("320px"), skeleton("320px")));
+ main.append(header, body);
+
+ const day = today();
+ let stats, recent;
+ try {
+ [stats, recent] = await Promise.all([getStats(user), getHistory(user, day, FORM_DAYS)]);
+ } catch {
+ header.setMeta("");
+ body.replaceChildren(emptyState("Couldn't load stats", "Check your connection and try again.",
+ h("button", { class: "btn btn-primary", type: "button", onclick: () => location.reload() }, "Retry")));
+ return;
+ }
+
+ if (!stats) {
+ header.setMeta("");
+ body.replaceChildren(emptyState(`No stats for ${user} yet`,
+ "Stats appear once a player finishes their first daily game. Check the spelling, or go and play!",
+ h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find another player"),
+ h("a", { class: "btn", href: "/wordle/#how" }, "How to play")));
+ return;
+ }
+
+ header.setMeta(stats.lastWin
+ ? `${stats.played} games · last win in ${stats.lastWin}/6`
+ : `${stats.played} games played`);
+
+ const todayBoard = recent.find(board => board.day === day);
+ body.replaceChildren(
+ statCards(stats),
+ h("div", { class: "grid-2" },
+ distribution(stats),
+ h("div", { class: "stack" }, todayCard(user, todayBoard, day), formCard(user, recent, day))));
+}
+
+function statCards(stats) {
+ const card = (value, label, { decimals = 0, suffix = "", fx = "", hot = false } = {}) => {
+ const number = h("span", {}, "0");
+ countUp(number, value, decimals);
+ return h("div", { class: hot ? "stat hot" : "stat" },
+ fx ? h("span", { class: "fx", "aria-hidden": "true" }, fx) : null,
+ h("div", { class: "value" }, number, suffix),
+ h("div", { class: "label" }, label));
+ };
+ return h("div", { class: "stat-grid" },
+ card(stats.played, "Played"),
+ card(stats.winRate, "Win rate", { suffix: "%" }),
+ card(stats.streak, "Current streak", { fx: stats.streak >= 3 ? "🔥" : "", hot: stats.streak >= 3 }),
+ card(stats.maxStreak, "Best streak", { fx: stats.maxStreak > 0 && stats.maxStreak === stats.streak ? "🏆" : "" }),
+ stats.average == null
+ ? h("div", { class: "stat" }, h("div", { class: "value" }, "-"), h("div", { class: "label" }, "Average guesses"))
+ : card(stats.average, "Average guesses", { decimals: 2 }));
+}
+
+function distribution(stats) {
+ const rows = [...stats.hist.map((count, i) => ({ label: String(i + 1), count, last: stats.lastWin === i + 1 })),
+ { label: "X", count: stats.losses, miss: true }];
+ const max = Math.max(1, ...rows.map(r => r.count));
+ return h("div", { class: "card" },
+ h("div", { class: "card-head" },
+ h("h2", {}, "Guess distribution"),
+ h("span", { class: "sub" }, stats.lastWin ? "Green is the last win" : "")),
+ h("div", { class: "dist" }, rows.map((row, i) =>
+ h("div", {
+ class: `dist-row${row.last ? " last" : ""}${row.miss ? " miss" : ""}`,
+ title: row.miss ? `${row.count} lost` : `${row.count} won in ${row.label}`,
+ },
+ h("span", { class: "n" }, row.label),
+ h("div", { class: "dist-bar", style: { "--w": `${(row.count / max) * 100}%`, "--i": i } }, String(row.count))))));
+}
+
+function todayCard(user, board, day) {
+ const content = board
+ ? [
+ h("a", { href: boardPage(user, day), title: "Open today's board" }, miniBoard(board, { large: true })),
+ h("div", { class: "text" },
+ h("div", { class: "big" }, board.won ? `Solved ${resultText(board)}` : "Missed it"),
+ h("p", { style: { margin: "6px 0 14px", color: "var(--muted)" } }, describeBoard(board)),
+ h("a", { class: "btn btn-sm", href: boardPage(user, day) }, icon("grid"), "View board"))]
+ : [
+ miniBoard(null, { large: true }),
+ h("div", { class: "text" },
+ h("div", { class: "big" }, "Not played yet"),
+ h("p", { style: { margin: "6px 0 0", color: "var(--muted)" } },
+ countdown(h("span", { class: "mono", style: { color: "var(--text)", "font-weight": "700" } })),
+ " left to play today"))];
+ return h("div", { class: "card" },
+ h("div", { class: "card-head" }, h("h2", {}, "Today"), h("span", { class: "sub" }, formatDay(day))),
+ h("div", { class: "today-card" }, content));
+}
+
+function formCard(user, recent, day) {
+ const byDay = new Map(recent.map(board => [board.day, board]));
+ const days = Array.from({ length: FORM_DAYS }, (_, i) => day - FORM_DAYS + 1 + i);
+ return h("div", { class: "card" },
+ h("div", { class: "card-head" },
+ h("h2", {}, "Recent form"),
+ h("span", { class: "sub" }, `${recent.length} of the last ${FORM_DAYS} days`)),
+ h("div", { class: "form-strip" }, days.map(d => {
+ const board = byDay.get(d);
+ const cls = d === day ? "form-day today" : "form-day";
+ if (!board) return h("span", { class: cls, title: `${relativeDay(d)}: not played` }, "·");
+ return h("a", {
+ class: cls,
+ href: boardPage(user, d),
+ "data-result": board.won ? "win" : "loss",
+ title: `${relativeDay(d)}: ${resultText(board)}`,
+ }, board.won ? String(board.guesses) : "✗");
+ })));
+}
diff --git a/www/html/wordle/wordle.css b/www/html/wordle/wordle.css
new file mode 100644
index 0000000..19da514
--- /dev/null
+++ b/www/html/wordle/wordle.css
@@ -0,0 +1,679 @@
+/* Shared styles for the Wordle pages. After changing this file bump the
+ ?v= on its in every page so browsers fetch the new version. */
+
+:root {
+ color-scheme: dark;
+ --bg: #070a08;
+ --surface: #0f1411;
+ --surface-2: #151c18;
+ --surface-3: #1b241f;
+ --border: #22302a;
+ --border-strong: #31463b;
+ --text: #e8f3ec;
+ --muted: #93a59a;
+ --faint: #5f7166;
+ --accent: #51ff7a;
+ --accent-strong: #3ee067;
+ --accent-ink: #03140a;
+ --glow: rgba(81, 255, 122, 0.3);
+ --danger: #ff6b6b;
+
+ --correct: #26b24b;
+ --present: #d0ad2c;
+ --absent: #3b433f;
+ --empty: #111814;
+ --tile-border: #26332c;
+ --tile-text: #ffffff;
+
+ --radius: 16px;
+ --radius-sm: 10px;
+ --shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
+ --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
+}
+
+:root[data-theme="light"] {
+ color-scheme: light;
+ --bg: #f1f5f2;
+ --surface: #ffffff;
+ --surface-2: #f5f8f6;
+ --surface-3: #e9efeb;
+ --border: #dbe4de;
+ --border-strong: #c2d0c7;
+ --text: #0e1a13;
+ --muted: #536559;
+ --faint: #879a8e;
+ --accent: #0f9d3a;
+ --accent-strong: #0b8430;
+ --accent-ink: #ffffff;
+ --glow: rgba(15, 157, 58, 0.22);
+ --danger: #d63939;
+
+ --correct: #5fa85a;
+ --present: #c9b458;
+ --absent: #7b8083;
+ --empty: #ffffff;
+ --tile-border: #d3d8d5;
+ --shadow: 0 18px 40px rgba(20, 40, 28, 0.12);
+}
+
+/* Colour-blind friendly tiles, like the real Wordle's high contrast mode */
+:root[data-contrast="high"] {
+ --correct: #f5793a;
+ --present: #85c0f9;
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg);
+ color: var(--text);
+ font: 16px/1.5 var(--font);
+ -webkit-font-smoothing: antialiased;
+ overflow-x: hidden;
+}
+
+/* Soft glow and faint grid behind everything */
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ background:
+ radial-gradient(60rem 30rem at 15% -10%, var(--glow), transparent 60%),
+ radial-gradient(40rem 25rem at 110% 10%, rgba(208, 173, 44, 0.08), transparent 60%),
+ linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 44px 44px,
+ linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 44px 44px;
+ mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.35) 45%, transparent 85%);
+ opacity: 0.55;
+}
+
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+h1, h2, h3 { line-height: 1.15; margin: 0; }
+code, kbd, .mono { font-family: var(--mono); }
+button { font: inherit; color: inherit; }
+:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }
+.sr-only {
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
+}
+
+.container { width: 100%; max-width: 1080px; margin: 0 auto; padding: 0 16px; }
+main { flex: 1; }
+main.container { padding-top: 32px; padding-bottom: 64px; }
+.stack > * + * { margin-top: 24px; }
+
+/* ---------- header / footer ---------- */
+
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ background: color-mix(in srgb, var(--bg) 78%, transparent);
+ border-bottom: 1px solid var(--border);
+}
+.site-header .container { display: flex; align-items: center; gap: 16px; height: 64px; }
+.site-nav { display: flex; align-items: center; gap: 4px; margin-left: auto; min-width: 0; }
+.site-nav a {
+ color: var(--muted);
+ padding: 8px 12px;
+ border-radius: 999px;
+ font-size: 0.92rem;
+ white-space: nowrap;
+}
+.site-nav a:hover { color: var(--text); background: var(--surface-2); text-decoration: none; }
+.site-nav a[aria-current="page"] { color: var(--text); background: var(--surface-3); }
+
+.logo { display: flex; gap: 4px; align-items: center; color: inherit; }
+.logo:hover { text-decoration: none; }
+.logo .tile {
+ width: 28px;
+ font-size: 0.95rem;
+ border-radius: 6px;
+ border-width: 0;
+}
+.logo:hover .tile { animation: hop 0.5s ease calc(var(--i) * 60ms); }
+@keyframes hop { 40% { transform: translateY(-6px) rotate(-4deg); } }
+
+.icon-btn {
+ display: inline-grid;
+ place-items: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ cursor: pointer;
+ flex: none;
+}
+.icon-btn:hover { background: var(--surface-3); }
+.icon { width: 20px; height: 20px; flex: none; }
+
+.site-footer {
+ border-top: 1px solid var(--border);
+ color: var(--muted);
+ font-size: 0.9rem;
+ padding: 24px 0;
+}
+.site-footer .container { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; justify-content: space-between; }
+
+/* ---------- buttons, inputs, chips ---------- */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 44px;
+ padding: 0 18px;
+ border-radius: 12px;
+ border: 1px solid var(--border-strong);
+ background: var(--surface-2);
+ color: var(--text);
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+ user-select: none;
+ transition: transform 0.12s ease, background 0.2s ease, box-shadow 0.2s ease;
+}
+.btn:hover { background: var(--surface-3); text-decoration: none; }
+.btn:active { transform: translateY(1px); }
+.btn-primary {
+ background: var(--accent);
+ border-color: transparent;
+ color: var(--accent-ink);
+ box-shadow: 0 0 0 0 var(--glow);
+}
+.btn-primary:hover { background: var(--accent-strong); box-shadow: 0 0 24px var(--glow); }
+.btn[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
+.btn-sm { min-height: 36px; padding: 0 12px; font-size: 0.88rem; border-radius: 10px; }
+.btn:disabled { opacity: 0.5; cursor: default; }
+
+.field {
+ flex: 1;
+ min-width: 0;
+ min-height: 48px;
+ padding: 0 16px;
+ border-radius: 12px;
+ border: 1px solid var(--border-strong);
+ background: var(--surface-2);
+ color: var(--text);
+ font: inherit;
+}
+.field::placeholder { color: var(--faint); }
+.field:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--glow); }
+
+.input-row { display: flex; gap: 8px; }
+.form-hint { min-height: 1.4em; margin: 8px 0 0; font-size: 0.88rem; color: var(--muted); }
+.form-hint.error { color: var(--danger); }
+
+.chips { display: flex; flex-wrap: wrap; gap: 8px; }
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 12px 4px 4px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--surface-2);
+ color: var(--text);
+ font-size: 0.9rem;
+}
+.chip:hover { border-color: var(--border-strong); text-decoration: none; }
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-size: 0.82rem;
+ font-weight: 700;
+ background: var(--surface-3);
+ color: var(--muted);
+ white-space: nowrap;
+}
+.badge.win { background: color-mix(in srgb, var(--correct) 22%, transparent); color: var(--text); }
+.badge.loss { background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--text); }
+
+/* ---------- cards & sections ---------- */
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 24px;
+ box-shadow: var(--shadow);
+}
+.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
+.card-head h2 { font-size: 1.15rem; }
+.card-head .sub { color: var(--muted); font-size: 0.9rem; }
+.eyebrow {
+ color: var(--accent);
+ font: 700 0.78rem/1 var(--mono);
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.grid-2 { display: grid; gap: 24px; grid-template-columns: 1fr; }
+@media (min-width: 900px) { .grid-2 { grid-template-columns: 1.1fr 0.9fr; align-items: start; } }
+
+.empty-state { text-align: center; padding: 40px 16px; color: var(--muted); }
+.empty-state h2 { color: var(--text); font-size: 1.4rem; margin-bottom: 8px; }
+.empty-state .actions { margin-top: 20px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
+
+/* ---------- tiles & boards ---------- */
+
+.tile {
+ --c: var(--empty);
+ display: grid;
+ place-items: center;
+ aspect-ratio: 1;
+ border-radius: 8px;
+ border: 2px solid var(--tile-border);
+ background: var(--empty);
+ color: var(--tile-text);
+ font-weight: 800;
+ text-transform: uppercase;
+ line-height: 1;
+ user-select: none;
+}
+.tile[data-state="correct"] { --c: var(--correct); background: var(--c); border-color: transparent; }
+.tile[data-state="present"] { --c: var(--present); background: var(--c); border-color: transparent; }
+.tile[data-state="absent"] { --c: var(--absent); background: var(--c); border-color: transparent; }
+:root[data-theme="light"] .tile[data-state="empty"] { color: var(--text); }
+
+.board {
+ display: grid;
+ gap: 7px;
+ width: min(100%, 340px);
+ margin: 0 auto;
+ perspective: 900px;
+}
+.board .row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; }
+.board .tile { font-size: clamp(1.4rem, 7vw, 2rem); }
+.tile .letter { transition: opacity 0.25s ease, filter 0.25s ease; }
+[data-letters="hidden"] .letter { opacity: 0; filter: blur(8px); }
+
+/* Tiles flip over one at a time to show their colour, like the game */
+.board.reveal .tile[data-state]:not([data-state="empty"]) {
+ animation: flip 0.6s ease both;
+ animation-delay: calc(var(--r) * 420ms + var(--c-i) * 110ms);
+}
+@keyframes flip {
+ 0% { transform: rotateX(0); background: var(--empty); border-color: var(--tile-border); }
+ 49.9% { transform: rotateX(-90deg); background: var(--empty); border-color: var(--tile-border); }
+ 50% { transform: rotateX(-90deg); background: var(--c); border-color: transparent; }
+ 100% { transform: rotateX(0); background: var(--c); border-color: transparent; }
+}
+.board.reveal .row.win-row .tile {
+ animation: flip 0.6s ease both, bounce 0.6s ease both;
+ animation-delay: calc(var(--r) * 420ms + var(--c-i) * 110ms), calc(var(--r) * 420ms + 900ms + var(--c-i) * 90ms);
+}
+@keyframes bounce {
+ 0%, 100% { transform: translateY(0); }
+ 40% { transform: translateY(-22px); }
+ 70% { transform: translateY(4px); }
+}
+
+/* Spoiler-free coloured squares */
+.mini {
+ --s: 10px;
+ display: grid;
+ grid-template-columns: repeat(5, var(--s));
+ gap: 2px;
+ flex: none;
+}
+.mini span { width: var(--s); height: var(--s); border-radius: 2px; background: var(--surface-3); }
+.mini span[data-state="correct"] { background: var(--correct); }
+.mini span[data-state="present"] { background: var(--present); }
+.mini span[data-state="absent"] { background: var(--absent); }
+.mini-lg { --s: 16px; gap: 3px; }
+.mini-lg span { border-radius: 3px; }
+
+/* ---------- avatar ---------- */
+
+.avatar {
+ --size: 44px;
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ gap: 2px;
+ width: var(--size);
+ height: var(--size);
+ padding: calc(var(--size) / 9);
+ border-radius: calc(var(--size) / 4);
+ background: var(--surface-3);
+ border: 1px solid var(--border);
+ flex: none;
+}
+.avatar span { border-radius: 2px; }
+.avatar span[data-state="correct"] { background: var(--correct); }
+.avatar span[data-state="present"] { background: var(--present); }
+.avatar-lg { --size: 84px; gap: 3px; }
+.chip .avatar { --size: 26px; gap: 1px; padding: 3px; border-radius: 7px; }
+.lb-row .avatar { --size: 36px; }
+.avatar-lg span { border-radius: 3px; }
+
+/* ---------- profile header (stats, history, board pages) ---------- */
+
+.profile { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
+.profile h1 { font-size: clamp(1.8rem, 5vw, 2.6rem); letter-spacing: -0.02em; word-break: break-word; }
+.profile .meta { color: var(--muted); margin-top: 4px; }
+.profile .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-left: auto; }
+
+.tabs { display: flex; gap: 4px; padding: 4px; border-radius: 14px; background: var(--surface); border: 1px solid var(--border); width: max-content; max-width: 100%; overflow-x: auto; }
+.tabs a { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 10px; color: var(--muted); font-weight: 600; white-space: nowrap; }
+@media (max-width: 480px) {
+ .tabs { width: 100%; }
+ .tabs a { flex: 1; justify-content: center; padding: 8px 6px; font-size: 0.88rem; }
+}
+.terminal-body .line { white-space: pre-wrap; }
+.tabs a:hover { color: var(--text); text-decoration: none; }
+.tabs a[aria-current="page"] { background: var(--surface-3); color: var(--text); }
+
+/* ---------- stats ---------- */
+
+.stat-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
+.stat {
+ position: relative;
+ overflow: hidden;
+ padding: 18px;
+ border-radius: var(--radius);
+ background: var(--surface);
+ border: 1px solid var(--border);
+}
+.stat .value { font: 800 2.2rem/1 var(--mono); letter-spacing: -0.03em; }
+.stat .label { margin-top: 8px; color: var(--muted); font-size: 0.88rem; }
+.stat .fx { position: absolute; right: 12px; top: 12px; font-size: 1.3rem; opacity: 0.9; }
+.stat.hot { border-color: color-mix(in srgb, var(--present) 55%, var(--border)); }
+.stat.hot .value { color: var(--present); }
+
+.dist { display: grid; gap: 8px; }
+.dist-row { display: grid; grid-template-columns: 1.6rem 1fr; align-items: center; gap: 10px; font-family: var(--mono); }
+.dist-row .n { color: var(--muted); text-align: right; font-weight: 700; }
+.dist-bar {
+ --w: 0%;
+ width: max(var(--w), 2.2rem);
+ min-height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 0 10px;
+ border-radius: 8px;
+ background: var(--absent);
+ color: #fff;
+ font-weight: 800;
+ transform-origin: left;
+ animation: grow 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both;
+ animation-delay: calc(var(--i) * 70ms);
+}
+.dist-row.last .dist-bar { background: var(--correct); box-shadow: 0 0 18px color-mix(in srgb, var(--correct) 45%, transparent); }
+.dist-row.miss .dist-bar { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); }
+@keyframes grow { from { transform: scaleX(0); } }
+
+.form-strip { display: flex; gap: 6px; flex-wrap: wrap; }
+.form-day {
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 8px;
+ font: 800 0.9rem/1 var(--mono);
+ background: var(--surface-2);
+ border: 1px dashed var(--border-strong);
+ color: var(--faint);
+}
+.form-day[data-result] { border: 0; color: #fff; }
+.form-day[data-result="win"] { background: var(--correct); }
+.form-day[data-result="loss"] { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); }
+a.form-day:hover { text-decoration: none; transform: translateY(-2px); }
+.form-day.today { outline: 2px solid var(--accent); outline-offset: 2px; }
+
+.today-card { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; }
+.today-card .text { flex: 1; min-width: 180px; }
+.today-card .big { font: 800 1.6rem/1.1 var(--mono); }
+
+/* ---------- history ---------- */
+
+.heatmap-wrap { overflow-x: auto; padding-bottom: 4px; }
+.heatmap { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 16px); grid-auto-columns: 16px; gap: 4px; width: max-content; }
+.heat {
+ border-radius: 4px;
+ background: var(--surface-3);
+ border: 1px solid transparent;
+}
+.heat.none { background: transparent; }
+.heat[data-level="1"] { background: color-mix(in srgb, var(--correct) 100%, transparent); }
+.heat[data-level="2"] { background: color-mix(in srgb, var(--correct) 75%, var(--surface-3)); }
+.heat[data-level="3"] { background: color-mix(in srgb, var(--correct) 50%, var(--surface-3)); }
+.heat[data-level="loss"] { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); }
+.heat.today { border-color: var(--accent); }
+a.heat:hover { outline: 2px solid var(--text); outline-offset: 1px; }
+.legend { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.8rem; margin-top: 10px; flex-wrap: wrap; }
+.legend .heat { width: 12px; height: 12px; display: inline-block; }
+
+.game-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
+.game-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 16px;
+ border-radius: var(--radius);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ color: var(--text);
+ transition: transform 0.15s ease, border-color 0.15s ease;
+}
+.game-card:hover { transform: translateY(-3px); border-color: var(--border-strong); text-decoration: none; }
+.game-card .date { font-size: 0.85rem; color: var(--muted); }
+
+/* ---------- board page ---------- */
+
+.board-stage { display: grid; gap: 22px; justify-items: center; text-align: center; }
+.board-stage .headline { font: 800 1.1rem/1.2 var(--mono); }
+.answer-row { display: flex; gap: 6px; justify-content: center; align-items: center; color: var(--muted); }
+.answer-row .tile { width: 38px; font-size: 1.1rem; }
+.hold-btn { touch-action: none; min-width: 230px; }
+.hold-btn .icon { transition: transform 0.2s ease; }
+.hold-btn[aria-pressed="true"] .icon { transform: scale(1.2); }
+.nav-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
+.kbd-hint { color: var(--faint); font-size: 0.82rem; }
+.kbd-hint kbd { padding: 1px 6px; border-radius: 5px; border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--muted); }
+
+/* ---------- home page ---------- */
+
+.hero { display: grid; gap: 40px; align-items: center; padding: 24px 0 8px; }
+@media (min-width: 900px) { .hero { grid-template-columns: 1.05fr 0.95fr; padding-top: 48px; } }
+.hero-title { display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 20px; }
+.hero-title .tile {
+ width: clamp(44px, 12vw, 72px);
+ font-size: clamp(1.6rem, 6vw, 2.6rem);
+ animation: drop 0.7s cubic-bezier(0.2, 0.9, 0.3, 1.3) both;
+ animation-delay: calc(var(--i) * 90ms);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
+}
+@keyframes drop { from { transform: translateY(-40px) rotate(-12deg); opacity: 0; } }
+.hero p.lead { font-size: 1.15rem; color: var(--muted); max-width: 34rem; margin: 0 0 24px; }
+.hero p.lead strong { color: var(--text); }
+
+.cmd {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ max-width: 30rem;
+ padding: 8px 8px 8px 18px;
+ border-radius: 14px;
+ background: #050806;
+ border: 1px solid var(--border-strong);
+ font-family: var(--mono);
+ color: #d7ffe2;
+ box-shadow: 0 0 0 1px rgba(81, 255, 122, 0.08), 0 12px 30px rgba(0, 0, 0, 0.35);
+}
+.cmd .prompt { color: #51ff7a; }
+.cmd code { flex: 1; overflow-x: auto; white-space: nowrap; }
+.countdown { margin-top: 18px; color: var(--muted); font-size: 0.95rem; }
+.countdown .mono { color: var(--text); font-weight: 700; font-size: 1.05rem; }
+
+.terminal {
+ border-radius: 16px;
+ overflow: hidden;
+ background: #050806;
+ border: 1px solid #1f2d25;
+ box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5), 0 0 60px rgba(81, 255, 122, 0.12);
+ font-family: var(--mono);
+ color: #cfe9d6;
+}
+.terminal-bar { display: flex; gap: 8px; align-items: center; padding: 12px 14px; background: #0c120e; border-bottom: 1px solid #1a261f; }
+.terminal-bar i { width: 12px; height: 12px; border-radius: 50%; background: #ff5f57; }
+.terminal-bar i:nth-child(2) { background: #febc2e; }
+.terminal-bar i:nth-child(3) { background: #28c840; }
+.terminal-bar span { margin-left: 8px; font-size: 0.8rem; color: #6c8a76; }
+.terminal-body { padding: 18px 20px 22px; min-height: 380px; font-size: 0.92rem; }
+.terminal-body .line { min-height: 1.5em; white-space: pre; overflow: hidden; }
+.terminal-body .prompt { color: #51ff7a; }
+.terminal-body .dim { color: #5f7a68; }
+.caret { display: inline-block; width: 0.6em; height: 1.1em; vertical-align: text-bottom; background: #51ff7a; animation: blink 1s steps(1) infinite; }
+@keyframes blink { 50% { opacity: 0; } }
+.term-board { display: grid; gap: 5px; width: 190px; margin: 12px 0 10px 2px; }
+.term-board .row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; }
+.term-board .tile { font-size: 1rem; border-radius: 3px; border-width: 1px; }
+.term-board .tile.pop { animation: pop 0.12s ease; }
+@keyframes pop { 50% { transform: scale(1.12); } }
+.term-board .tile.flip { animation: flip 0.5s ease both; animation-delay: calc(var(--c-i) * 120ms); }
+.terminal .tile { --empty: #0b120e; --tile-border: #24342b; }
+
+.leaderboard { display: grid; gap: 8px; }
+.lb-row {
+ display: grid;
+ grid-template-columns: 2rem auto 1fr auto;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 14px;
+ border-radius: 14px;
+ background: var(--surface-2);
+ border: 1px solid var(--border);
+ animation: rise 0.45s ease both;
+ animation-delay: calc(var(--i) * 60ms);
+}
+@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
+.lb-rank { font: 800 1.1rem/1 var(--mono); color: var(--faint); text-align: center; }
+.lb-row:nth-child(1) .lb-rank { color: #f5c542; }
+.lb-row:nth-child(2) .lb-rank { color: #c9d3cf; }
+.lb-row:nth-child(3) .lb-rank { color: #d9955b; }
+.lb-name { font-weight: 700; color: var(--text); word-break: break-word; }
+.lb-name:hover { text-decoration: underline; }
+.lb-meta { color: var(--muted); font-size: 0.85rem; display: flex; gap: 12px; flex-wrap: wrap; }
+.lb-today { display: flex; align-items: center; gap: 12px; justify-content: flex-end; }
+.lb-remove {
+ width: 30px; height: 30px; border-radius: 8px; border: 0; background: transparent; color: var(--faint); cursor: pointer;
+}
+.lb-remove:hover { background: var(--surface-3); color: var(--text); }
+.lb-right { display: flex; align-items: center; gap: 4px; justify-content: flex-end; }
+@media (max-width: 560px) {
+ .lb-row { grid-template-columns: 1.6rem auto 1fr; }
+ .lb-right { grid-column: 2 / -1; justify-content: space-between; }
+}
+
+.steps { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); counter-reset: step; }
+.step { padding: 20px; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); }
+.step::before {
+ counter-increment: step;
+ content: counter(step);
+ display: grid;
+ place-items: center;
+ width: 34px;
+ height: 34px;
+ margin-bottom: 12px;
+ border-radius: 9px;
+ background: var(--correct);
+ color: #fff;
+ font: 800 1rem/1 var(--mono);
+}
+.step:nth-child(2)::before { background: var(--present); }
+.step:nth-child(3)::before { background: var(--absent); }
+.step h3 { font-size: 1.05rem; margin-bottom: 6px; }
+.step p { margin: 0; color: var(--muted); font-size: 0.95rem; }
+
+/* ---------- skeleton loading ---------- */
+
+.skeleton {
+ border-radius: 10px;
+ background: linear-gradient(90deg, var(--surface-2), var(--surface-3), var(--surface-2)) 0 0 / 300% 100%;
+ animation: shimmer 1.4s ease infinite;
+}
+@keyframes shimmer { to { background-position: -150% 0; } }
+
+/* ---------- toast & dialog ---------- */
+
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: 28px;
+ z-index: 50;
+ transform: translate(-50%, 20px);
+ opacity: 0;
+ padding: 12px 18px;
+ border-radius: 12px;
+ background: var(--text);
+ color: var(--bg);
+ font-weight: 700;
+ box-shadow: var(--shadow);
+ transition: opacity 0.2s ease, transform 0.2s ease;
+ pointer-events: none;
+}
+.toast.show { opacity: 1; transform: translate(-50%, 0); }
+
+dialog {
+ width: min(92vw, 420px);
+ padding: 0;
+ border: 1px solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--surface);
+ color: var(--text);
+ box-shadow: var(--shadow);
+}
+dialog::backdrop { background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(3px); }
+.dialog-body { padding: 24px; display: grid; gap: 20px; }
+.dialog-body h2 { font-size: 1.2rem; }
+.setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
+.setting .desc { color: var(--muted); font-size: 0.85rem; }
+.segmented { display: inline-flex; padding: 3px; border-radius: 10px; background: var(--surface-3); }
+.segmented button { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); font-weight: 600; font-size: 0.88rem; }
+.segmented button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); }
+.switch { position: relative; width: 48px; height: 28px; border-radius: 999px; border: 0; background: var(--surface-3); cursor: pointer; flex: none; }
+.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: var(--muted); transition: transform 0.2s ease, background 0.2s ease; }
+.switch[aria-checked="true"] { background: var(--correct); }
+.switch[aria-checked="true"]::after { transform: translateX(20px); background: #fff; }
+.swatches { display: flex; gap: 4px; }
+.swatches span { width: 18px; height: 18px; border-radius: 4px; }
+
+#confetti { position: fixed; inset: 0; pointer-events: none; z-index: 40; }
+
+@media (max-width: 640px) {
+ main { padding-top: 20px; }
+ .card { padding: 18px; }
+ .site-nav a { padding: 8px 9px; font-size: 0.86rem; }
+ .site-nav .wide { display: none; }
+ .logo .tile { width: 22px; font-size: 0.8rem; }
+ .profile .actions { margin-left: 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ animation-delay: 0ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/www/html/wordle/wordle.js b/www/html/wordle/wordle.js
new file mode 100644
index 0000000..23d4524
--- /dev/null
+++ b/www/html/wordle/wordle.js
@@ -0,0 +1,608 @@
+// Shared code for the Wordle pages: reading the game's board and stats
+// files, and the bits of page every page uses.
+//
+// After changing this file bump the ?v= on the imports in the page scripts
+// (and on their