1 Commits
Author SHA1 Message Date
adam 39265c3c9d resume on correct row after save during row reveal 2025-05-04 12:33:56 +01:00
13 changed files with 43 additions and 2270 deletions

No files matched your search

-5
View File
@@ -26,10 +26,5 @@ jobs:
run: |
scp -i ~/.ssh/id_rsa wordle ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:${{ vars.REMOTE_DEST }}
- name: Transfer web pages via SCP
run: |
scp -i ~/.ssh/id_rsa www/html/wordle/* ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:/var/www/html/wordle/
- name: Clean up SSH key
if: always() # remove the key even if a transfer failed
run: rm -rf ~/.ssh
+43 -94
View File
@@ -33,7 +33,7 @@ function main() {
declare -g ANSWER
declare -g POOL='QWERTYUIOPASDFGHJKLZXCVBNM'
declare -Ag YELLOWS
declare -Ag KEYS
declare -g REVEALING=0
ANSWER=$(select_random_word)
set_options "$@"
@@ -51,31 +51,24 @@ function main() {
init_board
fi
[[ "${ONLINE}" == 1 ]] && save_game "${ROW}"
[[ "${ONLINE}" == 1 ]] && save_game "NO_EXIT"
render_board
for (( ROW; ROW<HEIGHT; ROW++ )); do
declare GUESS
[[ "${KEYBOARD}" == 1 ]] && render_keyboard
GUESS=$(retrieve_guess) || { echo -en "${NEWLINE}"; exit 0; }
GUESS=$(retrive_guess)
check_guess "${GUESS}" "${ROW}" ; WIN=$?
# Save before the reveal so quitting or disconnecting can't undo a guess
if [[ $WIN == 5 ]] || (( ROW+1 == HEIGHT )); then
save_result
elif [[ "${ONLINE}" == 1 ]]; then
save_game "$(( ROW+1 ))"
fi
reveal_row "${ROW}"
REVEALING=0
if [[ $WIN == 5 ]]; then
save_result
echo -e "${CLEAR_DOWN}YOU WIN"
if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\""
echo "Share/view your stats with -> curl \"${URL_BASE}/stats/${USER}\""
echo "Check out the new homepage in your browser -> ${URL_BASE}"
while read -r board_line; do echo "${board_line//[A-Z]/ }"; done < "${RESULT_FILE}"
fi
echo "Guess - $(( ROW+1 ))/6"
@@ -83,12 +76,16 @@ function main() {
fi
done
render_answer
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}"
echo -en "${CLEAR_LINE}${BOLD}"
for (( i=0; i<WIDTH; i++ )); do
echo -en " ${ANSWER:$i:1} "
done
echo -en "${NEWLINE}${RESET}"
save_result
if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\""
echo "Share/view your stats with -> curl \"${URL_BASE}/stats/${USER}\""
echo "Check out the new homepage in your browser -> ${URL_BASE}"
while read -r board_line; do echo "${board_line//[A-Z]/ }"; done < "${RESULT_FILE}"
fi
exit 0
@@ -140,15 +137,9 @@ function set_options() {
echo -en "${CLEAR_SCREEN}"
declare -g USER
read -r -p "Username: " USER || exit 1
read -r -p "Username: " USER
USER="${USER,,}"
# Username is used in file names and URLs so only allow safe characters
while [[ -z "${USER}" || "${USER}" == *[!a-z0-9_]* || ${#USER} -gt 32 ]]; do
read -r -p "${CLEAR_UP1}Username (letters, numbers and _ only): " USER || exit 1
USER="${USER,,}"
done
declare -g DAY
DAY=$(( EPOCHSECONDS / 86400 ))
@@ -182,15 +173,14 @@ function set_options() {
fi
fi
# Progress is saved after every guess so Ctrl-C only needs to tidy the terminal
trap 'echo -en "${SHOW_CURSOR}${RESET}"; exit 0' SIGINT
trap save_game SIGINT
;;
d)
DELAY=0
;;
h)
echo -e \
"Usage: wordle [OPTIONS]
"Usage: <program_name> [OPTIONS]
Play a game for Wordle
Options:
@@ -199,7 +189,6 @@ Options:
-H Enable hard mode
-S Enable strict hard mode (Cannot reuse grey letters)
-k disable on screen keyboard
-o Enable online mode
-d Enable debug mode (no delay during letter reveal)
-h Show this help message and exit"
@@ -238,54 +227,29 @@ function render_board() {
[[ "${BORDER}" == 1 ]] && echo -e "└─────────────────┘"
}
function render_answer() {
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}"
echo -en "${CLEAR_LINE}${BOLD}"
for (( i=0; i<WIDTH; i++ )); do
echo -en " ${ANSWER:$i:1} "
done
echo -en "${NEWLINE}${RESET}"
}
# Exits non-zero when input has ended so the caller can quit
function retrieve_guess() {
function retrive_guess() {
if [[ -n $1 ]]; then
read -r -p "${CLEAR_UP1}$1: " GUESS || end_of_input
read -r -p "${CLEAR_UP1}$1: " GUESS
else
read -r -p "${CLEAR_2END}Enter Guess: " GUESS || end_of_input
read -r -p "${CLEAR_2END}Enter Guess: " GUESS
fi
while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS || end_of_input
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS
done
declare valid_guess
validate_guess "${GUESS}" ; valid_guess=$?
declare message
declare -r pos="${HARD_MODE_POS}"
declare -r letter="${GUESS:pos:1}"
declare -ra ordinals=(1st 2nd 3rd 4th 5th)
[[ ${valid_guess} == 1 ]] && message="Not A Valid Word"
[[ ${valid_guess} == 3 ]] && message="${ordinals[pos]} letter must be ${greens[pos]}"
[[ ${valid_guess} == 4 ]] && message="Must contain ${yellows[pos]}"
[[ ${valid_guess} == 5 ]] && message="${letter^^} is not in the word"
[[ ${valid_guess} == 6 ]] && message="${ordinals[pos]} letter can't be ${letter^^}"
if [[ -n "${message}" ]]; then
GUESS=$(retrieve_guess "${message}") || exit 1
fi
[[ ${valid_guess} == 1 ]] && GUESS=$(retrive_guess "Not A Valid Word")
[[ ${valid_guess} == 2 ]] && GUESS=$(retrive_guess "Not A Valid Guess")
[[ ${valid_guess} == 3 ]] && GUESS=$(retrive_guess "GREEN Issue")
[[ ${valid_guess} == 4 ]] && GUESS=$(retrive_guess "YELLOW Issue")
[[ ${valid_guess} == 5 ]] && GUESS=$(retrive_guess "POOL Issue")
[[ ${valid_guess} == 6 ]] && GUESS=$(retrive_guess "NEW Issue")
echo -n "${GUESS^^}"
}
# Called when read gets Ctrl-D. On a terminal treat it like Enter, but if
# input has really ended (closed pipe) exit, or the retry loop spins forever
function end_of_input() {
[[ -t 0 ]] || exit 1
# Ctrl-D doesn't move to a new line like Enter, which the prompts expect
echo -en "${NEWLINE}" >&2
}
function render_keyboard() {
declare pos=$(( BORDER*2+7 ))
echo -en "${RESET}\e[$pos;0H"
@@ -295,12 +259,7 @@ function render_keyboard() {
elif [[ $i == 19 ]]; then
echo -en "${NEWLINE} "
fi
declare key="${POOL:$i:1}"
if [[ -n "${KEYS[$key]}" ]]; then
echo -n "${KEYS[$key]}${BOLD}${LETTER}${key}${RESET} "
else
echo -n "${key} "
fi
echo -n "${POOL:$i:1} "
done
echo -en "${NEWLINE}"
}
@@ -326,8 +285,6 @@ function hard_mode_check() {
declare GUESS=$1
for (( i=0; i<WIDTH; i++ )); do
HARD_MODE_POS=$i # letter position that failed, for the error message
if [[ "${STRICT_HARD_MODE}" == 1 ]]; then
[[ "${POOL}" == *${GUESS:i:1}* ]] || return 5
fi
@@ -343,7 +300,7 @@ function hard_mode_check() {
for (( r=ROW-1; r>=0; r-- )); do
CHAR="${GUESS:i:1}"
PREV_CHAR="${YELLOWS[$r,$i]}"
[[ "${CHAR}" == "${PREV_CHAR}" ]] && return 6
[[ "${CHAR}" == "${PREV_CHAR}" ]] && return 4
done
done
@@ -373,7 +330,6 @@ function check_guess() {
if [[ "${_char}" == "${_answer:$c:1}" ]]; then
greens[c]="${_char}"
KEYS[$_char]="${GREEN}"
BOARD[$_row,$c]="${GREEN}${BOLD}${LETTER} ${_char} ${UNBOLD}"
continue
fi
@@ -382,7 +338,6 @@ function check_guess() {
chars="${chars/${_char}/}"
yellows[c]="${_char}" # volatile - stores previous row yellows
YELLOWS[$_row,$c]="${_char}" # stores all yellow from all rounds
[[ "${KEYS[$_char]}" != "${GREEN}" ]] && KEYS[$_char]="${YELLOW}"
BOARD[$_row,$c]="${YELLOW}${BOLD}${LETTER} ${_char} ${UNBOLD}"
fi
[[ "${ANSWER}" =~ ${_char} ]] && continue
@@ -395,6 +350,7 @@ function check_guess() {
}
function reveal_row() {
REVEALING=1
echo -en "${UP1}${SAVE}"
echo -en "${HIDE_CURSOR}"
# Must increment by 1 Row as 0 == 1 for escape code
@@ -414,9 +370,8 @@ function load_game() {
source "${PROGRESS_FILE}"
}
# Takes the row the game should resume from
# shellcheck disable=SC2317 # Don't warn about unreachable commands in this function
function save_game() {
declare -r row=$1
: > "${PROGRESS_FILE}"
for key in "${!BOARD[@]}"; do
@@ -427,21 +382,8 @@ function save_game() {
echo "YELLOWS[$key]='${YELLOWS[$key]}'" >> "${PROGRESS_FILE}"
done
for key in "${!KEYS[@]}"; do
echo "KEYS[$key]='${KEYS[$key]}'" >> "${PROGRESS_FILE}"
done
# Previous row's hints, needed by hard_mode_check after a resume
for key in "${!greens[@]}"; do
echo "greens[$key]='${greens[$key]}'" >> "${PROGRESS_FILE}"
done
for key in "${!yellows[@]}"; do
echo "yellows[$key]='${yellows[$key]}'" >> "${PROGRESS_FILE}"
done
{
echo "ROW=${row}"
echo "ROW=$(( ROW + REVEALING ))"
echo "POOL=${POOL}"
echo "HARD_MODE=${HARD_MODE}"
echo "STRICT_HARD_MODE=${STRICT_HARD_MODE}"
@@ -450,17 +392,26 @@ function save_game() {
echo "IN_PROGRESS=1"
[[ "${PLAYED}" == 1 ]] && echo 'PLAYED=1'
} >> "${PROGRESS_FILE}"
echo -en "${RESET}"
[[ -z $1 ]] && exit 0
}
function save_result() {
[[ "${ONLINE}" == 0 ]] && return 0
[[ "${ONLINE}" == 1 ]] && update_stats
if [[ "${ONLINE}" == 1 ]] && [[ "${DAILY_DONE}" != 1 ]]; then
update_stats
render_board > "${RESULT_FILE}"
render_board > "${CUR_RESULT_FILE}"
if [[ $WIN != 5 ]]; then
render_answer >> "${RESULT_FILE}"
render_answer >> "${CUR_RESULT_FILE}"
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}" >> "${RESULT_FILE}"
echo -en "${CLEAR_LINE}${BOLD}" >> "${RESULT_FILE}"
for (( i=0; i<WIDTH; i++ )); do
echo -en " ${ANSWER:$i:1} " >> "${RESULT_FILE}"
echo -en " ${ANSWER:$i:1} " >> "${CUR_RESULT_FILE}"
done
echo -en "${NEWLINE}${RESET}" >> "${RESULT_FILE}"
echo -en "${NEWLINE}${RESET}" >> "${CUR_RESULT_FILE}"
fi
fi
echo 'PLAYED=1' > "${PROGRESS_FILE}"
@@ -500,7 +451,6 @@ function update_stats() {
integer_part=$(( result / 100 ))
decimal_part=$(( result % 100 ))
[[ ${decimal_part} -lt 10 ]] && decimal_part="0${decimal_part}"
AVERAGE="${integer_part}.${decimal_part}"
fi
@@ -559,8 +509,7 @@ function render_stats() {
axis=$(( i+1 ))
# Highlight the bar for this game's guess count, only if it was won
if [[ $WIN == 5 && "${ROW}" == "${i}" ]]; then
if [[ "${ROW}" == "${i}" ]]; then
echo "$axis│${LETTER_GR}$bar${RESET} $value"
else
echo "$axis│$bar $value"
-150
View File
@@ -1,150 +0,0 @@
import {
h, fill, icon, tile, bigBoard, emptyState, skeleton, toast, copyText, confetti, mountShell,
profileHeader, parseBoardName, boardPage, historyPage, getBoard, getLastBoard, findGame, today,
formatDay, resultText, shareText, verdict,
} from "./wordle.js?v=3";
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 {
fill(stage, 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)}`;
fill(stage,
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"),
h("div", { class: "answer-tiles" }, [...board.answer].map(letter => tile(letter, "correct"))));
fill(stage,
h("div", { class: "headline" },
h("span", { class: board.won ? "badge win" : "badge loss", style: { "font-size": "0.95rem", padding: "6px 14px" } },
`${verdict(board)} · ${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
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 257 B

-32
View File
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<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=3">
<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=3"></script>
</head>
<body>
<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>
-32
View File
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<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=3">
<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=3"></script>
</head>
<body>
<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>
-116
View File
@@ -1,116 +0,0 @@
import {
h, icon, miniBoard, emptyState, skeleton, mountShell, profileHeader, normalizeUser, boardPage,
getHistory, today, formatDay, relativeDay, weekdayIndex, dayOfMonth, resultText,
} from "./wordle.js?v=3";
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 stat-grid-2" }, 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" } },
h("div", { class: "grid-2" }, calendar, summary),
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)`)),
calendarGrid(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));
}
// Month-style calendar, a row per week starting on Monday. Played days
// show the result and link to their board.
function calendarGrid(user, boards, start, end) {
const byDay = new Map(boards.map(board => [board.day, board]));
const cells = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(name => h("span", { class: "cal-head" }, name));
for (let i = 0; i < weekdayIndex(start); i++) cells.push(h("span"));
for (let day = start; day <= end; day++) {
const board = byDay.get(day);
const date = h("span", { class: "date" }, dayOfMonth(day) === 1 ? formatDay(day, "month") : String(dayOfMonth(day)));
const cls = day === end ? "cal-day today" : "cal-day";
if (!board) {
cells.push(h("span", { class: cls, title: `${formatDay(day, "short")}: not played` }, date));
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)}`,
}, date, board.won ? String(board.guesses) : "✗"));
}
const key = (level, label) => [h("span", { class: "cal-key", "data-level": level }), label];
return h("div", {},
h("div", { class: "cal" }, cells),
h("div", { class: "legend" },
key("1", "1-2 guesses"), key("2", "3-4"), key("3", "5-6"), key("loss", "missed"),
[h("span", { class: "cal-key" }), "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
@@ -1,269 +0,0 @@
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=3";
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();
-32
View File
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<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=3">
<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=3"></script>
</head>
<body>
<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>
-32
View File
@@ -1,32 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<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=3">
<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/stats.js?v=3"></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
@@ -1,138 +0,0 @@
import {
h, icon, miniBoard, emptyState, skeleton, toast, copyText, countUp, countdown, mountShell,
profileHeader, normalizeUser, statsPage, boardPage, getStats, getHistory, today, formatDay,
relativeDay, resultText, describeBoard, verdict,
} from "./wordle.js?v=3";
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" }, `${verdict(board)} ${resultText(board)}`),
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) : "✗");
})));
}
-719
View File
@@ -1,719 +0,0 @@
/* 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); }
.header-search { position: relative; display: flex; align-items: center; }
.header-search .icon { position: absolute; left: 12px; width: 18px; height: 18px; color: var(--faint); pointer-events: none; }
.header-search input {
width: 190px;
height: 40px;
padding: 0 14px 0 38px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 0.92rem;
transition: width 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.header-search input::placeholder { color: var(--faint); }
.header-search input:focus { outline: none; width: 240px; border-color: var(--accent); box-shadow: 0 0 0 3px var(--glow); }
.icon-btn.search-link { display: none; color: var(--text); }
@media (max-width: 640px) {
.header-search { display: none; }
.icon-btn.search-link { display: inline-grid; }
}
.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="none"] { background: color-mix(in srgb, var(--text) 5%, transparent); }
.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: 38px; gap: 1px; padding: 4px; }
.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; }
}
.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: grid; grid-template-columns: repeat(7, minmax(0, 40px)); gap: 6px; }
.form-day {
display: grid;
place-items: center;
aspect-ratio: 1;
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 ---------- */
.stat-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cal { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; }
.cal-head { text-align: center; font: 700 0.7rem/1 var(--mono); color: var(--faint); text-transform: uppercase; letter-spacing: 0.08em; padding-bottom: 4px; }
.cal-day {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1;
border-radius: 10px;
background: var(--surface-2);
border: 1px solid var(--border);
color: #fff;
font: 800 1.05rem/1 var(--mono);
transition: transform 0.15s ease;
}
.cal-day .date { position: absolute; top: 5px; left: 7px; font-size: 0.64rem; font-weight: 600; color: var(--faint); }
.cal-day[data-level] { border-color: transparent; }
.cal-day[data-level] .date { color: rgba(255, 255, 255, 0.8); }
.cal-day.today { outline: 2px solid var(--accent); outline-offset: 2px; }
a.cal-day:hover { transform: translateY(-2px); text-decoration: none; }
@media (max-width: 480px) {
.cal { gap: 4px; }
.cal-day { border-radius: 7px; font-size: 0.85rem; }
.cal-day .date { top: 3px; left: 4px; font-size: 0.55rem; }
}
.cal-key { width: 12px; height: 12px; border-radius: 3px; display: inline-block; background: var(--surface-3); }
[data-level="1"] { background: var(--correct); }
[data-level="2"] { background: color-mix(in srgb, var(--correct) 72%, var(--surface-3)); }
[data-level="3"] { background: color-mix(in srgb, var(--correct) 48%, var(--surface-3)); }
[data-level="loss"] { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); }
.legend { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.8rem; margin-top: 14px; flex-wrap: wrap; }
.legend .cal-key:not(:first-child) { margin-left: 6px; }
.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: grid; gap: 8px; justify-items: center; color: var(--muted); font-size: 0.9rem; }
.answer-tiles { display: flex; gap: 6px; }
.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; }
@media (hover: none) { .kbd-hint { display: none; } } /* no keyboard on touch screens */
.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-wrap; }
.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;
}
}
-650
View File
@@ -1,650 +0,0 @@
// 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;
}
// Replace an element's children, skipping null/false like h() does
// (replaceChildren on its own would show them as the text "null")
export function fill(el, ...children) {
el.replaceChildren(...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" },
month: { 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 const dayOfMonth = day => new Date(day * DAY_MS).getUTCDate();
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`;
// What the real Wordle says for a win in 1-6 guesses
const PRAISE = ["Genius", "Magnificent", "Impressive", "Splendid", "Great", "Phew"];
export const verdict = board => (board.won ? PRAISE[board.guesses - 1] : "Missed it");
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);
const searchInput = h("input", {
id: "header-search",
type: "search",
placeholder: "Find a player",
autocomplete: "off",
autocapitalize: "none",
spellcheck: "false",
enterkeyhint: "go",
});
const search = h("form", { class: "header-search", role: "search" },
h("label", { class: "sr-only", for: "header-search" }, "Find a player"),
icon("search"),
searchInput);
search.addEventListener("submit", e => {
e.preventDefault();
const user = normalizeUser(searchInput.value);
if (!user) return;
addRecentSearch(user);
location.href = statsPage(user);
});
// "/" jumps to the search box, as on lots of sites
document.addEventListener("keydown", e => {
if (e.key !== "/" || e.target.closest?.("input, textarea") || !search.offsetParent) return;
e.preventDefault();
searchInput.focus();
});
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/#how", "How to play", "how", "wide")),
search,
// Phones get a button that jumps to the search box on the home page instead
h("a", { class: "icon-btn search-link", href: "/wordle/#find", "aria-label": "Find a player" }, icon("search")),
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 on the home page` : `Removed ${user} from your leaderboard`);
});
render();
return button;
}