From 6daf3237e8fe10391481141e5d1fa0d4bd6554a3 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Wed, 28 Oct 2026 10:39:32 -0400 Subject: [PATCH] Add saved starter words for signed-in players Players can save up to 3 favorite opening words and tap one to fill the current row. Words are stored on profiles.starter_words and saved through the set_starter_words RPC, which validates them against the dictionary. Co-Authored-By: Claude Opus 5.5 --- CSS/styles.css | 126 +++++++++++++++++++++++++- index.html | 24 ++++- script.js | 181 +++++++++++++++++++++++++++++++++++++ supabase/schema.sql | 55 ++++++++++- tests/fancy-wordle.spec.js | 45 +++++++++ 5 files changed, 427 insertions(+), 4 deletions(-) diff --git a/CSS/styles.css b/CSS/styles.css index 34bcdfb..c502a76 100644 --- a/CSS/styles.css +++ b/CSS/styles.css @@ -1015,6 +1015,123 @@ h2 { text-align: start; } +.starter-panel { + display: grid; + gap: 12px; + max-width: 34rem; + margin-top: 28px; + padding: 16px 18px; + border: 1px solid var(--border); + border-radius: 20px; + background: color-mix(in oklch, var(--surface), var(--bg) 40%); +} + +.starter-panel[hidden], +.starter-editor[hidden], +.starter-chips[hidden], +.starter-panel .link-button[hidden] { + display: none; +} + +.starter-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.starter-head .eyebrow { + margin: 0; +} + +.starter-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.starter-chips:empty { + display: none; +} + +.starter-chip { + min-height: 44px; + padding: 0 16px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--tile); + color: var(--fg); + cursor: pointer; + font: inherit; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + transition: transform 160ms ease, border-color 160ms ease, background-color 160ms ease; +} + +.starter-chip:hover, +.starter-chip:focus-visible { + transform: translateY(-1px); + border-color: var(--accent); + outline: none; +} + +.starter-chip:active { + background: var(--key); +} + +.starter-editor { + display: grid; + gap: 12px; +} + +.starter-inputs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.starter-input { + min-width: 0; + min-height: 44px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg); + color: var(--fg); + font: inherit; + font-weight: 800; + letter-spacing: 0.12em; + text-align: center; + text-transform: uppercase; +} + +.starter-input:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent), transparent 75%); +} + +.starter-input[aria-invalid] { + border-color: var(--accent); +} + +.starter-editor-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.starter-hint { + margin: 0; + color: var(--muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.starter-hint .link-button { + font-size: inherit; +} + @keyframes intro-tile-enter { 0% { transform: translate3d(0, 34px, -180px); @@ -1179,10 +1296,17 @@ h2 { } h1, - .deck { + .deck, + .starter-panel { margin-inline: auto; } + .starter-panel { + width: 100%; + margin-top: 18px; + text-align: start; + } + .deck { margin-top: 12px; } diff --git a/index.html b/index.html index 0f75fbe..318f8a9 100644 --- a/index.html +++ b/index.html @@ -5,12 +5,12 @@ Fancy Wordle - + - +
diff --git a/script.js b/script.js index 28ee756..f08ccc7 100644 --- a/script.js +++ b/script.js @@ -20,9 +20,14 @@ let lastDefinition = null let introDismissed = false let pendingInteractionStart = false let introTimers = [] +let starterWords = [] +let starterWordsLoaded = false +let starterWordsUnavailable = false +let isSavingStarterWords = false const WORD_LENGTH = 5 const MAX_GUESSES = 6 +const MAX_STARTER_WORDS = 3 const FLIP_ANIMATION_DURATION = 500 const DANCE_ANIMATION_DURATION = 500 const STATS_KEY = "fancy-wordle-stats-v2" @@ -132,6 +137,14 @@ const introOverlay = document.getElementById("intro-overlay") const introSkipButton = document.getElementById("intro-skip") const introTileRow = document.getElementById("intro-tiles") const introTiles = Array.from(document.querySelectorAll(".intro-tile")) +const starterPanel = document.getElementById("starter-panel") +const starterChips = document.getElementById("starter-chips") +const starterEditButton = document.getElementById("starter-edit") +const starterEditor = document.getElementById("starter-editor") +const starterInputs = Array.from(document.querySelectorAll(".starter-input")) +const starterCancelButton = document.getElementById("starter-cancel") +const starterSaveButton = document.getElementById("starter-save") +const starterHint = document.getElementById("starter-hint") if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initializeGame, { once: true }) @@ -372,10 +385,17 @@ async function initializeAuth() { authSession = session authProfile = session ? await getAuthProfile() : null updateAuthControls() + if (session) loadStarterWords() client.auth.onAuthStateChange((event, session) => { + const userChanged = authSession?.user?.id !== session?.user?.id authSession = session authProfile = null + if (userChanged) { + starterWords = [] + starterWordsLoaded = false + starterWordsUnavailable = false + } updateAuthControls() if (event === "PASSWORD_RECOVERY") { @@ -389,6 +409,7 @@ async function initializeAuth() { authProfile = profile updateAuthControls() }) + if (userChanged) loadStarterWords() } }) } catch (error) { @@ -445,6 +466,141 @@ function updateAuthControls() { if (authForm) authForm.hidden = !hasSupabase || Boolean(authSession) if (authGuestButton) authGuestButton.hidden = Boolean(authSession) if (authSignOutButton) authSignOutButton.hidden = !authSession + + renderStarterPanel() +} + +async function loadStarterWords() { + const client = getSupabaseClient() + if (!client || !authSession) return + + const { data, error } = await client + .from("profiles") + .select("starter_words") + .eq("id", authSession.user.id) + .maybeSingle() + + if (error) { + console.warn("Failed to load starter words:", error) + starterWordsUnavailable = true + renderStarterPanel() + return + } + + starterWords = normalizeStarterWords(data?.starter_words) + starterWordsLoaded = true + starterWordsUnavailable = false + renderStarterPanel() +} + +function normalizeStarterWords(words) { + if (!Array.isArray(words)) return [] + + return [...new Set(words.map(word => String(word || "").trim().toLowerCase()))] + .filter(word => /^[a-z]{5}$/.test(word)) + .slice(0, MAX_STARTER_WORDS) +} + +function renderStarterPanel() { + if (!starterPanel) return + + starterPanel.hidden = !getSupabaseClient() + if (starterPanel.hidden) return + + if (!authSession) { + setStarterEditing(false) + starterEditButton.hidden = true + starterChips.innerHTML = "" + starterHint.innerHTML = ` to save up to ${MAX_STARTER_WORDS} favorite openers.` + return + } + + starterEditButton.hidden = !starterWordsLoaded || !starterEditor.hidden + starterChips.innerHTML = starterWords.map(word => ` + + `).join("") + + if (!starterEditor.hidden) return + + starterHint.textContent = starterWordsUnavailable + ? "Starter words are unavailable right now." + : !starterWordsLoaded + ? "Loading your starter words…" + : starterWords.length + ? "Tap a word to drop it into your current row." + : `Save up to ${MAX_STARTER_WORDS} favorite openers, like plane or arise.` +} + +function setStarterEditing(isEditing) { + if (!starterEditor) return + + starterEditor.hidden = !isEditing + starterChips.hidden = isEditing + starterEditButton.hidden = isEditing + + if (isEditing) { + starterInputs.forEach((input, index) => { + input.value = starterWords[index] || "" + input.removeAttribute("aria-invalid") + }) + starterHint.textContent = "Five-letter words from the guess list." + starterInputs[0]?.focus() + } +} + +function useStarterWord(word) { + if (!hourlyRound || gameFinished || isAnimating || !introDismissed) return + if (!/^[a-z]{5}$/.test(word)) return + + while (currentTileIndex > 0) deleteKey() + word.split("").forEach(pressKey) +} + +async function saveStarterWords(event) { + event.preventDefault() + if (isSavingStarterWords) return + + const client = getSupabaseClient() + if (!client || !authSession) return + + const words = starterInputs.map(input => input.value.trim().toLowerCase()) + let firstInvalid = null + + starterInputs.forEach((input, index) => { + const word = words[index] + const isInvalid = word !== "" && (!/^[a-z]{5}$/.test(word) || (wordListMode === "full" && !dictionarySet.has(word))) + input.toggleAttribute("aria-invalid", isInvalid) + if (isInvalid && !firstInvalid) firstInvalid = input + }) + + if (firstInvalid) { + starterHint.textContent = `"${firstInvalid.value.trim().toUpperCase()}" is not in the word list.` + firstInvalid.focus() + return + } + + isSavingStarterWords = true + starterSaveButton.disabled = true + starterHint.textContent = "Saving…" + + const { data, error } = await client.rpc("set_starter_words", { words: words.filter(Boolean) }) + + isSavingStarterWords = false + starterSaveButton.disabled = false + + if (error) { + console.warn("Failed to save starter words:", error) + starterHint.textContent = error.message === "Not in word list" + ? "One of those words is not in the word list." + : "Could not save starter words. Try again." + return + } + + starterWords = normalizeStarterWords(data) + starterWordsLoaded = true + setStarterEditing(false) + renderStarterPanel() + showAlert(starterWords.length ? "Starter words saved" : "Starter words cleared") } function getAuthLabel() { @@ -725,6 +881,30 @@ function bindControls() { }) authGuestButton?.addEventListener("click", () => authModal.close()) authSignOutButton?.addEventListener("click", signOut) + starterEditButton?.addEventListener("click", () => setStarterEditing(true)) + starterCancelButton?.addEventListener("click", () => { + setStarterEditing(false) + renderStarterPanel() + }) + starterEditor?.addEventListener("submit", saveStarterWords) + starterInputs.forEach(input => { + input.addEventListener("input", () => { + input.value = input.value.replace(/[^a-z]/gi, "").toLowerCase() + input.removeAttribute("aria-invalid") + }) + }) + starterPanel?.addEventListener("click", event => { + if (event.target.closest("[data-starter-sign-in]")) { + openAuth() + return + } + + const chip = event.target.closest("[data-starter-word]") + if (!chip) return + + chip.blur() + useStarterWord(chip.dataset.starterWord) + }) resetStatsButton.addEventListener("click", resetStats) shareResultsButton.addEventListener("click", shareResult) document.querySelector(".brand").addEventListener("click", event => { @@ -951,6 +1131,7 @@ function handleMouseClick(event) { function handleKeyPress(event) { if (statsModal.open || authModal?.open) return + if (event.target.closest?.("input, textarea, select")) return if (event.key === "Enter") { submitGuess() diff --git a/supabase/schema.sql b/supabase/schema.sql index b59574d..528f9f4 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -18,7 +18,15 @@ create table if not exists public.profiles ( ); alter table public.profiles - add column if not exists username text; + add column if not exists username text, + add column if not exists starter_words text[] not null default '{}'; + +alter table public.profiles + drop constraint if exists profiles_starter_words_limit; + +alter table public.profiles + add constraint profiles_starter_words_limit + check (cardinality(starter_words) <= 3); create unique index if not exists profiles_username_key on public.profiles (lower(username)) @@ -490,6 +498,50 @@ begin end; $$; +create or replace function public.set_starter_words(words text[]) +returns text[] +language plpgsql +security definer +set search_path = public +as $$ +declare + normalized_words text[]; +begin + if auth.uid() is null then + raise exception 'Sign in to save starter words'; + end if; + + select coalesce(array_agg(word order by first_position), '{}') + into normalized_words + from ( + select lower(trim(candidate)) as word, min(position) as first_position + from unnest(coalesce(words, '{}')) with ordinality as input(candidate, position) + where nullif(trim(candidate), '') is not null + group by lower(trim(candidate)) + ) deduped; + + if cardinality(normalized_words) > 3 then + raise exception 'You can save up to 3 starter words'; + end if; + + if exists ( + select 1 + from unnest(normalized_words) as candidate(word) + where candidate.word !~ '^[a-z]{5}$' + or not exists (select 1 from public.wordle_dictionary dictionary where dictionary.word = candidate.word) + ) then + raise exception 'Not in word list'; + end if; + + update public.profiles + set starter_words = normalized_words, + updated_at = now() + where id = auth.uid(); + + return normalized_words; +end; +$$; + create or replace function public.get_player_current_streak(target_user_id uuid) returns integer language plpgsql @@ -743,6 +795,7 @@ grant select, update on public.profiles to authenticated; grant select on public.wordle_rounds to authenticated; grant select on public.wordle_guesses to authenticated; grant execute on function public.is_username_available(text) to anon, authenticated; +grant execute on function public.set_starter_words(text[]) to authenticated; grant execute on function public.require_confirmed_email() to authenticated; grant execute on function public.start_hourly_round() to authenticated; grant execute on function public.submit_guess(uuid, text) to authenticated; diff --git a/tests/fancy-wordle.spec.js b/tests/fancy-wordle.spec.js index ed385ee..f183d35 100644 --- a/tests/fancy-wordle.spec.js +++ b/tests/fancy-wordle.spec.js @@ -116,3 +116,48 @@ test("definition lookup leaves loading state when a provider times out", async ( timeout: 6500 }) }) + +test("guests are prompted to sign in to save starter words", async ({ page }) => { + await openApp(page) + await skipIntro(page) + + const starterPanel = page.locator("#starter-panel") + await expect(starterPanel).toBeVisible() + await expect(starterPanel).toContainText("to save up to 3 favorite openers") + await expect(page.locator("#starter-edit")).toBeHidden() + + await starterPanel.locator("[data-starter-sign-in]").click() + await expect(page.locator("#auth-modal")).toBeVisible() +}) + +test("starter words fill the current row and the editor keeps keys off the board", async ({ page }) => { + await page.route("**/rest/v1/rpc/set_starter_words", route => route.fulfill({ json: ["crane", "arise"] })) + await openApp(page) + await skipIntro(page) + await expect(page.locator("#round-status")).not.toContainText("Loading") + + await page.evaluate(() => { + authSession = { user: { id: "test-user", email: "player@example.test" } } + starterWords = ["plane", "arise"] + starterWordsLoaded = true + renderStarterPanel() + }) + + const readFirstRow = () => page.locator("[data-guess-grid] .tile").evaluateAll(tiles => + tiles.slice(0, 5).map(tile => tile.dataset.letter || "").join("") + ) + + await page.keyboard.press("Q") + await page.locator("[data-starter-word='arise']").click() + expect(await readFirstRow()).toBe("arise") + + await page.locator("#starter-edit").click() + const firstInput = page.locator(".starter-input").first() + await expect(firstInput).toHaveValue("plane") + await firstInput.fill("crane") + await firstInput.press("Enter") + + await expect(page.locator("[data-alert-container]")).toContainText("Starter words saved") + await expect(page.locator("[data-starter-word]")).toHaveText(["crane", "arise"]) + expect(await readFirstRow()).toBe("arise") +})