revamp the website

Rebuild the browser pages around shared wordle.css / wordle.js, still
served by the unchanged nginx config (same URLs and ?user= / ?board=
parameters, data from /wordle/rawstats and /wordle/rawboard):

- home: animated terminal demo, copyable ssh command, countdown to the
  next UTC day, player search with recent searches, and a leaderboard
  of followed players (kept in the browser) with today's results
- stats: stat cards, guess distribution with losses, today's result and
  the last 14 days
- history: calendar heatmap and a card per game, loading more on demand
- board: flip-in tiles, hold (or R) to reveal letters, the answer on a
  loss, copyable spoiler-free emoji result, previous/next game
- dark/light/system themes and a high contrast mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-09-29 18:54:29 +00:00
1 parent 5bafab3e10
commit fc3c74822f
11 files changed
+2055 -894

No files matched your search

+150
View File
@@ -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"));
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#26b24b"/><text x="32" y="45" font-family="Arial, Helvetica, sans-serif" font-size="38" font-weight="800" text-anchor="middle" fill="#fff">W</text></svg>

After

Width:  |  Height:  |  Size: 257 B

+25 -400
View File
@@ -1,407 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Wordle Board Viewer</title>
<style>
body {
background: #111;
color: #eee;
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
margin: 0;
min-height: 100vh;
}
h2 {
font-size: 26px;
margin-bottom: 6px;
color: white;
text-align: center;
}
.date-label {
margin-bottom: 16px;
font-size: 15px;
color: #ccc;
}
.nav {
margin-bottom: 18px;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
.nav a {
text-decoration: none;
padding: 0.35rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
/* --- grid --- */
.grid {
display: grid;
grid-template-columns: repeat(5, 50px);
grid-gap: 6px;
margin-bottom: 20px;
}
.tile {
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
font-weight: bold;
color: white;
border-radius: 6px;
box-shadow: 0 0 4px rgba(0,0,0,0.5);
transition: color 0.3s ease;
}
.empty {
background: #333 !important;
color: #666;
}
.hidden {
color: transparent !important;
}
/* Reveal button */
#reveal {
padding: 10px 18px;
font-size: 16px;
border: none;
border-radius: 6px;
background: #4CAF50;
color: white;
cursor: pointer;
margin-bottom: 25px;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
#reveal:hover {
background: #45A047;
}
#reveal.pressed {
transform: translateY(2px);
box-shadow: inset 0 0 10px rgba(0,0,0,0.4);
}
/* Raw ANSI box (hidden) */
pre {
background: #222;
color: #ccc;
padding: 10px;
border-radius: 6px;
width: 90%;
max-width: 600px;
overflow-x: auto;
display: none;
}
/* --- Error box (nice green glow) --- */
.error-box {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
margin-top: 25px;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
max-width: 600px;
text-align: center;
font-size: 1.1rem;
color: #ccc;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wordle board</title>
<meta name="description" content="A spoiler-free Wordle board - hold to reveal the letters.">
<meta name="color-scheme" content="dark light">
<link rel="icon" type="image/svg+xml" href="/wordle/favicon.svg">
<link rel="alternate icon" href="/wordle/favicon.ico">
<link rel="stylesheet" href="/wordle/wordle.css?v=2">
<script>
// Apply the saved theme before the page draws
try {
const s = JSON.parse(localStorage.getItem("wordle.settings") || "{}");
const light = s.theme === "light" || (s.theme !== "dark" && matchMedia("(prefers-color-scheme: light)").matches);
document.documentElement.dataset.theme = light ? "light" : "dark";
if (s.contrast) document.documentElement.dataset.contrast = "high";
} catch (e) {}
</script>
<script type="module" src="/wordle/board.js?v=2"></script>
</head>
<body>
<h2 id="title">Wordle Board</h2>
<div id="date" class="date-label"></div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="stats-link">📊 Back to stats</a>
<a href="#" id="games-link">📅 Recent games</a>
</div>
<div id="grid-container"></div>
<button id="reveal">Reveal Letters</button>
<div id="error" class="error-box" style="display:none;"></div>
<pre id="raw-output"></pre>
<script>
/* Convert xterm colors */
function xtermToRgb(n) {
// Custom overrides for Wordle colors
if (n === 10) return "rgb(44, 192, 44)"; // green
if (n === 3) return "rgb(176, 176, 33)"; // yellow
// Default behavior for all other xterm colors
if (n < 16) {
const base = [
'#000000','#800000','#008000','#808000',
'#000080','#800080','#008080','#c0c0c0',
'#808080','#ff0000','#00ff00','#ffff00',
'#0000ff','#ff00ff','#00ffff','#ffffff'
];
return base[n] || '#000';
}
if (n <= 231) {
n -= 16;
const r = Math.floor(n / 36) % 6;
const g = Math.floor(n / 6) % 6;
const b = n % 6;
const f = x => (x === 0 ? 0 : x * 40 + 55);
return `rgb(${f(r)}, ${f(g)}, ${f(b)})`;
}
const c = (n - 232) * 10 + 8;
return `rgb(${c}, ${c}, ${c})`;
}
/* Parse ANSI board text into rows of {char, bg} */
function parseAnsiBoard(text) {
const lines = text.split("\n").filter(l => l.includes("│"));
const board = [];
for (const line of lines) {
const inside = line.replace(/.*│(.*)│.*/, "$1").trim();
let row = [];
let bg = "#000";
let i = 0;
while (i < inside.length) {
if (inside[i] === "\x1b") {
const end = inside.indexOf("m", i);
const seq = inside.substring(i+2, end).split(";").map(Number);
if (seq[0] === 48 && seq[1] === 5) {
bg = xtermToRgb(seq[2]);
} else if (seq[0] === 0) {
bg = "#000";
}
i = end + 1;
continue;
}
if (/[A-Z]/.test(inside[i])) {
row.push({ char: inside[i], bg });
}
i++;
}
if (row.length === 5) board.push(row);
}
return board;
}
/* Fetch an epoch-day-based board (user-<day>.board) */
async function fetchBoard(user, epochDay) {
const boardName = `${user}-${epochDay}.board`;
const url = `/wordle/rawboard/${boardName}`;
const resp = await fetch(url);
if (!resp.ok) return null;
const text = await resp.text();
return { boardName, text };
}
async function loadBoard() {
const params = new URLSearchParams(location.search);
const boardName = params.get("board")?.toLowerCase(); // game saves names in lowercase
const errorBox = document.getElementById("error");
const dateEl = document.getElementById("date");
const titleEl = document.getElementById("title");
const gridContainer = document.getElementById("grid-container");
const rawOutput = document.getElementById("raw-output");
const revealBtn = document.getElementById("reveal");
// Hide button until we know we have a board
revealBtn.style.display = "none";
if (!boardName) {
errorBox.style.display = "block";
errorBox.textContent = "No board specified.";
return;
}
const user = boardName.replace(/-.*$/, "").replace(".board", "");
titleEl.textContent = `Wordle Board for ${user}`;
// Wire nav
document.getElementById("stats-link").href = `/stats.html?user=${encodeURIComponent(user)}`;
document.getElementById("games-link").href = `/games.html?user=${encodeURIComponent(user)}`;
let finalText = "";
let finalEpoch = null;
const idMatch = boardName.match(/-(\d+)\.board$/);
// Case 1: boardName has ID → user-<epochDay>.board
if (idMatch) {
const epochDay = Number(idMatch[1]);
const r = await fetchBoard(user, epochDay);
// If not found OR text-only (no ANSI), this day has no board
if (!r || !r.text.includes("\x1b[")) {
errorBox.style.display = "block";
errorBox.textContent = "This board does not exist for that day.";
return;
}
finalText = r.text;
finalEpoch = epochDay;
}
// Case 2: boardName has NO ID → "user.board" semantics
else {
const today = Math.floor(Date.now() / 1000 / 86400);
// First: check today's epoch board (user-<today>.board)
const rToday = await fetchBoard(user, today);
if (rToday && rToday.text.includes("\x1b[")) {
finalText = rToday.text;
finalEpoch = today;
} else {
// Fallback: last board played via /wordle/rawboard/user.board
const latestUrl = `/wordle/rawboard/${encodeURIComponent(user)}.board`;
const latestResp = await fetch(latestUrl);
if (!latestResp.ok) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
return;
}
const latestText = await latestResp.text();
// If that "latest" file is only text (no ANSI), treat as no boards
if (!latestText.includes("\x1b[")) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
return;
}
finalText = latestText;
finalEpoch = null;
dateEl.textContent = "Last board played";
}
}
// If we know the exact epoch day and haven't set date text yet, show it
if (finalEpoch !== null && !dateEl.textContent) {
// Epoch days start at midnight UTC, so format in UTC or viewers
// west of UTC see the previous day
dateEl.textContent = new Date(finalEpoch * 86400 * 1000)
.toLocaleDateString(undefined, {
timeZone: "UTC",
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
});
}
rawOutput.textContent = finalText;
let parsed = parseAnsiBoard(finalText);
// If somehow there is no parsed grid (safety), treat as "no boards"
if (!parsed.length) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
gridContainer.innerHTML = "";
return;
}
// Always fill to 6 rows with empties for a full Wordle grid
while (parsed.length < 6) {
parsed.push([
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true }
]);
}
const grid = document.createElement("div");
grid.className = "grid";
parsed.forEach(row => {
row.forEach(cell => {
const div = document.createElement("div");
div.className = "tile hidden";
div.textContent = cell.char.trim() === "" ? "" : cell.char;
if (cell.empty) {
div.classList.add("empty");
} else {
div.style.backgroundColor = cell.bg;
}
grid.appendChild(div);
});
});
gridContainer.innerHTML = "";
gridContainer.appendChild(grid);
// Now that we have a real board, show reveal button and wire press-to-reveal behavior
revealBtn.style.display = "inline-block";
const tiles = document.querySelectorAll(".tile");
function showLetters() {
tiles.forEach(t => t.classList.remove("hidden"));
revealBtn.classList.add("pressed");
}
function hideLetters() {
tiles.forEach(t => t.classList.add("hidden"));
revealBtn.classList.remove("pressed");
}
// Mouse support
revealBtn.addEventListener("mousedown", showLetters);
document.addEventListener("mouseup", hideLetters);
// Touch support
revealBtn.addEventListener("touchstart", (e) => {
e.preventDefault();
showLetters();
}, { passive: false });
document.addEventListener("touchend", hideLetters);
}
loadBoard();
</script>
<main id="main" class="container">
<noscript>
<h1>Wordle board</h1>
<p>This page needs JavaScript. From a terminal you can use
<code>curl https://www.adbraun.lol/wordle/&lt;user&gt;-&lt;day&gt;.board</code> instead.</p>
</noscript>
</main>
</body>
</html>
+25 -211
View File
@@ -1,218 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Recent Wordle Games</title>
<style>
body {
background: #0d0d0d;
color: #f2f2f2;
font-family: Arial, sans-serif;
padding: 2rem;
display: flex;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.container {
max-width: 700px;
width: 100%;
text-align: center;
animation: fadein 0.7s ease-out;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #51ff7a;
text-shadow: 0 0 8px rgba(81,255,122,0.7);
}
.subtitle {
margin-bottom: 1.5rem;
opacity: 0.9;
color: #b0ffca;
}
.nav {
margin-bottom: 1.5rem;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.nav a {
text-decoration: none;
padding: 0.4rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
.games-card {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
text-align: left;
margin: 0 auto;
}
ul.games {
list-style: none;
padding: 0;
margin: 0;
}
ul.games li {
padding: 0.5rem 0;
border-bottom: 1px solid #252525;
display: flex;
justify-content: space-between;
align-items: center;
}
ul.games li:last-child {
border-bottom: none;
}
.game-date {
color: #e0ffe8;
font-family: monospace;
}
.game-link {
text-decoration: none;
color: #51ff7a;
font-size: 0.9rem;
}
.game-link:hover {
text-decoration: underline;
}
.empty-msg {
color: #aaa;
font-style: italic;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wordle history</title>
<meta name="description" content="A player's recent Wordle games.">
<meta name="color-scheme" content="dark light">
<link rel="icon" type="image/svg+xml" href="/wordle/favicon.svg">
<link rel="alternate icon" href="/wordle/favicon.ico">
<link rel="stylesheet" href="/wordle/wordle.css?v=2">
<script>
// Apply the saved theme before the page draws
try {
const s = JSON.parse(localStorage.getItem("wordle.settings") || "{}");
const light = s.theme === "light" || (s.theme !== "dark" && matchMedia("(prefers-color-scheme: light)").matches);
document.documentElement.dataset.theme = light ? "light" : "dark";
if (s.contrast) document.documentElement.dataset.contrast = "high";
} catch (e) {}
</script>
<script type="module" src="/wordle/history.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>Recent Games for <span id="username">...</span></h1>
<div class="subtitle">Last 30 days (only days actually played)</div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="stats-link">📊 Back to stats</a>
</div>
<div class="games-card">
<ul class="games" id="games-list"></ul>
<div id="empty" class="empty-msg" style="display:none;">No games in the last 30 days.</div>
</div>
</div>
<script>
function formatDateFromEpochDays(epochDays) {
// Epoch days start at midnight UTC, so format in UTC or viewers
// west of UTC see the previous day
const date = new Date(epochDays * 86400 * 1000);
return date.toLocaleDateString(undefined, {
timeZone: "UTC",
weekday: "short",
year: "numeric",
month: "short",
day: "numeric"
});
}
(async () => {
const params = new URLSearchParams(location.search);
const user = params.get("user")?.toLowerCase(); // game saves names in lowercase
const usernameSpan = document.getElementById("username");
const gamesList = document.getElementById("games-list");
const emptyMsg = document.getElementById("empty");
const statsLink = document.getElementById("stats-link");
if (!user) {
usernameSpan.textContent = "Unknown";
emptyMsg.style.display = "block";
emptyMsg.textContent = "Error: missing ?user=username in URL.";
statsLink.removeAttribute("href");
return;
}
usernameSpan.textContent = user;
statsLink.href = `/stats.html?user=${encodeURIComponent(user)}`;
const todayEpochDays = Math.floor(Date.now() / 1000 / 86400);
const games = [];
// Look back over last 30 days
for (let d = todayEpochDays; d > todayEpochDays - 30; d--) {
const boardName = `${user}-${d}.board`;
const url = `/wordle/rawboard/${encodeURIComponent(boardName)}`;
try {
const resp = await fetch(url);
if (!resp.ok) continue;
const text = await resp.text();
// A "real" game day has ANSI escape codes
if (text.includes("\x1b[")) {
games.push({ epochDays: d, boardName });
}
} catch (e) {
// ignore errors for individual days
}
}
if (!games.length) {
emptyMsg.style.display = "block";
return;
}
gamesList.innerHTML = "";
games.forEach(game => {
const li = document.createElement("li");
const dateSpan = document.createElement("span");
dateSpan.className = "game-date";
dateSpan.textContent = formatDateFromEpochDays(game.epochDays);
const link = document.createElement("a");
link.className = "game-link";
link.href = `/game.html?board=${encodeURIComponent(game.boardName)}`;
link.textContent = "View board";
li.appendChild(dateSpan);
li.appendChild(link);
gamesList.appendChild(li);
});
})();
</script>
<main id="main" class="container">
<noscript>
<h1>Wordle history</h1>
<p>This page needs JavaScript. From a terminal you can use
<code>curl https://www.adbraun.lol/wordle/&lt;user&gt;-&lt;day&gt;.board</code> instead.</p>
</noscript>
</main>
</body>
</html>
+111
View File
@@ -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"));
}
+269
View File
@@ -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();
+25 -114
View File
@@ -1,121 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Wordle Viewer</title>
<style>
body {
background: #0b0b0b;
color: #f2f2f2;
font-family: Arial, sans-serif;
min-height: 100vh;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.container {
background: #141414;
border-radius: 12px;
padding: 2rem 2.5rem;
box-shadow: 0 0 30px rgba(0,0,0,0.7);
max-width: 420px;
width: 100%;
text-align: center;
}
h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 2rem;
color: #51ff7a;
text-shadow: 0 0 10px rgba(81,255,122,0.6);
}
p {
margin-top: 0;
margin-bottom: 2rem;
color: #aaa;
font-size: 0.95rem;
}
.form-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
input[type="text"] {
flex: 1;
padding: 0.6rem 0.7rem;
border-radius: 6px;
border: 1px solid #333;
background: #1c1c1c;
color: #f2f2f2;
font-size: 1rem;
}
input[type="text"]:focus {
outline: none;
border-color: #51ff7a;
box-shadow: 0 0 6px rgba(81,255,122,0.6);
}
button {
padding: 0.6rem 1rem;
border-radius: 6px;
border: none;
background: #51ff7a;
color: #061006;
font-weight: bold;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background: #3ee067;
}
.hint {
margin-top: 1rem;
font-size: 0.85rem;
color: #777;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wordle - the daily word game for your terminal</title>
<meta name="description" content="A daily word game played over SSH. Look up players' stats, boards and history.">
<meta name="color-scheme" content="dark light">
<link rel="icon" type="image/svg+xml" href="/wordle/favicon.svg">
<link rel="alternate icon" href="/wordle/favicon.ico">
<link rel="stylesheet" href="/wordle/wordle.css?v=2">
<script>
// Apply the saved theme before the page draws
try {
const s = JSON.parse(localStorage.getItem("wordle.settings") || "{}");
const light = s.theme === "light" || (s.theme !== "dark" && matchMedia("(prefers-color-scheme: light)").matches);
document.documentElement.dataset.theme = light ? "light" : "dark";
if (s.contrast) document.documentElement.dataset.contrast = "high";
} catch (e) {}
</script>
<script type="module" src="/wordle/home.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>Wordle Viewer</h1>
<p>Enter a username to view their stats and games.</p>
<form id="search-form">
<div class="form-row">
<input id="user-input" type="text" placeholder="Username (e.g. adam)" autocomplete="off" />
<button type="submit">View stats</button>
</div>
</form>
<div class="hint">
You’ll be taken to <code>stats.html?user=&lt;name&gt;</code>.
</div>
</div>
<script>
const form = document.getElementById("search-form");
const input = document.getElementById("user-input");
form.addEventListener("submit", (e) => {
e.preventDefault();
// The game saves usernames in lowercase
const user = input.value.trim().toLowerCase();
if (!user) return;
window.location.href = `/stats.html?user=${encodeURIComponent(user)}`;
});
</script>
<main id="main" class="container">
<noscript>
<h1>Wordle</h1>
<p>This site needs JavaScript. From a terminal you can use
<code>curl https://www.adbraun.lol/wordle/stats/&lt;user&gt;</code> instead.</p>
</noscript>
</main>
</body>
</html>
+24 -169
View File
@@ -1,177 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<title>Wordle Stats</title>
<style>
body {
background: #0d0d0d;
color: #f2f2f2;
font-family: monospace;
padding: 2rem;
display: flex;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.container {
max-width: 700px;
width: 100%;
text-align: center;
animation: fadein 0.7s ease-out;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #51ff7a;
text-shadow: 0 0 8px rgba(81,255,122,0.7);
}
.subtitle {
margin-bottom: 1.5rem;
opacity: 0.9;
color: #b0ffca;
}
.nav {
margin-bottom: 1.5rem;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.nav a {
text-decoration: none;
padding: 0.4rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
.stats-card {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
text-align: left;
margin: 0 auto;
white-space: pre-wrap;
}
pre {
font-family: inherit;
margin-top: 1rem;
white-space: pre-wrap;
}
/* ANSI color classes */
.ansi-black { color: #000; }
.ansi-red { color: #ff6b6b; }
.ansi-green { color: #51ff7a; }
.ansi-yellow { color: #ffe66d; }
.ansi-blue { color: #4dc0ff; }
.ansi-magenta { color: #ff70ff; }
.ansi-cyan { color: #63e6ff; }
.ansi-white { color: #ddd; }
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<div class="container">
<h1>Wordle Stats for <span id="username">...</span></h1>
<div class="subtitle">Daily performance summary</div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="games-link">📅 Recent games</a>
<a href="#" id="latest-board-link">🧩 Latest board</a>
</div>
<div class="stats-card">
<pre id="output">Loading…</pre>
</div>
</div>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wordle stats</title>
<meta name="description" content="A player's Wordle stats: wins, streaks and guess distribution.">
<meta name="color-scheme" content="dark light">
<link rel="icon" type="image/svg+xml" href="/wordle/favicon.svg">
<link rel="alternate icon" href="/wordle/favicon.ico">
<link rel="stylesheet" href="/wordle/wordle.css?v=2">
<script>
(async () => {
const params = new URLSearchParams(location.search);
const user = params.get("user")?.toLowerCase(); // game saves names in lowercase
const output = document.getElementById("output");
const usernameSpan = document.getElementById("username");
const gamesLink = document.getElementById("games-link");
const latestBoardLink = document.getElementById("latest-board-link");
if (!user) {
usernameSpan.textContent = "Unknown";
output.textContent = "Error: missing ?user=username in URL.";
gamesLink.removeAttribute("href");
latestBoardLink.removeAttribute("href");
return;
}
usernameSpan.textContent = user;
// Wire nav links for this user
gamesLink.href = `/games.html?user=${encodeURIComponent(user)}`;
latestBoardLink.href = `/game.html?board=${encodeURIComponent(user)}.board`;
// Apply the saved theme before the page draws
try {
const resp = await fetch(`/wordle/rawstats/${encodeURIComponent(user)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
let text = await resp.text();
const ansiMap = {
'30': 'ansi-black',
'31': 'ansi-red',
'32': 'ansi-green',
'33': 'ansi-yellow',
'34': 'ansi-blue',
'35': 'ansi-magenta',
'36': 'ansi-cyan',
'37': 'ansi-white'
};
text = text.replace(/\x1b\[(\d+)m/g, (match, code) => {
if (code === "0") return "</span>";
const cls = ansiMap[code];
return cls ? `<span class="${cls}">` : "";
});
if ((text.match(/<span/g) || []).length > (text.match(/<\/span>/g) || []).length) {
text += "</span>";
}
output.innerHTML = text;
} catch (err) {
output.textContent = `Error loading stats for ${user}: ${err.message}`;
}
})();
const s = JSON.parse(localStorage.getItem("wordle.settings") || "{}");
const light = s.theme === "light" || (s.theme !== "dark" && matchMedia("(prefers-color-scheme: light)").matches);
document.documentElement.dataset.theme = light ? "light" : "dark";
if (s.contrast) document.documentElement.dataset.contrast = "high";
} catch (e) {}
</script>
<script type="module" src="/wordle/stats.js?v=2"></script>
</head>
<body>
<main id="main" class="container">
<noscript>
<h1>Wordle stats</h1>
<p>This page needs JavaScript. From a terminal you can use
<code>curl https://www.adbraun.lol/wordle/stats/&lt;user&gt;</code> instead.</p>
</noscript>
</main>
</body>
</html>
+138
View File
@@ -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) : "✗");
})));
}
+679
View File
@@ -0,0 +1,679 @@
/* Shared styles for the Wordle pages. After changing this file bump the
?v= on its <link> 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;
}
}
+608
View File
@@ -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 <script> tags) so browsers don't mix old and new versions.
export const SSH_COMMAND = "ssh wordle@adbraun.lol";
const DAY_MS = 86400000;
/* ---------- small helpers ---------- */
export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
export const reducedMotion = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
// Build an element. Strings in children become text nodes, never HTML.
export function h(tag, props = {}, ...children) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(props ?? {})) {
if (value == null || value === false) continue;
if (key === "class") el.className = value;
else if (key === "style") {
for (const [prop, v] of Object.entries(value)) el.style.setProperty(prop, v);
} else if (key.startsWith("on")) el.addEventListener(key.slice(2), value);
else el.setAttribute(key, value === true ? "" : value);
}
el.append(...children.flat(Infinity).filter(child => child != null && child !== false));
return el;
}
const ICONS = {
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
star: '<path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.3l-5.5 2.9 1-6.2L3 9.6l6.2-.9z"/>',
copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/>',
share: '<path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7"/><path d="m16 6-4-4-4 4"/><path d="M12 2v13"/>',
eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
left: '<path d="m15 18-6-6 6-6"/>',
right: '<path d="m9 18 6-6-6-6"/>',
chart: '<path d="M3 3v18h18"/><path d="M8 17v-5M13 17V8M18 17v-9"/>',
calendar: '<rect x="3" y="4" width="18" height="17" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
close: '<path d="M18 6 6 18M6 6l12 12"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
terminal: '<path d="m4 17 6-6-6-6M12 19h8"/>',
};
// Icons are fixed strings from ICONS, so innerHTML is safe here
export function icon(name) {
const tpl = document.createElement("template");
tpl.innerHTML = `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`;
return tpl.content.firstChild;
}
/* ---------- browser storage (may be unavailable) ---------- */
function load(key, fallback) {
try {
const value = localStorage.getItem(key);
return value === null ? fallback : JSON.parse(value);
} catch {
return fallback;
}
}
function save(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// private browsing etc. - settings just won't persist
}
}
/* ---------- settings ---------- */
export function getSettings() {
return { theme: "system", contrast: false, ...load("wordle.settings", {}) };
}
export function applySettings(settings = getSettings()) {
const root = document.documentElement;
const systemLight = matchMedia("(prefers-color-scheme: light)").matches;
root.dataset.theme = settings.theme === "system" ? (systemLight ? "light" : "dark") : settings.theme;
root.dataset.contrast = settings.contrast ? "high" : "normal";
}
function updateSettings(change) {
const settings = { ...getSettings(), ...change };
save("wordle.settings", settings);
applySettings(settings);
return settings;
}
/* ---------- days ---------- */
// The game numbers days from the Unix epoch in UTC, so every date here is UTC
export const today = () => Math.floor(Date.now() / DAY_MS);
export function formatDay(day, style = "long") {
const styles = {
long: { weekday: "long", day: "numeric", month: "long", year: "numeric" },
medium: { day: "numeric", month: "short", year: "numeric" },
short: { weekday: "short", day: "numeric", month: "short" },
};
return new Date(day * DAY_MS).toLocaleDateString(undefined, { timeZone: "UTC", ...styles[style] });
}
export function relativeDay(day) {
const ago = today() - day;
if (ago === 0) return "Today";
if (ago === 1) return "Yesterday";
return formatDay(day, "short");
}
export const weekdayIndex = day => (new Date(day * DAY_MS).getUTCDay() + 6) % 7; // Monday = 0
export function formatCountdown(ms) {
const total = Math.max(0, Math.floor(ms / 1000));
const pad = n => String(n).padStart(2, "0");
return `${pad(Math.floor(total / 3600))}:${pad(Math.floor(total / 60) % 60)}:${pad(total % 60)}`;
}
// Keep an element counting down to the next UTC day
export function countdown(el) {
const tick = () => { el.textContent = formatCountdown((today() + 1) * DAY_MS - Date.now()); };
tick();
setInterval(tick, 1000);
return el;
}
/* ---------- players & links ---------- */
// The game only allows a-z, 0-9 and _ now, but older names may have other
// characters, so only reject what can't be a file name
export function normalizeUser(name) {
const user = (name ?? "").trim().toLowerCase();
return user && !user.includes("/") && user.length <= 64 ? user : null;
}
export const statsPage = user => `/stats.html?user=${encodeURIComponent(user)}`;
export const historyPage = user => `/games.html?user=${encodeURIComponent(user)}`;
export const boardPage = (user, day = null) =>
`/game.html?board=${encodeURIComponent(day == null ? `${user}.board` : `${user}-${day}.board`)}`;
// "adam-20725.board" -> { user: "adam", day: 20725 }, "adam.board" -> { user: "adam", day: null }
export function parseBoardName(name) {
const match = /^(.+?)(?:-(\d+))?\.board$/.exec((name ?? "").trim().toLowerCase());
const user = match && normalizeUser(match[1]);
return user ? { user, day: match[2] ? Number(match[2]) : null } : null;
}
export function following() {
const list = load("wordle.following", []);
return Array.isArray(list) ? list.filter(u => typeof u === "string") : [];
}
export const isFollowing = user => following().includes(user);
export function setFollowing(user, follow) {
const others = following().filter(u => u !== user);
save("wordle.following", follow ? [...others, user] : others);
}
export function recentSearches() {
const list = load("wordle.recent", []);
return Array.isArray(list) ? list.filter(u => typeof u === "string") : [];
}
export function addRecentSearch(user) {
save("wordle.recent", [user, ...recentSearches().filter(u => u !== user)].slice(0, 6));
}
/* ---------- reading the game's files ---------- */
const ESCAPE = /\x1b\[[\d;]*[A-Za-z]/g;
const TILE_COLOURS = { 10: "correct", 3: "present", 8: "absent" };
// Missing files still come back as 200 with a plain text message, so a null
// from the parsers means "no data" rather than an error
async function fetchText(url) {
const response = await fetch(url, { cache: "no-cache" });
return response.ok ? response.text() : null;
}
// Board files are the terminal output of the game: one line per row with a
// background colour escape before each tile, plus the answer after a loss
export function parseBoard(text) {
if (!text?.includes("\x1b[")) return null;
const rows = [];
let answer = null;
for (const line of text.split("\n")) {
if (line.includes("\x1b[48;5;")) {
const row = [];
let state = "absent";
for (const [, codes, letter] of line.matchAll(/\x1b\[([\d;]*)[A-Za-z]|([A-Z])/g)) {
if (letter) {
row.push({ letter, state });
} else {
const [kind, mode, colour] = codes.split(";");
if (kind === "48" && mode === "5") state = TILE_COLOURS[colour] ?? "absent";
}
}
if (row.length === 5) rows.push(row);
} else {
const letters = line.replace(ESCAPE, "").replace(/[^A-Z]/g, "");
if (letters.length === 5) answer = letters;
}
}
if (!rows.length) return null;
const won = rows.at(-1).every(tile => tile.state === "correct");
return {
rows,
won,
guesses: rows.length,
answer: won ? rows.at(-1).map(tile => tile.letter).join("") : answer,
};
}
// Stats files are what render_stats writes: a bar per guess count (the last
// win's bar is green) followed by "Name: value" lines
export function parseStats(text) {
if (!text?.includes("│")) return null;
const stats = { hist: [0, 0, 0, 0, 0, 0], lastWin: null, played: 0, wins: 0, losses: 0, streak: 0, maxStreak: 0 };
const names = { "Played": "played", "Wins": "wins", "Losses": "losses", "Streak": "streak", "Longest Streak": "maxStreak" };
let bars = 0;
for (const raw of text.split("\n")) {
const line = raw.replace(ESCAPE, "");
const bar = /^([1-6])│\S*\s+(\d+)\s*$/.exec(line);
if (bar) {
stats.hist[bar[1] - 1] = Number(bar[2]);
if (raw.includes("\x1b[32m")) stats.lastWin = Number(bar[1]);
bars++;
continue;
}
const value = /^([A-Za-z ]+):\s*(\d+)/.exec(line);
if (value && names[value[1]]) stats[names[value[1]]] = Number(value[2]);
}
if (!bars) return null;
const won = stats.hist.reduce((sum, n) => sum + n, 0);
const guesses = stats.hist.reduce((sum, n, i) => sum + n * (i + 1), 0);
// Worked out from the histogram (with the game's rounding down) so that
// averages saved by older versions of the game still show correctly
stats.average = won ? Math.floor((guesses * 100) / won) / 100 : null;
stats.winRate = stats.played ? Math.round((stats.wins * 100) / stats.played) : 0;
return stats;
}
export async function getStats(user) {
return parseStats(await fetchText(`/wordle/rawstats/${encodeURIComponent(user)}`));
}
export async function getBoard(user, day) {
const board = parseBoard(await fetchText(`/wordle/rawboard/${encodeURIComponent(`${user}-${day}.board`)}`));
return board && { ...board, user, day };
}
// <user>.board is the last daily game played, with no date attached
export async function getLastBoard(user) {
const board = parseBoard(await fetchText(`/wordle/rawboard/${encodeURIComponent(`${user}.board`)}`));
return board && { ...board, user, day: null };
}
// Run fn over items, at most `limit` at a time
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
const worker = async () => {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]);
}
};
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
// Games played in the `days` days ending at endDay, newest first
export async function getHistory(user, endDay, days) {
const list = Array.from({ length: days }, (_, i) => endDay - i);
const boards = await mapLimit(list, 6, day => getBoard(user, day).catch(() => null));
return boards.filter(Boolean);
}
// The nearest game from `from` stepping back (-1) or forward (+1), up to
// `range` days, never looking past today
export async function findGame(user, from, step, range = 60) {
for (let start = 0; start < range; start += 7) {
const days = [];
for (let i = start; i < Math.min(start + 7, range); i++) {
if (from + i * step <= today()) days.push(from + i * step);
}
if (!days.length) return null;
const boards = await Promise.all(days.map(day => getBoard(user, day).catch(() => null)));
const found = boards.find(Boolean);
if (found) return found;
}
return null;
}
// Wordle colouring, handling repeated letters the same way as the game
export function score(guess, answer) {
const result = Array(5).fill("absent");
const unused = [];
for (let i = 0; i < 5; i++) {
if (guess[i] === answer[i]) result[i] = "correct";
else unused.push(answer[i]);
}
for (let i = 0; i < 5; i++) {
const j = result[i] === "correct" ? -1 : unused.indexOf(guess[i]);
if (j !== -1) {
result[i] = "present";
unused.splice(j, 1);
}
}
return result;
}
/* ---------- board bits ---------- */
export const resultText = board => `${board.won ? board.guesses : "X"}/6`;
export function describeBoard(board) {
return board.won ? `Solved in ${board.guesses} of 6 guesses` : "Didn't find the word";
}
export function tile(letter = "", state = "empty", props = {}) {
return h("div", { class: "tile", "data-state": state, ...props }, h("span", { class: "letter" }, letter));
}
// Coloured squares only, so it never gives the word away
export function miniBoard(board, { large = false } = {}) {
const el = h("div", {
class: large ? "mini mini-lg" : "mini",
role: "img",
"aria-label": board ? describeBoard(board) : "Not played",
});
for (let r = 0; r < 6; r++) {
for (let c = 0; c < 5; c++) el.append(h("span", { "data-state": board?.rows[r]?.[c]?.state ?? "empty" }));
}
return el;
}
export function bigBoard(board, { animate = true } = {}) {
const el = h("div", {
class: animate ? "board reveal" : "board",
"data-letters": "hidden",
role: "img",
"aria-label": describeBoard(board),
});
for (let r = 0; r < 6; r++) {
const row = h("div", { class: board.won && r === board.guesses - 1 ? "row win-row" : "row" });
for (let c = 0; c < 5; c++) {
const t = board.rows[r]?.[c];
row.append(tile(t?.letter, t?.state, { style: { "--r": r, "--c-i": c } }));
}
el.append(row);
}
return el;
}
export function shareText(board, user, day) {
const contrast = document.documentElement.dataset.contrast === "high";
const squares = { correct: contrast ? "🟧" : "🟩", present: contrast ? "🟦" : "🟨", absent: "⬛" };
const date = day == null ? "" : ` · ${formatDay(day, "medium")}`;
return [
`Wordle · ${user}${date} · ${resultText(board)}`,
"",
...board.rows.map(row => row.map(t => squares[t.state]).join("")),
"",
location.origin + boardPage(user, day),
].join("\n");
}
// A little 5x5 tile pattern made from the username, mirrored like a GitHub identicon
export function avatar(user, { large = false } = {}) {
let hash = 2166136261;
for (const ch of user) hash = Math.imul(hash ^ ch.codePointAt(0), 16777619);
const el = h("div", { class: large ? "avatar avatar-lg" : "avatar", "aria-hidden": "true" });
for (let r = 0; r < 5; r++) {
const half = [];
for (let c = 0; c < 3; c++) {
const bits = (hash >>> ((r * 3 + c) * 2)) & 3;
half.push(bits === 0 ? "none" : bits === 3 ? "present" : "correct");
}
for (const state of [...half, half[1], half[0]]) el.append(h("span", { "data-state": state }));
}
return el;
}
/* ---------- page furniture ---------- */
export function emptyState(title, text, ...actions) {
return h("div", { class: "card empty-state" },
h("h2", {}, title),
h("p", {}, text),
actions.length ? h("div", { class: "actions" }, actions) : null,
);
}
export function skeleton(height, width = "100%") {
return h("div", { class: "skeleton", style: { height, width } });
}
let toastTimer;
export function toast(message) {
let el = document.querySelector(".toast");
if (!el) {
el = h("div", { class: "toast", role: "status", "aria-live": "polite" });
document.body.append(el);
}
el.textContent = message;
el.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("show"), 2200);
}
export async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const area = h("textarea", { style: { position: "fixed", opacity: "0" } }, text);
document.body.append(area);
area.select();
let copied = false;
try { copied = document.execCommand("copy"); } catch { /* nothing else to try */ }
area.remove();
return copied;
}
}
export function countUp(el, value, decimals = 0) {
if (reducedMotion() || !value) {
el.textContent = value.toFixed(decimals);
return;
}
const start = performance.now();
const frame = now => {
const t = Math.min(1, (now - start) / 900);
el.textContent = (value * (1 - (1 - t) ** 3)).toFixed(decimals);
if (t < 1) requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
export function confetti() {
if (reducedMotion()) return;
const canvas = h("canvas", { id: "confetti" });
document.body.append(canvas);
const ctx = canvas.getContext("2d");
const scale = devicePixelRatio || 1;
canvas.width = innerWidth * scale;
canvas.height = innerHeight * scale;
ctx.scale(scale, scale);
const style = getComputedStyle(document.documentElement);
const colours = ["--correct", "--present", "--accent"].map(name => style.getPropertyValue(name).trim());
const pieces = Array.from({ length: 140 }, () => ({
x: innerWidth / 2 + (Math.random() - 0.5) * 120,
y: innerHeight / 3,
vx: (Math.random() - 0.5) * 16,
vy: -Math.random() * 14 - 4,
size: 6 + Math.random() * 6,
spin: Math.random() * Math.PI,
colour: colours[Math.floor(Math.random() * colours.length)],
}));
const end = performance.now() + 2600;
const frame = now => {
ctx.clearRect(0, 0, innerWidth, innerHeight);
for (const p of pieces) {
p.vy += 0.35;
p.vx *= 0.99;
p.x += p.vx;
p.y += p.vy;
p.spin += 0.15;
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.spin);
ctx.fillStyle = p.colour;
ctx.globalAlpha = Math.max(0, Math.min(1, (end - now) / 600));
ctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2);
ctx.restore();
}
if (now < end) requestAnimationFrame(frame);
else canvas.remove();
};
requestAnimationFrame(frame);
}
function logo() {
const letters = [["W", "correct"], ["O", "present"], ["R", "absent"], ["D", "correct"], ["L", "present"], ["E", "correct"]];
return h("a", { class: "logo", href: "/wordle/", "aria-label": "Wordle home" },
letters.map(([letter, state], i) => tile(letter, state, { style: { "--i": i } })));
}
function settingsDialog() {
const settings = getSettings();
const themeButtons = ["system", "dark", "light"].map(theme =>
h("button", {
type: "button",
"aria-pressed": String(settings.theme === theme),
onclick: e => {
updateSettings({ theme });
themeButtons.forEach(b => b.setAttribute("aria-pressed", String(b === e.currentTarget)));
},
}, theme[0].toUpperCase() + theme.slice(1)));
const contrast = h("button", {
class: "switch",
type: "button",
role: "switch",
"aria-checked": String(settings.contrast),
"aria-label": "High contrast colours",
onclick: () => {
const on = contrast.getAttribute("aria-checked") !== "true";
updateSettings({ contrast: on });
contrast.setAttribute("aria-checked", String(on));
},
});
const dialog = h("dialog", { id: "settings", "aria-labelledby": "settings-title" },
h("form", { method: "dialog", class: "dialog-body" },
h("div", { class: "card-head", style: { "margin-bottom": "0" } },
h("h2", { id: "settings-title" }, "Settings"),
h("button", { class: "icon-btn", "aria-label": "Close" }, icon("close"))),
h("div", { class: "setting" },
h("div", {}, h("div", {}, "Theme"), h("div", { class: "desc" }, "System follows your device")),
h("div", { class: "segmented", role: "group", "aria-label": "Theme" }, themeButtons)),
h("div", { class: "setting" },
h("div", {},
h("div", {}, "High contrast"),
h("div", { class: "desc" }, "Orange and blue tiles for colour vision deficiency")),
h("div", { class: "swatches", "aria-hidden": "true" },
h("span", { style: { background: "var(--correct)" } }),
h("span", { style: { background: "var(--present)" } })),
contrast)));
document.body.append(dialog);
return dialog;
}
// Header, footer and settings shared by every page
export function mountShell({ active = null } = {}) {
applySettings();
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => applySettings());
const link = (href, text, key, cls) =>
h("a", { href, class: cls, "aria-current": active === key ? "page" : null }, text);
document.body.prepend(h("header", { class: "site-header" },
h("div", { class: "container" },
logo(),
h("nav", { class: "site-nav", "aria-label": "Main" },
link("/wordle/", "Home", "home"),
link("/wordle/#find", "Find a player", "find", "wide"),
link("/wordle/#how", "How to play", "how", "wide")),
h("button", {
class: "icon-btn",
type: "button",
"aria-label": "Settings",
onclick: () => (document.getElementById("settings") ?? settingsDialog()).showModal(),
}, icon("gear")))));
document.body.append(h("footer", { class: "site-footer" },
h("div", { class: "container" },
h("div", {}, "Play in your terminal: ", h("code", {}, SSH_COMMAND)),
h("div", {}, "Made with bash, nginx and not a single framework."))));
}
// Name, avatar and tabs at the top of the stats, history and board pages
export function profileHeader(user, active, { meta = "", actions = [] } = {}) {
const tab = (href, label, key, name) =>
h("a", { href, "aria-current": active === key ? "page" : null }, icon(name), " ", label);
const metaEl = h("div", { class: "meta" }, meta);
const el = h("section", { class: "stack" },
h("div", { class: "profile" },
avatar(user, { large: true }),
h("div", {}, h("div", { class: "eyebrow" }, "Player"), h("h1", {}, user), metaEl),
h("div", { class: "actions" }, followButton(user), actions)),
h("nav", { class: "tabs", "aria-label": `${user}'s pages` },
tab(statsPage(user), "Stats", "stats", "chart"),
tab(historyPage(user), "History", "history", "calendar"),
tab(boardPage(user), "Latest board", "board", "grid")));
el.setMeta = text => { metaEl.textContent = text; };
return el;
}
export function followButton(user) {
const button = h("button", { class: "btn btn-sm", type: "button" });
const render = () => {
const on = isFollowing(user);
button.replaceChildren(icon("star"), on ? "Following" : "Follow");
button.setAttribute("aria-pressed", String(on));
button.title = on ? "Remove from your leaderboard" : "Add to your leaderboard on the home page";
};
button.addEventListener("click", () => {
const on = !isFollowing(user);
setFollowing(user, on);
render();
toast(on ? `Added ${user} to your leaderboard` : `Removed ${user} from your leaderboard`);
});
render();
return button;
}