From 437d46e975818a1516626c65bc9d4242aad70dd9 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Tue, 29 Sep 2026 17:07:46 +0000 Subject: [PATCH 01/20] add colour to keyboard --- wordle | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/wordle b/wordle index f7ea4b0..e2003fe 100755 --- a/wordle +++ b/wordle @@ -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 "$@" @@ -262,7 +263,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}" } @@ -333,6 +339,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 @@ -341,6 +348,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 @@ -384,6 +392,10 @@ function save_game() { echo "YELLOWS[$key]='${YELLOWS[$key]}'" >> "${PROGRESS_FILE}" done + for key in "${!KEYS[@]}"; do + echo "KEYS[$key]='${KEYS[$key]}'" >> "${PROGRESS_FILE}" + done + { echo "ROW=${ROW}" echo "POOL=${POOL}" From 1f6a5b4a446ca3f9f8b3b7e243a343a2ed18dc0e Mon Sep 17 00:00:00 2001 From: abrown38 Date: Tue, 29 Sep 2026 17:12:44 +0000 Subject: [PATCH 02/20] 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 --- wordle | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/wordle b/wordle index e2003fe..db6ec40 100755 --- a/wordle +++ b/wordle @@ -140,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 )) From 73f403693a80d3568c7859083f764db40b7b6519 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:17:20 +0000 Subject: [PATCH 03/20] 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 --- wordle | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/wordle b/wordle index db6ec40..f11ac31 100755 --- a/wordle +++ b/wordle @@ -51,7 +51,7 @@ function main() { init_board fi - [[ "${ONLINE}" == 1 ]] && save_game "NO_EXIT" + [[ "${ONLINE}" == 1 ]] && save_game "${ROW}" render_board @@ -61,10 +61,16 @@ function main() { GUESS=$(retrieve_guess) check_guess "${GUESS}" "${ROW}" ; WIN=$? + # Save before the reveal so quitting or disconnecting can't undo a guess + if [[ $WIN == 5 ]] || (( ROW+1 == HEIGHT )); then + save_result + elif [[ "${ONLINE}" == 1 ]]; then + save_game "$(( ROW+1 ))" + fi + reveal_row "${ROW}" 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}\"" @@ -84,8 +90,7 @@ function main() { done echo -en "${NEWLINE}${RESET}" - 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}" @@ -182,7 +187,8 @@ 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 @@ -386,8 +392,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 @@ -403,18 +410,15 @@ function save_game() { 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() { From f29d81e932c45488212a9645fae387fa4e674f81 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:19:24 +0000 Subject: [PATCH 04/20] 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 --- wordle | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/wordle b/wordle index f11ac31..b6a0574 100755 --- a/wordle +++ b/wordle @@ -409,6 +409,15 @@ function save_game() { 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 "POOL=${POOL}" From 8dfebaa9f86687d41744fc518ae2d46a225b2d0e Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:24:24 +0000 Subject: [PATCH 05/20] 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 --- wordle | 1 + 1 file changed, 1 insertion(+) diff --git a/wordle b/wordle index b6a0574..46475e1 100755 --- a/wordle +++ b/wordle @@ -484,6 +484,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 From d34d98c15d104be6cdc94fd3f0b41e1014629f44 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:27:24 +0000 Subject: [PATCH 06/20] 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 --- wordle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordle b/wordle index 46475e1..cf9065e 100755 --- a/wordle +++ b/wordle @@ -432,8 +432,8 @@ function save_game() { 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 From a3f9ae6a1779a431cdc1dd5967f877501e913d01 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:31:21 +0000 Subject: [PATCH 07/20] 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 --- wordle | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/wordle b/wordle index cf9065e..1332835 100755 --- a/wordle +++ b/wordle @@ -58,7 +58,7 @@ function main() { for (( ROW; ROW Date: Tue, 29 Sep 2026 17:32:28 +0000 Subject: [PATCH 08/20] 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 --- wordle | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/wordle b/wordle index 1332835..7c353a5 100755 --- a/wordle +++ b/wordle @@ -83,12 +83,7 @@ function main() { fi done - [[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}" - echo -en "${CLEAR_LINE}${BOLD}" - for (( i=0; i curl \"${URL_BASE}/${RESULT_FILE}\"" @@ -243,6 +238,15 @@ 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 "${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> "${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}" From 0f8640e9e881783b8b71ea1a9abb77b8bf7db057 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:35:38 +0000 Subject: [PATCH 09/20] 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 --- wordle | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/wordle b/wordle index 7c353a5..4330009 100755 --- a/wordle +++ b/wordle @@ -262,12 +262,14 @@ function retrieve_guess() { validate_guess "${GUESS}" ; valid_guess=$? declare message + declare -r pos="${HARD_MODE_POS}" + declare -r letter="${GUESS:pos:1}" + declare -ra ordinals=(1st 2nd 3rd 4th 5th) [[ ${valid_guess} == 1 ]] && message="Not A Valid Word" - [[ ${valid_guess} == 2 ]] && message="Not A Valid Guess" - [[ ${valid_guess} == 3 ]] && message="GREEN Issue" - [[ ${valid_guess} == 4 ]] && message="YELLOW Issue" - [[ ${valid_guess} == 5 ]] && message="POOL Issue" - [[ ${valid_guess} == 6 ]] && message="NEW Issue" + [[ ${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 @@ -316,6 +318,8 @@ function hard_mode_check() { declare GUESS=$1 for (( i=0; i=0; r-- )); do CHAR="${GUESS:i:1}" PREV_CHAR="${YELLOWS[$r,$i]}" - [[ "${CHAR}" == "${PREV_CHAR}" ]] && return 4 + [[ "${CHAR}" == "${PREV_CHAR}" ]] && return 6 done done From 8e62094a6fc066d9c3e8bc84831ddc7450bf433a Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:39:02 +0000 Subject: [PATCH 10/20] 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 --- wordle | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/wordle b/wordle index 4330009..61084d5 100755 --- a/wordle +++ b/wordle @@ -247,16 +247,16 @@ function render_answer() { echo -en "${NEWLINE}${RESET}" } -# Exits non-zero at end of input (Ctrl-D) so the caller can quit +# 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 || exit 1 + read -r -p "${CLEAR_UP1}$1: " GUESS || end_of_input else - read -r -p "${CLEAR_2END}Enter Guess: " GUESS || exit 1 + 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 || exit 1 + read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS || end_of_input done declare valid_guess validate_guess "${GUESS}" ; valid_guess=$? @@ -278,6 +278,14 @@ function retrieve_guess() { 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" From 4c6c53ff13d4ce466927729be2a2d29305004d0d Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:50:22 +0000 Subject: [PATCH 11/20] 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 --- www/html/wordle/game.html | 404 +++++++++++++++++++++++++++++++++++++ www/html/wordle/games.html | 215 ++++++++++++++++++++ www/html/wordle/index.html | 120 +++++++++++ www/html/wordle/stats.html | 177 ++++++++++++++++ 4 files changed, 916 insertions(+) create mode 100644 www/html/wordle/game.html create mode 100644 www/html/wordle/games.html create mode 100644 www/html/wordle/index.html create mode 100644 www/html/wordle/stats.html diff --git a/www/html/wordle/game.html b/www/html/wordle/game.html new file mode 100644 index 0000000..5c829c0 --- /dev/null +++ b/www/html/wordle/game.html @@ -0,0 +1,404 @@ + + + + +Wordle Board Viewer + + + + + + +

Wordle Board

+
+ + + +
+ + + + + +

+
+
+
+
+
+
diff --git a/www/html/wordle/games.html b/www/html/wordle/games.html
new file mode 100644
index 0000000..705a53e
--- /dev/null
+++ b/www/html/wordle/games.html
@@ -0,0 +1,215 @@
+
+
+
+
+Recent Wordle Games
+
+
+
+
+
+
+

Recent Games for ...

+
Last 30 days (only days actually played)
+ + + +
+
    + +
    +
    + + + + + + diff --git a/www/html/wordle/index.html b/www/html/wordle/index.html new file mode 100644 index 0000000..01af05c --- /dev/null +++ b/www/html/wordle/index.html @@ -0,0 +1,120 @@ + + + + +Wordle Viewer + + + + + +
    +

    Wordle Viewer

    +

    Enter a username to view their stats and games.

    + +
    +
    + + +
    +
    + +
    + You’ll be taken to stats.html?user=<name>. +
    +
    + + + + + + diff --git a/www/html/wordle/stats.html b/www/html/wordle/stats.html new file mode 100644 index 0000000..334b29e --- /dev/null +++ b/www/html/wordle/stats.html @@ -0,0 +1,177 @@ + + + + +Wordle Stats + + + + + + +
    + +

    Wordle Stats for ...

    +
    Daily performance summary
    + + + +
    +
    Loading…
    +
    + +
    + + + + + + From 3d78ff7863eeea560b2429f727e9ce5fcbb18970 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 17:50:37 +0000 Subject: [PATCH 12/20] 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 --- www/html/wordle/game.html | 3 +++ www/html/wordle/games.html | 3 +++ 2 files changed, 6 insertions(+) diff --git a/www/html/wordle/game.html b/www/html/wordle/game.html index 5c829c0..c490f53 100644 --- a/www/html/wordle/game.html +++ b/www/html/wordle/game.html @@ -316,8 +316,11 @@ async function loadBoard() { // If we know the exact epoch day and haven't set date text yet, show it if (finalEpoch !== null && !dateEl.textContent) { + // Epoch days start at midnight UTC, so format in UTC or viewers + // west of UTC see the previous day dateEl.textContent = new Date(finalEpoch * 86400 * 1000) .toLocaleDateString(undefined, { + timeZone: "UTC", weekday: "long", year: "numeric", month: "long", diff --git a/www/html/wordle/games.html b/www/html/wordle/games.html index 705a53e..bdf1ac0 100644 --- a/www/html/wordle/games.html +++ b/www/html/wordle/games.html @@ -134,8 +134,11 @@ + - -

    Wordle Board

    -
    - - - -
    - - - - - -
    
    -
    -
    -
    +
    + +
    - diff --git a/www/html/wordle/games.html b/www/html/wordle/games.html index c82e95b..7859299 100644 --- a/www/html/wordle/games.html +++ b/www/html/wordle/games.html @@ -1,218 +1,32 @@ - + -Recent Wordle Games - - + +Wordle history + + + + + + + - -
    -

    Recent Games for ...

    -
    Last 30 days (only days actually played)
    - - - -
    -
      - -
      -
      - - - +
      + +
      - diff --git a/www/html/wordle/history.js b/www/html/wordle/history.js new file mode 100644 index 0000000..fea9722 --- /dev/null +++ b/www/html/wordle/history.js @@ -0,0 +1,111 @@ +import { + h, icon, miniBoard, emptyState, skeleton, mountShell, profileHeader, normalizeUser, boardPage, + getHistory, today, formatDay, relativeDay, weekdayIndex, resultText, +} from "./wordle.js?v=2"; + +mountShell(); +const main = document.getElementById("main"); +const user = normalizeUser(new URLSearchParams(location.search).get("user")); +const PAGE_DAYS = 35; // five weeks at a time + +if (!user) { + document.title = "Wordle history"; + main.append(emptyState("Whose history?", "No player was given in the link.", + h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find a player"))); +} else { + document.title = `${user} · Wordle history`; + show(user); +} + +async function show(user) { + const header = profileHeader(user, "history", { meta: "Loading games…" }); + const summary = h("div", { class: "stat-grid" }, Array.from({ length: 4 }, () => skeleton("104px"))); + const calendar = h("div", { class: "card" }, skeleton("150px")); + const games = h("div", { class: "game-grid" }, Array.from({ length: 6 }, () => skeleton("190px"))); + const more = h("button", { class: "btn", type: "button", hidden: true }, icon("calendar"), `Load ${PAGE_DAYS} more days`); + main.append(header, h("div", { class: "stack", style: { "margin-top": "28px" } }, + summary, calendar, + h("div", { class: "card-head", style: { "margin-bottom": "0" } }, h("h2", {}, "Games"), h("span", { class: "sub" }, "Newest first")), + games, + h("div", { style: { "text-align": "center" } }, more))); + + const end = today(); + let start = end - PAGE_DAYS + 1; + let boards = await getHistory(user, end, PAGE_DAYS); + + const render = () => { + const wins = boards.filter(b => b.won); + const guesses = wins.reduce((sum, b) => sum + b.guesses, 0); + const span = end - start + 1; + header.setMeta(`${boards.length} games in the last ${span} days`); + + summary.replaceChildren( + stat(boards.length, "Games"), + stat(wins.length, "Wins"), + stat(boards.length ? `${Math.round((wins.length * 100) / boards.length)}%` : "-", "Win rate"), + stat(wins.length ? (Math.floor((guesses * 100) / wins.length) / 100).toFixed(2) : "-", "Average guesses")); + + calendar.replaceChildren( + h("div", { class: "card-head" }, + h("h2", {}, "Calendar"), + h("span", { class: "sub" }, `${formatDay(start, "medium")} - ${formatDay(end, "medium")} (UTC)`)), + heatmap(user, boards, start, end)); + + games.replaceChildren(...(boards.length + ? boards.map(board => gameCard(user, board)) + : [h("p", { style: { color: "var(--muted)", "grid-column": "1 / -1" } }, + `No games in the last ${span} days. Try loading more.`)])); + more.hidden = false; + }; + render(); + + more.addEventListener("click", async () => { + more.disabled = true; + more.replaceChildren("Loading…"); + const older = await getHistory(user, start - 1, PAGE_DAYS); + boards = [...boards, ...older]; + start -= PAGE_DAYS; + more.disabled = false; + more.replaceChildren(icon("calendar"), `Load ${PAGE_DAYS} more days`); + render(); + }); +} + +function stat(value, label) { + return h("div", { class: "stat" }, h("div", { class: "value" }, String(value)), h("div", { class: "label" }, label)); +} + +// GitHub-style grid: a column per week, Monday at the top +function heatmap(user, boards, start, end) { + const byDay = new Map(boards.map(board => [board.day, board])); + const cells = Array.from({ length: weekdayIndex(start) }, () => h("span", { class: "heat none" })); + for (let day = start; day <= end; day++) { + const board = byDay.get(day); + const cls = day === end ? "heat today" : "heat"; + if (!board) { + cells.push(h("span", { class: cls, title: `${formatDay(day, "short")}: not played` })); + continue; + } + const level = !board.won ? "loss" : board.guesses <= 2 ? "1" : board.guesses <= 4 ? "2" : "3"; + cells.push(h("a", { + class: cls, + href: boardPage(user, day), + "data-level": level, + title: `${formatDay(day, "short")}: ${resultText(board)}`, + "aria-label": `${formatDay(day, "short")}: ${resultText(board)}`, + })); + } + const key = (level, label) => [h("span", { class: "heat", "data-level": level }), label]; + return h("div", {}, + h("div", { class: "heatmap-wrap" }, h("div", { class: "heatmap" }, cells)), + h("div", { class: "legend" }, + key("1", "1-2 guesses"), key("2", "3-4"), key("3", "5-6"), key("loss", "missed"), + [h("span", { class: "heat" }), "not played"])); +} + +function gameCard(user, board) { + return h("a", { class: "game-card", href: boardPage(user, board.day) }, + h("span", { class: "date" }, relativeDay(board.day)), + miniBoard(board, { large: true }), + h("span", { class: board.won ? "badge win" : "badge loss" }, board.won ? `Solved ${resultText(board)}` : "Missed X/6")); +} diff --git a/www/html/wordle/home.js b/www/html/wordle/home.js new file mode 100644 index 0000000..5694251 --- /dev/null +++ b/www/html/wordle/home.js @@ -0,0 +1,269 @@ +import { + SSH_COMMAND, h, icon, tile, avatar, miniBoard, emptyState, skeleton, toast, copyText, countdown, + mountShell, normalizeUser, statsPage, boardPage, following, setFollowing, recentSearches, + addRecentSearch, getStats, getBoard, today, resultText, score, sleep, reducedMotion, +} from "./wordle.js?v=2"; + +mountShell({ active: "home" }); +const main = document.getElementById("main"); + +/* ---------- hero ---------- */ + +const heroTiles = [["W", "correct"], ["O", "present"], ["R", "absent"], ["D", "correct"], ["L", "present"], ["E", "correct"]] + .map(([letter, state], i) => tile(letter, state, { style: { "--i": i } })); + +const copyCommand = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("copy"), "Copy"); +copyCommand.addEventListener("click", async () => { + toast(await copyText(SSH_COMMAND) ? "Copied - paste it into a terminal" : "Couldn't copy, sorry"); +}); + +const terminalBody = h("div", { class: "terminal-body", "aria-hidden": "true" }); + +main.append(h("section", { class: "hero" }, + h("div", {}, + h("div", { class: "eyebrow" }, "The daily word game for your terminal"), + h("h1", { class: "hero-title", "aria-label": "Wordle" }, heroTiles), + h("p", { class: "lead" }, + "Six guesses. One word. ", h("strong", {}, "Everyone gets the same word every day"), + " - played over SSH, with your stats and boards collected here."), + h("div", { class: "cmd" }, h("span", { class: "prompt" }, "$"), h("code", {}, SSH_COMMAND), copyCommand), + h("p", { class: "countdown" }, "New word in ", countdown(h("span", { class: "mono" })), " (midnight UTC)")), + h("div", { class: "terminal" }, + h("div", { class: "terminal-bar" }, h("i"), h("i"), h("i"), h("span", {}, "wordle - ssh")), + terminalBody))); + +/* ---------- find a player & leaderboard ---------- */ + +const findHint = h("p", { class: "form-hint" }); +const findInput = h("input", { + class: "field", id: "find-input", type: "text", placeholder: "Username, e.g. adam", + autocomplete: "off", autocapitalize: "none", spellcheck: "false", "aria-describedby": "find-hint", +}); +findHint.id = "find-hint"; + +const findForm = h("form", { class: "input-row", role: "search" }, + h("label", { class: "sr-only", for: "find-input" }, "Username"), + findInput, + h("button", { class: "btn btn-primary", type: "submit" }, icon("search"), "View stats")); +findForm.addEventListener("submit", e => { + e.preventDefault(); + const user = normalizeUser(findInput.value); + if (!user) { + findHint.textContent = "Type a username to look up."; + findHint.classList.add("error"); + return; + } + addRecentSearch(user); + location.href = statsPage(user); +}); + +const recent = recentSearches(); +const leaderboard = h("div", { class: "leaderboard", "aria-live": "polite" }); + +main.append(h("section", { class: "grid-2", style: { "margin-top": "56px" } }, + h("div", { class: "card", id: "find" }, + h("div", { class: "card-head" }, h("h2", {}, "Find a player"), h("span", { class: "sub" }, "Stats, history and boards")), + findForm, + findHint, + recent.length ? h("div", { style: { "margin-top": "12px" } }, + h("div", { class: "sub", style: { color: "var(--muted)", "font-size": "0.85rem", "margin-bottom": "8px" } }, "Recently viewed"), + h("div", { class: "chips" }, recent.map(user => + h("a", { class: "chip", href: statsPage(user) }, avatar(user), user)))) : null), + h("div", { class: "card" }, + h("div", { class: "card-head" }, h("h2", {}, "Your leaderboard"), h("span", { class: "sub" }, "Today's results for players you follow")), + leaderboard, + followForm()))); + +function followForm() { + const input = h("input", { + class: "field", type: "text", placeholder: "Follow a player", autocomplete: "off", + autocapitalize: "none", spellcheck: "false", "aria-label": "Username to follow", + }); + const hint = h("p", { class: "form-hint" }); + const button = h("button", { class: "btn", type: "submit" }, icon("plus"), "Follow"); + const form = h("form", { class: "input-row", style: { "margin-top": "16px" } }, input, button); + form.addEventListener("submit", async e => { + e.preventDefault(); + const user = normalizeUser(input.value); + hint.classList.remove("error"); + if (!user) return; + if (following().includes(user)) { + hint.textContent = `You already follow ${user}.`; + return; + } + button.disabled = true; + const stats = await getStats(user).catch(() => null); + button.disabled = false; + if (!stats) { + hint.textContent = `No stats for "${user}" yet - they need to finish a daily game first.`; + hint.classList.add("error"); + return; + } + setFollowing(user, true); + input.value = ""; + hint.textContent = ""; + toast(`Following ${user}`); + renderLeaderboard(); + }); + return h("div", {}, form, hint); +} + +async function renderLeaderboard() { + const users = following(); + if (!users.length) { + leaderboard.replaceChildren(h("div", { class: "empty-state", style: { padding: "20px 8px" } }, + h("p", { style: { margin: "0" } }, + "Follow players to race them every day. Add them below, or use the ", + h("strong", {}, "Follow"), " button on their stats page."))); + return; + } + + leaderboard.replaceChildren(...users.map(() => skeleton("64px"))); + const day = today(); + const players = await Promise.all(users.map(async user => { + const [stats, board] = await Promise.all([ + getStats(user).catch(() => null), + getBoard(user, day).catch(() => null), + ]); + return { user, stats, board }; + })); + + // Fewest guesses today first, then losses, then not played yet + const rank = p => (p.board ? (p.board.won ? p.board.guesses : 7) : 8); + players.sort((a, b) => rank(a) - rank(b) + || (b.stats?.streak ?? 0) - (a.stats?.streak ?? 0) + || a.user.localeCompare(b.user)); + + leaderboard.replaceChildren(...players.map((p, i) => { + const remove = h("button", { class: "lb-remove", type: "button", "aria-label": `Unfollow ${p.user}`, title: "Unfollow" }, icon("close")); + remove.addEventListener("click", () => { + setFollowing(p.user, false); + toast(`Unfollowed ${p.user}`); + renderLeaderboard(); + }); + + const meta = p.stats + ? [ + h("span", { title: "Current streak" }, "🔥 ", String(p.stats.streak)), + h("span", { title: "Win rate" }, `${p.stats.winRate}% wins`), + p.stats.average != null ? h("span", { title: "Average guesses" }, `avg ${p.stats.average.toFixed(2)}`) : null, + ] + : [h("span", {}, "No stats yet")]; + + const todayResult = p.board + ? h("a", { class: "lb-today", href: boardPage(p.user, day), title: "Today's board" }, + miniBoard(p.board), + h("span", { class: p.board.won ? "badge win" : "badge loss" }, resultText(p.board))) + : h("div", { class: "lb-today" }, h("span", { class: "badge" }, "Not played")); + + return h("div", { class: "lb-row", style: { "--i": i } }, + h("div", { class: "lb-rank" }, p.board ? String(i + 1) : "-"), + avatar(p.user), + h("div", { style: { "min-width": "0" } }, + h("a", { class: "lb-name", href: statsPage(p.user) }, p.user), + h("div", { class: "lb-meta" }, meta)), + h("div", { class: "lb-right" }, todayResult, remove)); + })); +} + +/* ---------- how to play ---------- */ + +main.append(h("section", { id: "how", style: { "margin-top": "56px" } }, + h("div", { class: "card-head" }, + h("h2", { style: { "font-size": "1.5rem" } }, "How it works"), + h("span", { class: "sub" }, "No app, no account, no browser needed to play")), + h("div", { class: "steps" }, + h("div", { class: "step" }, + h("h3", {}, "Connect"), + h("p", {}, "Run ", h("code", {}, SSH_COMMAND), " and pick a username. There's no password - your username is your identity, so choose something unique.")), + h("div", { class: "step" }, + h("h3", {}, "Guess"), + h("p", {}, "Find the five letter word in six tries. Green is the right spot, yellow is in the word, grey is out. Hard mode makes you use what you've found.")), + h("div", { class: "step" }, + h("h3", {}, "Compare"), + h("p", {}, "Your board and stats appear here straight away. Follow friends to see how everyone did today."))))); + +renderLeaderboard(); + +/* ---------- terminal demo ---------- */ + +// Made-up games whose answers are never daily answers, so nothing is spoiled +const DEMOS = [ + { user: "guest", answer: "BYTES", guesses: ["CRANE", "TYPES", "BYTES"] }, + { user: "ada", answer: "PIPES", guesses: ["SLATE", "SNIPE", "PIPES"] }, + { user: "linus", answer: "GREPS", guesses: ["AUDIO", "STARE", "CORES", "GREPS"] }, +]; + +function line(...children) { + const el = h("div", { class: "line" }, children); + terminalBody.append(el); + return el; +} + +async function type(el, text, speed = 55) { + const caret = h("span", { class: "caret" }); + el.append(caret); + for (const ch of text) { + caret.before(ch); + await sleep(speed + Math.random() * 40); + } + caret.remove(); +} + +function demoBoard(demo, filled) { + const rows = []; + for (let r = 0; r < 6; r++) { + const guess = demo.guesses[r]; + const states = guess && r < filled ? score(guess, demo.answer) : []; + rows.push(h("div", { class: "row" }, + Array.from({ length: 5 }, (_, c) => tile(r < filled ? guess[c] : "", states[c] ?? "empty")))); + } + return h("div", { class: "term-board" }, rows); +} + +async function playDemo(demo) { + terminalBody.replaceChildren(); + await type(line(h("span", { class: "prompt" }, "$ ")), SSH_COMMAND); + await sleep(500); + await type(line("Username: "), demo.user, 90); + await sleep(300); + line("Do you want to enable hard mode [y/N]? n"); + await sleep(400); + + const board = demoBoard(demo, 0); + terminalBody.append(board); + const prompt = line("Enter Guess: "); + + for (const [r, guess] of demo.guesses.entries()) { + const tiles = board.children[r].children; + for (let c = 0; c < 5; c++) { + await sleep(140 + Math.random() * 80); + tiles[c].querySelector(".letter").textContent = guess[c]; + tiles[c].classList.add("pop"); + } + await sleep(350); + score(guess, demo.answer).forEach((state, c) => { + tiles[c].dataset.state = state; + tiles[c].style.setProperty("--c-i", c); + tiles[c].classList.add("flip"); + }); + await sleep(1100); + } + prompt.replaceChildren("YOU WIN"); + line(h("span", { class: "dim" }, `Guess - ${demo.guesses.length}/6`)); + await sleep(3200); +} + +async function runDemos() { + if (reducedMotion()) { + const demo = DEMOS[0]; + line(h("span", { class: "prompt" }, "$ "), SSH_COMMAND); + line(`Username: ${demo.user}`); + terminalBody.append(demoBoard(demo, demo.guesses.length)); + line("YOU WIN"); + return; + } + for (let i = 0; ; i = (i + 1) % DEMOS.length) await playDemo(DEMOS[i]); +} + +runDemos(); diff --git a/www/html/wordle/index.html b/www/html/wordle/index.html index 1c0ecc8..88b157e 100644 --- a/www/html/wordle/index.html +++ b/www/html/wordle/index.html @@ -1,121 +1,32 @@ - + -Wordle Viewer - - + +Wordle - the daily word game for your terminal + + + + + + + - -
      -

      Wordle Viewer

      -

      Enter a username to view their stats and games.

      - -
      -
      - - -
      -
      - -
      - You’ll be taken to stats.html?user=<name>. -
      -
      - - - +
      + +
      - diff --git a/www/html/wordle/stats.html b/www/html/wordle/stats.html index 4c18948..45607a9 100644 --- a/www/html/wordle/stats.html +++ b/www/html/wordle/stats.html @@ -1,177 +1,32 @@ - + -Wordle Stats - - - - - - -
      - -

      Wordle Stats for ...

      -
      Daily performance summary
      - - - -
      -
      Loading…
      -
      - -
      - + +Wordle stats + + + + + - + + + +
      + +
      - diff --git a/www/html/wordle/stats.js b/www/html/wordle/stats.js new file mode 100644 index 0000000..d5ac2d5 --- /dev/null +++ b/www/html/wordle/stats.js @@ -0,0 +1,138 @@ +import { + h, icon, miniBoard, emptyState, skeleton, toast, copyText, countUp, countdown, mountShell, + profileHeader, normalizeUser, statsPage, boardPage, getStats, getHistory, today, formatDay, + relativeDay, resultText, describeBoard, +} from "./wordle.js?v=2"; + +mountShell(); +const main = document.getElementById("main"); +const user = normalizeUser(new URLSearchParams(location.search).get("user")); +const FORM_DAYS = 14; + +if (!user) { + document.title = "Wordle stats"; + main.append(emptyState("Whose stats?", "No player was given in the link.", + h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find a player"))); +} else { + document.title = `${user} · Wordle stats`; + show(user); +} + +async function show(user) { + const copyLink = h("button", { class: "btn btn-sm", type: "button" }, icon("share"), "Copy link"); + copyLink.addEventListener("click", async () => { + toast(await copyText(location.origin + statsPage(user)) ? "Link copied" : "Couldn't copy, sorry"); + }); + const header = profileHeader(user, "stats", { meta: "Loading stats…", actions: [copyLink] }); + const body = h("div", { class: "stack", style: { "margin-top": "28px" } }, + h("div", { class: "stat-grid" }, Array.from({ length: 5 }, () => skeleton("104px"))), + h("div", { class: "grid-2" }, skeleton("320px"), skeleton("320px"))); + main.append(header, body); + + const day = today(); + let stats, recent; + try { + [stats, recent] = await Promise.all([getStats(user), getHistory(user, day, FORM_DAYS)]); + } catch { + header.setMeta(""); + body.replaceChildren(emptyState("Couldn't load stats", "Check your connection and try again.", + h("button", { class: "btn btn-primary", type: "button", onclick: () => location.reload() }, "Retry"))); + return; + } + + if (!stats) { + header.setMeta(""); + body.replaceChildren(emptyState(`No stats for ${user} yet`, + "Stats appear once a player finishes their first daily game. Check the spelling, or go and play!", + h("a", { class: "btn btn-primary", href: "/wordle/#find" }, icon("search"), "Find another player"), + h("a", { class: "btn", href: "/wordle/#how" }, "How to play"))); + return; + } + + header.setMeta(stats.lastWin + ? `${stats.played} games · last win in ${stats.lastWin}/6` + : `${stats.played} games played`); + + const todayBoard = recent.find(board => board.day === day); + body.replaceChildren( + statCards(stats), + h("div", { class: "grid-2" }, + distribution(stats), + h("div", { class: "stack" }, todayCard(user, todayBoard, day), formCard(user, recent, day)))); +} + +function statCards(stats) { + const card = (value, label, { decimals = 0, suffix = "", fx = "", hot = false } = {}) => { + const number = h("span", {}, "0"); + countUp(number, value, decimals); + return h("div", { class: hot ? "stat hot" : "stat" }, + fx ? h("span", { class: "fx", "aria-hidden": "true" }, fx) : null, + h("div", { class: "value" }, number, suffix), + h("div", { class: "label" }, label)); + }; + return h("div", { class: "stat-grid" }, + card(stats.played, "Played"), + card(stats.winRate, "Win rate", { suffix: "%" }), + card(stats.streak, "Current streak", { fx: stats.streak >= 3 ? "🔥" : "", hot: stats.streak >= 3 }), + card(stats.maxStreak, "Best streak", { fx: stats.maxStreak > 0 && stats.maxStreak === stats.streak ? "🏆" : "" }), + stats.average == null + ? h("div", { class: "stat" }, h("div", { class: "value" }, "-"), h("div", { class: "label" }, "Average guesses")) + : card(stats.average, "Average guesses", { decimals: 2 })); +} + +function distribution(stats) { + const rows = [...stats.hist.map((count, i) => ({ label: String(i + 1), count, last: stats.lastWin === i + 1 })), + { label: "X", count: stats.losses, miss: true }]; + const max = Math.max(1, ...rows.map(r => r.count)); + return h("div", { class: "card" }, + h("div", { class: "card-head" }, + h("h2", {}, "Guess distribution"), + h("span", { class: "sub" }, stats.lastWin ? "Green is the last win" : "")), + h("div", { class: "dist" }, rows.map((row, i) => + h("div", { + class: `dist-row${row.last ? " last" : ""}${row.miss ? " miss" : ""}`, + title: row.miss ? `${row.count} lost` : `${row.count} won in ${row.label}`, + }, + h("span", { class: "n" }, row.label), + h("div", { class: "dist-bar", style: { "--w": `${(row.count / max) * 100}%`, "--i": i } }, String(row.count)))))); +} + +function todayCard(user, board, day) { + const content = board + ? [ + h("a", { href: boardPage(user, day), title: "Open today's board" }, miniBoard(board, { large: true })), + h("div", { class: "text" }, + h("div", { class: "big" }, board.won ? `Solved ${resultText(board)}` : "Missed it"), + h("p", { style: { margin: "6px 0 14px", color: "var(--muted)" } }, describeBoard(board)), + h("a", { class: "btn btn-sm", href: boardPage(user, day) }, icon("grid"), "View board"))] + : [ + miniBoard(null, { large: true }), + h("div", { class: "text" }, + h("div", { class: "big" }, "Not played yet"), + h("p", { style: { margin: "6px 0 0", color: "var(--muted)" } }, + countdown(h("span", { class: "mono", style: { color: "var(--text)", "font-weight": "700" } })), + " left to play today"))]; + return h("div", { class: "card" }, + h("div", { class: "card-head" }, h("h2", {}, "Today"), h("span", { class: "sub" }, formatDay(day))), + h("div", { class: "today-card" }, content)); +} + +function formCard(user, recent, day) { + const byDay = new Map(recent.map(board => [board.day, board])); + const days = Array.from({ length: FORM_DAYS }, (_, i) => day - FORM_DAYS + 1 + i); + return h("div", { class: "card" }, + h("div", { class: "card-head" }, + h("h2", {}, "Recent form"), + h("span", { class: "sub" }, `${recent.length} of the last ${FORM_DAYS} days`)), + h("div", { class: "form-strip" }, days.map(d => { + const board = byDay.get(d); + const cls = d === day ? "form-day today" : "form-day"; + if (!board) return h("span", { class: cls, title: `${relativeDay(d)}: not played` }, "·"); + return h("a", { + class: cls, + href: boardPage(user, d), + "data-result": board.won ? "win" : "loss", + title: `${relativeDay(d)}: ${resultText(board)}`, + }, board.won ? String(board.guesses) : "✗"); + }))); +} diff --git a/www/html/wordle/wordle.css b/www/html/wordle/wordle.css new file mode 100644 index 0000000..19da514 --- /dev/null +++ b/www/html/wordle/wordle.css @@ -0,0 +1,679 @@ +/* Shared styles for the Wordle pages. After changing this file bump the + ?v= on its in every page so browsers fetch the new version. */ + +:root { + color-scheme: dark; + --bg: #070a08; + --surface: #0f1411; + --surface-2: #151c18; + --surface-3: #1b241f; + --border: #22302a; + --border-strong: #31463b; + --text: #e8f3ec; + --muted: #93a59a; + --faint: #5f7166; + --accent: #51ff7a; + --accent-strong: #3ee067; + --accent-ink: #03140a; + --glow: rgba(81, 255, 122, 0.3); + --danger: #ff6b6b; + + --correct: #26b24b; + --present: #d0ad2c; + --absent: #3b433f; + --empty: #111814; + --tile-border: #26332c; + --tile-text: #ffffff; + + --radius: 16px; + --radius-sm: 10px; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.45); + --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #f1f5f2; + --surface: #ffffff; + --surface-2: #f5f8f6; + --surface-3: #e9efeb; + --border: #dbe4de; + --border-strong: #c2d0c7; + --text: #0e1a13; + --muted: #536559; + --faint: #879a8e; + --accent: #0f9d3a; + --accent-strong: #0b8430; + --accent-ink: #ffffff; + --glow: rgba(15, 157, 58, 0.22); + --danger: #d63939; + + --correct: #5fa85a; + --present: #c9b458; + --absent: #7b8083; + --empty: #ffffff; + --tile-border: #d3d8d5; + --shadow: 0 18px 40px rgba(20, 40, 28, 0.12); +} + +/* Colour-blind friendly tiles, like the real Wordle's high contrast mode */ +:root[data-contrast="high"] { + --correct: #f5793a; + --present: #85c0f9; +} + +*, *::before, *::after { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); + font: 16px/1.5 var(--font); + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* Soft glow and faint grid behind everything */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(60rem 30rem at 15% -10%, var(--glow), transparent 60%), + radial-gradient(40rem 25rem at 110% 10%, rgba(208, 173, 44, 0.08), transparent 60%), + linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 44px 44px, + linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 44px 44px; + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.35) 45%, transparent 85%); + opacity: 0.55; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +h1, h2, h3 { line-height: 1.15; margin: 0; } +code, kbd, .mono { font-family: var(--mono); } +button { font: inherit; color: inherit; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; } +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +.container { width: 100%; max-width: 1080px; margin: 0 auto; padding: 0 16px; } +main { flex: 1; } +main.container { padding-top: 32px; padding-bottom: 64px; } +.stack > * + * { margin-top: 24px; } + +/* ---------- header / footer ---------- */ + +.site-header { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + background: color-mix(in srgb, var(--bg) 78%, transparent); + border-bottom: 1px solid var(--border); +} +.site-header .container { display: flex; align-items: center; gap: 16px; height: 64px; } +.site-nav { display: flex; align-items: center; gap: 4px; margin-left: auto; min-width: 0; } +.site-nav a { + color: var(--muted); + padding: 8px 12px; + border-radius: 999px; + font-size: 0.92rem; + white-space: nowrap; +} +.site-nav a:hover { color: var(--text); background: var(--surface-2); text-decoration: none; } +.site-nav a[aria-current="page"] { color: var(--text); background: var(--surface-3); } + +.logo { display: flex; gap: 4px; align-items: center; color: inherit; } +.logo:hover { text-decoration: none; } +.logo .tile { + width: 28px; + font-size: 0.95rem; + border-radius: 6px; + border-width: 0; +} +.logo:hover .tile { animation: hop 0.5s ease calc(var(--i) * 60ms); } +@keyframes hop { 40% { transform: translateY(-6px) rotate(-4deg); } } + +.icon-btn { + display: inline-grid; + place-items: center; + width: 40px; + height: 40px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; + flex: none; +} +.icon-btn:hover { background: var(--surface-3); } +.icon { width: 20px; height: 20px; flex: none; } + +.site-footer { + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 0.9rem; + padding: 24px 0; +} +.site-footer .container { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; justify-content: space-between; } + +/* ---------- buttons, inputs, chips ---------- */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 44px; + padding: 0 18px; + border-radius: 12px; + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + font-weight: 600; + cursor: pointer; + text-decoration: none; + user-select: none; + transition: transform 0.12s ease, background 0.2s ease, box-shadow 0.2s ease; +} +.btn:hover { background: var(--surface-3); text-decoration: none; } +.btn:active { transform: translateY(1px); } +.btn-primary { + background: var(--accent); + border-color: transparent; + color: var(--accent-ink); + box-shadow: 0 0 0 0 var(--glow); +} +.btn-primary:hover { background: var(--accent-strong); box-shadow: 0 0 24px var(--glow); } +.btn[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); border-color: transparent; } +.btn-sm { min-height: 36px; padding: 0 12px; font-size: 0.88rem; border-radius: 10px; } +.btn:disabled { opacity: 0.5; cursor: default; } + +.field { + flex: 1; + min-width: 0; + min-height: 48px; + padding: 0 16px; + border-radius: 12px; + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + font: inherit; +} +.field::placeholder { color: var(--faint); } +.field:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--glow); } + +.input-row { display: flex; gap: 8px; } +.form-hint { min-height: 1.4em; margin: 8px 0 0; font-size: 0.88rem; color: var(--muted); } +.form-hint.error { color: var(--danger); } + +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 12px 4px 4px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-size: 0.9rem; +} +.chip:hover { border-color: var(--border-strong); text-decoration: none; } + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + background: var(--surface-3); + color: var(--muted); + white-space: nowrap; +} +.badge.win { background: color-mix(in srgb, var(--correct) 22%, transparent); color: var(--text); } +.badge.loss { background: color-mix(in srgb, var(--danger) 18%, transparent); color: var(--text); } + +/* ---------- cards & sections ---------- */ + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; + box-shadow: var(--shadow); +} +.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; } +.card-head h2 { font-size: 1.15rem; } +.card-head .sub { color: var(--muted); font-size: 0.9rem; } +.eyebrow { + color: var(--accent); + font: 700 0.78rem/1 var(--mono); + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.grid-2 { display: grid; gap: 24px; grid-template-columns: 1fr; } +@media (min-width: 900px) { .grid-2 { grid-template-columns: 1.1fr 0.9fr; align-items: start; } } + +.empty-state { text-align: center; padding: 40px 16px; color: var(--muted); } +.empty-state h2 { color: var(--text); font-size: 1.4rem; margin-bottom: 8px; } +.empty-state .actions { margin-top: 20px; display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } + +/* ---------- tiles & boards ---------- */ + +.tile { + --c: var(--empty); + display: grid; + place-items: center; + aspect-ratio: 1; + border-radius: 8px; + border: 2px solid var(--tile-border); + background: var(--empty); + color: var(--tile-text); + font-weight: 800; + text-transform: uppercase; + line-height: 1; + user-select: none; +} +.tile[data-state="correct"] { --c: var(--correct); background: var(--c); border-color: transparent; } +.tile[data-state="present"] { --c: var(--present); background: var(--c); border-color: transparent; } +.tile[data-state="absent"] { --c: var(--absent); background: var(--c); border-color: transparent; } +:root[data-theme="light"] .tile[data-state="empty"] { color: var(--text); } + +.board { + display: grid; + gap: 7px; + width: min(100%, 340px); + margin: 0 auto; + perspective: 900px; +} +.board .row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 7px; } +.board .tile { font-size: clamp(1.4rem, 7vw, 2rem); } +.tile .letter { transition: opacity 0.25s ease, filter 0.25s ease; } +[data-letters="hidden"] .letter { opacity: 0; filter: blur(8px); } + +/* Tiles flip over one at a time to show their colour, like the game */ +.board.reveal .tile[data-state]:not([data-state="empty"]) { + animation: flip 0.6s ease both; + animation-delay: calc(var(--r) * 420ms + var(--c-i) * 110ms); +} +@keyframes flip { + 0% { transform: rotateX(0); background: var(--empty); border-color: var(--tile-border); } + 49.9% { transform: rotateX(-90deg); background: var(--empty); border-color: var(--tile-border); } + 50% { transform: rotateX(-90deg); background: var(--c); border-color: transparent; } + 100% { transform: rotateX(0); background: var(--c); border-color: transparent; } +} +.board.reveal .row.win-row .tile { + animation: flip 0.6s ease both, bounce 0.6s ease both; + animation-delay: calc(var(--r) * 420ms + var(--c-i) * 110ms), calc(var(--r) * 420ms + 900ms + var(--c-i) * 90ms); +} +@keyframes bounce { + 0%, 100% { transform: translateY(0); } + 40% { transform: translateY(-22px); } + 70% { transform: translateY(4px); } +} + +/* Spoiler-free coloured squares */ +.mini { + --s: 10px; + display: grid; + grid-template-columns: repeat(5, var(--s)); + gap: 2px; + flex: none; +} +.mini span { width: var(--s); height: var(--s); border-radius: 2px; background: var(--surface-3); } +.mini span[data-state="correct"] { background: var(--correct); } +.mini span[data-state="present"] { background: var(--present); } +.mini span[data-state="absent"] { background: var(--absent); } +.mini-lg { --s: 16px; gap: 3px; } +.mini-lg span { border-radius: 3px; } + +/* ---------- avatar ---------- */ + +.avatar { + --size: 44px; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 2px; + width: var(--size); + height: var(--size); + padding: calc(var(--size) / 9); + border-radius: calc(var(--size) / 4); + background: var(--surface-3); + border: 1px solid var(--border); + flex: none; +} +.avatar span { border-radius: 2px; } +.avatar span[data-state="correct"] { background: var(--correct); } +.avatar span[data-state="present"] { background: var(--present); } +.avatar-lg { --size: 84px; gap: 3px; } +.chip .avatar { --size: 26px; gap: 1px; padding: 3px; border-radius: 7px; } +.lb-row .avatar { --size: 36px; } +.avatar-lg span { border-radius: 3px; } + +/* ---------- profile header (stats, history, board pages) ---------- */ + +.profile { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; } +.profile h1 { font-size: clamp(1.8rem, 5vw, 2.6rem); letter-spacing: -0.02em; word-break: break-word; } +.profile .meta { color: var(--muted); margin-top: 4px; } +.profile .actions { display: flex; gap: 8px; flex-wrap: wrap; margin-left: auto; } + +.tabs { display: flex; gap: 4px; padding: 4px; border-radius: 14px; background: var(--surface); border: 1px solid var(--border); width: max-content; max-width: 100%; overflow-x: auto; } +.tabs a { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 10px; color: var(--muted); font-weight: 600; white-space: nowrap; } +@media (max-width: 480px) { + .tabs { width: 100%; } + .tabs a { flex: 1; justify-content: center; padding: 8px 6px; font-size: 0.88rem; } +} +.terminal-body .line { white-space: pre-wrap; } +.tabs a:hover { color: var(--text); text-decoration: none; } +.tabs a[aria-current="page"] { background: var(--surface-3); color: var(--text); } + +/* ---------- stats ---------- */ + +.stat-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } +.stat { + position: relative; + overflow: hidden; + padding: 18px; + border-radius: var(--radius); + background: var(--surface); + border: 1px solid var(--border); +} +.stat .value { font: 800 2.2rem/1 var(--mono); letter-spacing: -0.03em; } +.stat .label { margin-top: 8px; color: var(--muted); font-size: 0.88rem; } +.stat .fx { position: absolute; right: 12px; top: 12px; font-size: 1.3rem; opacity: 0.9; } +.stat.hot { border-color: color-mix(in srgb, var(--present) 55%, var(--border)); } +.stat.hot .value { color: var(--present); } + +.dist { display: grid; gap: 8px; } +.dist-row { display: grid; grid-template-columns: 1.6rem 1fr; align-items: center; gap: 10px; font-family: var(--mono); } +.dist-row .n { color: var(--muted); text-align: right; font-weight: 700; } +.dist-bar { + --w: 0%; + width: max(var(--w), 2.2rem); + min-height: 30px; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 10px; + border-radius: 8px; + background: var(--absent); + color: #fff; + font-weight: 800; + transform-origin: left; + animation: grow 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both; + animation-delay: calc(var(--i) * 70ms); +} +.dist-row.last .dist-bar { background: var(--correct); box-shadow: 0 0 18px color-mix(in srgb, var(--correct) 45%, transparent); } +.dist-row.miss .dist-bar { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); } +@keyframes grow { from { transform: scaleX(0); } } + +.form-strip { display: flex; gap: 6px; flex-wrap: wrap; } +.form-day { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + font: 800 0.9rem/1 var(--mono); + background: var(--surface-2); + border: 1px dashed var(--border-strong); + color: var(--faint); +} +.form-day[data-result] { border: 0; color: #fff; } +.form-day[data-result="win"] { background: var(--correct); } +.form-day[data-result="loss"] { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); } +a.form-day:hover { text-decoration: none; transform: translateY(-2px); } +.form-day.today { outline: 2px solid var(--accent); outline-offset: 2px; } + +.today-card { display: flex; gap: 20px; align-items: center; flex-wrap: wrap; } +.today-card .text { flex: 1; min-width: 180px; } +.today-card .big { font: 800 1.6rem/1.1 var(--mono); } + +/* ---------- history ---------- */ + +.heatmap-wrap { overflow-x: auto; padding-bottom: 4px; } +.heatmap { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 16px); grid-auto-columns: 16px; gap: 4px; width: max-content; } +.heat { + border-radius: 4px; + background: var(--surface-3); + border: 1px solid transparent; +} +.heat.none { background: transparent; } +.heat[data-level="1"] { background: color-mix(in srgb, var(--correct) 100%, transparent); } +.heat[data-level="2"] { background: color-mix(in srgb, var(--correct) 75%, var(--surface-3)); } +.heat[data-level="3"] { background: color-mix(in srgb, var(--correct) 50%, var(--surface-3)); } +.heat[data-level="loss"] { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); } +.heat.today { border-color: var(--accent); } +a.heat:hover { outline: 2px solid var(--text); outline-offset: 1px; } +.legend { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.8rem; margin-top: 10px; flex-wrap: wrap; } +.legend .heat { width: 12px; height: 12px; display: inline-block; } + +.game-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } +.game-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 16px; + border-radius: var(--radius); + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + transition: transform 0.15s ease, border-color 0.15s ease; +} +.game-card:hover { transform: translateY(-3px); border-color: var(--border-strong); text-decoration: none; } +.game-card .date { font-size: 0.85rem; color: var(--muted); } + +/* ---------- board page ---------- */ + +.board-stage { display: grid; gap: 22px; justify-items: center; text-align: center; } +.board-stage .headline { font: 800 1.1rem/1.2 var(--mono); } +.answer-row { display: flex; gap: 6px; justify-content: center; align-items: center; color: var(--muted); } +.answer-row .tile { width: 38px; font-size: 1.1rem; } +.hold-btn { touch-action: none; min-width: 230px; } +.hold-btn .icon { transition: transform 0.2s ease; } +.hold-btn[aria-pressed="true"] .icon { transform: scale(1.2); } +.nav-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } +.kbd-hint { color: var(--faint); font-size: 0.82rem; } +.kbd-hint kbd { padding: 1px 6px; border-radius: 5px; border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--muted); } + +/* ---------- home page ---------- */ + +.hero { display: grid; gap: 40px; align-items: center; padding: 24px 0 8px; } +@media (min-width: 900px) { .hero { grid-template-columns: 1.05fr 0.95fr; padding-top: 48px; } } +.hero-title { display: flex; gap: 8px; flex-wrap: wrap; margin: 18px 0 20px; } +.hero-title .tile { + width: clamp(44px, 12vw, 72px); + font-size: clamp(1.6rem, 6vw, 2.6rem); + animation: drop 0.7s cubic-bezier(0.2, 0.9, 0.3, 1.3) both; + animation-delay: calc(var(--i) * 90ms); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35); +} +@keyframes drop { from { transform: translateY(-40px) rotate(-12deg); opacity: 0; } } +.hero p.lead { font-size: 1.15rem; color: var(--muted); max-width: 34rem; margin: 0 0 24px; } +.hero p.lead strong { color: var(--text); } + +.cmd { + display: flex; + align-items: center; + gap: 12px; + max-width: 30rem; + padding: 8px 8px 8px 18px; + border-radius: 14px; + background: #050806; + border: 1px solid var(--border-strong); + font-family: var(--mono); + color: #d7ffe2; + box-shadow: 0 0 0 1px rgba(81, 255, 122, 0.08), 0 12px 30px rgba(0, 0, 0, 0.35); +} +.cmd .prompt { color: #51ff7a; } +.cmd code { flex: 1; overflow-x: auto; white-space: nowrap; } +.countdown { margin-top: 18px; color: var(--muted); font-size: 0.95rem; } +.countdown .mono { color: var(--text); font-weight: 700; font-size: 1.05rem; } + +.terminal { + border-radius: 16px; + overflow: hidden; + background: #050806; + border: 1px solid #1f2d25; + box-shadow: 0 30px 70px rgba(0, 0, 0, 0.5), 0 0 60px rgba(81, 255, 122, 0.12); + font-family: var(--mono); + color: #cfe9d6; +} +.terminal-bar { display: flex; gap: 8px; align-items: center; padding: 12px 14px; background: #0c120e; border-bottom: 1px solid #1a261f; } +.terminal-bar i { width: 12px; height: 12px; border-radius: 50%; background: #ff5f57; } +.terminal-bar i:nth-child(2) { background: #febc2e; } +.terminal-bar i:nth-child(3) { background: #28c840; } +.terminal-bar span { margin-left: 8px; font-size: 0.8rem; color: #6c8a76; } +.terminal-body { padding: 18px 20px 22px; min-height: 380px; font-size: 0.92rem; } +.terminal-body .line { min-height: 1.5em; white-space: pre; overflow: hidden; } +.terminal-body .prompt { color: #51ff7a; } +.terminal-body .dim { color: #5f7a68; } +.caret { display: inline-block; width: 0.6em; height: 1.1em; vertical-align: text-bottom; background: #51ff7a; animation: blink 1s steps(1) infinite; } +@keyframes blink { 50% { opacity: 0; } } +.term-board { display: grid; gap: 5px; width: 190px; margin: 12px 0 10px 2px; } +.term-board .row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 5px; } +.term-board .tile { font-size: 1rem; border-radius: 3px; border-width: 1px; } +.term-board .tile.pop { animation: pop 0.12s ease; } +@keyframes pop { 50% { transform: scale(1.12); } } +.term-board .tile.flip { animation: flip 0.5s ease both; animation-delay: calc(var(--c-i) * 120ms); } +.terminal .tile { --empty: #0b120e; --tile-border: #24342b; } + +.leaderboard { display: grid; gap: 8px; } +.lb-row { + display: grid; + grid-template-columns: 2rem auto 1fr auto; + align-items: center; + gap: 14px; + padding: 12px 14px; + border-radius: 14px; + background: var(--surface-2); + border: 1px solid var(--border); + animation: rise 0.45s ease both; + animation-delay: calc(var(--i) * 60ms); +} +@keyframes rise { from { opacity: 0; transform: translateY(8px); } } +.lb-rank { font: 800 1.1rem/1 var(--mono); color: var(--faint); text-align: center; } +.lb-row:nth-child(1) .lb-rank { color: #f5c542; } +.lb-row:nth-child(2) .lb-rank { color: #c9d3cf; } +.lb-row:nth-child(3) .lb-rank { color: #d9955b; } +.lb-name { font-weight: 700; color: var(--text); word-break: break-word; } +.lb-name:hover { text-decoration: underline; } +.lb-meta { color: var(--muted); font-size: 0.85rem; display: flex; gap: 12px; flex-wrap: wrap; } +.lb-today { display: flex; align-items: center; gap: 12px; justify-content: flex-end; } +.lb-remove { + width: 30px; height: 30px; border-radius: 8px; border: 0; background: transparent; color: var(--faint); cursor: pointer; +} +.lb-remove:hover { background: var(--surface-3); color: var(--text); } +.lb-right { display: flex; align-items: center; gap: 4px; justify-content: flex-end; } +@media (max-width: 560px) { + .lb-row { grid-template-columns: 1.6rem auto 1fr; } + .lb-right { grid-column: 2 / -1; justify-content: space-between; } +} + +.steps { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); counter-reset: step; } +.step { padding: 20px; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); } +.step::before { + counter-increment: step; + content: counter(step); + display: grid; + place-items: center; + width: 34px; + height: 34px; + margin-bottom: 12px; + border-radius: 9px; + background: var(--correct); + color: #fff; + font: 800 1rem/1 var(--mono); +} +.step:nth-child(2)::before { background: var(--present); } +.step:nth-child(3)::before { background: var(--absent); } +.step h3 { font-size: 1.05rem; margin-bottom: 6px; } +.step p { margin: 0; color: var(--muted); font-size: 0.95rem; } + +/* ---------- skeleton loading ---------- */ + +.skeleton { + border-radius: 10px; + background: linear-gradient(90deg, var(--surface-2), var(--surface-3), var(--surface-2)) 0 0 / 300% 100%; + animation: shimmer 1.4s ease infinite; +} +@keyframes shimmer { to { background-position: -150% 0; } } + +/* ---------- toast & dialog ---------- */ + +.toast { + position: fixed; + left: 50%; + bottom: 28px; + z-index: 50; + transform: translate(-50%, 20px); + opacity: 0; + padding: 12px 18px; + border-radius: 12px; + background: var(--text); + color: var(--bg); + font-weight: 700; + box-shadow: var(--shadow); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; +} +.toast.show { opacity: 1; transform: translate(-50%, 0); } + +dialog { + width: min(92vw, 420px); + padding: 0; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); +} +dialog::backdrop { background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(3px); } +.dialog-body { padding: 24px; display: grid; gap: 20px; } +.dialog-body h2 { font-size: 1.2rem; } +.setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.setting .desc { color: var(--muted); font-size: 0.85rem; } +.segmented { display: inline-flex; padding: 3px; border-radius: 10px; background: var(--surface-3); } +.segmented button { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; cursor: pointer; color: var(--muted); font-weight: 600; font-size: 0.88rem; } +.segmented button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); } +.switch { position: relative; width: 48px; height: 28px; border-radius: 999px; border: 0; background: var(--surface-3); cursor: pointer; flex: none; } +.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 22px; height: 22px; border-radius: 50%; background: var(--muted); transition: transform 0.2s ease, background 0.2s ease; } +.switch[aria-checked="true"] { background: var(--correct); } +.switch[aria-checked="true"]::after { transform: translateX(20px); background: #fff; } +.swatches { display: flex; gap: 4px; } +.swatches span { width: 18px; height: 18px; border-radius: 4px; } + +#confetti { position: fixed; inset: 0; pointer-events: none; z-index: 40; } + +@media (max-width: 640px) { + main { padding-top: 20px; } + .card { padding: 18px; } + .site-nav a { padding: 8px 9px; font-size: 0.86rem; } + .site-nav .wide { display: none; } + .logo .tile { width: 22px; font-size: 0.8rem; } + .profile .actions { margin-left: 0; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/www/html/wordle/wordle.js b/www/html/wordle/wordle.js new file mode 100644 index 0000000..23d4524 --- /dev/null +++ b/www/html/wordle/wordle.js @@ -0,0 +1,608 @@ +// Shared code for the Wordle pages: reading the game's board and stats +// files, and the bits of page every page uses. +// +// After changing this file bump the ?v= on the imports in the page scripts +// (and on their - +
      diff --git a/www/html/wordle/games.html b/www/html/wordle/games.html index 7859299..2f399f9 100644 --- a/www/html/wordle/games.html +++ b/www/html/wordle/games.html @@ -8,7 +8,7 @@ - + - +
      diff --git a/www/html/wordle/history.js b/www/html/wordle/history.js index fea9722..8bd62d4 100644 --- a/www/html/wordle/history.js +++ b/www/html/wordle/history.js @@ -1,7 +1,7 @@ import { h, icon, miniBoard, emptyState, skeleton, mountShell, profileHeader, normalizeUser, boardPage, getHistory, today, formatDay, relativeDay, weekdayIndex, resultText, -} from "./wordle.js?v=2"; +} from "./wordle.js?v=3"; mountShell(); const main = document.getElementById("main"); diff --git a/www/html/wordle/home.js b/www/html/wordle/home.js index 5694251..1fa6b99 100644 --- a/www/html/wordle/home.js +++ b/www/html/wordle/home.js @@ -2,7 +2,7 @@ import { SSH_COMMAND, h, icon, tile, avatar, miniBoard, emptyState, skeleton, toast, copyText, countdown, mountShell, normalizeUser, statsPage, boardPage, following, setFollowing, recentSearches, addRecentSearch, getStats, getBoard, today, resultText, score, sleep, reducedMotion, -} from "./wordle.js?v=2"; +} from "./wordle.js?v=3"; mountShell({ active: "home" }); const main = document.getElementById("main"); diff --git a/www/html/wordle/index.html b/www/html/wordle/index.html index 88b157e..3857550 100644 --- a/www/html/wordle/index.html +++ b/www/html/wordle/index.html @@ -8,7 +8,7 @@ - + - +
      diff --git a/www/html/wordle/stats.html b/www/html/wordle/stats.html index 45607a9..4499b70 100644 --- a/www/html/wordle/stats.html +++ b/www/html/wordle/stats.html @@ -8,7 +8,7 @@ - + - +
      diff --git a/www/html/wordle/stats.js b/www/html/wordle/stats.js index d5ac2d5..80aa310 100644 --- a/www/html/wordle/stats.js +++ b/www/html/wordle/stats.js @@ -2,7 +2,7 @@ import { h, icon, miniBoard, emptyState, skeleton, toast, copyText, countUp, countdown, mountShell, profileHeader, normalizeUser, statsPage, boardPage, getStats, getHistory, today, formatDay, relativeDay, resultText, describeBoard, -} from "./wordle.js?v=2"; +} from "./wordle.js?v=3"; mountShell(); const main = document.getElementById("main"); diff --git a/www/html/wordle/wordle.js b/www/html/wordle/wordle.js index 23d4524..abf3adc 100644 --- a/www/html/wordle/wordle.js +++ b/www/html/wordle/wordle.js @@ -27,6 +27,13 @@ export function h(tag, props = {}, ...children) { 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: '', gear: '', From 710c9c5b79083c5b7bcede1ce958d243391355d9 Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 21:30:02 +0000 Subject: [PATCH 19/20] 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 --- www/html/wordle/wordle.css | 22 ++++++++++++++++++++++ www/html/wordle/wordle.js | 31 ++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/www/html/wordle/wordle.css b/www/html/wordle/wordle.css index 19da514..b1c33d1 100644 --- a/www/html/wordle/wordle.css +++ b/www/html/wordle/wordle.css @@ -134,6 +134,28 @@ main.container { padding-top: 32px; padding-bottom: 64px; } .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 { diff --git a/www/html/wordle/wordle.js b/www/html/wordle/wordle.js index abf3adc..87e9bf8 100644 --- a/www/html/wordle/wordle.js +++ b/www/html/wordle/wordle.js @@ -557,13 +557,42 @@ export function mountShell({ active = null } = {}) { 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/#find", "Find a player", "find", "wide"), 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", From 698fe9d14a79c9f37b9389dff354ad806e7efdae Mon Sep 17 00:00:00 2001 From: adam Date: Tue, 29 Sep 2026 21:36:27 +0000 Subject: [PATCH 20/20] 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 --- www/html/wordle/board.js | 6 ++-- www/html/wordle/history.js | 31 +++++++++++-------- www/html/wordle/stats.js | 4 +-- www/html/wordle/wordle.css | 62 ++++++++++++++++++++++++-------------- www/html/wordle/wordle.js | 8 ++++- 5 files changed, 70 insertions(+), 41 deletions(-) diff --git a/www/html/wordle/board.js b/www/html/wordle/board.js index 7c46fdb..63df518 100644 --- a/www/html/wordle/board.js +++ b/www/html/wordle/board.js @@ -1,7 +1,7 @@ import { h, fill, icon, tile, bigBoard, emptyState, skeleton, toast, copyText, confetti, mountShell, profileHeader, parseBoardName, boardPage, historyPage, getBoard, getLastBoard, findGame, today, - formatDay, resultText, shareText, + formatDay, resultText, shareText, verdict, } from "./wordle.js?v=3"; mountShell(); @@ -57,12 +57,12 @@ async function show(user, day) { const answerEl = board.won || !board.answer ? null : h("div", { class: "answer-row", "data-letters": "hidden" }, h("span", {}, "The word was"), - [...board.answer].map(letter => tile(letter, "correct"))); + 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" } }, - board.won ? `Solved in ${resultText(board)}` : `Missed it - ${resultText(board)}`)), + `${verdict(board)} · ${resultText(board)}`)), boardEl, answerEl, revealButton(boardEl, answerEl), diff --git a/www/html/wordle/history.js b/www/html/wordle/history.js index 8bd62d4..451b43b 100644 --- a/www/html/wordle/history.js +++ b/www/html/wordle/history.js @@ -1,6 +1,6 @@ import { h, icon, miniBoard, emptyState, skeleton, mountShell, profileHeader, normalizeUser, boardPage, - getHistory, today, formatDay, relativeDay, weekdayIndex, resultText, + getHistory, today, formatDay, relativeDay, weekdayIndex, dayOfMonth, resultText, } from "./wordle.js?v=3"; mountShell(); @@ -19,12 +19,12 @@ if (!user) { async function show(user) { const header = profileHeader(user, "history", { meta: "Loading games…" }); - const summary = h("div", { class: "stat-grid" }, Array.from({ length: 4 }, () => skeleton("104px"))); + const 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" } }, - summary, calendar, + 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))); @@ -49,7 +49,7 @@ async function show(user) { h("div", { class: "card-head" }, h("h2", {}, "Calendar"), h("span", { class: "sub" }, `${formatDay(start, "medium")} - ${formatDay(end, "medium")} (UTC)`)), - heatmap(user, boards, start, end)); + calendarGrid(user, boards, start, end)); games.replaceChildren(...(boards.length ? boards.map(board => gameCard(user, board)) @@ -75,15 +75,19 @@ function stat(value, label) { return h("div", { class: "stat" }, h("div", { class: "value" }, String(value)), h("div", { class: "label" }, label)); } -// GitHub-style grid: a column per week, Monday at the top -function heatmap(user, boards, start, end) { +// 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 = Array.from({ length: weekdayIndex(start) }, () => h("span", { class: "heat none" })); + 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 cls = day === end ? "heat today" : "heat"; + 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` })); + 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"; @@ -93,14 +97,15 @@ function heatmap(user, boards, start, end) { "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: "heat", "data-level": level }), label]; + + const key = (level, label) => [h("span", { class: "cal-key", "data-level": level }), label]; return h("div", {}, - h("div", { class: "heatmap-wrap" }, h("div", { class: "heatmap" }, cells)), + 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: "heat" }), "not played"])); + [h("span", { class: "cal-key" }), "not played"])); } function gameCard(user, board) { diff --git a/www/html/wordle/stats.js b/www/html/wordle/stats.js index 80aa310..5d99587 100644 --- a/www/html/wordle/stats.js +++ b/www/html/wordle/stats.js @@ -1,7 +1,7 @@ import { h, icon, miniBoard, emptyState, skeleton, toast, copyText, countUp, countdown, mountShell, profileHeader, normalizeUser, statsPage, boardPage, getStats, getHistory, today, formatDay, - relativeDay, resultText, describeBoard, + relativeDay, resultText, describeBoard, verdict, } from "./wordle.js?v=3"; mountShell(); @@ -102,7 +102,7 @@ function todayCard(user, board, day) { ? [ h("a", { href: boardPage(user, day), title: "Open today's board" }, miniBoard(board, { large: true })), h("div", { class: "text" }, - h("div", { class: "big" }, board.won ? `Solved ${resultText(board)}` : "Missed it"), + h("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"))] : [ diff --git a/www/html/wordle/wordle.css b/www/html/wordle/wordle.css index b1c33d1..a964afc 100644 --- a/www/html/wordle/wordle.css +++ b/www/html/wordle/wordle.css @@ -379,11 +379,12 @@ main.container { padding-top: 32px; padding-bottom: 64px; } 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: 36px; } +.lb-row .avatar { --size: 38px; gap: 1px; padding: 4px; } .avatar-lg span { border-radius: 3px; } /* ---------- profile header (stats, history, board pages) ---------- */ @@ -399,7 +400,6 @@ main.container { padding-top: 32px; padding-bottom: 64px; } .tabs { width: 100%; } .tabs a { flex: 1; justify-content: center; padding: 8px 6px; font-size: 0.88rem; } } -.terminal-body .line { white-space: pre-wrap; } .tabs a:hover { color: var(--text); text-decoration: none; } .tabs a[aria-current="page"] { background: var(--surface-3); color: var(--text); } @@ -443,12 +443,11 @@ main.container { padding-top: 32px; padding-bottom: 64px; } .dist-row.miss .dist-bar { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); } @keyframes grow { from { transform: scaleX(0); } } -.form-strip { display: flex; gap: 6px; flex-wrap: wrap; } +.form-strip { display: grid; grid-template-columns: repeat(7, minmax(0, 40px)); gap: 6px; } .form-day { display: grid; place-items: center; - width: 34px; - height: 34px; + aspect-ratio: 1; border-radius: 8px; font: 800 0.9rem/1 var(--mono); background: var(--surface-2); @@ -467,22 +466,39 @@ a.form-day:hover { text-decoration: none; transform: translateY(-2px); } /* ---------- history ---------- */ -.heatmap-wrap { overflow-x: auto; padding-bottom: 4px; } -.heatmap { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 16px); grid-auto-columns: 16px; gap: 4px; width: max-content; } -.heat { - border-radius: 4px; - background: var(--surface-3); - border: 1px solid transparent; +.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; } -.heat.none { background: transparent; } -.heat[data-level="1"] { background: color-mix(in srgb, var(--correct) 100%, transparent); } -.heat[data-level="2"] { background: color-mix(in srgb, var(--correct) 75%, var(--surface-3)); } -.heat[data-level="3"] { background: color-mix(in srgb, var(--correct) 50%, var(--surface-3)); } -.heat[data-level="loss"] { background: color-mix(in srgb, var(--danger) 70%, var(--absent)); } -.heat.today { border-color: var(--accent); } -a.heat:hover { outline: 2px solid var(--text); outline-offset: 1px; } -.legend { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.8rem; margin-top: 10px; flex-wrap: wrap; } -.legend .heat { width: 12px; height: 12px; display: inline-block; } +.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 { @@ -504,13 +520,15 @@ a.heat:hover { outline: 2px solid var(--text); outline-offset: 1px; } .board-stage { display: grid; gap: 22px; justify-items: center; text-align: center; } .board-stage .headline { font: 800 1.1rem/1.2 var(--mono); } -.answer-row { display: flex; gap: 6px; justify-content: center; align-items: center; color: var(--muted); } +.answer-row { 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 ---------- */ @@ -562,7 +580,7 @@ a.heat:hover { outline: 2px solid var(--text); outline-offset: 1px; } .terminal-bar i:nth-child(3) { background: #28c840; } .terminal-bar span { margin-left: 8px; font-size: 0.8rem; color: #6c8a76; } .terminal-body { padding: 18px 20px 22px; min-height: 380px; font-size: 0.92rem; } -.terminal-body .line { min-height: 1.5em; white-space: pre; overflow: hidden; } +.terminal-body .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; } diff --git a/www/html/wordle/wordle.js b/www/html/wordle/wordle.js index 87e9bf8..e0134f9 100644 --- a/www/html/wordle/wordle.js +++ b/www/html/wordle/wordle.js @@ -107,6 +107,7 @@ export function formatDay(day, style = "long") { 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] }); } @@ -119,6 +120,7 @@ export function relativeDay(day) { } 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)); @@ -329,6 +331,10 @@ export function score(guess, answer) { 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"; } @@ -637,7 +643,7 @@ export function followButton(user) { const on = !isFollowing(user); setFollowing(user, on); render(); - toast(on ? `Added ${user} to your leaderboard` : `Removed ${user} from your leaderboard`); + toast(on ? `Added ${user} to your leaderboard on the home page` : `Removed ${user} from your leaderboard`); }); render(); return button;