Merge pull request 'clause fixes' (#16) from dev into main
Deploy game to server / deploy (push) Successful in 8s
Deploy game to server / deploy (push) Successful in 8s
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
commit
ab34594e0b
13 files changed
+2263
-38
No files matched your search
@@ -26,5 +26,10 @@ jobs:
|
||||
run: |
|
||||
scp -i ~/.ssh/id_rsa wordle ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:${{ vars.REMOTE_DEST }}
|
||||
|
||||
- name: Transfer web pages via SCP
|
||||
run: |
|
||||
scp -i ~/.ssh/id_rsa www/html/wordle/* ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:/var/www/html/wordle/
|
||||
|
||||
- name: Clean up SSH key
|
||||
if: always() # remove the key even if a transfer failed
|
||||
run: rm -rf ~/.ssh
|
||||
@@ -33,6 +33,7 @@ function main() {
|
||||
declare -g ANSWER
|
||||
declare -g POOL='QWERTYUIOPASDFGHJKLZXCVBNM'
|
||||
declare -Ag YELLOWS
|
||||
declare -Ag KEYS
|
||||
ANSWER=$(select_random_word)
|
||||
|
||||
set_options "$@"
|
||||
@@ -50,20 +51,26 @@ function main() {
|
||||
init_board
|
||||
fi
|
||||
|
||||
[[ "${ONLINE}" == 1 ]] && save_game "NO_EXIT"
|
||||
[[ "${ONLINE}" == 1 ]] && save_game "${ROW}"
|
||||
|
||||
render_board
|
||||
|
||||
for (( ROW; ROW<HEIGHT; ROW++ )); do
|
||||
declare GUESS
|
||||
[[ "${KEYBOARD}" == 1 ]] && render_keyboard
|
||||
GUESS=$(retrieve_guess)
|
||||
GUESS=$(retrieve_guess) || { echo -en "${NEWLINE}"; exit 0; }
|
||||
check_guess "${GUESS}" "${ROW}" ; WIN=$?
|
||||
|
||||
# Save before the reveal so quitting or disconnecting can't undo a guess
|
||||
if [[ $WIN == 5 ]] || (( ROW+1 == HEIGHT )); then
|
||||
save_result
|
||||
elif [[ "${ONLINE}" == 1 ]]; then
|
||||
save_game "$(( ROW+1 ))"
|
||||
fi
|
||||
|
||||
reveal_row "${ROW}"
|
||||
|
||||
if [[ $WIN == 5 ]]; then
|
||||
save_result
|
||||
echo -e "${CLEAR_DOWN}YOU WIN"
|
||||
if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
|
||||
echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\""
|
||||
@@ -76,14 +83,8 @@ function main() {
|
||||
fi
|
||||
done
|
||||
|
||||
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}"
|
||||
echo -en "${CLEAR_LINE}${BOLD}"
|
||||
for (( i=0; i<WIDTH; i++ )); do
|
||||
echo -en " ${ANSWER:$i:1} "
|
||||
done
|
||||
echo -en "${NEWLINE}${RESET}"
|
||||
render_answer
|
||||
|
||||
save_result
|
||||
if [[ "${ONLINE}" == 1 && "${DAILY_DONE}" != 1 ]]; then
|
||||
echo "Share/view your game with -> curl \"${URL_BASE}/${RESULT_FILE}\""
|
||||
echo "Share/view your stats with -> curl \"${URL_BASE}/stats/${USER}\""
|
||||
@@ -139,9 +140,15 @@ function set_options() {
|
||||
echo -en "${CLEAR_SCREEN}"
|
||||
|
||||
declare -g USER
|
||||
read -r -p "Username: " USER
|
||||
read -r -p "Username: " USER || exit 1
|
||||
USER="${USER,,}"
|
||||
|
||||
# Username is used in file names and URLs so only allow safe characters
|
||||
while [[ -z "${USER}" || "${USER}" == *[!a-z0-9_]* || ${#USER} -gt 32 ]]; do
|
||||
read -r -p "${CLEAR_UP1}Username (letters, numbers and _ only): " USER || exit 1
|
||||
USER="${USER,,}"
|
||||
done
|
||||
|
||||
declare -g DAY
|
||||
DAY=$(( EPOCHSECONDS / 86400 ))
|
||||
|
||||
@@ -175,7 +182,8 @@ function set_options() {
|
||||
fi
|
||||
fi
|
||||
|
||||
trap save_game SIGINT
|
||||
# Progress is saved after every guess so Ctrl-C only needs to tidy the terminal
|
||||
trap 'echo -en "${SHOW_CURSOR}${RESET}"; exit 0' SIGINT
|
||||
;;
|
||||
d)
|
||||
DELAY=0
|
||||
@@ -230,29 +238,54 @@ function render_board() {
|
||||
[[ "${BORDER}" == 1 ]] && echo -e "└─────────────────┘"
|
||||
}
|
||||
|
||||
function render_answer() {
|
||||
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}"
|
||||
echo -en "${CLEAR_LINE}${BOLD}"
|
||||
for (( i=0; i<WIDTH; i++ )); do
|
||||
echo -en " ${ANSWER:$i:1} "
|
||||
done
|
||||
echo -en "${NEWLINE}${RESET}"
|
||||
}
|
||||
|
||||
# Exits non-zero when input has ended so the caller can quit
|
||||
function retrieve_guess() {
|
||||
if [[ -n $1 ]]; then
|
||||
read -r -p "${CLEAR_UP1}$1: " GUESS
|
||||
read -r -p "${CLEAR_UP1}$1: " GUESS || end_of_input
|
||||
else
|
||||
read -r -p "${CLEAR_2END}Enter Guess: " GUESS
|
||||
read -r -p "${CLEAR_2END}Enter Guess: " GUESS || end_of_input
|
||||
fi
|
||||
|
||||
while [[ ${#GUESS} -ne 5 ]] || [[ ! "${GUESS,,}" =~ ^[a-z]{5}$ ]]; do
|
||||
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS
|
||||
read -r -p "${CLEAR_UP1}Must be 5 letters: " GUESS || end_of_input
|
||||
done
|
||||
declare valid_guess
|
||||
validate_guess "${GUESS}" ; valid_guess=$?
|
||||
|
||||
[[ ${valid_guess} == 1 ]] && GUESS=$(retrieve_guess "Not A Valid Word")
|
||||
[[ ${valid_guess} == 2 ]] && GUESS=$(retrieve_guess "Not A Valid Guess")
|
||||
[[ ${valid_guess} == 3 ]] && GUESS=$(retrieve_guess "GREEN Issue")
|
||||
[[ ${valid_guess} == 4 ]] && GUESS=$(retrieve_guess "YELLOW Issue")
|
||||
[[ ${valid_guess} == 5 ]] && GUESS=$(retrieve_guess "POOL Issue")
|
||||
[[ ${valid_guess} == 6 ]] && GUESS=$(retrieve_guess "NEW Issue")
|
||||
declare message
|
||||
declare -r pos="${HARD_MODE_POS}"
|
||||
declare -r letter="${GUESS:pos:1}"
|
||||
declare -ra ordinals=(1st 2nd 3rd 4th 5th)
|
||||
[[ ${valid_guess} == 1 ]] && message="Not A Valid Word"
|
||||
[[ ${valid_guess} == 3 ]] && message="${ordinals[pos]} letter must be ${greens[pos]}"
|
||||
[[ ${valid_guess} == 4 ]] && message="Must contain ${yellows[pos]}"
|
||||
[[ ${valid_guess} == 5 ]] && message="${letter^^} is not in the word"
|
||||
[[ ${valid_guess} == 6 ]] && message="${ordinals[pos]} letter can't be ${letter^^}"
|
||||
|
||||
if [[ -n "${message}" ]]; then
|
||||
GUESS=$(retrieve_guess "${message}") || exit 1
|
||||
fi
|
||||
|
||||
echo -n "${GUESS^^}"
|
||||
}
|
||||
|
||||
# Called when read gets Ctrl-D. On a terminal treat it like Enter, but if
|
||||
# input has really ended (closed pipe) exit, or the retry loop spins forever
|
||||
function end_of_input() {
|
||||
[[ -t 0 ]] || exit 1
|
||||
# Ctrl-D doesn't move to a new line like Enter, which the prompts expect
|
||||
echo -en "${NEWLINE}" >&2
|
||||
}
|
||||
|
||||
function render_keyboard() {
|
||||
declare pos=$(( BORDER*2+7 ))
|
||||
echo -en "${RESET}\e[$pos;0H"
|
||||
@@ -262,7 +295,12 @@ function render_keyboard() {
|
||||
elif [[ $i == 19 ]]; then
|
||||
echo -en "${NEWLINE} "
|
||||
fi
|
||||
echo -n "${POOL:$i:1} "
|
||||
declare key="${POOL:$i:1}"
|
||||
if [[ -n "${KEYS[$key]}" ]]; then
|
||||
echo -n "${KEYS[$key]}${BOLD}${LETTER}${key}${RESET} "
|
||||
else
|
||||
echo -n "${key} "
|
||||
fi
|
||||
done
|
||||
echo -en "${NEWLINE}"
|
||||
}
|
||||
@@ -288,6 +326,8 @@ function hard_mode_check() {
|
||||
declare GUESS=$1
|
||||
|
||||
for (( i=0; i<WIDTH; i++ )); do
|
||||
HARD_MODE_POS=$i # letter position that failed, for the error message
|
||||
|
||||
if [[ "${STRICT_HARD_MODE}" == 1 ]]; then
|
||||
[[ "${POOL}" == *${GUESS:i:1}* ]] || return 5
|
||||
fi
|
||||
@@ -303,7 +343,7 @@ function hard_mode_check() {
|
||||
for (( r=ROW-1; r>=0; r-- )); do
|
||||
CHAR="${GUESS:i:1}"
|
||||
PREV_CHAR="${YELLOWS[$r,$i]}"
|
||||
[[ "${CHAR}" == "${PREV_CHAR}" ]] && return 4
|
||||
[[ "${CHAR}" == "${PREV_CHAR}" ]] && return 6
|
||||
done
|
||||
done
|
||||
|
||||
@@ -333,6 +373,7 @@ function check_guess() {
|
||||
|
||||
if [[ "${_char}" == "${_answer:$c:1}" ]]; then
|
||||
greens[c]="${_char}"
|
||||
KEYS[$_char]="${GREEN}"
|
||||
BOARD[$_row,$c]="${GREEN}${BOLD}${LETTER} ${_char} ${UNBOLD}"
|
||||
continue
|
||||
fi
|
||||
@@ -341,6 +382,7 @@ function check_guess() {
|
||||
chars="${chars/${_char}/}"
|
||||
yellows[c]="${_char}" # volatile - stores previous row yellows
|
||||
YELLOWS[$_row,$c]="${_char}" # stores all yellow from all rounds
|
||||
[[ "${KEYS[$_char]}" != "${GREEN}" ]] && KEYS[$_char]="${YELLOW}"
|
||||
BOARD[$_row,$c]="${YELLOW}${BOLD}${LETTER} ${_char} ${UNBOLD}"
|
||||
fi
|
||||
[[ "${ANSWER}" =~ ${_char} ]] && continue
|
||||
@@ -372,8 +414,9 @@ function load_game() {
|
||||
source "${PROGRESS_FILE}"
|
||||
}
|
||||
|
||||
# shellcheck disable=SC2317 # Don't warn about unreachable commands in this function
|
||||
# Takes the row the game should resume from
|
||||
function save_game() {
|
||||
declare -r row=$1
|
||||
: > "${PROGRESS_FILE}"
|
||||
|
||||
for key in "${!BOARD[@]}"; do
|
||||
@@ -384,8 +427,21 @@ function save_game() {
|
||||
echo "YELLOWS[$key]='${YELLOWS[$key]}'" >> "${PROGRESS_FILE}"
|
||||
done
|
||||
|
||||
for key in "${!KEYS[@]}"; do
|
||||
echo "KEYS[$key]='${KEYS[$key]}'" >> "${PROGRESS_FILE}"
|
||||
done
|
||||
|
||||
# Previous row's hints, needed by hard_mode_check after a resume
|
||||
for key in "${!greens[@]}"; do
|
||||
echo "greens[$key]='${greens[$key]}'" >> "${PROGRESS_FILE}"
|
||||
done
|
||||
|
||||
for key in "${!yellows[@]}"; do
|
||||
echo "yellows[$key]='${yellows[$key]}'" >> "${PROGRESS_FILE}"
|
||||
done
|
||||
|
||||
{
|
||||
echo "ROW=${ROW}"
|
||||
echo "ROW=${row}"
|
||||
echo "POOL=${POOL}"
|
||||
echo "HARD_MODE=${HARD_MODE}"
|
||||
echo "STRICT_HARD_MODE=${STRICT_HARD_MODE}"
|
||||
@@ -394,26 +450,17 @@ function save_game() {
|
||||
echo "IN_PROGRESS=1"
|
||||
[[ "${PLAYED}" == 1 ]] && echo 'PLAYED=1'
|
||||
} >> "${PROGRESS_FILE}"
|
||||
|
||||
echo -en "${RESET}"
|
||||
[[ -z $1 ]] && exit 0
|
||||
}
|
||||
|
||||
function save_result() {
|
||||
[[ "${ONLINE}" == 0 ]] && return 0
|
||||
[[ "${ONLINE}" == 1 ]] && update_stats
|
||||
if [[ "${ONLINE}" == 1 ]] && [[ "${DAILY_DONE}" != 1 ]]; then
|
||||
update_stats
|
||||
render_board > "${RESULT_FILE}"
|
||||
render_board > "${CUR_RESULT_FILE}"
|
||||
if [[ $WIN != 5 ]]; then
|
||||
[[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}" >> "${RESULT_FILE}"
|
||||
echo -en "${CLEAR_LINE}${BOLD}" >> "${RESULT_FILE}"
|
||||
for (( i=0; i<WIDTH; i++ )); do
|
||||
echo -en " ${ANSWER:$i:1} " >> "${RESULT_FILE}"
|
||||
echo -en " ${ANSWER:$i:1} " >> "${CUR_RESULT_FILE}"
|
||||
done
|
||||
echo -en "${NEWLINE}${RESET}" >> "${RESULT_FILE}"
|
||||
echo -en "${NEWLINE}${RESET}" >> "${CUR_RESULT_FILE}"
|
||||
render_answer >> "${RESULT_FILE}"
|
||||
render_answer >> "${CUR_RESULT_FILE}"
|
||||
fi
|
||||
fi
|
||||
echo 'PLAYED=1' > "${PROGRESS_FILE}"
|
||||
@@ -453,6 +500,7 @@ function update_stats() {
|
||||
integer_part=$(( result / 100 ))
|
||||
decimal_part=$(( result % 100 ))
|
||||
|
||||
[[ ${decimal_part} -lt 10 ]] && decimal_part="0${decimal_part}"
|
||||
AVERAGE="${integer_part}.${decimal_part}"
|
||||
fi
|
||||
|
||||
@@ -511,7 +559,8 @@ function render_stats() {
|
||||
|
||||
axis=$(( i+1 ))
|
||||
|
||||
if [[ "${ROW}" == "${i}" ]]; then
|
||||
# Highlight the bar for this game's guess count, only if it was won
|
||||
if [[ $WIN == 5 && "${ROW}" == "${i}" ]]; then
|
||||
echo "$axis│${LETTER_GR}$bar${RESET} $value"
|
||||
else
|
||||
echo "$axis│$bar $value"
|
||||
|
||||
@@ -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