diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 321d8a9..bd565b0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/wordle b/wordle index f7ea4b0..21a6cb3 100755 --- a/wordle +++ b/wordle @@ -33,6 +33,7 @@ function main() { declare -g ANSWER declare -g POOL='QWERTYUIOPASDFGHJKLZXCVBNM' declare -Ag YELLOWS + declare -Ag KEYS ANSWER=$(select_random_word) set_options "$@" @@ -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 curl \"${URL_BASE}/${RESULT_FILE}\"" @@ -76,15 +83,9 @@ function main() { fi done - [[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}" - echo -en "${CLEAR_LINE}${BOLD}" - for (( i=0; i curl \"${URL_BASE}/${RESULT_FILE}\"" echo "Share/view your stats with -> curl \"${URL_BASE}/stats/${USER}\"" echo "Check out the new homepage in your browser -> ${URL_BASE}" @@ -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&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=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,36 +427,40 @@ function save_game() { echo "YELLOWS[$key]='${YELLOWS[$key]}'" >> "${PROGRESS_FILE}" done + for key in "${!KEYS[@]}"; do + echo "KEYS[$key]='${KEYS[$key]}'" >> "${PROGRESS_FILE}" + done + + # Previous row's hints, needed by hard_mode_check after a resume + for key in "${!greens[@]}"; do + echo "greens[$key]='${greens[$key]}'" >> "${PROGRESS_FILE}" + done + + for key in "${!yellows[@]}"; do + echo "yellows[$key]='${yellows[$key]}'" >> "${PROGRESS_FILE}" + done + { - echo "ROW=${ROW}" + echo "ROW=${row}" echo "POOL=${POOL}" echo "HARD_MODE=${HARD_MODE}" echo "STRICT_HARD_MODE=${STRICT_HARD_MODE}" echo "WIN=${WIN}" echo "ANSWER=${ANSWER}" echo "IN_PROGRESS=1" - [[ "${PLAYED}" == 1 ]] && echo 'PLAYED=1' + [[ "${PLAYED}" == 1 ]] && echo 'PLAYED=1' } >> "${PROGRESS_FILE}" - - echo -en "${RESET}" - [[ -z $1 ]] && exit 0 } function save_result() { [[ "${ONLINE}" == 0 ]] && return 0 - [[ "${ONLINE}" == 1 ]] && update_stats if [[ "${ONLINE}" == 1 ]] && [[ "${DAILY_DONE}" != 1 ]]; then + update_stats render_board > "${RESULT_FILE}" render_board > "${CUR_RESULT_FILE}" if [[ $WIN != 5 ]]; then - [[ "${BORDER}" == 1 ]] && echo -en "${CLEAR_DOWN}${INDENT}${INDENT}" >> "${RESULT_FILE}" - echo -en "${CLEAR_LINE}${BOLD}" >> "${RESULT_FILE}" - for (( i=0; i> "${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" diff --git a/www/html/wordle/board.js b/www/html/wordle/board.js new file mode 100644 index 0000000..63df518 --- /dev/null +++ b/www/html/wordle/board.js @@ -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")); +} diff --git a/www/html/wordle/favicon.svg b/www/html/wordle/favicon.svg new file mode 100644 index 0000000..50e94f1 --- /dev/null +++ b/www/html/wordle/favicon.svg @@ -0,0 +1 @@ +W diff --git a/www/html/wordle/game.html b/www/html/wordle/game.html new file mode 100644 index 0000000..881b6f0 --- /dev/null +++ b/www/html/wordle/game.html @@ -0,0 +1,32 @@ + + + + + +Wordle board + + + + + + + + + +
+ +
+ + diff --git a/www/html/wordle/games.html b/www/html/wordle/games.html new file mode 100644 index 0000000..2f399f9 --- /dev/null +++ b/www/html/wordle/games.html @@ -0,0 +1,32 @@ + + + + + +Wordle history + + + + + + + + + +
+ +
+ + diff --git a/www/html/wordle/history.js b/www/html/wordle/history.js new file mode 100644 index 0000000..451b43b --- /dev/null +++ b/www/html/wordle/history.js @@ -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")); +} diff --git a/www/html/wordle/home.js b/www/html/wordle/home.js new file mode 100644 index 0000000..1fa6b99 --- /dev/null +++ b/www/html/wordle/home.js @@ -0,0 +1,269 @@ +import { + SSH_COMMAND, h, icon, tile, avatar, miniBoard, emptyState, skeleton, toast, copyText, countdown, + mountShell, normalizeUser, statsPage, boardPage, following, setFollowing, recentSearches, + addRecentSearch, getStats, getBoard, today, resultText, score, sleep, reducedMotion, +} from "./wordle.js?v=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(); diff --git a/www/html/wordle/index.html b/www/html/wordle/index.html new file mode 100644 index 0000000..3857550 --- /dev/null +++ b/www/html/wordle/index.html @@ -0,0 +1,32 @@ + + + + + +Wordle - the daily word game for your terminal + + + + + + + + + +
+ +
+ + diff --git a/www/html/wordle/stats.html b/www/html/wordle/stats.html new file mode 100644 index 0000000..4499b70 --- /dev/null +++ b/www/html/wordle/stats.html @@ -0,0 +1,32 @@ + + + + + +Wordle stats + + + + + + + + + +
+ +
+ + diff --git a/www/html/wordle/stats.js b/www/html/wordle/stats.js new file mode 100644 index 0000000..5d99587 --- /dev/null +++ b/www/html/wordle/stats.js @@ -0,0 +1,138 @@ +import { + h, icon, miniBoard, emptyState, skeleton, toast, copyText, countUp, countdown, mountShell, + profileHeader, normalizeUser, statsPage, boardPage, getStats, getHistory, today, formatDay, + relativeDay, resultText, describeBoard, 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) : "✗"); + }))); +} diff --git a/www/html/wordle/wordle.css b/www/html/wordle/wordle.css new file mode 100644 index 0000000..a964afc --- /dev/null +++ b/www/html/wordle/wordle.css @@ -0,0 +1,719 @@ +/* Shared styles for the Wordle pages. After changing this file bump the + ?v= on its in every page so browsers fetch the new version. */ + +:root { + color-scheme: dark; + --bg: #070a08; + --surface: #0f1411; + --surface-2: #151c18; + --surface-3: #1b241f; + --border: #22302a; + --border-strong: #31463b; + --text: #e8f3ec; + --muted: #93a59a; + --faint: #5f7166; + --accent: #51ff7a; + --accent-strong: #3ee067; + --accent-ink: #03140a; + --glow: rgba(81, 255, 122, 0.3); + --danger: #ff6b6b; + + --correct: #26b24b; + --present: #d0ad2c; + --absent: #3b433f; + --empty: #111814; + --tile-border: #26332c; + --tile-text: #ffffff; + + --radius: 16px; + --radius-sm: 10px; + --shadow: 0 18px 40px rgba(0, 0, 0, 0.45); + --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #f1f5f2; + --surface: #ffffff; + --surface-2: #f5f8f6; + --surface-3: #e9efeb; + --border: #dbe4de; + --border-strong: #c2d0c7; + --text: #0e1a13; + --muted: #536559; + --faint: #879a8e; + --accent: #0f9d3a; + --accent-strong: #0b8430; + --accent-ink: #ffffff; + --glow: rgba(15, 157, 58, 0.22); + --danger: #d63939; + + --correct: #5fa85a; + --present: #c9b458; + --absent: #7b8083; + --empty: #ffffff; + --tile-border: #d3d8d5; + --shadow: 0 18px 40px rgba(20, 40, 28, 0.12); +} + +/* Colour-blind friendly tiles, like the real Wordle's high contrast mode */ +:root[data-contrast="high"] { + --correct: #f5793a; + --present: #85c0f9; +} + +*, *::before, *::after { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); + font: 16px/1.5 var(--font); + -webkit-font-smoothing: antialiased; + overflow-x: hidden; +} + +/* Soft glow and faint grid behind everything */ +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(60rem 30rem at 15% -10%, var(--glow), transparent 60%), + radial-gradient(40rem 25rem at 110% 10%, rgba(208, 173, 44, 0.08), transparent 60%), + linear-gradient(var(--border) 1px, transparent 1px) 0 0 / 44px 44px, + linear-gradient(90deg, var(--border) 1px, transparent 1px) 0 0 / 44px 44px; + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.35) 45%, transparent 85%); + opacity: 0.55; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +h1, h2, h3 { line-height: 1.15; margin: 0; } +code, kbd, .mono { font-family: var(--mono); } +button { font: inherit; color: inherit; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; } +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +.container { width: 100%; max-width: 1080px; margin: 0 auto; padding: 0 16px; } +main { flex: 1; } +main.container { padding-top: 32px; padding-bottom: 64px; } +.stack > * + * { margin-top: 24px; } + +/* ---------- header / footer ---------- */ + +.site-header { + position: sticky; + top: 0; + z-index: 20; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + background: color-mix(in srgb, var(--bg) 78%, transparent); + border-bottom: 1px solid var(--border); +} +.site-header .container { display: flex; align-items: center; gap: 16px; height: 64px; } +.site-nav { display: flex; align-items: center; gap: 4px; margin-left: auto; min-width: 0; } +.site-nav a { + color: var(--muted); + padding: 8px 12px; + border-radius: 999px; + font-size: 0.92rem; + white-space: nowrap; +} +.site-nav a:hover { color: var(--text); background: var(--surface-2); text-decoration: none; } +.site-nav a[aria-current="page"] { color: var(--text); background: var(--surface-3); } + +.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; + } +} diff --git a/www/html/wordle/wordle.js b/www/html/wordle/wordle.js new file mode 100644 index 0000000..e0134f9 --- /dev/null +++ b/www/html/wordle/wordle.js @@ -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