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
-
+
-
+
@@ -61,6 +61,26 @@
Hourly challenge
Guess the word in six tries.
Play one five-letter word per hour. Valid words reveal with smooth flips, warm feedback, confetti, sound, and a definition at the end.
+
+
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")
+})