Compare commits
83
Commits
f43f5216f5
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
698fe9d14a | ||
|
|
710c9c5b79 | ||
|
|
f278ab0ac1 | ||
|
|
fc3c74822f | ||
|
|
5bafab3e10 | ||
|
|
eccee4d3f1 | ||
|
|
45de1fd982 | ||
|
|
7a95e50fe7 | ||
|
|
3d78ff7863 | ||
|
|
4c6c53ff13 | ||
|
|
8e62094a6f | ||
|
|
0f8640e9e8 | ||
|
|
6b07093d68 | ||
|
|
a3f9ae6a17 | ||
|
|
d34d98c15d | ||
|
|
8dfebaa9f8 | ||
|
|
f29d81e932 | ||
|
|
73f403693a | ||
|
|
1f6a5b4a44 | ||
|
|
437d46e975 | ||
|
|
7621938f04 | ||
|
|
1a9a440509 | ||
|
|
d9a7c94125 | ||
|
|
efa709e34e | ||
|
|
a7cdb712fd | ||
|
|
75074d8e84 | ||
|
|
3487adf34c | ||
|
|
2194d495e6 | ||
|
|
005a129919 | ||
|
|
bdb3d4e4d1 | ||
|
|
6bbcc5229b | ||
|
|
4029c00edc | ||
|
|
8327678209 | ||
|
|
d496319361 | ||
|
|
107fd30b00 | ||
|
|
0a5d0be050 | ||
|
|
357c74cf08 | ||
|
|
e138f71a62 | ||
|
|
064b85aac2 | ||
|
|
fba5cc07ae | ||
|
|
ce2a14fbe6 | ||
|
|
138c254591 | ||
|
|
dfe8ea7f13 | ||
|
|
1ee78db9bf | ||
|
|
da696e5df2 | ||
|
|
00490f53c7 | ||
|
|
8d92cfe57d | ||
|
|
a7a19d33b6 | ||
|
|
2dc552c74e | ||
|
|
556c1ec598 | ||
|
|
ab97bcea65 | ||
|
|
7d14d5dae9 | ||
|
|
f73667b6a9 | ||
|
|
47ab1d243e | ||
|
|
1cbdfe5858 | ||
|
|
31a7332af1 | ||
|
|
7c3b695737 | ||
|
|
e6b5a3da7a | ||
|
|
c530058c9b | ||
|
|
fa2fdf80b7 | ||
|
|
2ff11722b4 | ||
|
|
26e4eeb356 | ||
|
|
7e280ce64b | ||
|
|
0cf5a68052 | ||
|
|
6733031ed9 | ||
|
|
9745876be1 | ||
|
|
5640a28b5b | ||
|
|
cf7f5da7e5 | ||
|
|
2ee79b85db | ||
|
|
1e6942c5b7 | ||
|
|
b31e0442e3 | ||
|
|
9218601b52 | ||
|
|
c925f66e4d | ||
|
|
828c9190b1 | ||
|
|
8122eab5b4 | ||
|
|
a179fed550 | ||
|
|
0c2b212c72 | ||
|
|
22a02a6dcf | ||
|
|
88a4761538 | ||
|
|
08f51a6a92 | ||
|
|
f3d2d8d0b8 | ||
|
|
5c8a63ef76 | ||
|
|
f1c214ddd4 |
No files matched your search
@@ -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
|
||||||
@@ -6,96 +6,92 @@ 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 CLEAR_LINE=$'\e[1F\e[K'
|
declare -gr UNBOLD=$'\e[22m'
|
||||||
|
declare -gr CLEAR_UP1=$'\e[1F\e[K'
|
||||||
|
declare -gr CLEAR_LINE=$'\e[K'
|
||||||
declare -gr CLEAR_2END=$'\e[0K'
|
declare -gr CLEAR_2END=$'\e[0K'
|
||||||
declare -gr CLEAR_SCREEN=$'\e[H\e[J'
|
declare -gr CLEAR_SCREEN=$'\e[H\e[J'
|
||||||
|
declare -gr CLEAR_DOWN=$'\e[0J'
|
||||||
|
declare -gr NEWLINE=$'\n'
|
||||||
declare -gr SAVE=$'\e[s'
|
declare -gr SAVE=$'\e[s'
|
||||||
declare -gr RESTORE=$'\e[u'
|
declare -gr RESTORE=$'\e[u'
|
||||||
declare -gr UP1=$'\e[1F'
|
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'
|
||||||
|
|
||||||
function reveal_row() {
|
function main() {
|
||||||
echo -en "${SAVE}"
|
|
||||||
echo -en "${HIDE_CURSOR}"
|
|
||||||
# Must increment by 1 Row as 0 == 1 for escape code
|
|
||||||
declare RNUM=$(( $1+1 ))
|
|
||||||
echo -en "\e[$RNUM;0H"
|
|
||||||
for (( C=0; C<WIDTH; C++ )); do
|
|
||||||
echo -n "${BOARD[$1,$C]}"
|
|
||||||
read -r -s -t 0.5
|
|
||||||
done
|
|
||||||
echo -en "${SHOW_CURSOR}"
|
|
||||||
echo -en "${RESTORE}${UP1}"
|
|
||||||
}
|
|
||||||
|
|
||||||
function check_guess() {
|
declare -Ag BOARD
|
||||||
declare -r _guess=$1
|
declare -g ROW=0
|
||||||
declare -r _row=$2
|
declare -g WIN=0
|
||||||
declare -r _answer="${ANSWER}"
|
declare -g ANSWER
|
||||||
declare green_count=0
|
declare -g POOL='QWERTYUIOPASDFGHJKLZXCVBNM'
|
||||||
declare chars=""
|
declare -Ag YELLOWS
|
||||||
|
declare -Ag KEYS
|
||||||
|
ANSWER=$(select_random_word)
|
||||||
|
|
||||||
for ((i=0; i<WIDTH; i++)); do
|
set_options "$@"
|
||||||
[[ "${_answer:i:1}" != "${_guess:i:1}" ]] && chars+=${_answer:i:1}
|
|
||||||
done
|
|
||||||
|
|
||||||
for (( c=0; c<WIDTH; c++ )); do
|
echo -en "${CLEAR_SCREEN}"
|
||||||
BOARD[$_row,$c]="${BOLD}${LETTER}${GREY} ${_guess:$c:1} "
|
echo -en "${GOHOME}"
|
||||||
|
|
||||||
declare _char=${_guess:$c:1}
|
if [[ "${RESUME}" == 1 ]]; then
|
||||||
|
load_game
|
||||||
if [[ "${_char}" == "${_answer:$c:1}" ]]; then
|
if [[ "${PLAYED}" == 1 ]] && [[ "${IN_PROGRESS}" != 1 ]]; then
|
||||||
green_count=$((green_count+1))
|
ANSWER=$(select_random_word)
|
||||||
BOARD[$_row,$c]="${BOLD}${LETTER}${GREEN} ${_char} "
|
init_board
|
||||||
[[ $green_count == 5 ]] && return 5
|
|
||||||
continue
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${chars}" == *${_char}* ]]; then
|
|
||||||
chars="${chars/${_char}/}"
|
|
||||||
BOARD[$_row,$c]="${BOLD}${LETTER}${YELLOW} ${_char} "
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
function validate_guess() {
|
|
||||||
declare GUESS=$1
|
|
||||||
|
|
||||||
for WORD in ${VALID_GUESSES}; do
|
|
||||||
if [[ "${WORD}" == "${GUESS,,}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function retrive_guess() {
|
|
||||||
if [[ -n $1 ]]; then
|
|
||||||
read -r -p "${CLEAR_LINE}$1: " GUESS
|
|
||||||
else
|
else
|
||||||
read -r -p "${CLEAR_2END}Enter Guess: " GUESS
|
init_board
|
||||||
fi
|
fi
|
||||||
|
|
||||||
while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do
|
[[ "${ONLINE}" == 1 ]] && save_game "${ROW}"
|
||||||
read -r -p "${CLEAR_LINE}Must be 5 letters: " GUESS
|
|
||||||
|
render_board
|
||||||
|
|
||||||
|
for (( ROW; ROW<HEIGHT; ROW++ )); do
|
||||||
|
declare GUESS
|
||||||
|
[[ "${KEYBOARD}" == 1 ]] && render_keyboard
|
||||||
|
GUESS=$(retrieve_guess) || { echo -en "${NEWLINE}"; exit 0; }
|
||||||
|
check_guess "${GUESS}" "${ROW}" ; WIN=$?
|
||||||
|
|
||||||
|
# Save before the reveal so quitting or disconnecting can't undo a guess
|
||||||
|
if [[ $WIN == 5 ]] || (( ROW+1 == HEIGHT )); then
|
||||||
|
save_result
|
||||||
|
elif [[ "${ONLINE}" == 1 ]]; then
|
||||||
|
save_game "$(( ROW+1 ))"
|
||||||
|
fi
|
||||||
|
|
||||||
|
reveal_row "${ROW}"
|
||||||
|
|
||||||
|
if [[ $WIN == 5 ]]; then
|
||||||
|
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
|
||||||
|
|
||||||
declare valid_guess
|
render_answer
|
||||||
validate_guess "${GUESS}" &
|
|
||||||
declare validation_pid=$!
|
|
||||||
wait $validation_pid
|
|
||||||
valid_guess=$?
|
|
||||||
|
|
||||||
[[ ${valid_guess} != 0 ]] && GUESS=$(retrive_guess "Not A Valid Word")
|
if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
|
||||||
|
echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\""
|
||||||
echo -n "${GUESS^^}"
|
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() {
|
||||||
@@ -104,22 +100,120 @@ function select_random_word() {
|
|||||||
echo "${answers[random_index]^^}"
|
echo "${answers[random_index]^^}"
|
||||||
}
|
}
|
||||||
|
|
||||||
function render_board() {
|
function set_options() {
|
||||||
echo -en "${GOHOME}"
|
|
||||||
|
|
||||||
declare row
|
declare -g BORDER=1
|
||||||
declare char
|
declare -g HARD_MODE=0
|
||||||
|
declare -g STRICT_HARD_MODE=0
|
||||||
|
declare -g DELAY=0.5
|
||||||
|
declare -g KEYBOARD=1
|
||||||
|
declare -g ONLINE=0
|
||||||
|
declare -g RESUME=0
|
||||||
|
|
||||||
for (( row=0; row<HEIGHT; row++ )); do
|
while getopts "bw:HSkodh" OPT; do
|
||||||
for (( char=0; char<WIDTH; char++ )); do
|
case "${OPT}" in
|
||||||
echo -n "${BOARD[${row},${char}]}${RESET}"
|
b)
|
||||||
|
BORDER=0
|
||||||
|
;;
|
||||||
|
w)
|
||||||
|
ANSWER="${OPTARG^^}"
|
||||||
|
validate_guess "${ANSWER}"
|
||||||
|
if [[ $? == 1 ]]; then
|
||||||
|
echo "Not a valid word"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
H)
|
||||||
|
HARD_MODE=1
|
||||||
|
;;
|
||||||
|
S)
|
||||||
|
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
|
done
|
||||||
echo -en "${DOWN1}"
|
|
||||||
|
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)
|
||||||
|
DELAY=0
|
||||||
|
;;
|
||||||
|
h)
|
||||||
|
echo -e \
|
||||||
|
"Usage: wordle [OPTIONS]
|
||||||
|
Play a game for Wordle
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-b Toggle borderless mode
|
||||||
|
-w Set target word
|
||||||
|
-H Enable hard mode
|
||||||
|
-S Enable strict hard mode (Cannot reuse grey letters)
|
||||||
|
-k disable on screen keyboard
|
||||||
|
-o Enable online mode
|
||||||
|
-d Enable debug mode (no delay during letter reveal)
|
||||||
|
-h Show this help message and exit"
|
||||||
|
|
||||||
|
exit
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
shift $((OPTIND - 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
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} "
|
||||||
@@ -127,30 +221,363 @@ function init_board() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function render_board() {
|
||||||
echo -en "${CLEAR_SCREEN}"
|
|
||||||
declare -g ANSWER
|
|
||||||
ANSWER=$(select_random_word)
|
|
||||||
[[ -n $1 ]] && ANSWER=${1^^}
|
|
||||||
|
|
||||||
init_board
|
|
||||||
|
|
||||||
render_board
|
|
||||||
|
|
||||||
declare row
|
declare row
|
||||||
for (( row=0; row<HEIGHT; row++ )); do
|
declare char
|
||||||
declare GUESS
|
|
||||||
GUESS=$(retrive_guess)
|
|
||||||
check_guess "${GUESS}" "${row}"
|
|
||||||
win=$?
|
|
||||||
|
|
||||||
reveal_row "${row}"
|
[[ "${BORDER}" == 1 ]] && echo -e "┌─────────────────┐"
|
||||||
[[ $win == 5 ]] && echo -e "\n${CLEAR_LINE}YOU WIN" && exit 0
|
for (( row=0; row<HEIGHT; row++ )); do
|
||||||
|
[[ "${BORDER}" == 1 ]] && echo -en "│ "
|
||||||
|
for (( char=0; char<WIDTH; char++ )); do
|
||||||
|
echo -n "${BOARD[${row},${char}]}${RESET}"
|
||||||
done
|
done
|
||||||
IFS=
|
[[ "${BORDER}" == 1 ]] && echo -en " │"
|
||||||
result="${ANSWER//?/ & }"
|
echo -en "${NEWLINE}"
|
||||||
result=${result%}
|
done
|
||||||
echo -e "\n${CLEAR_LINE}YOU LOSE\n${result} ${RESET}" && exit 0
|
[[ "${BORDER}" == 1 ]] && echo -e "└─────────────────┘"
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_answer() {
|
||||||
|
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}"
|
||||||
|
echo -en "${CLEAR_LINE}${BOLD}"
|
||||||
|
for (( i=0; i<WIDTH; i++ )); do
|
||||||
|
echo -en " ${ANSWER:$i:1} "
|
||||||
|
done
|
||||||
|
echo -en "${NEWLINE}${RESET}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Exits non-zero when input has ended so the caller can quit
|
||||||
|
function retrieve_guess() {
|
||||||
|
if [[ -n $1 ]]; then
|
||||||
|
read -r -p "${CLEAR_UP1}$1: " GUESS || end_of_input
|
||||||
|
else
|
||||||
|
read -r -p "${CLEAR_2END}Enter Guess: " GUESS || end_of_input
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do
|
||||||
|
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS || end_of_input
|
||||||
|
done
|
||||||
|
declare valid_guess
|
||||||
|
validate_guess "${GUESS}" ; valid_guess=$?
|
||||||
|
|
||||||
|
declare message
|
||||||
|
declare -r pos="${HARD_MODE_POS}"
|
||||||
|
declare -r letter="${GUESS:pos:1}"
|
||||||
|
declare -ra ordinals=(1st 2nd 3rd 4th 5th)
|
||||||
|
[[ ${valid_guess} == 1 ]] && message="Not A Valid Word"
|
||||||
|
[[ ${valid_guess} == 3 ]] && message="${ordinals[pos]} letter must be ${greens[pos]}"
|
||||||
|
[[ ${valid_guess} == 4 ]] && message="Must contain ${yellows[pos]}"
|
||||||
|
[[ ${valid_guess} == 5 ]] && message="${letter^^} is not in the word"
|
||||||
|
[[ ${valid_guess} == 6 ]] && message="${ordinals[pos]} letter can't be ${letter^^}"
|
||||||
|
|
||||||
|
if [[ -n "${message}" ]]; then
|
||||||
|
GUESS=$(retrieve_guess "${message}") || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
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() {
|
||||||
|
declare GUESS=$1
|
||||||
|
|
||||||
|
for WORD in ${VALID_GUESSES}; do
|
||||||
|
if [[ "${WORD}" == "${GUESS,,}" ]]; then
|
||||||
|
if [[ "${HARD_MODE}" == 1 ]]; then
|
||||||
|
hard_mode_check "${GUESS^^}" || return $?
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function hard_mode_check() {
|
||||||
|
[[ -z "${ROW}" || "${ROW}" == 0 ]] && return 0
|
||||||
|
|
||||||
|
declare GUESS=$1
|
||||||
|
|
||||||
|
for (( i=0; i<WIDTH; i++ )); do
|
||||||
|
HARD_MODE_POS=$i # letter position that failed, for the error message
|
||||||
|
|
||||||
|
if [[ "${STRICT_HARD_MODE}" == 1 ]]; then
|
||||||
|
[[ "${POOL}" == *${GUESS:i:1}* ]] || return 5
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${greens[i]}" ]]; then
|
||||||
|
[[ "${greens[i]}" != "${GUESS:i:1}" ]] && return 3
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_guess() {
|
||||||
|
declare -r _guess=$1
|
||||||
|
declare -r _row=$2
|
||||||
|
declare -r _answer="${ANSWER}"
|
||||||
|
declare chars=""
|
||||||
|
|
||||||
|
declare -ga yellows=()
|
||||||
|
declare -ga greens=()
|
||||||
|
|
||||||
|
|
||||||
|
for (( i=0; i<WIDTH; i++ )); do
|
||||||
|
[[ "${_answer:i:1}" != "${_guess:i:1}" ]] && chars+=${_answer:i:1}
|
||||||
|
done
|
||||||
|
|
||||||
|
for (( c=0; c<WIDTH; c++ )); do
|
||||||
|
BOARD[$_row,$c]="${GREY}${BOLD}${LETTER} ${_guess:$c:1} ${UNBOLD}"
|
||||||
|
|
||||||
|
declare _char=${_guess:$c:1}
|
||||||
|
|
||||||
|
if [[ "${_char}" == "${_answer:$c:1}" ]]; then
|
||||||
|
greens[c]="${_char}"
|
||||||
|
KEYS[$_char]="${GREEN}"
|
||||||
|
BOARD[$_row,$c]="${GREEN}${BOLD}${LETTER} ${_char} ${UNBOLD}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${chars}" == *${_char}* ]]; then
|
||||||
|
chars="${chars/${_char}/}"
|
||||||
|
yellows[c]="${_char}" # volatile - stores previous row yellows
|
||||||
|
YELLOWS[$_row,$c]="${_char}" # stores all yellow from all rounds
|
||||||
|
[[ "${KEYS[$_char]}" != "${GREEN}" ]] && KEYS[$_char]="${YELLOW}"
|
||||||
|
BOARD[$_row,$c]="${YELLOW}${BOLD}${LETTER} ${_char} ${UNBOLD}"
|
||||||
|
fi
|
||||||
|
[[ "${ANSWER}" =~ ${_char} ]] && continue
|
||||||
|
[[ " ${yellows[*]} " =~ ${_char} ]] && continue
|
||||||
|
[[ " ${greens[*]} " =~ ${_char} ]] && continue
|
||||||
|
POOL="${POOL/${_char}/_}"
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "${#greens[*]}" == 5 ]] && return 5
|
||||||
|
}
|
||||||
|
|
||||||
|
function reveal_row() {
|
||||||
|
echo -en "${UP1}${SAVE}"
|
||||||
|
echo -en "${HIDE_CURSOR}"
|
||||||
|
# Must increment by 1 Row as 0 == 1 for escape code
|
||||||
|
declare RNUM=$(( $1+1+BORDER ))
|
||||||
|
declare OFFSET=$(( BORDER * 3 ))
|
||||||
|
|
||||||
|
echo -en "\e[${RNUM};${OFFSET}H"
|
||||||
|
for (( C=0; C<WIDTH; C++ )); do
|
||||||
|
echo -n "${BOARD[$1,$C]}"
|
||||||
|
read -r -s -t $DELAY
|
||||||
|
done
|
||||||
|
echo -en "${SHOW_CURSOR}${RESET}${RESTORE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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/<user>-<day>.board</code> instead.</p>
|
||||||
|
</noscript>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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/<user>-<day>.board</code> instead.</p>
|
||||||
|
</noscript>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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/<user></code> instead.</p>
|
||||||
|
</noscript>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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/<user></code> instead.</p>
|
||||||
|
</noscript>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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) : "✗");
|
||||||
|
})));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user