From c324f829c79a777832d04503b15161ea104512fc Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 13 Apr 2026 23:27:42 +0100 Subject: [PATCH 1/2] feat: native comments, bug reports, word dictionary, and type safety overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments system: - Replace Giscus (GitHub Discussions) with native comment section on word pages - Comments table in Postgres (shared with bug reports via type field) - Real-time game result badges via single SQL query with correlated subquery - Badge rules: N/maxGuesses for wordle modes, N guesses for semantic, skip speed - Mode label only shown when user has 2+ badges for the same word - Basic moderation: profanity filter, URL blocking, rate limiting, hidden flag - Flat list, auth required to post, public to read Bug reports & feedback: - In-app report modal replacing GitHub Issues link in sidebar - Reports stored in comments table (type='report'/'feedback') - Auto-collects URL, browser, screen size, PWA status, language, mode - Optional email notification via Resend (works without it) Word page improvements: - WordDictionary: full Wiktionary entries (senses, etymology, IPA, forms) - WordTranslations: cross-language word links with SSR-safe language names - Fix hydration mismatch: translations now use server-resolved names instead of client-side cache (useNuxtData), eliminating 80+ hydration warnings that were corrupting Vue's component tree and breaking teleports/event handlers - Fix @unhead/vue dispose crash: merge 3 useHead/useSeoMeta calls into 1 - JSON-LD enriched with pronunciation and etymology from kaikki data - Image handling moved from HEAD-probe to server-provided image_url Type safety (45 errors → 0): - Add type annotations to all implicit-any parameters across server utils - Extend UiStrings and LanguageHelp types with missing optional properties - Fix null vs undefined mismatches in Vue components - Fix Nuxt route type recursion (ofetch import bypasses typed $fetch) - Add trackCustomEvent to analytics composable - Fix semantic.vue notification, game complete params, and compass types - Add TargetNeighbor model to Prisma schema (was orphaned, 4.4M rows) Passkey auth improvements (LoginModal rewrite): - Multi-step flow: main → passkey choice → register/authenticate - Platform-aware ordering (passkey first on iOS, Google first elsewhere) - Email sign-in placeholder Other: - Dictionary import script (scripts/import_dictionary.py) - Gitignore: design/, kaikki_processed/, public exploration files - TODO: leaderboard comments, comment admin tooling, Resend setup --- .env.example | 4 + .gitignore | 5 + TODO.md | 563 +++---------------- app.vue | 2 + components/account/LoginModal.vue | 352 +++++++++--- components/app/AppShell.vue | 2 +- components/app/AppSidebar.vue | 52 +- components/app/SidebarItem.vue | 21 +- components/game/MultiBoardLayout.vue | 4 +- components/game/PageShell.vue | 2 +- components/shared/BaseTooltip.vue | 158 ++++++ components/shared/ReportModal.vue | 164 ++++++ components/shared/StreakCalendar.vue | 4 +- components/word/NearbyInMeaning.vue | 41 +- components/word/WordComments.vue | 542 ++++++++++++++++++ components/word/WordDictionary.vue | 360 ++++++++++++ components/word/WordTranslations.vue | 145 +++++ composables/useAnalytics.ts | 11 + composables/useBadgeNotification.ts | 4 +- composables/useDefinitions.ts | 8 +- composables/useReportModal.ts | 20 + composables/useWordData.ts | 24 + nuxt.config.ts | 1 + pages/[lang]/semantic.vue | 13 +- pages/[lang]/speed.vue | 4 +- pages/[lang]/word/[slug].vue | 602 +++++++++++--------- pages/index.vue | 2 +- pages/profile.vue | 5 +- plugins/sync.client.ts | 3 +- prisma/prisma.config.ts | 1 + prisma/schema.prisma | 64 +++ pyproject.toml | 1 + scripts/import_dictionary.py | 604 +++++++++++++++++++++ server/api/[lang]/semantic/guess.post.ts | 2 +- server/api/[lang]/word-image/[word].get.ts | 14 +- server/api/[lang]/word/[slug].get.ts | 81 ++- server/api/[lang]/words.get.ts | 26 +- server/api/auth/forgot-password.post.ts | 2 +- server/api/auth/register.post.ts | 2 +- server/api/comments.get.ts | 130 +++++ server/api/comments.post.ts | 113 ++++ server/api/report.post.ts | 136 +++++ server/api/user/game-result.post.ts | 4 +- server/api/user/profile.get.ts | 2 +- server/api/user/sync.post.ts | 6 +- server/api/webauthn/authenticate.post.ts | 14 +- server/api/webauthn/register.post.ts | 10 + server/routes/auth/google.get.ts | 2 +- server/utils/_semantic-db.ts | 8 +- server/utils/badge-evaluator.ts | 4 +- server/utils/comments.ts | 28 + server/utils/definitions.ts | 87 ++- server/utils/moderation.ts | 59 ++ server/utils/name-generator.ts | 2 +- server/utils/prisma.ts | 6 +- server/utils/rate-limit.ts | 2 +- server/utils/word-selection.ts | 141 ++++- utils/types.ts | 21 + uv.lock | 48 ++ 59 files changed, 3732 insertions(+), 1006 deletions(-) create mode 100644 components/shared/BaseTooltip.vue create mode 100644 components/shared/ReportModal.vue create mode 100644 components/word/WordComments.vue create mode 100644 components/word/WordDictionary.vue create mode 100644 components/word/WordTranslations.vue create mode 100644 composables/useReportModal.ts create mode 100644 scripts/import_dictionary.py create mode 100644 server/api/comments.get.ts create mode 100644 server/api/comments.post.ts create mode 100644 server/api/report.post.ts create mode 100644 server/utils/comments.ts create mode 100644 server/utils/moderation.ts diff --git a/.env.example b/.env.example index 568af02e..9bdf6556 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ RESEND_API_KEY="" # Base URL for links in emails (verification, password reset). NUXT_BASE_URL="http://localhost:3000" +# ─── Reports ─── +# Email address for bug reports and feedback. Defaults to hugo@wordle.global. +REPORT_EMAIL="" + # ─── Dev tools ─── # Set to "true" to enable /api/auth/dev-login (bypasses OAuth for testing). # NEVER enable in production. diff --git a/.gitignore b/.gitignore index 4b359265..985a21c1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ docs/ # Design exploration HTML mockups (local only) +design/ public/design-explorations/ +public/comments-exploration.html # Node.js / Frontend node_modules/ @@ -48,6 +50,9 @@ scripts/.freq_data # Curation review temp files scripts/.curation_review/ + +# Processed kaikki dictionary data (190MB+, regenerate with import_dictionary.py) +scripts/.kaikki_processed/ .mcp.json # Archive (legacy assets, not tracked) diff --git a/TODO.md b/TODO.md index e73436ab..db7aa0c4 100644 --- a/TODO.md +++ b/TODO.md @@ -1,531 +1,90 @@ -# Hebrew Bugs — Investigation & Fix Plan (2026-03-19) +# Wordle Global — TODO -Hebrew has 885 weekly sessions but only 7% completion rate (vs 37% baseline). -Four bugs found during investigation. Bugs #1, #1b, and #2 are **systemic**. +## Bugs ---- +- [x] **Hebrew blocklist too large** — 57K blocked→valid (fixed 2026-03-20) +- [x] **Multi-board missing analytics** — added trackGameComplete to dordle/quordle handlers (fixed 2026-03-21) +- [x] **Definitions missing for 57+ languages** — rewritten to DB-backed 2-tier system (LLM + kaikki) +- [x] **Keyboard colors wrong for final-form letters** — Hebrew כ↔ך etc, Greek σ↔ς. normalizeMap now includes final_form_map +- [x] **`/en/speed` 500 error** — useGameShare composable missing, fixed +- [x] **Dordle/quordle pages duplicated** — consolidated into `pages/[lang]/[mode].vue` +- [ ] **Hebrew checkWord() rejects final-form words** — `checkWord()` should normalize final forms before lookup. Low real-world impact (only ~68 obscure words affected) +- [ ] **Hardcoded "6" in community percentile** — `utils/stats.ts`, `server/utils/word-stats.ts` reject attempts > 6. Non-classic modes can't record community stats. Fix: pass `maxGuesses` from GameConfig +- [ ] **English-only strings in speed/multi-board UI** — StatsModal, SpeedResults, MultiBoardLayout, AppSidebar have hardcoded English. Route through `language_config.json` -## Bug 1: Keyboard colors wrong key for final-form letters (SYSTEMIC) +## Semantic Explorer -**Impact**: Hebrew (5 letter pairs) + Greek (σ/ς). Misleading keyboard feedback. -**Severity**: High — keyboard shows wrong letters as green/yellow, confusing players. +- [ ] **#16 Viewport-locked layout** — semantic.vue uses scrollable layout while all other modes use viewport-locked PageShell. Causes double scrollbar, map overflow, input below fold. Proper fix: flex columns, no page-level scroll. Consolidates #21 and #24 +- [ ] **#21 Mobile layout reorder** — guesses list at top, compass below, map below that, input pinned to bottom. Prioritizes typing-relevant info over the visual map +- [ ] **#24 Virtual keyboard covers input** — partially fixed with `preventScroll`. Proper fix: `visualViewport` API to offset input by keyboard height. Moot if #16 is done first +- [ ] **#9 Mobile tabbed bottom panel** — Rank / Compass / List tabs instead of vertical stack. Eliminates scroll during gameplay +- [ ] **#17 OG image** — design `public/images/og-semantic.png` (1200x630) with meaning map + editorial aesthetic +- [ ] **#18 Best starting words** — add semantic-specific tips to `/en/best-starting-words` -### Root cause +## Word Pages & Dictionary -When user types פ (regular pe) at the last position: +- [ ] **Bulk kaikki import** — download kaikki.org JSONL dumps for all 79 languages, extract structured dictionary data (senses, etymology, IPA, forms, translations) into Postgres `definitions` table. Script: modify `scripts/deprecated/build_definitions.py`. Native editions for 13 langs (cs, de, el, es, fr, it, ko, nl, pl, pt, ru, tr, vi), English edition for the rest. ~1.2 GB for all 1.4M words. +- [ ] **Native language definitions** — for the 13 languages with native kaikki editions, import native-language glosses instead of English. Show native as default, English as secondary. +- [ ] **Definition fallback chain** — when LLM fails, fall back to kaikki data before writing negative cache. Never overwrite a kaikki definition with a negative entry. +- [ ] **Multi-length word lists** — expand beyond 5-letter words for semantic explorer in all languages. Enables cross-language translation links for words of any length. +- [ ] **Cross-language word links via translations** — kaikki `translations` field maps words between languages. Currently seeded for test words; bulk import will populate for all words. Translation links auto-expand as word lists grow. -1. `addChar()` (game.ts:259) calls `toFinalForm('פ', true, config)` → stores **ף** in tile -2. `updateColors()` (game.ts:320) reads `guessChar = 'ף'` from tile -3. `pendingKeyUpdates` (game.ts:326) stores `{ char: 'ף' }` -4. `updateKeyColor('ף', ...)` (game.ts:395) colors the **ף** key on keyboard -5. The **פ** key (which user actually pressed) stays uncolored +## SEO & Content -`updateKeyColor` (game.ts:397-408) propagates colors via `normalizeMap`, but that's built -from `diacritic_map` only (utils/diacritics.ts:30). It does NOT include `final_form_map`. +- [ ] **Redesign share images** — `scripts/generate_share_images.py` still uses old dark theme. Update to editorial design system colors +- [ ] **Submit new URLs to GSC** — 400+ game mode URLs in sitemap, not yet crawled. Submit sitemap + request indexing for top 50 +- [ ] **#19 useGameSeo refactor** — silent 60-char title truncation, hardcoded `| Wordle English` suffix. Add per-mode configurable suffix + length validation -### Affected languages +## Stats & Sync -| Language | final_form_map | Letter pairs | -|----------|---------------|--------------| -| **Hebrew** | כ↔ך, מ↔ם, נ↔ן, פ↔ף, צ↔ץ | 5 pairs, all on keyboard | -| **Greek** | σ↔ς | 1 pair (21% of daily words end with ς) | +- [ ] **#12 Stats modal redesign** — show daily + unlimited stats per mode in separate sections. Data already split (`en_dordle` vs `en_dordle_daily`) +- [ ] **#14 Sync endpoint: coerce att:0 on losses** — old localStorage stored `attempts: 0` for losses. Coerce to `maxGuesses`. Affects 48 legacy records +- [ ] **#15 Reactive stats store** — make `stats` a `computed` derived from reactive `gameResults` + `currentStatsKey` instead of imperative `calculateStats()`. Eliminates stale-stats bugs -### Fix +## Infrastructure -In `updateKeyColor()` (game.ts:371-409), after the diacritic propagation block, -also propagate to final/regular form pairs: - -```typescript -// Also update final form ↔ regular form pairs -const config = lang.config ?? {}; -if (config.final_form_map) { - const finalForm = config.final_form_map[char]; - if (finalForm) updateSingleKey(finalForm, newState); - - const regularForm = lang.finalFormReverseMap.get(char); - if (regularForm) updateSingleKey(regularForm, newState); -} -``` - ---- - -## Bug 1b: checkWord() rejects valid Hebrew words (CRITICAL — likely cause of 7%) - -**Impact**: Hebrew and Greek. Words typed with final forms may fail validation. -**Severity**: CRITICAL — this likely causes most of the 7% completion rate. - -### Root cause - -When user types a word and presses Enter: - -1. `addChar()` at position 4 converts פ→ף via `toFinalForm` -2. Tiles now contain e.g. `['פ','י','ל','ג','ף']` (note ף at end) -3. `enterGuess()` (game.ts:463): `const typedWord = row.join('').toLowerCase()` → `"פילגף"` -4. `checkWord("פילגף")` checks: - - `wordListSet.has("פילגף")` → **false** (word list has `"פילגש"`, not `"פילגף"`) - - `normalizeWord("פילגף", normalizeMap)` — normalizeMap is diacritics only, doesn't - convert final forms → still `"פילגף"` - - `getNormalizedWordMap().get("פילגף")` → **miss** (map was built with regular forms) - - Returns **null** → word rejected as invalid! - -Wait — the word פילגש ends with ש which has no final form. So this specific word wouldn't -hit the bug. But ANY Hebrew word the user tries to guess where they type a final-form letter -(כ→ך, מ→ם, נ→ן, פ→ף, צ→ץ) at the last position WILL fail validation if the word list -stores the word with a regular form at that position (which shouldn't happen for properly -formed Hebrew — final forms should be at the end). - -### When does it actually break? - -The bug triggers when: -- User types a letter with a final form variant at position 4 (last) -- `toFinalForm` converts it to the final form -- The word in the word list ALSO has the final form at that position -- → In this case it should MATCH (both have final form) - -But it breaks when: -- User types e.g. "שלמ" (3 letters), backspaces, retypes — intermediate states may leave - final forms in non-final positions -- Or: the word list has inconsistent final form usage - -### Verification needed - -```bash -# Check: do ALL Hebrew words in word list use proper final forms at end? -python3 -c " -import json -d = json.load(open('data/languages/he/words.json')) -finals = {'ך':'כ', 'ם':'מ', 'ן':'נ', 'ף':'פ', 'ץ':'צ'} -regulars = {v:k for k,v in finals.items()} -for w in d['words'][:20]: - word = w['word'] - # Check if last char SHOULD be final but isn't - if word[-1] in regulars: - print(f' {word} — ends with {word[-1]} (should be {regulars[word[-1]]}?)') -" -``` - -### Fix - -In `checkWord()` (game.ts:276-291), normalize final forms to regular forms before lookup: - -```typescript -function checkWord(word: string): string | null { - // Normalize positional final forms → regular forms before lookup - const lang = useLanguageStore(); - let normalized = word; - if (lang.finalFormReverseMap.size > 0) { - normalized = [...word].map(c => lang.finalFormReverseMap.get(c) || c).join(''); - } - - if (lang.wordListSet.has(normalized)) return normalized; - // ... rest of existing logic -} -``` - -### Backspace edge case - -`deleteChar()` (game.ts:581-590) doesn't re-evaluate final forms when removing the last char. -If user types "שלמ" where מ is at pos 2 (not final, stays מ), then types at pos 3 where the -new char becomes final, then backspaces — the char at pos 2 doesn't get re-evaluated. -This can leave regular forms where final forms are expected and vice versa. - ---- - -## Bug 2: No definition or word image shown after game over (SYSTEMIC) - -**Impact**: Hebrew + likely 70+ other languages without pre-cached definitions. -**Severity**: Medium — missing content, doesn't block gameplay. - -### Root cause - -Definition system is 3-tier: disk cache → LLM (GPT-5.2) → kaikki (offline Wiktionary). - -For Hebrew (פילגש): -- **Disk cache**: `word-defs/he/` doesn't exist (only de, en, es cached) -- **LLM**: Needs `OPENAI_API_KEY`. If set and returns confidence < 0.3, writes negative cache -- **Kaikki**: `he.json` (native) missing. `he_en.json` exists and HAS the word - -The kaikki English fallback SHOULD return `{ definition: "mistress, concubine..." }`. - -Most likely failure: LLM is called (key is set on production), returns null or low confidence, -**negative cache is written** (definitions.ts:318: `{ not_found: true, ts: ... }`). -Subsequent requests within 24h hit the negative cache and return null. Kaikki is never reached. - -### Verified on production - -``` -Hebrew: GET /api/he/definition/פילגש → 404 -Arabic: GET /api/ar/definition/كتاب → 404 -Croatian: GET /api/hr/definition/kuća → 404 -English: GET /api/en/definition/hello → 200 (LLM cached) -Finnish: GET /api/fi/definition/koira → 200 (native kaikki) -German: GET /api/de/definition/hallo → 200 (native kaikki) -``` - -**Pattern**: Languages with native kaikki files work. Languages with ONLY `_en.json` fail. - -### Kaikki file coverage - -- **13 languages** have native `{lang}.json`: cs, de, el, en, es, fr, it, nl, pl, pt, ru, tr, vi -- **57 languages** have only `{lang}_en.json` (English definitions) — ALL returning 404 -- **25 languages** have no kaikki files at all - -### Root cause (confirmed) - -The LLM (GPT-5.2) is called first on production (OPENAI_API_KEY is set). For non-Western -words it either returns low confidence or fails. Then a **negative cache** is written -(`{ not_found: true, ts: ... }`). The kaikki fallback IS tried in the same request, but -if it ALSO returns null (e.g., lookup fails), the negative cache persists for 24h. - -The English kaikki lookup (`lookupKaikki(word, lang, 'en')`) should work for Hebrew since -`he_en.json` has the word. But on production the file may not be found (path resolution -issue) or the lookup is failing for another reason. - -### Fix - -The definitions code has **zero logging** outside the LLM path. Kaikki lookups, negative -cache hits, file existence checks, and final results are all silent. We can't diagnose -further from Render logs — need to add logging and redeploy. - -Add to `server/utils/definitions.ts`: - -```typescript -// In resolveDefinitionsDir() — log once at startup: -console.log(`[DEFS] Definitions dir: ${result}`); - -// In loadKaikkiFile() — log file existence: -console.log(`[KAIKKI] Loading ${filePath}: exists=${existsSync(filePath)}, keys=${Object.keys(result).length}`); - -// In fetchDefinition() — log the decision at each tier: -console.log(`[DEFS] ${langCode}/${word}: cache=${existsSync(cachePath) ? 'hit' : 'miss'}`); -// After LLM: -console.log(`[DEFS] ${langCode}/${word}: llm=${result ? 'ok' : 'null'}`); -// After kaikki: -console.log(`[DEFS] ${langCode}/${word}: kaikki=${result ? result.source : 'null'}`); -// Final: -console.log(`[DEFS] ${langCode}/${word}: final=${result ? result.source : 'not_found'}`); -``` - -This will reveal whether: (a) kaikki file isn't found on Render, (b) negative cache is -blocking, or (c) the lookup key doesn't match. One deploy and we'll know. - -### Systemic scope — LARGE - -57 languages with English-only kaikki are all affected. This includes Arabic (2.1K sessions/wk), -Croatian (1.3K), Bulgarian (963), Hebrew (885), Swedish (1K), and many more. -Only 13 Western European languages have working definitions. - ---- - -## Bug 3: Day index (NOT a production bug) - -Local debugging used wrong epoch. Production formula is: -`dayIdx = nDaysSinceUnixEpoch - 18992 + 195` → day 1734 for 2026-03-19. -Correctly uses consistent hash (1734 > MIGRATION_DAY_IDX 1681). - ---- - -## Systemic Assessment - -### Which languages are affected by which bugs? - -| Bug | Languages | Sessions/week | Severity | -|-----|-----------|--------------|----------| -| #1 keyboard colors | Hebrew, Greek | 885 + 54 | High | -| #1b checkWord rejection | Hebrew, Greek | 885 + 54 | **CRITICAL** | -| #2 missing definitions | ~76 languages | most traffic | Medium | - -### Greek impact - -Greek has 29% completion (PostHog 12h: 7 starts, 2 completes). With only σ↔ς as the -final form pair, 21% of daily words end with ς. Both bugs #1 and #1b apply. -Greek's low completion may be partly caused by these bugs. - -### Other languages unaffected by final form bugs - -All other languages (including Arabic, Persian, Korean) don't have `final_form_map` in their -config. Their issues are separate (RTL input, composition scripts, etc.). - ---- - -## Bug 4: Hebrew blocklist massively too large (ROOT CAUSE of 8.5% completion) - -**Impact**: Hebrew — 885 sessions/week, 8.5% completion. -**Severity**: CRITICAL — this is the primary cause of Hebrew's broken completion rate. - -### Root cause - -Hebrew had **57,483 blocked words (57% of total)** — vs 0-4% for every other language. -Common everyday words like ישראל (Israel), אנחנו (we), ילדים (children), מחלקה (department), -מכבסה (laundromat), שמיעה (hearing) were all blocked. Users type normal Hebrew words and -get "word not valid" → give up. - -The blocklist was imported during the word pipeline migration and never reviewed. An update -on Mar 15 intended to remove prefixed forms from DAILY tier but moved them to valid, not -blocked. The 57K blocked words predate that commit. - -### Fix (DONE 2026-03-20) - -Moved all 57,483 Hebrew `blocked` → `valid`. Now: daily=1,018, valid=99,664, blocked=0. -Words are guessable but won't be selected as the daily word. - -### Follow-up needed - -- **LLM curate the 57K promoted words**: Some may be genuine gibberish, conjugated - fragments, or transliterations that shouldn't be guessable. Run LLM curation to review - and re-block true garbage while keeping real Hebrew words as valid. -- **Check Irish (ga)**: 12% blocked (602 words) — given Irish already has 80% invalid - word rate, this may be making it worse. Review and promote if appropriate. -- **Audit all languages**: Verify no other language has an accidentally aggressive blocklist. - ---- - -## Priority - -1. **P0 — DONE: Fix Bug 4** (Hebrew blocklist) — 57K blocked→valid. Deploy needed. -2. **P1 — Fix Bug 1** (keyboard sofit color propagation) — cosmetic but confusing for Hebrew/Greek. -3. **P1 — Fix Bug 2** (definitions) — add diagnostic logging, deploy, check Render logs. -4. **P2 — Bug 1b** (checkWord sofit) — only affects 68 obscure words, not a real issue. -5. **P2 — LLM curate Hebrew's 57K promoted words** — clean up true garbage. -6. **P2 — Generate native definition files** for high-traffic languages beyond en/de/es. -7. **P3 — Push notifications** — "Your daily Wordle is ready!" via Notification API. Works on - desktop Chrome/Edge/Firefox without PWA install. Highest-ROI retention feature for desktop. - One-time permission prompt, daily trigger to bring users back. -8. **P3 — Email digest** — "Your streak is at 47 days!" daily/weekly reminder. Heavier to build - (needs email service + subscription flow) but powerful for retention. -9. **P4 — Homepage/new tab extension** — Chrome extension that shows Wordle on new tab. Niche - but high engagement for power users. - ---- - -# Phase 1 Audit — Remaining Items (2026-03-20) - -## Medium Priority (should fix before production) - -### ~~Multi-board missing analytics~~ ✅ FIXED (2026-03-21 hotfix) -Added `analytics.trackGameComplete()` with `game_mode`, frustration state, and timing to both `handleMultiBoardWon()` and `handleMultiBoardLost()`. Dordle/tridle/quordle completions now tracked. -Speed streak already tracked via `speed_session_complete` event (added by design agent). - -### Hardcoded "6" in community percentile + word stats -**Files:** `utils/stats.ts:27,31`, `server/utils/word-stats.ts:98` -**Issue:** `calculateCommunityPercentile` rejects attempts > 6. Server-side word stats recording ignores attempts > 6. -**Impact:** Non-classic modes (dordle=7, tridle=8, quordle=9, semantic=10) can't submit/display community stats. -**Fix:** Pass `maxGuesses` from `GameConfig` to both functions. - -### Dordle/tridle/quordle pages are 95% identical -**Files:** `pages/[lang]/dordle.vue`, `pages/[lang]/tridle.vue`, `pages/[lang]/quordle.vue` -**Issue:** ~90 lines each, differing only in mode string and SEO text. -**Fix:** Consolidate into `pages/[lang]/[mode].vue` dynamic route, or extract shared template into a thin wrapper. - -## SEO Follow-ups (2026-03-21) - -### Redesign classic share images to match editorial design system -**Files:** `scripts/generate_share_images.py`, `public/images/share/` (553 files) -**Issue:** Share result images (e.g., en_3.png) still use old dark theme (#171717 background, Tailwind green #22c55e). Don't match the new editorial design system. -**Fix:** Update `generate_share_images.py` to use design system colors (paper background, correct green #2d8544, Wordle.Global masthead). Regenerate all 553 images. - -### Submit new URLs to Google Search Console -**Issue:** 400+ new game mode URLs are in the sitemap but Google hasn't crawled them yet. -**Fix:** Go to Google Search Console → Sitemaps → submit `https://wordle.global/sitemap.xml`. Then request indexing for top 50 URLs (top 10 languages × 5 modes). - -### `/en/speed` 500 error — `useGameShare is not defined` -**Files:** `pages/[lang]/speed.vue` -**Issue:** Speed mode page crashes on SSR. `useGameShare` composable is referenced but not defined — likely from the BoardState refactor agent's changes. -**Fix:** The other agent needs to create/export the `useGameShare` composable or fix the import. - -## Low Priority (cleanup) - -### English-only strings in speed/multi-board UI -**Files:** StatsModal, SpeedResults, SpeedStatsStrip, MultiBoardLayout, AppSidebar -**Issue:** Strings like "Solved in N guesses", "Board 1", "Tap to expand", "Speed Streak", sidebar labels are hardcoded English. -**Fix:** Route through `language_config.json` UI translations (i18n). Phase 2 work. - -### Unused exports in game-modes.ts -**Exports:** `SocialMode`, `PlayType`, `GameModeDefinition` -**Issue:** Defined for future use (party mode, etc.) but not currently imported. -**Fix:** Keep — they're part of the planned architecture. - -### Unused helpers in storage.ts -**Exports:** `removeLocal`, `readBool`, `writeBool`, `readJson`, `writeJson`, `dismissWithCooldown`, `isDismissedWithCooldown` -**Issue:** Pre-existing dead code, not from our refactor. -**Fix:** Low priority cleanup. - ---- - -# Daily/Unlimited System — Open Decisions (2026-04-10) - -These items need owner input before or during implementation. None block Phases 1-3. - -### 1. `/en/unlimited` page long-term -Add `` now, or keep both pages independent? -- **Recommendation**: Add canonical now. Keep the page working. Soft-deprecate later. - -### 2. Daily Speed word pool size -How many deterministic words in the daily speed pool? Most players solve 10-20 in 5 min. -- **Recommendation**: 50, hardcoded. - -### 3. First-visit UX for daily multi-board -Player first visits `/en/dordle?play=daily` — empty game, no saved state. Show banner? -- **Recommendation**: No banner. Same as classic daily first visit. - -### 4. Language picker component reuse -Extract homepage language grid into shared component for modal reuse, or build independently? -- **Recommendation**: Build independently, refactor later. - -### 5. Archive mode tabs — data timeline -Mode filter tabs on archive page: when to implement per-mode daily word history? -- **Recommendation**: Ship tabs with Classic only. Per-mode archive data is separate project. - -### 6. Multi-board archive card design -For 8+ boards, cards become summaries. Implement card variants now or stub? -- **Recommendation**: Stub with "Coming soon". Full variants are separate design task. - -### 7. Word page "Appeared in" section -Cross-mode daily history on word detail pages. Needs server-side cross-mode word index. -- **Recommendation**: Defer. Index doesn't exist yet. - -### 8. Homepage mode cards redesign -Homepage still shows old flat mode list with hardcoded `HOMEPAGE_MODE_IDS`. Needs: -- Remove standalone "unlimited" card (it's Classic's unlimited variant) -- Add daily/unlimited labels per mode card -- For returning users: adapt cards to game state (in-progress, solved, "Play Unlimited →") -- Feature Semantic Explorer prominently -- Design explorations exist in editorial page (Screen 01, F1 Hub) and system design doc (section 7) but no final design decided - -### 9. Semantic mobile tabbed bottom panel -Current mobile semantic layout stacks map + leaderboard + compass vertically — player must scroll between them. A tabbed bottom panel (Rank / Compass / List tabs) would show one section at a time above the fixed input, eliminating scroll during gameplay. Described in session but not implemented. - -### 10. Verify `useFetch` key with playType -`pages/[lang]/[mode].vue` uses `key: 'lang-data-${lang}-${mode}-${playType.value}'`. If `playType` changes after initial SSR (e.g., localStorage preference read), the cached response may be stale. Likely a non-issue because page remounts on route change, but needs explicit verification. - -### 11. Sidebar visual polish -- Sub-panel dismiss behavior when clicking different modes (implemented but not verified) -- Active border-left vs sub-panel warm-bg highlight conflict (CSS added but not verified) -- Daily multi-board persistence end-to-end test (save/restore on reload) - -### 12. Stats modal/page redesign — combined daily + unlimited view -Stats should show both play types per mode: daily stats (with streak) and unlimited stats (play count, win rate, no streak) in separate sections. Current stats modal only shows one set of stats per mode. Needs: -- Stats modal: two-section layout (Daily / Unlimited) for modes that support both -- Stats page (`/stats`): same combined view with mode tabs -- Existing stats data is preserved — `"en_dordle"` (unlimited) and `"en_dordle_daily"` (daily) are already separate keys -- No data migration needed, just a UI redesign to surface both - ---- - -## CI / Infrastructure - -### 13. Ephemeral Postgres for CI tests -E2E and integration tests currently hit the production database, creating junk accounts (86 cleaned up on 2026-04-11). Tests should use an ephemeral Postgres instance instead. - -**Approach:** GitHub Actions service container: -```yaml -services: - postgres: - image: postgres:16 - env: - POSTGRES_DB: wordle_test - POSTGRES_USER: test - POSTGRES_PASSWORD: test - ports: - - 5432:5432 -``` -Then set `DATABASE_URL=postgresql://test:test@localhost:5432/wordle_test` in CI env, run `prisma db push` before tests. Zero test data in prod. - -### 14. Sync endpoint: coerce att:0 on losses -Old localStorage results stored losses as `{ won: false, attempts: 0 }` because attempt count wasn't tracked on losses. The `/api/user/sync` POST endpoint should coerce `attempts: 0` on `won: false` results to `attempts: maxGuesses` (6 for classic). Affects 48 legacy records from 6 users (confirmed 2026-04-11). - -### 15. Make stats store reactive instead of imperative -Currently `stats` is a manually-triggered snapshot: `calculateStats(key)` reads `gameResults` and writes to `stats` ref. Any code that reloads `gameResults` without calling `calculateStats` produces stale/empty stats (bug hit on 2026-04-11 with scoped storage — fixed with a band-aid in `loadGameResults`). - -**Correct architecture:** make `stats` a `computed` that derives from reactive `gameResults` + a reactive `currentStatsKey`: -```ts -const currentStatsKey = ref(); -const currentMaxGuesses = ref(6); -const stats = computed(() => { - if (!currentStatsKey.value) return emptyStats(currentMaxGuesses.value); - const results = gameResults.value[currentStatsKey.value]; - return results?.length ? computeStats(results, currentMaxGuesses.value) : emptyStats(currentMaxGuesses.value); -}); -``` -Then `calculateStats(key, max)` becomes `setStatsKey(key, max)` — just sets the refs, Vue handles the rest. Eliminates the entire class of "forgot to recalculate" bugs. Touches: `stores/stats.ts`, `composables/useGamePage.ts`, `pages/profile.vue`. - -### 16. Semantic Explorer: viewport-locked layout (like other game modes) -The semantic page uses a scrollable layout (`overflow-y: auto` on `.semantic-body`) while every other game mode uses `h-[100dvh]` viewport-locked layout via `PageShell`. This causes: -- Double scrollbar on short desktops (page scroll + browser scroll) -- Map SVG overflows its `max-height` container because it renders at intrinsic 520px -- `min-height: 280px` fights `max-height: calc(100dvh - 310px)` on short viewports -- Input gets pushed below the fold - -**Proper fix:** Refactor `semantic.vue` to use viewport-locked layout like `PageShell`: -- Left column (map + input): flex column, map grows to fill, input pinned to bottom -- Right column (compass + hint + leaderboard): flex column with overflow scroll -- No page-level scroll — everything fits in viewport -- Expand button goes truly fullscreen (overlay), not just "fill the column" - -Current band-aid: `min-height: min(200px, calc(100dvh - 310px))` prevents `min-height` from exceeding viewport, but the SVG still overflows on short desktops. `:deep()` CSS hacks were tried and reverted because they broke the expanded map aspect ratio. - ---- +- [ ] **#13 Ephemeral Postgres for CI** — E2E tests hit production DB, creating junk accounts. Use GitHub Actions Postgres service container +- [ ] **#22 Image thumbnail optimization** — generate 300px thumbnails alongside 1024px originals. Archive pages load 960KB of images displayed at 200px; thumbnails would be 60KB total ## DB Migration — Remove Disk Fallback Paths -**Added**: 2026-04-12 -**Status**: Monitoring — disk fallback paths emit console.warn when hit - -Data has been migrated from Render's persistent disk to Postgres: -- 253K definitions (77K kaikki native + 98K kaikki-en + 77K LLM, source/model provenance tracked) -- 2.8K word stats -- 50K embeddings with UMAP/PCA2D coordinates, 70 axes, 4.4M neighbor ranks -- `model` column added to definitions table (gpt-5.2, wiktionary-kaikki-2024, legacy-unknown) - -### Phase 1: Remove disk fallback code (after 2 weeks stable, ~2026-04-26) +**Status**: Monitoring — disk fallback paths emit console.warn when hit. Target: ~2026-04-26. -- [ ] `server/utils/definitions.ts` — remove Tier 1 disk read, disk write, kaikki in-memory cache (`_kaikkiCache`, `loadKaikkiFile`, `lookupKaikki`, `resolveDefinitionsDir`, `DEFINITIONS_DIR`). Kaikki data is now in the `definitions` table with source='kaikki'/'kaikki-en'. -- [ ] `server/utils/word-stats.ts` — remove disk read/write fallback + `proper-lockfile` dependency -- [ ] `server/utils/wiktionary.ts` — remove `readCache`/`writeCache` disk functions -- [ ] `server/api/[lang]/semantic/hint.post.ts` — remove disk read/write for hints -- [ ] `server/utils/data-loader.ts` — remove `WORD_DEFS_DIR`, `WORD_STATS_DIR` exports +### Phase 1: Remove disk fallback code +- [x] `definitions.ts` — disk fallback removed (DB-only) +- [x] `word-stats.ts` — disk fallback removed (DB-only via db-cache.ts) +- [x] `wiktionary.ts` — disk fallback removed (DB-only via db-cache.ts) +- [x] `hint.post.ts` — no disk I/O (LLM with DB session cache) +- [x] `semantic.ts` legacy in-memory loader — endpoints migrated to `_semantic-db.ts` +- [ ] `data-loader.ts` — still exports `WORD_DEFS_DIR`, `WORD_STATS_DIR` - [ ] Remove `proper-lockfile` from package.json -- [ ] Remove fs imports (`existsSync`, `readFileSync`, `writeFileSync`, `mkdirSync`) from all above files ### Phase 2: Migrate remaining disk-dependent features +- [ ] **Word history** → DB table `(lang, day_idx, word)`. Eliminates 546MB of `.txt` files +- [ ] **Word images** → keep on Render disk ($0.40/mo) or move to Cloudflare R2 -- [ ] **Word history** → new DB table `(lang, day_idx, word)`. ~136K rows (80 langs × 1700 days). Eliminates 546MB of `.txt` files on Render disk and disk reads in `word-selection.ts`. Algorithm is deterministic but cache is a safety net against word list changes. -- [ ] **Word images** → decide: keep on Render persistent disk ($0.40/month for 1.5GB), or move to Cloudflare R2 (free egress, ~$0.003/month for 204MB). Only feature still requiring the persistent disk. No urgency — current setup works. -- [ ] **`semantic.ts` legacy in-memory loader** — `start.post.ts` and `word/[slug].get.ts` still import `loadSemanticData` which loads the 98MB embedding matrix. Migrate these two endpoints to use `_semantic-db.ts` (DB-backed), then delete `loadSemanticData`/`loadSemanticDataSafe`/`loadEmbeddings` and the entire in-memory path. - -### Phase 3: Remove committed heavy files from git - +### Phase 3: Remove heavy files from git - [ ] `data/semantic/embeddings.f32` + `embeddings.meta.json` (~99MB) — in pgvector -- [ ] `data/semantic/embeddings.json` (~230MB if present) — in pgvector - [ ] `data/semantic/axes.json` — in `semantic_axes` table - [ ] `data/semantic/umap.json`, `pca2d.json` — in `word_embeddings` columns -- [ ] `data/semantic/targets.json`, `vocabulary.json` — queryable from `word_embeddings` -- [ ] Keep `data/semantic/valid_words.json` (loaded into memory for spellcheck, no DB table) -- [ ] Keep `data/definitions/` as archive (kaikki data now in DB, but files are small and useful for re-seeding) - -### 17. Semantic Explorer OG image -Design and add `public/images/og-semantic.png` (1200x630) showing the meaning map with dots, compass needle, and the editorial aesthetic. Currently falls back to generic `og-image.png`. +- [ ] `data/semantic/targets.json`, `vocabulary.json` — queryable from DB +- Keep: `valid_words.json` (runtime spellcheck), `data/definitions/` (archive for re-seeding) -### 18. Semantic best starting words -Add semantic-specific content to the `/en/best-starting-words` page — tips for first guesses in semantic mode (broad category words, high-information starters). Could be a separate section or tab. +## Accounts & Passkeys -### 19. useGameSeo refactor -- Silent 60-char truncation: configured titles are dropped without warning when too long. Should either warn at build time or use the configured title regardless. -- Hardcoded `| Wordle English` suffix: not all modes benefit from Wordle brand. Add configurable suffix per mode. -- No length validation at config time — easy to write titles/descriptions that get silently truncated. +- [ ] **Passkey signup flow broken on Apple** — `authenticate()` called first with no credentials triggers confusing "external passkey" OS dialog. After dismissal, error may not match cancel check → falls through to register. No success state after account creation (modal just closes). `authenticate.post.ts` looks up by `email` but passkey users have `null` email, so re-auth never works → loop creates duplicate accounts. Fixes: (1) don't authenticate-first for new users, (2) fix credential lookup, (3) set `authenticatorAttachment: 'platform'`, (4) add welcome/success state, (5) guard against double-registration +- [ ] **Safari→PWA localStorage loss** — iOS gives PWAs a separate localStorage partition. Anonymous users lose all game history when installing. No cookie-based transfer exists. Fix: rework PWA install CTA to prompt sign-in first (so data syncs to server), only show install CTA after account exists. Fallback: cookie hack to serialize localStorage before install -### 21. Semantic mobile layout experiment -On mobile, reorder the semantic layout: -1. **Guesses list** at the very top (always visible above keyboard) -2. **Compass hints** below guesses -3. **Map** below compass (less important on mobile — player rarely needs it mid-guess) -4. **Oracle hint** below map -5. **Input** pinned to bottom (already done) +## Community & Comments -This prioritizes the information the player actually needs while typing (rank feedback, compass direction) over the visual map. The map is still accessible by scrolling but doesn't dominate the viewport. +- [ ] **Leaderboard discussion** — Add `WordComments` component to `/leaderboard` page with `targetType='leaderboard'` and `targetKey='{lang}-{mode}-{dayIdx}'`. Same component, different target. Consider spoiler gating: only show comments to users who've already played that day. +- [ ] **Leaderboard daily bragging** — Surface comment count / teaser in the post-game panel: "3 players are talking about today's word" linking to leaderboard discussion. +- [ ] **Comment admin tooling** — Simple admin page or Prisma Studio workflow to review reports (type='report'/'feedback') and moderate comments (toggle `hidden` flag). No UI yet. +- [ ] **Resend email setup** — Configure `RESEND_API_KEY` in production `.env` to enable email notifications for bug reports, password reset, and email verification flows. -### 22. Image thumbnail optimization -Generate a 300px thumbnail alongside the 1024px original when DALL-E images are created (one extra `sharp` call). Serve thumbnails on archive pages instead of full 1024x1024. Archive currently loads 12 × ~80KB = 960KB of images that display at ~200px. Thumbnails would be ~5KB each = 60KB total. Change is in `server/api/[lang]/word-image/[word].get.ts` — add a `?size=thumb` param. +## Open Design Decisions -### 23. Language switcher preserves current page -When switching language via the sidebar language picker, navigate to the same page type in the new language. For example, `/en/archive` → `/de/archive`, `/en/dordle` → `/de/dordle`. Currently always navigates to `/{lang}` (classic daily). The `LanguagePickerModal` has a `currentModeSuffix` prop that partially handles this but doesn't cover non-game pages like archive, best-starting-words, or word pages. +- [ ] `/en/unlimited` canonical — add ``? +- [ ] Homepage mode cards redesign — remove standalone "unlimited" card, add daily/unlimited labels, feature Semantic prominently +- [x] Language switcher preserves page — LanguagePickerModal now keeps path suffix when switching +- [ ] Sidebar visual polish — sub-panel dismiss behavior, active border highlight (implemented, unverified) diff --git a/app.vue b/app.vue index 0c0dd6d3..cb815a9f 100644 --- a/app.vue +++ b/app.vue @@ -3,6 +3,7 @@ import { usePageDirection } from '~/composables/usePageDirection'; const { direction } = usePageDirection(); const { showLoginModal, closeLoginModal } = useLoginModal(); +const { showReportModal, closeReportModal } = useReportModal(); /** Direction-aware page transition. No 'out-in' — it causes blank screens * because Nuxt's Suspense blocks the enter while out-in already removed @@ -29,4 +30,5 @@ useHead({ + diff --git a/components/account/LoginModal.vue b/components/account/LoginModal.vue index 5b45aa1d..d34313fe 100644 --- a/components/account/LoginModal.vue +++ b/components/account/LoginModal.vue @@ -1,124 +1,306 @@ + + diff --git a/components/app/AppShell.vue b/components/app/AppShell.vue index 9e7e0b9f..7f26908e 100644 --- a/components/app/AppShell.vue +++ b/components/app/AppShell.vue @@ -8,7 +8,7 @@ :lang-code="lang" :language-name="langName" :is-rtl="isRtl" - :current-mode="currentMode" + :current-mode="currentMode ?? undefined" :ui="ui" @close="sidebarOpen = false" @select-mode="onSelectMode" diff --git a/components/app/AppSidebar.vue b/components/app/AppSidebar.vue index 661ea53c..33067949 100644 --- a/components/app/AppSidebar.vue +++ b/components/app/AppSidebar.vue @@ -240,11 +240,13 @@ icon="ChartNoAxesCombined" :label="ui?.statistics || 'Statistics'" href="/profile#statistics" + @click="close()" /> - - - - - {{ - ui?.report_issue || 'Report a bug' - }} - + @@ -276,11 +270,13 @@ icon="Trophy" label="Leaderboard" :href="`/leaderboard?lang=${langCode}`" + @click="close()" /> @@ -354,7 +350,6 @@ import { Globe, ChevronRight, ChevronDown, - Bug, Grid2x2, CalendarDays, Infinity as InfinityIcon, @@ -399,6 +394,7 @@ const emit = defineEmits<{ const { loggedIn, user, avatarUrl: authAvatarUrl, loginWithGoogle, logout } = useAuth(); const { openLoginModal } = useLoginModal(); +const { openReportModal } = useReportModal(); const route = useRoute(); const sidebarEl = ref(null); @@ -541,34 +537,6 @@ const disabledModes = computed(() => GAME_MODES_UI.filter((m) => !m.enabled && m.id !== 'unlimited') ); -// Pre-fill bug report with language, mode, and device info -const bugReportUrl = computed(() => { - const params = new URLSearchParams({ - template: 'bug.yml', - labels: 'bug', - language: props.langCode || '', - }); - if (import.meta.client) { - const ua = navigator.userAgent; - const platform = /iPhone|iPad/.test(ua) - ? 'iOS' - : /Android/.test(ua) - ? 'Android' - : /Mac/.test(ua) - ? 'Mac' - : /Windows/.test(ua) - ? 'Windows' - : 'Other'; - params.set( - 'device', - `${platform} / ${navigator.userAgent.split(') ').pop()?.split('/')[0] || 'Unknown'}` - ); - } - if (props.currentMode && props.currentMode !== 'classic') { - params.set('title', `Bug in ${props.currentMode} mode (${props.langCode})`); - } - return `https://github.com/Hugo0/wordle/issues/new?${params}`; -}); diff --git a/components/shared/ReportModal.vue b/components/shared/ReportModal.vue new file mode 100644 index 00000000..fa068550 --- /dev/null +++ b/components/shared/ReportModal.vue @@ -0,0 +1,164 @@ + + +