Compare commits

Author SHA1 Message Date
adam ab34594e0b Merge pull request 'clause fixes' (#16) from dev into main
Deploy game to server / deploy (push) Successful in 8s
Reviewed-on: #16
2026-09-29 22:13:57 +00:00
adamandClaude Opus 5.5 698fe9d14a polish the website
- show Wordle's praise (Genius ... Phew) for wins on the board and
  stats pages instead of repeating the score
- history: month-style calendar with dates and results, next to the
  summary numbers, replacing the small heatmap strip
- recent form as two rows of a week each
- fuller identicons at small sizes
- put "The word was" above the answer tiles, wrap the terminal demo
  on phones and hide keyboard hints on touch screens
- say where followed players show up (the home page leaderboard)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:36:27 +00:00
adamandClaude Opus 5.5 710c9c5b79 replace the duplicate header link with a player search
"Home" and "Find a player" both went to the home page. Swap the second
link for a search box in the header so a player can be looked up from
any page ("/" focuses it). Phones get a search button that jumps to the
search on the home page instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:30:02 +00:00
adamandClaude Opus 5.5 f278ab0ac1 stop won boards showing "null" on the website
A won board has no answer row, and passing that null straight to
replaceChildren printed it as the text "null" under the board. Add a
fill() helper that skips null children like h() does and use it on the
board page. Bump the asset version so browsers load the new wordle.js
alongside the new board.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:27:10 +00:00
adamandClaude Opus 5.5 fc3c74822f 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>
2026-09-29 18:54:29 +00:00
adamandClaude Opus 5.5 5bafab3e10 don't highlight a stats bar after a loss
Since progress is saved before the final reveal, save_result runs with
ROW still on the last row, so losing on the sixth guess highlighted the
"6" bar as if the game had been won. Only highlight after a win.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:52:55 +00:00
adamandClaude Opus 5.5 eccee4d3f1 always remove the SSH key after a deploy
The clean up step was skipped whenever a transfer failed, leaving the
deploy key on the runner. Run it even when an earlier step fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:21:50 +00:00
adamandClaude Opus 5.5 45de1fd982 deploy website pages with the game
Copy the pages in www/html/wordle to /var/www/html/wordle on the server,
where nginx serves them from, on every deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:21:41 +00:00
adamandClaude Opus 5.5 7a95e50fe7 lowercase usernames on the website
The game saves usernames in lowercase, but the search box and the
user/board URL parameters were used as typed, so searching "Adam"
reported that the user had never played. Lowercase them before use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:50 +00:00
adamandClaude Opus 5.5 3d78ff7863 show board dates in UTC on the website
Board days are UTC epoch days (midnight UTC), but toLocaleDateString
formatted them in the viewer's timezone, so anyone west of UTC saw
every game dated the day before. Format them in UTC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:37 +00:00
adamandClaude Opus 5.5 4c6c53ff13 add website pages
Copies of the pages served from /var/www/html/wordle on the server:
the user search page (index.html), stats.html, games.html and
game.html. Browsers are sent here by nginx while curl gets the raw
board and stats files. These aren't deployed by the workflow yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:22 +00:00
adamandClaude Opus 5.5 8e62094a6f treat Ctrl-D at the guess prompt like Enter
Ctrl-D doesn't move the cursor to a new line, but the retry prompts
move up a line assuming Enter was pressed, so each Ctrl-D climbed up
the screen over the keyboard and board. It also quit the game, losing
an offline game to a stray key press.

On a terminal, print the missing newline and carry on as if Enter was
pressed. Only exit when input has really ended (not a terminal), so a
closed pipe still can't spin the retry loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:39:02 +00:00
adamandClaude Opus 5.5 0f8640e9e8 explain hard mode errors in guess prompt
Replace "GREEN Issue", "YELLOW Issue" and "POOL Issue" with messages
that say which letter broke which rule:

  3rd letter must be A
  Must contain R
  1st letter can't be R   (was yellow there before)
  S is not in the word    (strict mode, grey letter reused)

hard_mode_check now records the failing position, and a yellow letter
reused in the same spot gets its own code (6, previously the unused
"NEW Issue"). Drop the unused "Not A Valid Guess" code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:35:38 +00:00
adamandClaude Opus 5.5 6b07093d68 write the same answer line to both board files
On a loss the answer line was written to RESULT_FILE with the border
indent and bold, but CUR_RESULT_FILE only got the letters. Move the
answer line into render_answer and use it for the terminal and both
files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:32:28 +00:00
adamandClaude Opus 5.5 a3f9ae6a17 quit when input ends at the guess prompt
At end of input (Ctrl-D, or stdin closed) read returns straight away
with an empty guess, so the "Must be 5 letters" loop spun forever at
full CPU. If the game's parent process was killed first, the subshell
was left orphaned and spinning.

Exit retrieve_guess when read hits EOF, pass that back through the
retry prompts with a single recursive call, and quit from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:31:21 +00:00
adamandClaude Opus 5.5 d34d98c15d only update stats for the daily game
update_stats ran for every online game, so practice games played after
the daily counted towards played/wins/losses and a practice loss reset
the streak. Move it inside the DAILY_DONE check with the board files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:27:24 +00:00
adamandClaude Opus 5.5 8dfebaa9f8 zero pad decimal part of average guesses
An average of 3.04 was shown as 3.4 because the hundredths weren't
padded. Pad with parameter expansion to keep the script bash-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:24:24 +00:00
adamandClaude Opus 5.5 f29d81e932 keep hard mode hints when resuming a game
hard_mode_check uses the previous row's greens and yellows, but these
weren't written to the progress file, so after resuming a game the next
guess could drop known green and yellow letters. Save them alongside
the rest of the game state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:19:24 +00:00
adamandClaude Opus 5.5 73f403693a save progress after every guess
Progress was only saved at the start of a game and on Ctrl-C, so a
dropped connection lost every guess made so far. Ctrl-C during the
reveal also saved the scored row without advancing ROW, which gave the
player that guess back on resume.

Save straight after each guess is scored (before the reveal) with the
next row, record the result before the final reveal, and make the
SIGINT trap only tidy up the terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:17:20 +00:00
abrown38andClaude Opus 5.5 1f6a5b4a44 validate username in online mode
Usernames are used in file names and URLs, so a '/' could write files
outside the game directory and a space broke the share links. Only
allow a-z, 0-9 and _ (max 32 chars) and re-prompt otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:12:44 +00:00
abrown38 437d46e975 add colour to keyboard 2026-09-29 17:07:46 +00:00
adam 7621938f04 set program name in help message
Deploy game to server / deploy (push) Successful in 8s
2026-02-11 21:43:00 +00:00
adam 1a9a440509 fix duplicate path in URL
Deploy game to server / deploy (push) Successful in 6s
2026-01-21 12:48:48 +00:00
adam d9a7c94125 add link to website
Deploy game to server / deploy (push) Successful in 7s
2025-11-29 03:50:26 +00:00
13 changed files with 2269 additions and 42 deletions

No files matched your search

+5
View File
@@ -26,5 +26,10 @@ 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
+93 -42
View File
@@ -33,6 +33,7 @@ function main() {
declare -g ANSWER
declare -g POOL='QWERTYUIOPASDFGHJKLZXCVBNM'
declare -Ag YELLOWS
declare -Ag KEYS
ANSWER=$(select_random_word)
set_options "$@"
@@ -50,24 +51,31 @@ function main() {
init_board
fi
[[ "${ONLINE}" == 1 ]] && save_game "NO_EXIT"
[[ "${ONLINE}" == 1 ]] && save_game "${ROW}"
render_board
for (( ROW; ROW<HEIGHT; ROW++ )); do
declare GUESS
[[ "${KEYBOARD}" == 1 ]] && render_keyboard
GUESS=$(retrieve_guess)
GUESS=$(retrieve_guess) || { echo -en "${NEWLINE}"; exit 0; }
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}"
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"
@@ -75,17 +83,12 @@ function main() {
fi
done
[[ "${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}"
render_answer
save_result
if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
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
@@ -137,9 +140,15 @@ function set_options() {
echo -en "${CLEAR_SCREEN}"
declare -g USER
read -r -p "Username: " USER
read -r -p "Username: " USER || exit 1
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 ))
@@ -173,14 +182,15 @@ function set_options() {
fi
fi
trap save_game SIGINT
# Progress is saved after every guess so Ctrl-C only needs to tidy the terminal
trap 'echo -en "${SHOW_CURSOR}${RESET}"; exit 0' SIGINT
;;
d)
DELAY=0
;;
h)
echo -e \
"Usage: <program_name> [OPTIONS]
"Usage: wordle [OPTIONS]
Play a game for Wordle
Options:
@@ -228,29 +238,54 @@ 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() {
if [[ -n $1 ]]; then
read -r -p "${CLEAR_UP1}$1: " GUESS
read -r -p "${CLEAR_UP1}$1: " GUESS || end_of_input
else
read -r -p "${CLEAR_2END}Enter Guess: " GUESS
read -r -p "${CLEAR_2END}Enter Guess: " GUESS || end_of_input
fi
while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS
done
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS || end_of_input
done
declare valid_guess
validate_guess "${GUESS}" ; valid_guess=$?
[[ ${valid_guess} == 1 ]] && GUESS=$(retrieve_guess "Not A Valid Word")
[[ ${valid_guess} == 2 ]] && GUESS=$(retrieve_guess "Not A Valid Guess")
[[ ${valid_guess} == 3 ]] && GUESS=$(retrieve_guess "GREEN Issue")
[[ ${valid_guess} == 4 ]] && GUESS=$(retrieve_guess "YELLOW Issue")
[[ ${valid_guess} == 5 ]] && GUESS=$(retrieve_guess "POOL Issue")
[[ ${valid_guess} == 6 ]] && GUESS=$(retrieve_guess "NEW Issue")
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
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"
@@ -260,7 +295,12 @@ function render_keyboard() {
elif [[ $i == 19 ]]; then
echo -en "${NEWLINE} "
fi
echo -n "${POOL:$i:1} "
declare key="${POOL:$i:1}"
if [[ -n "${KEYS[$key]}" ]]; then
echo -n "${KEYS[$key]}${BOLD}${LETTER}${key}${RESET} "
else
echo -n "${key} "
fi
done
echo -en "${NEWLINE}"
}
@@ -286,6 +326,8 @@ 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
@@ -301,7 +343,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 4
[[ "${CHAR}" == "${PREV_CHAR}" ]] && return 6
done
done
@@ -331,6 +373,7 @@ 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
@@ -339,6 +382,7 @@ 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
@@ -370,8 +414,9 @@ function load_game() {
source "${PROGRESS_FILE}"
}
# shellcheck disable=SC2317 # Don't warn about unreachable commands in this function
# Takes the row the game should resume from
function save_game() {
declare -r row=$1
: > "${PROGRESS_FILE}"
for key in "${!BOARD[@]}"; do
@@ -382,36 +427,40 @@ 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}"
echo "POOL=${POOL}"
echo "HARD_MODE=${HARD_MODE}"
echo "STRICT_HARD_MODE=${STRICT_HARD_MODE}"
echo "WIN=${WIN}"
echo "ANSWER=${ANSWER}"
echo "IN_PROGRESS=1"
[[ "${PLAYED}" == 1 ]] && echo 'PLAYED=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
[[ "${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}"
render_answer >> "${RESULT_FILE}"
render_answer >> "${CUR_RESULT_FILE}"
fi
fi
echo 'PLAYED=1' > "${PROGRESS_FILE}"
@@ -451,6 +500,7 @@ 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
@@ -509,7 +559,8 @@ function render_stats() {
axis=$(( i+1 ))
if [[ "${ROW}" == "${i}" ]]; then
# Highlight the bar for this game's guess count, only if it was won
if [[ $WIN == 5 && "${ROW}" == "${i}" ]]; then
echo "$axis│${LETTER_GR}$bar${RESET} $value"
else
echo "$axis│$bar $value"
+150
View File
@@ -0,0 +1,150 @@
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
@@ -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

+32
View File
@@ -0,0 +1,32 @@
<!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
@@ -0,0 +1,32 @@
<!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
@@ -0,0 +1,116 @@
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
@@ -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=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
@@ -0,0 +1,32 @@
<!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
@@ -0,0 +1,32 @@
<!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
@@ -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, 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
@@ -0,0 +1,719 @@
/* 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
@@ -0,0 +1,650 @@
// 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;
}