Compare commits

72 Commits
Author SHA1 Message Date
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
adam efa709e34e add online mode flag to help message
Deploy game to server / deploy (push) Successful in 8s
2025-11-23 21:22:10 +00:00
adam a7cdb712fd fix typo in function name
Deploy game to server / deploy (push) Successful in 7s
2025-09-27 21:14:39 +00:00
adam 75074d8e84 add stats url to online mode
Deploy game to server / deploy (push) Successful in 7s
2025-08-28 09:59:26 +00:00
adam 3487adf34c exit save_result if not in online mode
Deploy game to server / deploy (push) Successful in 30s
2025-04-23 21:22:03 +00:00
adam 2194d495e6 extra file for current board
Deploy game to server / deploy (push) Successful in 42s
2025-03-03 12:07:43 +00:00
adam 005a129919 change help message format
Deploy game to server / deploy (push) Successful in 6s
2025-02-22 20:00:30 +00:00
adam bdb3d4e4d1 Move signal trap initialization after game mode questions - Prevent progress files after incorrect username 2025-02-22 19:41:46 +00:00
adam 6bbcc5229b save progress at start of a game 2025-02-22 19:38:06 +00:00
adam 4029c00edc remove $ where not needed 2025-02-22 19:22:45 +00:00
adam 8327678209 only show board on daily win
Deploy game to server / deploy (push) Successful in 6s
2025-02-21 13:21:04 +00:00
adam d496319361 Add guess count out of 6 on win
Deploy game to server / deploy (push) Successful in 7s
2025-02-21 13:12:19 +00:00
adam 107fd30b00 print spoiler free board after a game 2025-02-21 13:07:03 +00:00
adam 0a5d0be050 change spacing on math
Deploy game to server / deploy (push) Successful in 7s
2025-02-20 12:50:17 +00:00
adam 357c74cf08 calculate average guesses and fix divide by 0 bug
Deploy game to server / deploy (push) Successful in 8s
2025-02-16 17:05:57 +00:00
adam e138f71a62 start games played at 0
Deploy game to server / deploy (push) Successful in 6s
2025-02-14 16:31:02 +00:00
adam 064b85aac2 add 1 to graph axis to match number of guesses
Deploy game to server / deploy (push) Successful in 7s
2025-02-14 16:27:01 +00:00
adam fba5cc07ae fix pool issue bug
Deploy game to server / deploy (push) Successful in 5s
2025-02-14 16:14:31 +00:00
adam ce2a14fbe6 track stat
Deploy game to server / deploy (push) Successful in 6s
2025-02-14 16:00:39 +00:00
adam 138c254591 initalise stats to 0 and always update
Deploy game to server / deploy (push) Successful in 7s
2025-02-14 14:43:07 +00:00
adam dfe8ea7f13 track user stats 2025-02-14 14:05:45 +00:00
adam 1ee78db9bf only print share URL after daily wordle
Deploy game to server / deploy (push) Successful in 7s
2025-02-13 23:43:14 +00:00
adam da696e5df2 display share URL with online mode
Deploy game to server / deploy (push) Successful in 7s
2025-02-13 23:37:03 +00:00
adam 00490f53c7 Merge branch 'feature/online_mode'
Deploy game to server / deploy (push) Successful in 7s
2025-02-12 19:42:35 +00:00
adam 8d92cfe57d fix strict hard mode pool bug with double letters 2025-02-12 19:21:14 +00:00
adam a7a19d33b6 uncomment epoch thing 2025-02-12 13:50:06 +00:00
adam 2dc552c74e after daily word complete new words can be played 2025-02-12 13:36:01 +00:00
adam 556c1ec598 working, may still be bugs 2025-02-12 11:30:38 +00:00
adam ab97bcea65 resume partially working 2025-02-11 20:43:28 +00:00
adam 7d14d5dae9 use proper newline
Deploy game to server / deploy (push) Successful in 7s
2025-02-11 19:57:08 +00:00
adam f73667b6a9 change format order 2025-02-10 19:12:35 +00:00
adam 47ab1d243e only bold for letters 2025-02-10 16:38:47 +00:00
adam 1cbdfe5858 go to start of terminal outside of render board 2025-02-10 16:24:28 +00:00
adam 31a7332af1 divide instead to get number of day since epoch
Deploy game to server / deploy (push) Successful in 8s
2025-02-10 16:09:13 +00:00
adam 7c3b695737 output user results
Deploy game to server / deploy (push) Successful in 6s
2025-02-10 16:00:30 +00:00
adam e6b5a3da7a Interactive prompt for online mode
Deploy game to server / deploy (push) Successful in 6s
2025-02-10 13:05:06 +00:00
adam c530058c9b hard mode now check all previous rounds for yellow placement
Deploy game to server / deploy (push) Successful in 7s
2025-02-10 13:00:11 +00:00
adam fa2fdf80b7 fix filename
Deploy game to server / deploy (push) Successful in 5s
2025-02-08 20:12:34 +00:00
adam 2ff11722b4 add deploy workflow
Deploy game to server / deploy (push) Failing after 4s
2025-02-08 20:11:31 +00:00
adam 26e4eeb356 remove output 2025-02-07 16:19:06 +00:00
adam 7e280ce64b fix win logic error 2025-02-07 15:39:05 +00:00
adam 0cf5a68052 reset before restoring cursor position 2025-02-07 15:26:06 +00:00
adam 6733031ed9 test fix for mac terminal 2025-02-07 15:20:40 +00:00
adam 9745876be1 remove extra space 2025-01-30 00:07:47 +00:00
adam 5640a28b5b remove unused variable 2025-01-30 00:06:17 +00:00
adam cf7f5da7e5 keyboard toggle option 2025-01-29 22:41:04 +00:00
adam 2ee79b85db basic keyboard function 2025-01-29 22:41:04 +00:00
adam 1e6942c5b7 combine into one line 2025-01-29 21:21:24 +00:00
adam b31e0442e3 save correct position 2025-01-29 21:21:24 +00:00
adam 9218601b52 fix logic for hard mode and strict hard mode
Reviewed-on: #2
2025-01-29 13:19:30 +00:00
13 changed files with 2588 additions and 63 deletions

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
name: Deploy game to server
on:
workflow_dispatch: # Allows manual execution
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H "${{ vars.REMOTE_SERVER }}" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Transfer file via SCP
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
+382 -63
View File
@@ -6,9 +6,11 @@ declare -gr HEIGHT=6
declare -gr LETTER=$'\e[97m' declare -gr LETTER=$'\e[97m'
declare -gr GREY=$'\e[48;5;8m' declare -gr GREY=$'\e[48;5;8m'
declare -gr GREEN=$'\e[48;5;10m' declare -gr GREEN=$'\e[48;5;10m'
declare -gr LETTER_GR=$'\e[32m'
declare -gr YELLOW=$'\e[48;5;3m' declare -gr YELLOW=$'\e[48;5;3m'
declare -gr RESET=$'\e[0m' declare -gr RESET=$'\e[0m'
declare -gr BOLD=$'\e[1m' declare -gr BOLD=$'\e[1m'
declare -gr UNBOLD=$'\e[22m'
declare -gr CLEAR_UP1=$'\e[1F\e[K' declare -gr CLEAR_UP1=$'\e[1F\e[K'
declare -gr CLEAR_LINE=$'\e[K' declare -gr CLEAR_LINE=$'\e[K'
declare -gr CLEAR_2END=$'\e[0K' declare -gr CLEAR_2END=$'\e[0K'
@@ -21,40 +23,75 @@ declare -gr UP1=$'\e[1F'
declare -gr HIDE_CURSOR=$'\e[?25l' declare -gr HIDE_CURSOR=$'\e[?25l'
declare -gr SHOW_CURSOR=$'\e[?25h' declare -gr SHOW_CURSOR=$'\e[?25h'
declare -gr GOHOME=$'\e[H' declare -gr GOHOME=$'\e[H'
declare -gr DOWN1=$'\e[1E'
declare -gr INDENT=$'\e[1C' declare -gr INDENT=$'\e[1C'
function main() { function main() {
declare -Ag BOARD
declare -g ROW=0
declare -g WIN=0
declare -g ANSWER declare -g ANSWER
declare -g POOL='ABCDEFGHIJKLMNOPQRSTUVWXYZ' declare -g POOL='QWERTYUIOPASDFGHJKLZXCVBNM'
declare -Ag YELLOWS
declare -Ag KEYS
ANSWER=$(select_random_word) ANSWER=$(select_random_word)
set_options "$@" set_options "$@"
echo -en "${CLEAR_SCREEN}" echo -en "${CLEAR_SCREEN}"
echo -en "${GOHOME}"
if [[ "${RESUME}" == 1 ]]; then
load_game
if [[ "${PLAYED}" == 1 ]] && [[ "${IN_PROGRESS}" != 1 ]]; then
ANSWER=$(select_random_word)
init_board init_board
fi
else
init_board
fi
[[ "${ONLINE}" == 1 ]] && save_game "${ROW}"
render_board render_board
declare row for (( ROW; ROW<HEIGHT; ROW++ )); do
for (( row=0; row<HEIGHT; row++ )); do
declare GUESS declare GUESS
GUESS=$(retrive_guess) [[ "${KEYBOARD}" == 1 ]] && render_keyboard
check_guess "${GUESS}" "${row}" GUESS=$(retrieve_guess) || { echo -en "${NEWLINE}"; exit 0; }
win=$? check_guess "${GUESS}" "${ROW}" ; WIN=$?
reveal_row "${row}" # Save before the reveal so quitting or disconnecting can't undo a guess
[[ $win == 5 ]] && echo -e "${CLEAR_DOWN}YOU WIN" && exit 0 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
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"
exit 0
fi
done done
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}" render_answer
echo -en "${CLEAR_LINE}${BOLD}"
for (( i=0; i<WIDTH; i++ )); do
echo -en " ${ANSWER:$i:1} "
done
echo -en "${NEWLINE}${RESET}"
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
} }
function select_random_word() { function select_random_word() {
@@ -64,12 +101,16 @@ function select_random_word() {
} }
function set_options() { function set_options() {
declare -g BORDER=1 declare -g BORDER=1
declare -g HARD_MODE=0 declare -g HARD_MODE=0
declare -g STRICT_HARD_MODE=0 declare -g STRICT_HARD_MODE=0
declare -g DELAY=0.5 declare -g DELAY=0.5
declare -g KEYBOARD=1
declare -g ONLINE=0
declare -g RESUME=0
while getopts "bw:HSdh" OPT; do while getopts "bw:HSkodh" OPT; do
case "${OPT}" in case "${OPT}" in
b) b)
BORDER=0 BORDER=0
@@ -89,17 +130,77 @@ function set_options() {
HARD_MODE=1 HARD_MODE=1
STRICT_HARD_MODE=1 STRICT_HARD_MODE=1
;; ;;
k)
KEYBOARD=0
;;
o)
ONLINE=1
declare -gr URL_BASE="https://www.adbraun.lol/wordle"
echo -en "${CLEAR_SCREEN}"
declare -g 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 ))
declare -gr PROGRESS_FILE=".${USER}-${DAY}.progress"
declare -gr RESULT_FILE="${USER}-${DAY}.board"
declare -gr CUR_RESULT_FILE="${USER}.board"
declare -gr STATS_SOURCE=".${USER}.stats"
declare -gr STATS_FILE="${USER}.stats"
if [[ -f "${PROGRESS_FILE}" ]] && [[ -s "${PROGRESS_FILE}" ]]; then
load_game
RESUME=1
[[ "${PLAYED}" == 1 ]] && declare -g DAILY_DONE=1
fi
if [[ -z "${IN_PROGRESS}" ]]; then
declare HM
read -r -p "Do you want to enable hard mode [y/N]? " -n 1 HM
if [[ "${HM}" =~ ^[Yy]$ ]]; then
HARD_MODE=1
echo -en "${NEWLINE}"
read -r -p "Do you want to enable strict hard mode [y/N]? " -n 1 HM
[[ "${HM}" =~ ^[Yy]$ ]] && STRICT_HARD_MODE=1
fi
fi
# Progress is saved after every guess so Ctrl-C only needs to tidy the terminal
trap 'echo -en "${SHOW_CURSOR}${RESET}"; exit 0' SIGINT
;;
d) d)
DELAY=0 DELAY=0
;; ;;
h) h)
echo -e "Usage: <program_name> [OPTIONS] echo -e \
"Usage: wordle [OPTIONS]
Play a game for Wordle Play a game for Wordle
Options: Options:
-b Toggle borderless mode -b Toggle borderless mode
-w Set target word -w Set target word
-H Enable hard mode -H Enable hard mode
-S Enable strict hard mode (Cannot reuse grey letters) -S Enable strict hard mode (Cannot reuse grey letters)
-k disable on screen keyboard
-o Enable online mode
-d Enable debug mode (no delay during letter reveal)
-h Show this help message and exit" -h Show this help message and exit"
exit exit
@@ -113,7 +214,6 @@ Options:
} }
function init_board() { function init_board() {
declare -Ag BOARD
for (( row=0; row<HEIGHT; row++ )); do for (( row=0; row<HEIGHT; row++ )); do
for (( char=0; char<WIDTH; char++ )); do for (( char=0; char<WIDTH; char++ )); do
BOARD[${row},${char}]="${GREY} " BOARD[${row},${char}]="${GREY} "
@@ -122,7 +222,6 @@ function init_board() {
} }
function render_board() { function render_board() {
echo -en "${GOHOME}"
declare row declare row
declare char declare char
@@ -134,33 +233,78 @@ function render_board() {
echo -n "${BOARD[${row},${char}]}${RESET}" echo -n "${BOARD[${row},${char}]}${RESET}"
done done
[[ "${BORDER}" == 1 ]] && echo -en " │" [[ "${BORDER}" == 1 ]] && echo -en " │"
echo -en "${DOWN1}" echo -en "${NEWLINE}"
done done
[[ "${BORDER}" == 1 ]] && echo -e "└─────────────────┘" [[ "${BORDER}" == 1 ]] && echo -e "└─────────────────┘"
} }
function retrive_guess() { 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 if [[ -n $1 ]]; then
read -r -p "${CLEAR_UP1}$1: " GUESS read -r -p "${CLEAR_UP1}$1: " GUESS || end_of_input
else else
read -r -p "${CLEAR_2END}Enter Guess: " GUESS read -r -p "${CLEAR_2END}Enter Guess: " GUESS || end_of_input
fi fi
while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS || end_of_input
done done
declare valid_guess declare valid_guess
validate_guess "${GUESS}" & validate_guess "${GUESS}" ; valid_guess=$?
declare validation_pid=$!
wait $validation_pid
valid_guess=$?
[[ ${valid_guess} == 1 ]] && GUESS=$(retrive_guess "Not A Valid Word") declare message
[[ ${valid_guess} == 2 ]] && GUESS=$(retrive_guess "Not A Valid Guess") 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^^}" 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"
for (( i=0; i<${#POOL}; i++ )); do
if [[ $i == 10 ]]; then
echo -en "${NEWLINE}${INDENT}"
elif [[ $i == 19 ]]; then
echo -en "${NEWLINE} "
fi
declare key="${POOL:$i:1}"
if [[ -n "${KEYS[$key]}" ]]; then
echo -n "${KEYS[$key]}${BOLD}${LETTER}${key}${RESET} "
else
echo -n "${key} "
fi
done
echo -en "${NEWLINE}"
}
function validate_guess() { function validate_guess() {
declare GUESS=$1 declare GUESS=$1
@@ -177,76 +321,81 @@ function validate_guess() {
} }
function hard_mode_check() { function hard_mode_check() {
[[ -z "${row}" || "${row}" == 0 ]] && return 0 [[ -z "${ROW}" || "${ROW}" == 0 ]] && return 0
declare GUESS=$1 declare GUESS=$1
for (( i=0; i<WIDTH; i++ )); do 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 if [[ "${STRICT_HARD_MODE}" == 1 ]]; then
[[ "${POOL}" == *${GUESS:i:1}* ]] || return 2 [[ "${POOL}" == *${GUESS:i:1}* ]] || return 5
fi fi
color_check "${GUESS:i:1}" $i && continue if [[ -n "${greens[i]}" ]]; then
[[ "${greens[i]}" != "${GUESS:i:1}" ]] && return 3
if [[ "${yellows}" == *${GUESS:i:1}* ]]; then
yellows="${yellows/${GUESS:i:1}/}"
color_check "${GUESS:i:1}" $i && return 2
fi fi
if [[ -n "${yellows[i]}" ]]; then
[[ ! "${GUESS}" == *"${yellows[i]}"* ]] && return 4
fi
for (( r=ROW-1; r>=0; r-- )); do
CHAR="${GUESS:i:1}"
PREV_CHAR="${YELLOWS[$r,$i]}"
[[ "${CHAR}" == "${PREV_CHAR}" ]] && return 6
done
done done
return 0 return 0
} }
function color_check() {
declare -r CHAR=$1
declare -r c=$2
declare -r r=$(( row-1 ))
PREV_ROW="${BOARD[$r,$c]}"
PREV_CHAR="${PREV_ROW: -2:1}"
[[ "${CHAR}" == "${PREV_CHAR}" ]] && return 0 || return 1
}
function check_guess() { function check_guess() {
declare -r _guess=$1 declare -r _guess=$1
declare -r _row=$2 declare -r _row=$2
declare -r _answer="${ANSWER}" declare -r _answer="${ANSWER}"
declare green_count=0
declare chars="" declare chars=""
declare -g greens=""
declare -g yellows="" declare -ga yellows=()
declare -ga greens=()
for (( i=0; i<WIDTH; i++ )); do for (( i=0; i<WIDTH; i++ )); do
[[ "${_answer:i:1}" != "${_guess:i:1}" ]] && chars+=${_answer:i:1} [[ "${_answer:i:1}" != "${_guess:i:1}" ]] && chars+=${_answer:i:1}
done done
for (( c=0; c<WIDTH; c++ )); do for (( c=0; c<WIDTH; c++ )); do
BOARD[$_row,$c]="${BOLD}${LETTER}${GREY} ${_guess:$c:1} " BOARD[$_row,$c]="${GREY}${BOLD}${LETTER} ${_guess:$c:1} ${UNBOLD}"
declare _char=${_guess:$c:1} declare _char=${_guess:$c:1}
if [[ "${_char}" == "${_answer:$c:1}" ]]; then if [[ "${_char}" == "${_answer:$c:1}" ]]; then
green_count=$(( green_count+1 )) greens[c]="${_char}"
greens+="${_char}" KEYS[$_char]="${GREEN}"
BOARD[$_row,$c]="${BOLD}${LETTER}${GREEN} ${_char} " BOARD[$_row,$c]="${GREEN}${BOLD}${LETTER} ${_char} ${UNBOLD}"
[[ $green_count == 5 ]] && return 5
continue continue
fi fi
if [[ "${chars}" == *${_char}* ]]; then if [[ "${chars}" == *${_char}* ]]; then
chars="${chars/${_char}/}" chars="${chars/${_char}/}"
yellows+="${_char}" yellows[c]="${_char}" # volatile - stores previous row yellows
BOARD[$_row,$c]="${BOLD}${LETTER}${YELLOW} ${_char} " YELLOWS[$_row,$c]="${_char}" # stores all yellow from all rounds
else [[ "${KEYS[$_char]}" != "${GREEN}" ]] && KEYS[$_char]="${YELLOW}"
POOL="${POOL/${_char}/}" BOARD[$_row,$c]="${YELLOW}${BOLD}${LETTER} ${_char} ${UNBOLD}"
fi fi
[[ "${ANSWER}" =~ ${_char} ]] && continue
[[ " ${yellows[*]} " =~ ${_char} ]] && continue
[[ " ${greens[*]} " =~ ${_char} ]] && continue
POOL="${POOL/${_char}/_}"
done done
[[ "${#greens[*]}" == 5 ]] && return 5
} }
function reveal_row() { function reveal_row() {
echo -en "${SAVE}" echo -en "${UP1}${SAVE}"
echo -en "${HIDE_CURSOR}" echo -en "${HIDE_CURSOR}"
# Must increment by 1 Row as 0 == 1 for escape code # Must increment by 1 Row as 0 == 1 for escape code
declare RNUM=$(( $1+1+BORDER )) declare RNUM=$(( $1+1+BORDER ))
@@ -257,8 +406,178 @@ function reveal_row() {
echo -n "${BOARD[$1,$C]}" echo -n "${BOARD[$1,$C]}"
read -r -s -t $DELAY read -r -s -t $DELAY
done done
echo -en "${SHOW_CURSOR}" echo -en "${SHOW_CURSOR}${RESET}${RESTORE}"
echo -en "${RESTORE}${UP1}" }
function load_game() {
# shellcheck source=/dev/null
source "${PROGRESS_FILE}"
}
# Takes the row the game should resume from
function save_game() {
declare -r row=$1
: > "${PROGRESS_FILE}"
for key in "${!BOARD[@]}"; do
echo "BOARD[$key]='${BOARD[$key]}'" >> "${PROGRESS_FILE}"
done
for key in "${!BOARD[@]}"; do
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 "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'
} >> "${PROGRESS_FILE}"
}
function save_result() {
[[ "${ONLINE}" == 0 ]] && return 0
if [[ "${ONLINE}" == 1 ]] && [[ "${DAILY_DONE}" != 1 ]]; then
update_stats
render_board > "${RESULT_FILE}"
render_board > "${CUR_RESULT_FILE}"
if [[ $WIN != 5 ]]; then
render_answer >> "${RESULT_FILE}"
render_answer >> "${CUR_RESULT_FILE}"
fi
fi
echo 'PLAYED=1' > "${PROGRESS_FILE}"
}
function update_stats() {
if [[ -f "${STATS_SOURCE}" ]]; then
# shellcheck source=/dev/null
source "${STATS_SOURCE}"
else
create_stats
# shellcheck source=/dev/null
source "${STATS_SOURCE}"
fi
GAMES_PLAYED=$(( GAMES_PLAYED+1 ))
[[ $WIN == 5 ]] && WINS=$(( WINS+1 ))
[[ $WIN != 5 ]] && LOSSES=$(( LOSSES+1 ))
[[ $WIN == 5 ]] && SCORE="${GAME_HIST[$ROW]}"
[[ $WIN == 5 ]] && SCORE=$(( SCORE+1 ))
[[ $WIN == 5 ]] && GAME_HIST[ROW]="${SCORE}"
[[ $WIN == 5 ]] && STREAK=$(( STREAK+1 ))
[[ $WIN != 5 ]] && STREAK=0
[[ $LONGEST_STREAK -lt $STREAK ]] && LONGEST_STREAK=$(( LONGEST_STREAK+1 ))
declare num=0
declare den=0
for (( i=0; i<${#GAME_HIST[@]}; i++)); do
(( den += ${GAME_HIST[$i]} ))
(( num += ${GAME_HIST[$i]}*(i+1) ))
done
if [[ "${den}" != 0 ]]; then
result=$(( (num * 100) / den ))
integer_part=$(( result / 100 ))
decimal_part=$(( result % 100 ))
[[ ${decimal_part} -lt 10 ]] && decimal_part="0${decimal_part}"
AVERAGE="${integer_part}.${decimal_part}"
fi
: > "${STATS_SOURCE}"
{
echo "GAMES_PLAYED=${GAMES_PLAYED}"
echo "WINS=${WINS}"
echo "LOSSES=${LOSSES}"
echo "GAME_HIST=(${GAME_HIST[*]})"
echo "STREAK=${STREAK}"
echo "LONGEST_STREAK=${LONGEST_STREAK}"
echo "AVERAGE=${AVERAGE}"
} >> "${STATS_SOURCE}"
render_stats
}
function create_stats() {
{
echo "GAMES_PLAYED=0"
echo "WINS=0"
echo "LOSSES=0"
echo "GAME_HIST=(0 0 0 0 0 0)"
echo "STREAK=0"
echo "LONGEST_STREAK=0"
echo "AVERAGE=0"
} >> "${STATS_SOURCE}"
}
function render_stats() {
[[ -f "${STATS_SOURCE}" ]] || return
# shellcheck source=/dev/null
# https://www.shellcheck.net/wiki/SC1091 https://www.shellcheck.net/wiki/SC1090
source "${STATS_SOURCE}"
declare highest=0
declare -r max_width=20
declare axis
for (( i=0; i<HEIGHT; i++ )); do
[[ "${GAME_HIST[$i]}" -gt "${highest}" ]] && highest="${GAME_HIST[$i]}"
done
for (( i=0; i<HEIGHT; i++ )); do
value="${GAME_HIST[$i]}"
if [[ "${highest}" == 0 ]]; then
bar_length=0
else
bar_length=$(( value * max_width / highest ))
fi
bar='▇'
for (( j=0; j<bar_length; j++)); do
bar+='▇'
done
axis=$(( i+1 ))
# 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"
fi
done > "${STATS_FILE}"
{
echo
echo "Played: ${GAMES_PLAYED}"
echo "Wins: ${WINS}"
echo "Losses: ${LOSSES}"
echo "Streak: ${STREAK}"
echo "Longest Streak: ${LONGEST_STREAK}"
echo "Average: ${AVERAGE}"
} >> "${STATS_FILE}"
} }
declare -gr VALID_GUESSES="aahed aalii aapas aargh aarti abaca abaci aback abacs abaft abaht abaka abamp aband abase abash abask abate abaya abbas abbed abbes abbey abbot abcee abeam abear abeat abeer abele abeng abers abets abeys abhor abide abies abius abjad abjud abled abler ables ablet ablow abmho abnet abode abohm aboil aboma aboon abord abore aborn abort about above abram abray abrim abrin abris absey absit abuna abune abura aburn abuse abuts abuzz abyes abysm abyss acais acara acari accas accha accoy accra acedy acene acerb acers aceta achar ached acher aches achey achoo acids acidy acies acing acini ackee acker acmes acmic acned acnes acock acoel acold acone acorn acral acred acres acrid acron acros acryl actas acted actin acton actor actus acute acyls adage adapt adats adawn adaws adays adbot addas addax added adder addin addio addle addra adead adeem adept adhan adhoc adieu adios adits adlib adman admen admin admit admix adnex adobe adobo adoon adopt adorb adore adorn adown adoze adrad adraw adred adret adrip adsum aduki adult adunc adust advew advts adyta adyts adzed adzes aecia aedes aeger aegis aeons aerie aeros aesir aevum afald afanc afara afars afear affix affly afion afire afizz aflaj aflap aflow afoam afoot afore afoul afret afrit afros after aftos again agals agama agami agamy agape agars agasp agast agate agaty agave agaze agbas agene agent agers aggag agger aggie aggri aggro aggry aghas agidi agila agile aging agios agism agist agita aglee aglet agley agloo aglow aglus agmas agoge agogo agone agons agony agood agora agree agria agrin agros agrum agued agues aguey aguna agush aguti ahead aheap ahent ahigh ahind ahing ahint ahold ahole ahull ahuru aidas aided aider aides aidoi aidos aiery aigas aight ailed aimag aimak aimed aimer ainee ainga aioli aired airer airns airth airts aisle aitch aitus aiver aixes aiyah aiyee aiyoh aiyoo aizle ajies ajiva ajuga ajupa ajwan akara akees akela akene aking akita akkas akker akoia akoja akoya aksed akses alaap alack alala alamo aland alane alang alans alant alapa alaps alarm alary alata alate alays albas albee albid album alcea alces alcid alcos aldea alder aldol aleak aleck alecs aleem alefs aleft aleph alert alews aleye alfas algae algal algas algid algin algor algos algum alias alibi alick alien alifs align alike alims aline alios alist alive aliya alkie alkin alkos alkyd alkyl allan allay allee allel allen aller alley allin allis allod allot allow alloy allus allyl almah almas almeh almes almud almug alods aloed aloes aloft aloha aloin alone along aloof aloos alose aloud alowe alpha altar alter altho altos alula alums alumy alure alurk alvar alway amahs amain amari amaro amass amate amaut amaze amban amber ambit amble ambos ambry ameba ameer amend amene amens ament amias amice amici amide amido amids amies amiga amigo amine amino amins amirs amiss amity amlas amman ammas ammon ammos amnia amnic amnio amoks amole among amore amort amour amove amowt amped ample amply ampul amrit amuck amuse amyls anana anata ancho ancle ancon andic andro anear anele anent angas angel anger angle anglo angry angst anigh anile anils anima anime animi anion anise anker ankhs ankle ankus anlas annal annan annas annat annex annoy annul annum annus anoas anode anole anomy ansae ansas antae antar antas anted antes antic antis antra antre antsy anura anvil anyon aorta apace apage apaid apart apayd apays apeak apeek apers apert apery apgar aphid aphis apian aping apiol apish apism apnea apode apods apols apoop aport appal appam appay appel apple apply appro appts appui appuy apres apron apses apsis apsos apted apter aptly aquae aquas araba araks arame arars arbah arbas arbor arced archi arcos arcus ardeb ardor ardri aread areae areal arear areas areca aredd arede arefy areic arena arene arepa arere arete arets arett argal argan argil argle argol argon argot argue argus arhat arias ariel ariki arils ariot arise arish arith arked arled arles armed armer armet armil armor arnas arnis arnut aroba aroha aroid aroma arose arpas arpen arrah arras array arret arris arrow arroz arsed arses arsey arsis arson artal artel arter artic artis artly artsy aruhe arums arval arvee arvos aryls asada asana ascon ascot ascus asdic ashed ashen ashes ashet aside asity askar asked asker askew askoi askos aspen asper aspic aspie aspis aspro assai assam assay assed asses asset assez assot aster astir astun asura asway aswim asyla ataps ataxy atigi atilt atimy atlas atman atmas atmos atocs atoke atoks atoll atoms atomy atone atony atopy atria atrip attap attar attas atter attic atuas aucht audad audax audio audit augen auger auges aught augur aulas aulic auloi aulos aumil aunes aunts aunty aurae aural aurar auras aurei aures auric auris aurum autos auxin avail avale avant avast avels avens avers avert avgas avian avine avion avise aviso avize avoid avows avyze await awake award aware awari awarn awash awato awave aways awdls aweel aweto awful awing awkin awmry awned awner awoke awols awork axels axiaLine truncated declare -gr VALID_GUESSES="aahed aalii aapas aargh aarti abaca abaci aback abacs abaft abaht abaka abamp aband abase abash abask abate abaya abbas abbed abbes abbey abbot abcee abeam abear abeat abeer abele abeng abers abets abeys abhor abide abies abius abjad abjud abled abler ables ablet ablow abmho abnet abode abohm aboil aboma aboon abord abore aborn abort about above abram abray abrim abrin abris absey absit abuna abune abura aburn abuse abuts abuzz abyes abysm abyss acais acara acari accas accha accoy accra acedy acene acerb acers aceta achar ached acher aches achey achoo acids acidy acies acing acini ackee acker acmes acmic acned acnes acock acoel acold acone acorn acral acred acres acrid acron acros acryl actas acted actin acton actor actus acute acyls adage adapt adats adawn adaws adays adbot addas addax added adder addin addio addle addra adead adeem adept adhan adhoc adieu adios adits adlib adman admen admin admit admix adnex adobe adobo adoon adopt adorb adore adorn adown adoze adrad adraw adred adret adrip adsum aduki adult adunc adust advew advts adyta adyts adzed adzes aecia aedes aeger aegis aeons aerie aeros aesir aevum afald afanc afara afars afear affix affly afion afire afizz aflaj aflap aflow afoam afoot afore afoul afret afrit afros after aftos again agals agama agami agamy agape agars agasp agast agate agaty agave agaze agbas agene agent agers aggag agger aggie aggri aggro aggry aghas agidi agila agile aging agios agism agist agita aglee aglet agley agloo aglow aglus agmas agoge agogo agone agons agony agood agora agree agria agrin agros agrum agued agues aguey aguna agush aguti ahead aheap ahent ahigh ahind ahing ahint ahold ahole ahull ahuru aidas aided aider aides aidoi aidos aiery aigas aight ailed aimag aimak aimed aimer ainee ainga aioli aired airer airns airth airts aisle aitch aitus aiver aixes aiyah aiyee aiyoh aiyoo aizle ajies ajiva ajuga ajupa ajwan akara akees akela akene aking akita akkas akker akoia akoja akoya aksed akses alaap alack alala alamo aland alane alang alans alant alapa alaps alarm alary alata alate alays albas albee albid album alcea alces alcid alcos aldea alder aldol aleak aleck alecs aleem alefs aleft aleph alert alews aleye alfas algae algal algas algid algin algor algos algum alias alibi alick alien alifs align alike alims aline alios alist alive aliya alkie alkin alkos alkyd alkyl allan allay allee allel allen aller alley allin allis allod allot allow alloy allus allyl almah almas almeh almes almud almug alods aloed aloes aloft aloha aloin alone along aloof aloos alose aloud alowe alpha altar alter altho altos alula alums alumy alure alurk alvar alway amahs amain amari amaro amass amate amaut amaze amban amber ambit amble ambos ambry ameba ameer amend amene amens ament amias amice amici amide amido amids amies amiga amigo amine amino amins amirs amiss amity amlas amman ammas ammon ammos amnia amnic amnio amoks amole among amore amort amour amove amowt amped ample amply ampul amrit amuck amuse amyls anana anata ancho ancle ancon andic andro anear anele anent angas angel anger angle anglo angry angst anigh anile anils anima anime animi anion anise anker ankhs ankle ankus anlas annal annan annas annat annex annoy annul annum annus anoas anode anole anomy ansae ansas antae antar antas anted antes antic antis antra antre antsy anura anvil anyon aorta apace apage apaid apart apayd apays apeak apeek apers apert apery apgar aphid aphis apian aping apiol apish apism apnea apode apods apols apoop aport appal appam appay appel apple apply appro appts appui appuy apres apron apses apsis apsos apted apter aptly aquae aquas araba araks arame arars arbah arbas arbor arced archi arcos arcus ardeb ardor ardri aread areae areal arear areas areca aredd arede arefy areic arena arene arepa arere arete arets arett argal argan argil argle argol argon argot argue argus arhat arias ariel ariki arils ariot arise arish arith arked arled arles armed armer armet armil armor arnas arnis arnut aroba aroha aroid aroma arose arpas arpen arrah arras array arret arris arrow arroz arsed arses arsey arsis arson artal artel arter artic artis artly artsy aruhe arums arval arvee arvos aryls asada asana ascon ascot ascus asdic ashed ashen ashes ashet aside asity askar asked asker askew askoi askos aspen asper aspic aspie aspis aspro assai assam assay assed asses asset assez assot aster astir astun asura asway aswim asyla ataps ataxy atigi atilt atimy atlas atman atmas atmos atocs atoke atoks atoll atoms atomy atone atony atopy atria atrip attap attar attas atter attic atuas aucht audad audax audio audit augen auger auges aught augur aulas aulic auloi aulos aumil aunes aunts aunty aurae aural aurar auras aurei aures auric auris aurum autos auxin avail avale avant avast avels avens avers avert avgas avian avine avion avise aviso avize avoid avows avyze await awake award aware awari awarn awash awato awave aways awdls aweel aweto awful awing awkin awmry awned awner awoke awols awork axels axiaLine truncated
+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;
}