From 4eac22f61818a8670a77c0b93f873c0715ce3a8c Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 16 Mar 2026 09:48:56 +0000 Subject: [PATCH 1/8] chore: remove internal docs from git tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ is gitignored — these files were force-added before the rule. Internal analytics and architecture docs should stay local only. --- docs/WORD_DATA_ARCHITECTURE.md | 379 ------------------------- docs/nuxt-migration.md | 488 --------------------------------- 2 files changed, 867 deletions(-) delete mode 100644 docs/WORD_DATA_ARCHITECTURE.md delete mode 100644 docs/nuxt-migration.md diff --git a/docs/WORD_DATA_ARCHITECTURE.md b/docs/WORD_DATA_ARCHITECTURE.md deleted file mode 100644 index 61eab6a1..00000000 --- a/docs/WORD_DATA_ARCHITECTURE.md +++ /dev/null @@ -1,379 +0,0 @@ -# Word Data Architecture - -> Status: IMPLEMENTED (words.json per language, pipeline reads/writes JSON directly) -> Date: 2026-03-15 -> Context: PR #149 (language expansion), Issue #157 (sub-component tiles), Nuxt migration - -## Problem - -The current per-language data is spread across up to 9 files with implicit relationships: - -``` -{lang}_5words.txt ← main word list (community-contributed source of truth) -{lang}_5words_supplement.txt ← auto-generated additional valid guesses -{lang}_daily_words.txt ← auto-generated subset for daily selection -{lang}_blocklist.txt ← words excluded from daily selection -{lang}_curated_schedule.txt ← hand-picked daily words for specific dates -{lang}_characters.txt ← derivable from word list -{lang}_keyboard.json ← keyboard layout(s) -language_config.json ← UI translations + script normalization + display settings -{lang}_word_history.txt ← frozen past daily words -``` - -Problems: -- A word's status is scattered across 4+ files (5words, supplement, daily, blocklist) -- `_characters.txt` is fully derivable — redundant -- `language_config.json` mixes UI text, script config, and game settings -- Naming is hardcoded to 5 letters — can't scale to 4/6/7/8/9/10-letter modes -- No per-word metadata (frequency, difficulty, LLM classification, source) -- Adding a new game mode (phrase-of-the-day, multi-board) requires new file types - -## Decision: Single JSON per language - -### File structure - -``` -{lang}/ -├── words.json ← ALL words, all lengths, fully scored + classified (pipeline output) -├── contribute/ -│ ├── words.txt ← community word submissions (plain text, one per line) -│ └── overrides.json ← community corrections (tier overrides, flags) -├── keyboard.json ← keyboard layout(s) (manually maintained) -└── language_config.json ← UI translations + script normalization (manually maintained) -``` - -### Why JSON - -- **~50x faster** to parse than YAML (critical for 78 languages, 104MB total) -- **No compile step** needed — `words.json` is both source of truth and runtime format -- **Standard**: no extra dependency (pyyaml removed) -- **Readable**: `indent=2, ensure_ascii=False` for clean diffs -- Pipeline reads and writes `words.json` directly - -## words.json schema - -```json -{ - "metadata": { - "language_code": "ko", - "language_name": "Korean", - "last_pipeline_run": "2026-03-14T12:00:00Z", - "sources": [ - {"name": "jmdict", "type": "dictionary", "version": "3.6.2"}, - {"name": "wordfreq", "type": "frequency", "version": "3.1"} - ] - }, - "words": [ - { - "word": "정보", - "length": 2, - "tier": "daily", - "frequency": 4.2, - "sources": ["jmdict", "wordfreq"], - "llm": { - "tier": "daily", - "confidence": 5, - "reason": "common noun: information" - }, - "reviewed": true - }, - { - "word": "나쁜말", - "length": 3, - "tier": "blocked", - "frequency": 3.1, - "sources": ["jmdict"], - "flags": {"phrase": true}, - "llm": { - "tier": "reject", - "confidence": 5, - "reason": "phrase: for many days" - } - } - ] -} -``` - -Sparse format: fields with default values (empty lists, false bools, null) are omitted. - -### Field definitions - -| Field | Type | Required | Description | -|---|---|---|---| -| `word` | string | yes | The word itself | -| `length` | int | yes | Character count (grapheme-aware for Indic scripts) | -| `tier` | enum | yes | `daily` (puzzle answer), `valid` (guess only), `blocked` (excluded from daily) | -| `frequency` | float | yes | Zipf frequency score (0-7). Primary ranking signal. | -| `difficulty` | float | no | 0.0-1.0. Computed from frequency + letter rarity + LLM adjustment. | -| `sources` | list | yes | Which data sources this word came from | -| `flags` | object | no | Boolean properties explaining why a word might not be daily-quality | -| `llm` | object | no | LLM curation results (tier, confidence, reason, definitions) | -| `reviewed` | bool | no | Human review flag. If true, pipeline preserves this word's tier. | - -### Tier definitions - -| Tier | Can be daily? | Valid guess? | When | -|---|---|---|---| -| `daily` | Yes | Yes | Common, standalone word. Good puzzle answer. | -| `valid` | No | Yes | Real word but not daily-quality (too technical, obscure, compound). | -| `blocked` | No | Yes | Excluded for specific reason (profanity, keyboard limitation, proper noun). | -| `phrase` | Mode-dependent | Mode-dependent | Multi-word entry for phrase-of-the-day mode. | - -### How game modes consume this data - -| Mode | Filter | Daily pool | Guesses | -|---|---|---|---| -| Classic 5-letter | `length == 5` | `tier == daily` | `tier in (daily, valid, blocked)` | -| Classic 4-letter | `length == 4` | `tier == daily` | same | -| Classic N-letter | `length == N` | `tier == daily` | same | -| Phrase of the day | `flags.phrase == true` | `tier == phrase` | all phrases | -| Unlimited | any length | random from `tier == daily` | all valid | -| Challenge (URL) | any | word from URL | all valid | -| Multi-board (Quordle) | `length == 5` | N words from `tier == daily` | same as classic | - -### Pool depth validation - -Not every language can support every mode. Before offering a mode, check: -``` -pool = words where length == N and tier == daily -if len(pool) < 365: # less than a year of daily words - don't offer this mode for this language -``` - -Example: Hausa has 435 daily 5-letter words → can support classic-5. Can't support classic-5 + quordle simultaneously (needs 4x words/day). - -## config.yaml schema - -Replaces `language_config.json`. YAML for readability and comments. - -```yaml -# Language identity -language_code: ko -name: Korean -name_native: 한국어 -timezone: Asia/Seoul - -# Script behavior -right_to_left: false -grapheme_mode: false # true for Devanagari, Bengali, Gurmukhi -hide_diacritic_hints: true # true when diacritic_map is for encoding, not accents - -# Character normalization -diacritic_map: - ㄱ: [ᄀ, ᆨ] # Compatibility Jamo → Hangul Jamo - ㄴ: [ᄂ, ᆫ] - # ... - -# Positional forms (Hebrew sofit, Greek final sigma) -final_form_map: - כ: ך - מ: ם - -# Physical keyboard bypass (IME languages) -physical_key_map: - KeyQ: ㅂ - KeyW: ㅈ - ShiftKeyQ: ㅃ - # ... - -# UI translations -meta: - locale: ko - wordle_native: 워들 - title: 일일 단어 게임 - description: ... - keywords: ... - -text: - share: 공유 - next_word: 다음 단어 - # ... - -help: - title: 게임 방법 - # ... - -ui: - settings: 설정 - dark_mode: 다크 모드 - # ... -``` - -## contribute/ directory - -### contribute/words.txt - -Lowest-friction contribution format. One word per line, no metadata. - -``` -사랑 -행복 -우정 -``` - -Pipeline picks these up, scores them, and merges into `words.json` with `sources: [community]`. If a word is already in `words.json`, it's skipped. If it's new, pipeline assigns tier, frequency, etc. - -### contribute/overrides.json - -For corrections that override pipeline decisions: - -```yaml -# This word was wrongly classified as a phrase — it's a real compound noun -- word: 밝다 - tier: blocked - reason: "compound jongseong, not typeable on default keyboard" - reviewed: true - -# This word should be daily — pipeline ranked it too low -- word: 우정 - tier: daily - reviewed: true -``` - -The `reviewed: true` flag tells the pipeline not to override this classification. - -## Difficulty scoring - -Computed from multiple signals: - -```python -difficulty = weighted_average( - (1 - frequency_percentile) * 0.4, # rare words harder - unique_letter_ratio * 0.2, # repeated letters harder - character_rarity_score * 0.2, # unusual characters harder - consonant_cluster_score * 0.1, # complex combos harder - llm_adjustment * 0.1, # LLM can nudge (false friends, archaic, etc.) -) -``` - -Use cases: -- Post-game WordleBot-style analysis: "This was a hard word (difficulty: 0.82)" -- Mode difficulty settings: easy mode only picks words with difficulty < 0.4 -- Balancing daily words: avoid too many hard words in a row - -## LLM curation - -### Process - -For each language with daily words, spawn an LLM agent (Claude Opus 4.6) that: -1. Self-assesses confidence for that language (1-5) -2. Processes words in batches of 50 -3. Classifies each as daily/valid/reject with reasoning -4. Optionally generates native + English definitions in same pass - -### Output - -Stored in `llm` field per word in `words.json`. The `llm.tier` is a recommendation — the pipeline makes the final `tier` decision based on `llm.tier` + `llm.confidence` + `flags`. - -Decision logic: -```python -if word.reviewed: - tier = word.tier # human override, don't touch -elif word.llm.confidence >= 3 and word.llm.tier == 'reject': - tier = 'blocked' -elif word.llm.confidence >= 3 and word.llm.tier == 'valid': - tier = 'valid' -else: - tier = pipeline_computed_tier # fall back to frequency + dictionary gate -``` - -### Cost - -~38 languages × 2,000 words × ~20 tokens/word ≈ 1.5M tokens. ~$20-30 one-time with Opus. - -## Pipeline stages - -``` -┌─────────────────────────────────────────────────────────┐ -│ Stage 1: SOURCE │ -│ Inputs: JMdict, kaikki, Leipzig, FrequencyWords, │ -│ wordfreq, Hunspell, community words.txt │ -│ Output: raw word pool per language (all lengths) │ -└──────────────────────┬──────────────────────────────────┘ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Stage 2: NORMALIZE │ -│ Character normalization, grapheme-aware length calc, │ -│ dedup, lowercase, encoding fixes │ -│ Output: clean word pool with length field │ -└──────────────────────┬──────────────────────────────────┘ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Stage 3: SCORE │ -│ wordfreq (primary) + FrequencyWords (secondary) │ -│ Dictionary verification gate (kaikki/Leipzig/Hunspell) │ -│ Output: words with frequency + sources │ -└──────────────────────┬──────────────────────────────────┘ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Stage 4: CURATE │ -│ LLM classification (tier + confidence + definitions) │ -│ Profanity/name/foreign detection │ -│ Apply community overrides │ -│ Output: words with tier + flags + llm fields │ -└──────────────────────┬──────────────────────────────────┘ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Stage 5: FREEZE │ -│ Update word_history.jsonl with past daily words │ -│ Lock selections — past words never change │ -└─────────────────────────────────────────────────────────┘ -``` - -## Migration strategy - -### Phase 1: Ship PR #149 as-is (NOW) -- Current file structure works -- LLM curation as `_word_scores.jsonl` sidecar (forward-compatible) -- Use scores to filter existing `daily_words.txt` - -### Phase 2: YAML migration (with Nuxt refactor) -- Write migration script: existing files + word_scores → words.json -- New pipeline reads/writes YAML -- Nuxt build step compiles YAML → JSON -- One PR per language batch (or big-bang with verification) -- Backward compatibility: if words.json doesn't exist, fall back to old files - -### Phase 3: Multi-mode support (after Nuxt) -- Pipeline generates all-length word pools -- Frontend parameterizes word length -- Daily algorithm supports mode + length dimensions -- Only offer modes where pool depth is sufficient - -## Languages with special considerations - -| Language | Issue | How words.json handles it | -|---|---|---| -| Korean (ko) | Jamo encoding mismatch, compound jongseong keyboard gap | `diacritic_map` in config.yaml, `flags.keyboard_gap: true` on blocked words | -| Hindi (hi) | Grapheme mode, only 15 daily words from pipeline | LLM curation bypasses broken dictionary gate, scores full word list directly | -| Bengali (bn) | Grapheme mode, particle noise (~6%) | LLM classifies particle expressions as `tier: blocked` | -| Japanese (ja) | Hiragana-only, phrase fragments (~7%), needs JMdict source | LLM filters expressions, `sources: [jmdict]` tracks provenance | -| German (de) | ß diacritic, broken multi-char mapping fixed | `diacritic_map: {s: [ß]}` in config.yaml | -| French (fr) | œ/æ diacritic, same multi-char fix | `diacritic_map: {o: [ô, œ], a: [à, â, æ]}` | -| Hausa (ha) | Small dictionary (435 daily words) | Pool depth check prevents offering modes it can't support | -| Yoruba (yo) | Small dictionary (896 daily words), tone marks | Same pool depth check | -| Hebrew (he) | RTL, sofit final forms, large word list (64K) | `final_form_map` in config.yaml | -| Arabic (ar) | RTL, character difficulty filtering | Rare char filter encoded as `flags` | -| Finnish (fi) | Pre-curated, excluded from pipeline | `reviewed: true` on all words, pipeline skips | -| English (en) | Pre-curated, high quality baseline | Same as Finnish | - -## Sub-component tile coloring (Issue #157) - -The words.json schema is forward-compatible with the future sub-component tile system: - -```yaml -- word: 한 - length: 1 # 1 syllable block - components: [ㅎ, ㅏ, ㄴ] # sub-components for per-jamo coloring -``` - -The `components` field would only be populated for composition-based scripts (Korean syllable blocks, Tamil aksharas, Hindi aksharas). Latin/Cyrillic/Arabic words would have `components: null` (each character is its own component). - -This is a future addition — not needed for the initial YAML migration. - -## Open questions - -1. **Word history format**: Keep as flat text files (simple) or migrate to JSONL (supports mode + length dimensions)? -2. **Curated schedule**: Keep as a separate concept or fold into words.json with a `scheduled_day` field? -3. **Definition caching**: Currently a separate disk cache system. Should definitions live in words.json or stay separate? (words.json would get very large with definitions for 50K+ words) -4. **Keyboard config**: Keep as JSON or migrate to YAML? JSON is what the frontend expects directly. diff --git a/docs/nuxt-migration.md b/docs/nuxt-migration.md deleted file mode 100644 index 63aa4a7c..00000000 --- a/docs/nuxt-migration.md +++ /dev/null @@ -1,488 +0,0 @@ -# Nuxt 3 Migration Plan — Wordle Global - -## Context - -Wordle Global is migrating from Flask+Jinja+Vue (Options API) to Nuxt 3 with SSR. The goal: one language (TypeScript), one server (Node/Nitro), best-in-class SEO (full SSR), and a modular component architecture ready for multiple game modes, auth, and challenge features. Python scripts remain as offline tooling for word pipeline, share image generation, and data analysis. - -**Replaces:** `docs/architecture-migration.md` (Jan 2026, now outdated) - ---- - -## Architecture Overview - -``` -BEFORE AFTER -────── ───── -Flask (Python) Nuxt 3 (Node/Nitro) -├── Jinja2 templates (SSR) ├── Vue SFCs + SSR -├── Vue 3 Options API (client) ├── Vue 3 Composition API -├── Vite (build only) ├── Nuxt build system (Vite internal) -├── Gunicorn (8 workers) ├── Nitro server -└── 1,527-line app.py monolith └── Modular server/api/ routes - -Python scripts (offline) Python scripts (unchanged) -├── improve_word_lists.py ├── improve_word_lists.py -├── generate_share_images.py ├── generate_share_images.py -├── pregenerate_definitions.py ├── pregenerate_definitions.py -└── pytest data validation └── pytest data validation -``` - ---- - -## Target Directory Structure - -``` -wordle/ -├── nuxt/ # Nuxt 3 app (replaces webapp/ + frontend/) -│ ├── nuxt.config.ts -│ ├── app.vue # Root (dark mode init, NuxtLayout, NuxtPage) -│ ├── error.vue # 404 page -│ ├── assets/css/main.css # Tailwind + custom animations -│ ├── components/ -│ │ ├── game/ -│ │ │ ├── GameBoard.vue # 6x5 tile grid -│ │ │ ├── TileRow.vue # Single row with shake binding -│ │ │ ├── Tile.vue # Individual tile with flip -│ │ │ ├── GameKeyboard.vue # On-screen keyboard -│ │ │ ├── KeyboardKey.vue # Individual key with hints -│ │ │ ├── GameHeader.vue # Title bar + buttons -│ │ │ ├── StatsModal.vue # 3-tab statistics -│ │ │ ├── HelpModal.vue # Tutorial -│ │ │ ├── SettingsModal.vue # Settings toggles -│ │ │ └── NotificationToast.vue -│ │ └── shared/ -│ │ ├── ToggleSwitch.vue -│ │ └── ModalBackdrop.vue -│ ├── composables/ -│ │ ├── useHaptics.ts # Vibration feedback -│ │ ├── useSounds.ts # Web Audio tones -│ │ ├── useDefinitions.ts # Fetch definitions + images -│ │ ├── useEmbed.ts # Iframe detection -│ │ └── useHreflang.ts # SEO hreflang tags -│ ├── layouts/ -│ │ ├── default.vue # Standard page layout -│ │ └── game.vue # Full-height game layout (100dvh) -│ ├── pages/ -│ │ ├── index.vue # / — homepage -│ │ ├── stats.vue # /stats -│ │ └── [lang]/ -│ │ ├── index.vue # / — game page -│ │ ├── words.vue # //words — archive -│ │ └── word/[id].vue # //word/ — word detail -│ ├── plugins/ -│ │ ├── analytics.client.ts # GA4 + PostHog -│ │ ├── pwa.client.ts # PWA install prompts -│ │ └── debug.client.ts # Console debug tools -│ ├── public/ # Static files (favicons, sw.js, share images) -│ ├── server/ -│ │ ├── api/ -│ │ │ ├── languages.get.ts # All language metadata -│ │ │ ├── stats.get.ts # Site-wide statistics -│ │ │ └── [lang]/ -│ │ │ ├── data.get.ts # Language data (word list, config, keyboard) -│ │ │ ├── definition/[word].get.ts -│ │ │ ├── word-image/[word].get.ts -│ │ │ └── word-stats.post.ts -│ │ ├── routes/ -│ │ │ ├── robots.txt.ts -│ │ │ ├── llms.txt.ts -│ │ │ ├── sitemap.xml.ts -│ │ │ ├── sitemap-main.xml.ts -│ │ │ └── sitemap-words-[lang].xml.ts -│ │ └── utils/ -│ │ ├── data-loader.ts # Load word lists, configs at startup -│ │ ├── word-selection.ts # Daily word algorithm (ported from Python) -│ │ ├── definitions.ts # LLM + kaikki fallback -│ │ ├── word-stats.ts # Atomic stats read/write -│ │ └── language-builder.ts # Build Language object -│ ├── stores/ # Pinia -│ │ ├── game.ts # Tiles, colors, game state, animations -│ │ ├── settings.ts # Dark mode, haptics, hard mode, colorblind -│ │ ├── stats.ts # Game results, streaks, distributions -│ │ └── language.ts # Current language config + word lists -│ └── utils/ # Shared (server + client) -│ ├── types.ts # All TypeScript interfaces -│ ├── diacritics.ts # Accent normalization (direct port) -│ ├── positional.ts # Final form mapping (direct port) -│ ├── stats.ts # Percentile calculation (direct port) -│ └── graphemes.ts # Intl.Segmenter wrapper (direct port) -├── webapp/data/ # Language data files (unchanged, read by Nuxt server) -├── scripts/ # Python offline tooling (unchanged) -├── tests/ # pytest data validation (unchanged) -└── docs/ # Updated documentation -``` - ---- - -## Migration Phases - -### Phase 0: Foundation (Week 1) - -**Goal:** Scaffolded Nuxt project that builds and serves a "hello world" page. - -- [ ] Initialize Nuxt 3 project in `nuxt/` directory -- [ ] Install deps: `@pinia/nuxt`, `@vite-pwa/nuxt`, `openai`, `proper-lockfile`, `sharp` -- [ ] Configure Tailwind v4 via `@tailwindcss/vite` plugin in nuxt.config.ts -- [ ] Copy `frontend/src/style.css` → `nuxt/assets/css/main.css` -- [ ] Copy pure utility modules verbatim: `diacritics.ts`, `positional.ts`, `stats.ts`, `graphemes.ts`, `types.ts` -- [ ] Symlink `webapp/data/` → accessible from Nuxt server -- [ ] Copy `webapp/static/` assets → `nuxt/public/` (favicons, offline pages, share images) -- [ ] Pre-compute shuffled word lists: Run a one-time Python script that writes `{lang}_5words_shuffled.json` for each language (avoids replicating Python's PRNG in Node) -- [ ] Deprecate `docs/architecture-migration.md`, replace with this document - -### Phase 1: Server Logic Port (Week 2-3) - -**Goal:** All Flask backend logic running in Nitro with verified algorithm parity. - -**Data Loading** (`server/utils/data-loader.ts`): -- [ ] Port `load_words()` — read pre-shuffled JSON lists -- [ ] Port `load_supplemental_words()` -- [ ] Port `load_blocklist()` -- [ ] Port `load_daily_words()` -- [ ] Port `load_curated_schedule()` -- [ ] Port `load_language_config()` — deep merge with defaults -- [ ] Port `load_keyboard()` — normalize layouts, generate alphabetical fallback -- [ ] Port `load_characters()` — auto-generate from word list if missing -- [ ] Implement startup cache (all data loaded once, same as Flask) - -**Word Selection** (`server/utils/word-selection.ts`): -- [ ] Port `get_todays_idx(timezone)` — use `Intl.DateTimeFormat` for timezone -- [ ] Port `_word_hash()` / `_day_hash()` — use `crypto.createHash('sha256')` -- [ ] Port `get_daily_word_consistent_hash()` — binary search for closest hash -- [ ] Port `get_daily_word_legacy()` — read pre-shuffled list, `words[idx % len]` -- [ ] Port `get_word_for_day()` — 3-tier selection + disk caching -- [ ] Port `_compute_word_for_day()` — curated schedule → daily words → filtered main -- [ ] Port `idx_to_date()` — reverse mapping -- [ ] `MIGRATION_DAY_IDX = 1681` constant -- [ ] **PARITY TEST**: Script that verifies identical word selection for all 77 langs × days 1-1800 - -**Definitions** (`server/utils/definitions.ts`): -- [ ] Port `fetch_definition()` — 3-tier: cache → LLM → kaikki -- [ ] Port `_call_llm_definition()` — use OpenAI TypeScript SDK -- [ ] Port `lookup_kaikki_native()` / `lookup_kaikki_english()` — read JSON files -- [ ] Port `_wiktionary_url()` — URL construction -- [ ] Port cache read/write — same JSON format, same paths -- [ ] Port negative cache with 24h TTL - -**Stats** (`server/utils/word-stats.ts`): -- [ ] Port `_load_word_stats()` — read JSON file -- [ ] Port `_update_word_stats()` — use `proper-lockfile` instead of `fcntl.flock()` -- [ ] Port IP/client deduplication (in-memory Map, 50K cap, daily reset) -- [ ] Port `_build_stats_data()` — aggregate all languages, 5-min cache - -**Language Builder** (`server/utils/language-builder.ts`): -- [ ] Port `Language` class — daily word, keyboard layouts, diacritic hints, timezone offset - -### Phase 2: API Routes (Week 3-4) - -**Goal:** All Flask routes working as Nitro endpoints. - -**API routes** (`server/api/`): -- [ ] `GET /api/languages` — all language metadata (replaces homepage data injection) -- [ ] `GET /api/[lang]/data` — word list, supplement, characters, config, todaysIdx, todaysWord, timezoneOffset, keyboard -- [ ] `GET /api/[lang]/definition/[word]` — definition JSON -- [ ] `GET /api/[lang]/word-image/[word]` — DALL-E image (use `sharp` for WebP) -- [ ] `POST /api/[lang]/word-stats` — anonymous stats submission -- [ ] `GET /api/stats` — site-wide statistics aggregation - -**Non-API routes** (`server/routes/`): -- [ ] `robots.txt` — same content -- [ ] `llms.txt` — same content -- [ ] `sitemap.xml` — sitemap index -- [ ] `sitemap-main.xml` — homepage, language pages, word hubs -- [ ] `sitemap-words-[lang].xml` — per-language word pages - -**Middleware**: -- [ ] HTTP → HTTPS redirect (non-localhost) - -### Phase 3: Frontend — Pinia Stores (Week 4-5) - -**Goal:** All game state and logic decomposed into Pinia stores. - -**`stores/game.ts`** — extracted from game.ts (1,816 lines): -- [ ] Tile state: tiles, tileClasses, tilesVisual, tileClassesVisual -- [ ] Game state: activeRow, activeCell, gameOver, gameWon, gameLost, attempts -- [ ] Keyboard: keyClasses, pendingKeyUpdates -- [ ] Animation: animating, shakingRow -- [ ] Notifications: show, fading, message -- [ ] Modals: showHelpModal, showStatsModal, showOptionsModal -- [ ] Actions: addChar(), deleteChar(), submitGuess(), updateColors() -- [ ] Animations: revealRow(), shakeRow(), bounceRow(), _nudgeKey() -- [ ] Share: getEmojiBoard(), share() -- [ ] localStorage save/load -- [ ] Hard mode validation -- [ ] Colorblind/high-contrast mode - -**`stores/settings.ts`**: -- [ ] darkMode, feedbackEnabled, wordInfoEnabled, hardMode, highContrast -- [ ] All toggles with localStorage persistence -- [ ] SSR-safe: read localStorage only in `onMounted` / `import.meta.client` - -**`stores/stats.ts`**: -- [ ] gameResults (from localStorage) -- [ ] calculateStats(langCode), calculateTotalStats() -- [ ] saveResult(), loadGameResults() -- [ ] submitWordStats() — POST to API - -**`stores/language.ts`**: -- [ ] config, wordList, wordListSupplement, characters -- [ ] todaysIdx, todaysWord, timezoneOffset -- [ ] keyboard, keyboardLayouts, keyDiacriticHints -- [ ] Normalization maps (built from config) - -### Phase 4: Frontend — Vue Components (Week 5-7) - -**Goal:** All Jinja templates replaced with Vue SFCs. - -**Game components** (from game.html 715 lines): -- [ ] `GameBoard.vue` — 6x5 grid with perspective -- [ ] `TileRow.vue` — single row, shake binding -- [ ] `Tile.vue` — individual tile, v-bind:class from store -- [ ] `GameKeyboard.vue` — keyboard rows from language store -- [ ] `KeyboardKey.vue` — key with diacritic hints, color state -- [ ] `GameHeader.vue` — title, help/stats/settings buttons -- [ ] `StatsModal.vue` — 3 tabs (Today/Stats/Global), guess distribution, streaks -- [ ] `HelpModal.vue` — tutorial with localized text -- [ ] `SettingsModal.vue` — toggles (dark mode, feedback, hard mode, colorblind, word info) -- [ ] `NotificationToast.vue` — slide-down + fade-out - -**Shared components** (from Jinja partials): -- [ ] `ToggleSwitch.vue` — replaces `_toggle_switch.html` macro -- [ ] `ModalBackdrop.vue` — replaces `_modal_backdrop.html` - -**Pages**: -- [ ] `pages/index.vue` — homepage (replaces index.html + index-app.ts) -- [ ] `pages/[lang]/index.vue` — game page (replaces game.html + game.ts) -- [ ] `pages/[lang]/words.vue` — word archive (replaces words_hub.html) -- [ ] `pages/[lang]/word/[id].vue` — word detail (replaces word.html) -- [ ] `pages/stats.vue` — site stats (replaces stats.html) -- [ ] `error.vue` — 404 page (replaces 404.html) - -**Composables** (from frontend modules): -- [ ] `useHaptics.ts` — wrap haptics.ts with SSR guard -- [ ] `useSounds.ts` — wrap sounds.ts with SSR guard -- [ ] `useDefinitions.ts` — definition + image fetching (use `$fetch`) -- [ ] `useEmbed.ts` — iframe detection -- [ ] `useHreflang.ts` — SEO hreflang via `useHead()` - -**Plugins** (client-only): -- [ ] `analytics.client.ts` — GA4 gtag + PostHog init -- [ ] `pwa.client.ts` — PWA install prompts (@khmyznikov/pwa-install) -- [ ] `debug.client.ts` — console debug tools (ASCII banner) - -**Layouts**: -- [ ] `default.vue` — standard pages (homepage, stats, word pages) -- [ ] `game.vue` — full-height game layout (100dvh, overflow-hidden, safe-area) - -**SEO per page** (via `useHead` / `useSeoMeta`): -- [ ] Title, description, og:title, og:description, og:image, og:url -- [ ] hreflang for all 77 languages -- [ ] JSON-LD structured data (WebApplication, BreadcrumbList) -- [ ] canonical URLs -- [ ] Share result meta tags (when `?r=` query param present) - -### Phase 5: PWA, Analytics, Service Worker (Week 7-8) - -- [ ] Configure `@vite-pwa/nuxt` — manifest, workbox, offline fallback -- [ ] Port service worker caching strategy (network-first HTML, cache-first assets) -- [ ] GA4 gtag initialization (deferred script load) -- [ ] PostHog initialization (3% session recording) -- [ ] Dark mode flash prevention (inline script via `useHead`) -- [ ] PWA install banner (after game win, 7-day dismiss cooldown) -- [ ] Embed banner (iframe detection) - -### Phase 6: Testing & Verification (Week 8-9) - -**Algorithm parity** (most critical): -- [ ] Script that tests `getWordForDay(lang, dayIdx)` for all 77 langs × days 1-1800 -- [ ] Compare Flask output vs Nuxt output — must be 100% identical - -**SEO parity**: -- [ ] Crawl both servers with Screaming Frog -- [ ] Compare: title tags, meta descriptions, canonical URLs, hreflang, structured data -- [ ] Compare sitemap XML byte-for-byte -- [ ] Verify robots.txt and llms.txt match - -**Vitest** (port from frontend/src/__tests__/): -- [ ] game-logic.test.ts — color calculation, stats -- [ ] diacritics.test.ts — accent normalization -- [ ] positional.test.ts — final form mapping -- [ ] definitions.test.ts — API mocking -- [ ] percentile.test.ts — community percentile - -**Playwright E2E** (port from e2e/): -- [ ] Homepage loads, language picker works -- [ ] Game page loads for 5 languages (en, fi, ar, he, ko) -- [ ] Typing, submitting, win/loss flows -- [ ] Dark mode toggle -- [ ] Mobile viewport - -**Visual regression**: -- [ ] Screenshot comparison (Flask vs Nuxt) for 10 key pages -- [ ] Light + dark mode - -### Phase 7: Deployment & Switchover (Week 9-10) - -**Render configuration**: -- [ ] Build command: `cd nuxt && pnpm install && pnpm build` -- [ ] Start command: `node nuxt/.output/server/index.mjs` -- [ ] Runtime: Node.js (not Python) -- [ ] Persistent disk: same `/data` mount (word-images, word-defs, word-stats, word-history) -- [ ] Environment: `DATA_DIR=/data`, `OPENAI_API_KEY=...`, `NODE_ENV=production` - -**Switchover plan**: -1. Deploy Nuxt to staging URL on Render (separate service, shared disk) -2. Run full parity + SEO tests against staging -3. Switch main service: update build/start commands, change runtime to Node -4. Monitor for 24h: error rates, word selection, stats submissions -5. Rollback plan: revert to Flask commands (both stacks read same data files) - -**Post-migration cleanup** (after 2 weeks stable): -- [ ] Remove `webapp/` (Flask app, Jinja templates) -- [ ] Remove `frontend/` (old Vite setup) -- [ ] Remove `vite.config.js`, `gunicorn.dev.py`, `Procfile`, `Dockerfile` -- [ ] Update `pyproject.toml` — remove Flask, gunicorn deps (keep pytest, ruff, scripts deps) -- [ ] Update `CLAUDE.md` — new architecture, new dev commands -- [ ] Update CI workflow — no Python in build/lint jobs -- [ ] Keep `tests/` for pytest data validation (still Python) - ---- - -## Key Technical Decisions - -### Word list shuffle parity -Python's `random.seed(42)` + `random.shuffle()` uses Mersenne Twister. Replicating this in Node is fragile. **Solution:** keep backwards compatibility to not change historic words, but we're fine with future word changes using new deterministic shuffle. - -### Atomic file writes (replaces fcntl.flock) -Use `proper-lockfile` npm package. Lock file, read-modify-write, unlock. Non-blocking retry with skip (same behavior as Flask's non-blocking flock). - -### Daily word in client bundle -Keep current approach: `todaysWord` included in SSR payload (visible in page source). This is the existing security posture. Server-side guess validation would add latency and break offline play. - -### Tailwind v4 in Nuxt -Use `@tailwindcss/vite` directly in `nuxt.config.ts` under `vite.plugins`. Avoids `@nuxtjs/tailwindcss` module which is v3-centric. - -### Dark mode flash -Inline script in `` via `useHead({ script: [{ innerHTML: '...', tagPosition: 'head' }] })`. Same approach as current `_dark_mode_init.html`. - -### SSR + localStorage -All localStorage reads happen behind `import.meta.client` guards or in `onMounted()`. Never during SSR. Pinia stores initialize with defaults, then hydrate from localStorage on mount. - -### Share images -`scripts/generate_share_images.py` stays as Python offline tool. Generates static PNGs at build time. DALL-E runtime image generation uses OpenAI TS SDK + `sharp` for WebP conversion. - ---- - -## File Migration Map - -### Python → TypeScript (Server) - -| Python Source | TypeScript Destination | ~Lines | -|---|---|---| -| `webapp/app.py` data loading (lines 117-457) | `server/utils/data-loader.ts` | 250 | -| `webapp/app.py` word selection (lines 337-510) | `server/utils/word-selection.ts` | 120 | -| `webapp/app.py` Language class (lines 636-792) | `server/utils/language-builder.ts` | 120 | -| `webapp/app.py` stats (lines 810-872) | `server/utils/word-stats.ts` | 80 | -| `webapp/app.py` routes (lines 874-1527) | `server/api/**/*.ts` + `server/routes/**/*.ts` | 400 | -| `webapp/definitions.py` (321 lines) | `server/utils/definitions.ts` | 250 | - -### Frontend → Nuxt (Client) - -| Source | Destination | Approach | -|---|---|---| -| `frontend/src/game.ts` (1,816 lines) | `stores/game.ts` + 10 components | Decompose | -| `frontend/src/index-app.ts` (350 lines) | `pages/index.vue` | Rewrite | -| `frontend/src/main.ts` (48 lines) | Eliminated (Nuxt handles entry) | — | -| `frontend/src/analytics.ts` (839 lines) | `plugins/analytics.client.ts` | Minimal wrapper | -| `frontend/src/posthog.ts` (131 lines) | `plugins/analytics.client.ts` | Merge | -| `frontend/src/definitions.ts` (193 lines) | `composables/useDefinitions.ts` | Use `$fetch` | -| `frontend/src/haptics.ts` (123 lines) | `composables/useHaptics.ts` | SSR guard | -| `frontend/src/sounds.ts` (107 lines) | `composables/useSounds.ts` | SSR guard | -| `frontend/src/pwa.ts` (167 lines) | `plugins/pwa.client.ts` | Use @vite-pwa/nuxt | -| `frontend/src/embed.ts` (63 lines) | `composables/useEmbed.ts` | Composable | -| `frontend/src/debug.ts` (124 lines) | `plugins/debug.client.ts` | Client-only | -| `frontend/src/diacritics.ts` (86 lines) | `utils/diacritics.ts` | Copy verbatim | -| `frontend/src/positional.ts` (110 lines) | `utils/positional.ts` | Copy verbatim | -| `frontend/src/stats.ts` (45 lines) | `utils/stats.ts` | Copy verbatim | -| `frontend/src/graphemes.ts` (19 lines) | `utils/graphemes.ts` | Copy verbatim | -| `frontend/src/types/index.ts` (206 lines) | `utils/types.ts` | Remove Window augmentation | -| `frontend/src/style.css` (255 lines) | `assets/css/main.css` | Copy | - -### Jinja Templates → Vue SFCs - -| Jinja Template | Vue Destination | -|---|---| -| `game.html` (715 lines) | `pages/[lang]/index.vue` + 10 game components | -| `index.html` (327 lines) | `pages/index.vue` | -| `stats.html` (420 lines) | `pages/stats.vue` | -| `word.html` (281 lines) | `pages/[lang]/word/[id].vue` | -| `words_hub.html` (164 lines) | `pages/[lang]/words.vue` | -| `404.html` (30 lines) | `error.vue` | -| `_base_head.html` | `nuxt.config.ts` + `app.vue` | -| `_dark_mode_init.html` | `app.vue` (inline script) | -| `_hreflang.html` | `composables/useHreflang.ts` | -| `_breadcrumb_schema.html` | `composables/useBreadcrumb.ts` | -| `_toggle_switch.html` | `components/shared/ToggleSwitch.vue` | -| `_modal_backdrop.html` | `components/shared/ModalBackdrop.vue` | -| `_loading_skeleton.html` | Eliminated (SSR replaces skeleton) | -| `_pwa_install.html` | `@vite-pwa/nuxt` module | -| `sitemap_*.xml` | `server/routes/sitemap*.ts` | - ---- - -## Risks & Mitigations - -| Risk | Severity | Mitigation | -|---|---|---| -| Word selection mismatch | Critical | Pre-shuffled lists + exhaustive parity test (77 langs × 1800 days) | -| SEO ranking drop | High | Exact URL parity, crawl comparison, staged rollout | -| localStorage SSR hydration | Medium | All reads behind `import.meta.client` / `onMounted()` | -| Performance regression | Medium | Nitro is typically faster than Gunicorn for page serving. Monitor TTFB. | -| PWA cache conflicts | Medium | Increment SW cache version. New asset URL pattern (`/_nuxt/`). | -| Persistent disk access | Low | Same paths, same env var. No data migration needed. | - ---- - -## Estimated Effort - -| Phase | Duration | Key Deliverable | -|---|---|---| -| 0: Foundation | 3 days | Nuxt project scaffolded, utils copied | -| 1: Server logic | 8 days | All Python→TS ports, word selection parity verified | -| 2: API routes | 4 days | All 13 Flask routes working in Nitro | -| 3: Pinia stores | 5 days | game.ts monolith decomposed into 4 stores | -| 4: Vue components | 8 days | All templates → SFCs, all pages SSR | -| 5: PWA/Analytics | 3 days | Service worker, GA4, PostHog, dark mode | -| 6: Testing | 5 days | Parity, SEO, visual regression, E2E | -| 7: Deployment | 3 days | Staging → production switchover | -| **Total** | **~5-6 weeks** | | - ---- - -## First Steps (actionable now) - -1. Create `nuxt/` directory and initialize project -2. Write Python script to pre-compute shuffled word lists -3. Port `word-selection.ts` and run parity test — this is the highest-risk piece -4. Copy pure utility modules (diacritics, positional, stats, types) -5. Build first API route (`/api/[lang]/data`) and first page (`pages/[lang]/index.vue`) - ---- - -## Verification - -After each phase: -- `pnpm dev` in nuxt/ — verify pages render -- Run parity tests against Flask (word selection, definitions) -- `pnpm test` — vitest passes -- `pnpm build` — production build succeeds -- Screaming Frog crawl comparison (Phase 6+) - -Final verification before switchover: -- All 77 languages load and render correctly -- Word selection matches Flask for today + past 1800 days -- Sitemaps identical -- Share images served correctly -- Stats submission works -- Definition caching works -- PWA installs and works offline From bb7b9edd5e62d60249fca814bcc5ddbcf83c438b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 19 Mar 2026 00:07:03 +0000 Subject: [PATCH 2/8] feat: RTL fix, diacritic popup, cross-language state fix, accessibility, PostHog proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug Fixes - Fix Hebrew/Arabic/Persian/Kurdish/Urdu RTL typing (#178) — CSS `direction: rtl` on tile grid instead of array reversal - Fix cross-language game state bleed — `resetGameState()` before `loadFromLocalStorage()`, plus `definePageMeta({ key })` for full remount - Fix Esperanto diacritic input (#177) — iOS-style long-press popup for accented characters on all languages with diacritic maps ## Accessibility (WCAG 2.1 AA — zero visual impact for sighted users) - Screen reader: tile states announced ("A, correct position"), guess results via aria-live region - Screen reader: toast notifications use `role="alert"` with `aria-live="assertive"` - Screen reader: header buttons labeled ("How to play", "Statistics", "Settings") - Screen reader: keyboard key states announced ("c, correct" / "e, absent") - Screen reader: Enter/Backspace keys properly labeled (not Unicode symbols) - Keyboard: skip-to-content link, focus-visible rings on all interactive elements - Modals: `role="dialog"`, `aria-modal="true"`, `aria-labelledby` support - Reduced motion: all animations disabled when OS prefers-reduced-motion is set (CSS + JS) - Semantic HTML: game board uses role="grid"/role="row"/role="gridcell" - New /accessibility page with WCAG statement - WCAG badge in README, GitHub repo topics (a11y, accessibility, wcag, etc.) ## Analytics - PostHog: migrated from manual init to @posthog/nuxt module with /t proxy route (defeats ad blockers) - PostHog: `capture_pageview: 'history_change'` for automatic SPA pageview tracking - invalid_word events now track the actual word (capped at 10 chars) - Error tracking delegated to @posthog/nuxt module (automatic $exception capture) ## Code Quality - Extracted `isStandalone()` to shared `utils/storage.ts` (was duplicated in 2 files) - Extracted `getOrCreateId()` usage (was duplicated in analytics + game store) - Removed dead RTL parameter from `animateRevealRow()` signature - Cleaned up animation callbacks (visualIdx === dataIdx after RTL CSS change) - ToggleSwitch: added `role="switch"`, `aria-label` prop ## Tests - 4 E2E tests for diacritic popup (long-press, slide-select, quick-tap, no-popup) - 1 E2E test for cross-language state isolation - All 150 vitest + 25/26 E2E tests passing --- README.md | 3 +- assets/css/main.css | 45 +++ components/game/GameBoard.vue | 10 +- components/game/GameHeader.vue | 9 +- components/game/GameKeyboard.vue | 6 +- components/game/HelpModal.vue | 4 +- components/game/KeyboardKey.vue | 342 ++++++++++++++++++++- components/game/NotificationToast.vue | 3 + components/game/SettingsModal.vue | 42 ++- components/game/StatsModal.vue | 47 +-- components/game/Tile.vue | 21 +- components/game/TileRow.vue | 8 +- components/shared/BaseModal.vue | 14 +- components/shared/ToggleSwitch.vue | 9 +- composables/useAnalytics.ts | 92 +----- data/default_language_config.json | 37 ++- e2e/gameplay.spec.ts | 118 +++++++ layouts/game.vue | 1 + nuxt.config.ts | 17 +- package.json | 4 +- pages/[lang]/index.vue | 14 +- pages/[lang]/word/[id].vue | 17 +- pages/[lang]/words.vue | 14 +- pages/accessibility.vue | 101 ++++++ plugins/analytics.client.ts | 42 +-- plugins/pwa.client.ts | 25 +- pnpm-lock.yaml | 205 +++++++++++- qa-screenshots.ts | 130 -------- server/api/[lang]/word-image/[word].get.ts | 37 +-- server/routes/t/[...path].ts | 31 ++ server/utils/definitions.ts | 32 +- stores/game.ts | 108 +++++-- utils/game/useGameAnimations.ts | 41 ++- utils/storage.ts | 13 + 34 files changed, 1211 insertions(+), 431 deletions(-) create mode 100644 pages/accessibility.vue delete mode 100644 qa-screenshots.ts create mode 100644 server/routes/t/[...path].ts diff --git a/README.md b/README.md index 379c170e..51f484ab 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Wordle Global [![Tests](https://github.com/Hugo0/wordle/actions/workflows/test.yml/badge.svg)](https://github.com/Hugo0/wordle/actions/workflows/test.yml) -[![Languages](https://img.shields.io/badge/languages-78-blue)](https://wordle.global) +[![Languages](https://img.shields.io/badge/languages-79-blue)](https://wordle.global) +[![Accessibility](https://img.shields.io/badge/a11y-WCAG_2.1_AA-green)](https://wordle.global/accessibility) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/Hugo0/wordle/pulls) diff --git a/assets/css/main.css b/assets/css/main.css index 39a78baf..2d331d41 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -251,3 +251,48 @@ .dark .pop { border-color: #565758 !important; } + +/* Respect user's reduced motion preference */ +@media (prefers-reduced-motion: reduce) { + .shake, + .pop, + .tile-bounce, + .key-shake, + .key-pulse, + .modal-animate, + .key-correct, + .key-semicorrect { + animation: none !important; + } + + .diacritic-popup { + animation: none !important; + } +} + +/* Skip to content link — visible only on keyboard focus */ +.skip-link { + position: absolute; + top: -100%; + left: 50%; + transform: translateX(-50%); + z-index: 9999; + padding: 8px 16px; + background: #3b82f6; + color: white; + font-weight: 600; + border-radius: 0 0 6px 6px; + text-decoration: none; +} + +.skip-link:focus { + top: 0; +} + +/* Focus-visible ring for interactive elements (keyboard only, not mouse) */ +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} diff --git a/components/game/GameBoard.vue b/components/game/GameBoard.vue index 0ce92747..e8d07ec3 100644 --- a/components/game/GameBoard.vue +++ b/components/game/GameBoard.vue @@ -1,7 +1,9 @@ diff --git a/components/game/HelpModal.vue b/components/game/HelpModal.vue index 3d7bf861..ec2ac7fa 100644 --- a/components/game/HelpModal.vue +++ b/components/game/HelpModal.vue @@ -120,7 +120,7 @@ d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5" /> - {{ lang.config?.ui?.report_issue || 'Report an Issue' }} + {{ lang.config?.ui?.report_issue }} - {{ lang.config?.ui?.view_source || 'View Source Code' }} + {{ lang.config?.ui?.view_source }} diff --git a/components/game/KeyboardKey.vue b/components/game/KeyboardKey.vue index d2968f7f..e6808685 100644 --- a/components/game/KeyboardKey.vue +++ b/components/game/KeyboardKey.vue @@ -1,24 +1,356 @@ + + diff --git a/components/game/NotificationToast.vue b/components/game/NotificationToast.vue index 7575b3ad..bc25b62f 100644 --- a/components/game/NotificationToast.vue +++ b/components/game/NotificationToast.vue @@ -1,6 +1,9 @@ diff --git a/components/game/TileRow.vue b/components/game/TileRow.vue index b7c4522e..23148c3e 100644 --- a/components/game/TileRow.vue +++ b/components/game/TileRow.vue @@ -1,5 +1,10 @@ diff --git a/nuxt.config.ts b/nuxt.config.ts index d41972d0..86aaaacb 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,7 +4,22 @@ export default defineNuxtConfig({ compatibilityDate: '2025-06-14', devtools: { enabled: true }, - modules: ['@pinia/nuxt', '@vite-pwa/nuxt'], + modules: ['@pinia/nuxt', '@vite-pwa/nuxt', '@posthog/nuxt'], + + posthogConfig: { + publicKey: 'phc_DMY07B83ghetzxgIbBhobbdSjlueym6vNVVZwM79SPp', + clientConfig: { + api_host: '/t', // Proxied through Nitro server route + ui_host: 'https://eu.posthog.com', + autocapture: false, + capture_pageview: 'history_change', // Auto-track SPA navigations + capture_pageleave: true, + session_recording: { + sampleRate: 0.03, + }, + persistence: 'localStorage+cookie', + }, + }, css: ['~/assets/css/main.css'], diff --git a/package.json b/package.json index 86f08888..46703cba 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,10 @@ "dependencies": { "@khmyznikov/pwa-install": "^0.6.2", "@pinia/nuxt": "^0.10.1", + "@posthog/nuxt": "^1.5.87", "nuxt": "^3.17.5", "openai": "^5.8.2", "pinia": "^3.0.3", - "posthog-js": "^1.360.2", "proper-lockfile": "^4.1.2", "sharp": "^0.34.3", "vue": "^3.5.27" @@ -38,8 +38,8 @@ "@tailwindcss/vite": "^4.1.18", "@types/proper-lockfile": "^4.1.4", "@vite-pwa/nuxt": "^0.10.8", - "playwright": "^1.58.2", "jsdom": "^27.4.0", + "playwright": "^1.58.2", "prettier": "^3.8.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", diff --git a/pages/[lang]/index.vue b/pages/[lang]/index.vue index 1823af4f..e3e52b02 100644 --- a/pages/[lang]/index.vue +++ b/pages/[lang]/index.vue @@ -6,7 +6,12 @@ * initializes stores, and renders the game board + keyboard. */ -definePageMeta({ layout: 'game' }); +definePageMeta({ + layout: 'game', + // Force full remount when language changes — prevents game state bleed + // between languages (Pinia stores are singletons, stale tiles/colors persist) + key: (route) => route.params.lang as string, +}); const route = useRoute(); const lang = route.params.lang as string; @@ -17,9 +22,7 @@ const showImprovementBanner = ref(false); function onBannerClick() { try { - import('posthog-js').then((mod) => - mod.default.capture('language_interest', { language: lang }) - ); + usePostHog()?.capture('language_interest', { language: lang }); } catch { // Silently fail } @@ -241,8 +244,7 @@ onMounted(() => { gameOver: game.gameOver, lastGuessValid: true, })); - analytics.initErrorTracking(langStore.languageCode); - analytics.identifyUser(langStore.languageCode); + analytics.identifyUser(stats.gameResults); } catch (err) { console.warn('[wordle] Failed to restore game state:', err); } diff --git a/pages/[lang]/word/[id].vue b/pages/[lang]/word/[id].vue index 22e26908..1b4b30b4 100644 --- a/pages/[lang]/word/[id].vue +++ b/pages/[lang]/word/[id].vue @@ -8,6 +8,7 @@ const route = useRoute(); const lang = route.params.lang as string; +const langStore = useLanguageStore(); const dayIdx = parseInt(route.params.id as string, 10); const { data: wordData, error } = await useFetch(`/api/${lang}/word/${dayIdx}`); @@ -324,7 +325,7 @@ onMounted(() => { @@ -363,7 +364,7 @@ onMounted(() => { - Definition + {{ langStore.config?.ui?.definition }} { - Definition + {{ langStore.config?.ui?.definition }} { - Definition + {{ langStore.config?.ui?.definition }} {

- Community Stats + {{ langStore.config?.ui?.community_stats }}

{{ wordStats.total }}

- Players + {{ langStore.config?.ui?.players }}

@@ -624,7 +625,7 @@ onMounted(() => { rel="noopener noreferrer" class="text-xs text-neutral-400 hover:text-neutral-500 dark:hover:text-neutral-300" > - Report bad word + {{ langStore.config?.ui?.report_bad_word }}

diff --git a/pages/[lang]/words.vue b/pages/[lang]/words.vue index 665f849e..78dd1cc4 100644 --- a/pages/[lang]/words.vue +++ b/pages/[lang]/words.vue @@ -9,6 +9,7 @@ const route = useRoute(); const lang = route.params.lang as string; +const langStore = useLanguageStore(); const page = computed(() => parseInt((route.query.page as string) || '1', 10)); const { data: wordsData, error } = await useFetch(`/api/${lang}/words`, { @@ -200,10 +201,11 @@ onMounted(() => { ← Play Wordle {{ langNameNative }}

- Wordle {{ langNameNative }} — All Words + Wordle {{ langNameNative }} — {{ langStore.config?.ui?.all_words }}

- {{ todaysIdx.toLocaleString() }} daily words and counting + {{ todaysIdx.toLocaleString() }} + {{ langStore.config?.ui?.daily_words_counting }}

@@ -231,7 +233,7 @@ onMounted(() => {

- Today's word — Play to reveal! + {{ langStore.config?.ui?.todays_word_reveal }}

@@ -256,7 +258,7 @@ onMounted(() => {

- Today + {{ langStore.config?.ui?.today }}

@@ -306,8 +308,8 @@ onMounted(() => { v-if="w.stats && w.stats.total > 0" class="flex justify-center gap-3 mt-2 text-[10px] text-neutral-400" > - {{ w.stats.total }} plays - {{ winRate(w.stats) }}% win + {{ w.stats.total }} {{ langStore.config?.ui?.plays }} + {{ winRate(w.stats) }}% {{ langStore.config?.ui?.win }}
diff --git a/pages/accessibility.vue b/pages/accessibility.vue new file mode 100644 index 00000000..8521e608 --- /dev/null +++ b/pages/accessibility.vue @@ -0,0 +1,101 @@ + + + diff --git a/plugins/analytics.client.ts b/plugins/analytics.client.ts index 5e36194b..204b19a6 100644 --- a/plugins/analytics.client.ts +++ b/plugins/analytics.client.ts @@ -1,12 +1,10 @@ /** * Analytics Plugin (client-only) * - * Initializes Google Analytics (GA4) and PostHog. - * Defers script loading to after page load for performance. + * Initializes Google Analytics (GA4). + * PostHog is handled by @posthog/nuxt module (see nuxt.config.ts). */ -import posthog from 'posthog-js'; - declare global { interface Window { dataLayer: unknown[]; @@ -15,8 +13,6 @@ declare global { } const GA_MEASUREMENT_ID = 'G-273H1MLL3T'; -const POSTHOG_KEY = 'phc_DMY07B83ghetzxgIbBhobbdSjlueym6vNVVZwM79SPp'; -const POSTHOG_HOST = 'https://eu.i.posthog.com'; function loadGtagScript() { const script = document.createElement('script'); @@ -42,40 +38,6 @@ function initGA4() { } } -function initPostHog() { - try { - posthog.init(POSTHOG_KEY, { - api_host: POSTHOG_HOST, - defaults: '2026-01-30', - autocapture: false, - capture_pageview: false, // We track pageviews via trackPageView/trackHomepageView - capture_pageleave: true, - disable_session_recording: false, - session_recording: { - sampleRate: 0.03, - }, - persistence: 'localStorage+cookie', - loaded: (ph) => { - // Register language as a super property if available via route - const route = useRoute(); - const lang = route.params.lang as string | undefined; - if (lang) { - ph.register({ language: lang }); - } - }, - }); - } catch { - // Silently fail - analytics should never break the app - } -} - export default defineNuxtPlugin(() => { initGA4(); - initPostHog(); - - return { - provide: { - posthog: posthog, - }, - }; }); diff --git a/plugins/pwa.client.ts b/plugins/pwa.client.ts index e9ccf1fd..ea107a67 100644 --- a/plugins/pwa.client.ts +++ b/plugins/pwa.client.ts @@ -13,6 +13,7 @@ import '@khmyznikov/pwa-install'; import type { BeforeInstallPromptEvent, PWAStatus } from '~/utils/types'; +import { isStandalone } from '~/utils/storage'; const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 1 week @@ -35,13 +36,6 @@ function isIOS(): boolean { ); } -function isStandalone(): boolean { - return ( - window.matchMedia('(display-mode: standalone)').matches || - (navigator as Navigator & { standalone?: boolean }).standalone === true - ); -} - export default defineNuxtPlugin(() => { let deferredPrompt: BeforeInstallPromptEvent | null = null; let dismissed = isDismissed(); @@ -75,6 +69,9 @@ export default defineNuxtPlugin(() => { const banner = getBanner(); if (banner && (deferredPrompt || isIOS())) { banner.style.display = 'flex'; + try { + usePostHog()?.capture('pwa_prompt_shown'); + } catch {} } } @@ -97,9 +94,14 @@ export default defineNuxtPlugin(() => { if (deferredPrompt) { deferredPrompt.prompt(); deferredPrompt.userChoice.then((choice) => { - if (choice.outcome === 'accepted') { - // Analytics tracking could go here - } + try { + const ph = usePostHog(); + if (choice.outcome === 'accepted') { + ph?.capture('pwa_install'); + } else { + ph?.capture('pwa_dismiss'); + } + } catch {} deferredPrompt = null; hideBanner(); }); @@ -113,6 +115,9 @@ export default defineNuxtPlugin(() => { } catch { // localStorage may throw in private browsing mode } + try { + usePostHog()?.capture('pwa_dismiss'); + } catch {} hideBanner(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7002a69c..d8a81583 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@pinia/nuxt': specifier: ^0.10.1 version: 0.10.1(magicast@0.5.2)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3))) + '@posthog/nuxt': + specifier: ^1.5.87 + version: 1.5.87(magicast@0.5.2) nuxt: specifier: ^3.17.5 version: 3.21.2(@parcel/watcher@2.5.6)(@types/node@25.5.0)(@vue/compiler-sfc@3.5.30)(cac@6.7.14)(db0@0.3.4)(ioredis@5.10.0)(lightningcss@1.31.1)(magicast@0.5.2)(rollup-plugin-visualizer@6.0.11(rollup@4.59.0))(rollup@4.59.0)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@3.2.5(typescript@5.9.3))(yaml@2.8.2) @@ -23,9 +26,6 @@ importers: pinia: specifier: ^3.0.3 version: 3.0.4(typescript@5.9.3)(vue@3.5.30(typescript@5.9.3)) - posthog-js: - specifier: ^1.360.2 - version: 1.360.2 proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -1682,11 +1682,20 @@ packages: '@poppinss/exception@1.2.3': resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} - '@posthog/core@1.23.4': - resolution: {integrity: sha512-gSM1gnIuw5UOBUOTz0IhCTH8jOHoFr5rzSDb5m7fn9ofLHvz3boZT1L1f+bcuk+mvzNJfrJ3ByVQGKmUQnKQ8g==} + '@posthog/cli@0.7.2': + resolution: {integrity: sha512-xHZ+S+BL8JUz0T18jS4gWanXVhyBLZcTtS4LaQHm4mc5tPcii7oA2rkZGdxtlw7vlC5vXNBmKJgz2yD4TFZwOg==} + engines: {node: '>=14', npm: '>=6'} + hasBin: true + + '@posthog/core@1.24.0': + resolution: {integrity: sha512-Wkp9mgNfgdf6+G4C1VMKakm2RXKQFf4bb5/CPQRAjpqv9l6BY36zZrD1+X5Y2XIAzZqbMKRxsDu3V1r6uKu7/A==} + + '@posthog/nuxt@1.5.87': + resolution: {integrity: sha512-AKNFY1NQWDozG2UL9olCstZVTiSr/mmfWcSRDbx7BiWdPx5SGmWdxKwEoyN7KR0/Ijjm9dHsERqtNKy5NbMrDQ==} + engines: {node: ^20.20.0 || >=22.22.0} - '@posthog/types@1.360.2': - resolution: {integrity: sha512-U48CbtmX5kETZvWjaJVlublSA1aLV99m71TQtgxWksBMXINS/3C7j+KqlMO6wH7SuaEZQnjaxh1KYGH4nRCaaA==} + '@posthog/types@1.362.0': + resolution: {integrity: sha512-15wOI5uulkfzpkSQKVN4atZecAla2Hxr8IBIB8islqDvqY+42vbR+tMeDKMman9+FUoAqMzE0OnB8VIbM1QY0w==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2338,6 +2347,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} @@ -2353,6 +2365,12 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axios-proxy-builder@0.1.2: + resolution: {integrity: sha512-6uBVsBZzkB3tCC8iyx59mCjQckhB8+GQrI9Cop8eC7ybIsvs/KtnNgEBfRMSEa7GqK2VBGUzgjNYMdPIfotyPA==} + + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + b4a@1.8.0: resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} peerDependencies: @@ -2534,6 +2552,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -2548,6 +2570,10 @@ packages: colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -2579,6 +2605,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console.table@0.10.0: + resolution: {integrity: sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g==} + engines: {node: '> 0.10'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2743,6 +2773,9 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2762,6 +2795,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -2818,6 +2855,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + easy-table@1.1.0: + resolution: {integrity: sha512-oq33hWOSSnl2Hoh00tZWaIPi1ievrD9aFG82/IgjlycAnW9hHx5PkJiXpxPsgEE+H7BsbVQXFVFST8TEXS6/pA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2991,6 +3031,15 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2999,6 +3048,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -3633,10 +3686,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -4108,8 +4169,17 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} - posthog-js@1.360.2: - resolution: {integrity: sha512-/Wed0mOuRUfyEGT/BRQaokCqBlxrEceE7MDT9A00lU5tXo443/2Pg9ZiqN5sucUluZF47hwGORpYPoVUt32UFw==} + posthog-js@1.362.0: + resolution: {integrity: sha512-qPHkAk9G19xVDAQLoQ1FOLNE9BBq+FDePhkOevbdUQcFJVNHVc+j7E/ndQ+olGnDuiSMdgAb5c6yGk7PD9Z0ug==} + + posthog-node@5.28.4: + resolution: {integrity: sha512-j8JBDNuSwUWR0TBZNuCwNRHQ+OReVz1UwDYMDol+iqFTbYrIKZnyC05oyGtSBRGntrYBiaIWFbH9N3kZuxfVdg==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true preact@10.29.0: resolution: {integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==} @@ -4145,6 +4215,9 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4258,6 +4331,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + rollup-plugin-visualizer@6.0.11: resolution: {integrity: sha512-TBwVHVY7buHjIKVLqr9scTVFwqZqMXINcCphPwIWKPDCOBIa+jCQfafvbjRJDZgXdq/A996Dy6yGJ/+/NtAXDQ==} engines: {node: '>=18'} @@ -4659,6 +4737,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} @@ -5054,6 +5136,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-vitals@5.1.0: resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==} @@ -6933,11 +7018,33 @@ snapshots: '@poppinss/exception@1.2.3': {} - '@posthog/core@1.23.4': + '@posthog/cli@0.7.2': + dependencies: + axios: 1.13.6 + axios-proxy-builder: 0.1.2 + console.table: 0.10.0 + detect-libc: 2.1.2 + rimraf: 6.1.3 + transitivePeerDependencies: + - debug + + '@posthog/core@1.24.0': dependencies: cross-spawn: 7.0.6 - '@posthog/types@1.360.2': {} + '@posthog/nuxt@1.5.87(magicast@0.5.2)': + dependencies: + '@nuxt/kit': 4.4.2(magicast@0.5.2) + '@posthog/cli': 0.7.2 + '@posthog/core': 1.24.0 + posthog-js: 1.362.0 + posthog-node: 5.28.4 + transitivePeerDependencies: + - debug + - magicast + - rxjs + + '@posthog/types@1.362.0': {} '@protobufjs/aspromise@1.1.2': {} @@ -7617,6 +7724,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + at-least-node@1.0.0: {} autoprefixer@10.4.27(postcss@8.5.8): @@ -7632,6 +7741,18 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axios-proxy-builder@0.1.2: + dependencies: + tunnel: 0.0.6 + + axios@1.13.6: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + b4a@1.8.0: {} babel-plugin-polyfill-corejs2@0.4.16(@babel/core@7.29.0): @@ -7821,6 +7942,9 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clone@1.0.4: + optional: true + cluster-key-slot@1.1.2: {} color-convert@2.0.1: @@ -7831,6 +7955,10 @@ snapshots: colord@2.9.3: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@11.1.0: {} commander@2.20.3: {} @@ -7855,6 +7983,10 @@ snapshots: consola@3.4.2: {} + console.table@0.10.0: + dependencies: + easy-table: 1.1.0 + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} @@ -8017,6 +8149,11 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 + defaults@1.0.4: + dependencies: + clone: 1.0.4 + optional: true + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -8035,6 +8172,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -8085,6 +8224,10 @@ snapshots: eastasianwidth@0.2.0: {} + easy-table@1.1.0: + optionalDependencies: + wcwidth: 1.0.1 + ee-first@1.1.1: {} ejs@3.1.10: @@ -8312,6 +8455,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -8321,6 +8466,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fraction.js@5.3.4: {} fresh@2.0.0: {} @@ -8975,8 +9128,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -9658,15 +9817,15 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-js@1.360.2: + posthog-js@1.362.0: dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/api-logs': 0.208.0 '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) - '@posthog/core': 1.23.4 - '@posthog/types': 1.360.2 + '@posthog/core': 1.24.0 + '@posthog/types': 1.362.0 core-js: 3.48.0 dompurify: 3.3.3 fflate: 0.4.8 @@ -9674,6 +9833,10 @@ snapshots: query-selector-shadow-dom: 1.0.1 web-vitals: 5.1.0 + posthog-node@5.28.4: + dependencies: + '@posthog/core': 1.24.0 + preact@10.29.0: {} prettier@3.8.1: {} @@ -9709,6 +9872,8 @@ snapshots: '@types/node': 25.5.0 long: 5.3.2 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} quansync@0.2.11: {} @@ -9828,6 +9993,11 @@ snapshots: rfdc@1.4.1: {} + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + rollup-plugin-visualizer@6.0.11(rollup@4.59.0): dependencies: open: 8.4.2 @@ -10323,6 +10493,8 @@ snapshots: tslib@2.8.1: optional: true + tunnel@0.0.6: {} + type-fest@0.16.0: {} type-fest@5.4.4: @@ -10704,6 +10876,11 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + optional: true + web-vitals@5.1.0: {} webidl-conversions@3.0.1: {} diff --git a/qa-screenshots.ts b/qa-screenshots.ts deleted file mode 100644 index a21fcd1b..00000000 --- a/qa-screenshots.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * QA Screenshot Script - * Takes screenshots of every page and reports console errors. - * Run: npx tsx qa-screenshots.ts - */ -import { chromium } from 'playwright'; -import { mkdirSync } from 'fs'; - -const BASE = 'http://localhost:3000'; -const OUT = './qa-screenshots'; - -const PAGES = [ - { name: '01-homepage', url: '/', wait: 2000 }, - { name: '02-game-en', url: '/en', wait: 3000 }, - { name: '03-game-fi', url: '/fi', wait: 2000 }, - { name: '04-game-he-rtl', url: '/he', wait: 2000 }, - { name: '05-game-ar-rtl', url: '/ar', wait: 2000 }, - { name: '06-words-en', url: '/en/words', wait: 2000 }, - { name: '07-word-detail', url: '/en/word/1720', wait: 2000 }, - { name: '08-word-today', url: '/en/word/1729', wait: 2000 }, - { name: '09-stats', url: '/stats', wait: 2000 }, - { name: '10-homepage-dark', url: '/', wait: 2000, dark: true }, - { name: '11-game-en-dark', url: '/en', wait: 2000, dark: true }, -]; - -async function run() { - mkdirSync(OUT, { recursive: true }); - const browser = await chromium.launch(); - - const errors: string[] = []; - const warnings: string[] = []; - - for (const page of PAGES) { - console.log(`\n=== ${page.name} (${page.url}) ===`); - - const context = await browser.newContext({ - viewport: { width: 390, height: 844 }, // iPhone 14 size - colorScheme: page.dark ? 'dark' : 'light', - }); - - if (page.dark) { - await context.addInitScript(() => { - localStorage.setItem('darkMode', 'true'); - }); - } - - const p = await context.newPage(); - const pageErrors: string[] = []; - const pageWarnings: string[] = []; - - p.on('console', (msg) => { - if (msg.type() === 'error') { - const text = msg.text(); - if (!text.includes('favicon') && !text.includes('DevTools')) { - pageErrors.push(text); - } - } - if (msg.type() === 'warning') { - const text = msg.text(); - if (text.includes('Failed to resolve') || text.includes('missing template')) { - pageWarnings.push(text); - } - } - }); - - p.on('pageerror', (err) => { - pageErrors.push(`PAGE ERROR: ${err.message}`); - }); - - try { - const response = await p.goto(`${BASE}${page.url}`, { - waitUntil: 'networkidle', - timeout: 15000, - }); - console.log(` Status: ${response?.status()}`); - - await p.waitForTimeout(page.wait); - - await p.screenshot({ - path: `${OUT}/${page.name}.png`, - fullPage: true, - }); - console.log(` Screenshot: ${OUT}/${page.name}.png`); - - if (pageErrors.length > 0) { - console.log(` ERRORS (${pageErrors.length}):`); - for (const e of pageErrors.slice(0, 5)) { - console.log(` - ${e.substring(0, 200)}`); - errors.push(`[${page.name}] ${e.substring(0, 200)}`); - } - } - if (pageWarnings.length > 0) { - console.log(` WARNINGS (${pageWarnings.length}):`); - for (const w of pageWarnings.slice(0, 3)) { - console.log(` - ${w.substring(0, 200)}`); - warnings.push(`[${page.name}] ${w.substring(0, 200)}`); - } - } - if (pageErrors.length === 0 && pageWarnings.length === 0) { - console.log(` ✓ Clean`); - } - } catch (err: any) { - console.log(` FAILED: ${err.message}`); - errors.push(`[${page.name}] Navigation failed: ${err.message}`); - } - - await context.close(); - } - - await browser.close(); - - console.log('\n\n========== QA SUMMARY =========='); - console.log(`Pages tested: ${PAGES.length}`); - console.log(`Errors: ${errors.length}`); - console.log(`Warnings: ${warnings.length}`); - - if (errors.length > 0) { - console.log('\nAll Errors:'); - for (const e of errors) console.log(` ✗ ${e}`); - } - if (warnings.length > 0) { - console.log('\nAll Warnings:'); - for (const w of warnings) console.log(` ⚠ ${w}`); - } - if (errors.length === 0 && warnings.length === 0) { - console.log('\n✓ All pages clean — no errors or component warnings'); - } -} - -run().catch(console.error); diff --git a/server/api/[lang]/word-image/[word].get.ts b/server/api/[lang]/word-image/[word].get.ts index 7cecdccc..a0323eb4 100644 --- a/server/api/[lang]/word-image/[word].get.ts +++ b/server/api/[lang]/word-image/[word].get.ts @@ -10,37 +10,38 @@ import { getTodaysIdx, getWordForDay } from '../../../utils/word-selection'; import { fetchDefinition } from '../../../utils/definitions'; // Top 30 languages get DALL-E images (cost control) +// Top 30 languages by sessions (updated Mar 18, 2026 from GA4) const LANGUAGE_POPULARITY = [ - 'fi', 'en', + 'fi', + 'de', 'ar', - 'tr', + 'es', 'hr', + 'tr', + 'sv', 'bg', - 'de', 'he', - 'sv', + 'it', + 'da', 'ru', 'hu', - 'es', - 'et', - 'da', + 'pt', + 'fr', 'sr', + 'et', + 'nb', + 'sk', 'ro', 'ca', - 'sk', - 'it', - 'az', - 'fr', - 'lv', - 'la', - 'gl', 'mk', - 'uk', - 'pt', - 'vi', + 'nl', 'pl', - 'hy', + 'la', + 'ja', + 'az', + 'uk', + 'gl', ]; const IMAGE_LANGUAGES = new Set(LANGUAGE_POPULARITY); const IMAGE_MIN_DAY_IDX = 1708; diff --git a/server/routes/t/[...path].ts b/server/routes/t/[...path].ts new file mode 100644 index 00000000..60d18510 --- /dev/null +++ b/server/routes/t/[...path].ts @@ -0,0 +1,31 @@ +/** + * PostHog reverse proxy — forwards /t/* to eu.i.posthog.com + * + * Defeats ad blockers by routing PostHog traffic through our own domain. + * Preserves client IP for geolocation via x-forwarded-for. + */ +export default defineEventHandler(async (event) => { + const path = event.context.params?.path || ''; + const url = getRequestURL(event); + const search = url.search || ''; + + // Reject oversized payloads (PostHog batches are typically <10KB) + const contentLength = getHeader(event, 'content-length'); + if (contentLength && parseInt(contentLength, 10) > 1_000_000) { + setResponseStatus(event, 413); + return 'Payload too large'; + } + + const hostname = path.startsWith('static/') ? 'eu-assets.i.posthog.com' : 'eu.i.posthog.com'; + const targetUrl = `https://${hostname}/${path}${search}`; + + // Forward client IP for geolocation + const clientIp = getHeader(event, 'x-forwarded-for') || getRequestIP(event); + + return proxyRequest(event, targetUrl, { + headers: { + host: hostname, + ...(clientIp ? { 'x-forwarded-for': clientIp } : {}), + }, + }); +}); diff --git a/server/utils/definitions.ts b/server/utils/definitions.ts index a3e08abe..c2f827b7 100644 --- a/server/utils/definitions.ts +++ b/server/utils/definitions.ts @@ -138,10 +138,35 @@ const LLM_LANG_NAMES: Record = { hyw: 'Western Armenian', ckb: 'Central Kurdish', pau: 'Palauan', + ia: 'Interlingua', ie: 'Interlingue', rw: 'Kinyarwanda', tlh: 'Klingon', qya: 'Quenya', + // Added: languages that were missing LLM definition support + bn: 'Bengali', + eo: 'Esperanto', + fo: 'Faroese', + fur: 'Friulian', + fy: 'West Frisian', + gd: 'Scottish Gaelic', + ha: 'Hausa', + hi: 'Hindi', + ja: 'Japanese', + lb: 'Luxembourgish', + ltg: 'Latgalian', + mi: 'Māori', + mn: 'Mongolian', + mr: 'Marathi', + nds: 'Low German', + ne: 'Nepali', + pa: 'Punjabi', + sw: 'Swahili', + tk: 'Turkmen', + tl: 'Tagalog', + ur: 'Urdu', + uz: 'Uzbek', + yo: 'Yoruba', }; const LLM_MODEL = 'gpt-5.2'; @@ -259,7 +284,12 @@ export async function fetchDefinition( } // Expired — fall through to LLM } else if (loaded && Object.keys(loaded).length > 0) { - return loaded; + // If cached result is English-only (kaikki-en fallback), try LLM for native + if (loaded.source === 'kaikki-en' && !loaded.definition_native) { + // Fall through to LLM to get native definition + } else { + return loaded; + } } } catch { // Fall through diff --git a/stores/game.ts b/stores/game.ts index 13bc08ef..713cb698 100644 --- a/stores/game.ts +++ b/stores/game.ts @@ -29,6 +29,7 @@ import { calculateCommunityPercentile } from '~/utils/stats'; import { WORD_LENGTH, MAX_GUESSES } from '~/utils/types'; import type { KeyState, TileColor, Notification } from '~/utils/types'; import { animateRevealRow, animateKeyNudge } from '~/utils/game/useGameAnimations'; +import { getOrCreateId } from '~/utils/storage'; // --------------------------------------------------------------------------- // Constants @@ -133,6 +134,9 @@ export const useGameStore = defineStore('game', () => { const shareButtonState = ref<'idle' | 'success'>('idle'); + /** Screen reader announcement — updated after each guess reveal. */ + const srAnnouncement = ref(''); + // Definition & word image for stats modal display const todayDefinition = ref<{ word: string; @@ -274,10 +278,11 @@ export const useGameStore = defineStore('game', () => { const lang = useLanguageStore(); - // Try exact match first + // Exact match — respect what the user typed (e.g., "lapiz" stays "lapiz") if (lang.wordListSet.has(word)) return word; - // Try normalized match (e.g., "borde" matches "börde") + // Normalized match — auto-correct to canonical form (e.g., "borde" → "börde") + // Only triggers when the typed form isn't in the word list itself const normalized = normalizeWord(word, lang.normalizeMap); const canonical = getNormalizedWordMap().get(normalized); if (canonical) return canonical; @@ -448,7 +453,9 @@ export const useGameStore = defineStore('game', () => { if (['Enter', '⇨', '⟹', 'ENTER'].includes(key)) { if (!fullWordInputted.value) { shakeRow(activeRow.value); - showNotification('Please enter a full word'); + showNotification( + lang.config?.text?.['notification-partial-word'] || 'Please enter a full word', + ); return; } @@ -525,6 +532,23 @@ export const useGameStore = defineStore('game', () => { animating.value = false; showTiles(); + // Announce guess result for screen readers + const rowTiles = tiles.value[revealingRow]; + const rowColors = tileColors.value[revealingRow]; + if (rowTiles && rowColors) { + const parts = rowTiles.map((letter, i) => { + const color = rowColors[i]; + const state = + color === 'correct' + ? 'correct' + : color === 'semicorrect' + ? 'present' + : 'absent'; + return `${letter} ${state}`; + }); + srAnnouncement.value = `Row ${revealingRow + 1}: ${parts.join(', ')}`; + } + // Compare normalized forms for win detection const normalizedGuess = normalizeWord(canonicalWord, lang.normalizeMap); const normalizedTarget = normalizeWord(lang.todaysWord, lang.normalizeMap); @@ -542,12 +566,15 @@ export const useGameStore = defineStore('game', () => { haptic.error(); } shakeRow(activeRow.value); - showNotification('Word is not valid'); + showNotification( + lang.config?.text?.['notification-word-not-valid'] || 'Word is not valid', + ); // Track invalid word and update session frustration state analytics.trackInvalidWordAndUpdateState({ language: lang.languageCode, attempt_number: activeRow.value + 1, + word: typedWord, }); analytics.trackGuessSubmit(lang.languageCode, activeRow.value + 1, false); } @@ -573,21 +600,15 @@ export const useGameStore = defineStore('game', () => { // ---- Visual sync ---- - /** Sync data layer to visual layer, handling RTL reversal. */ + /** Sync data layer to visual layer. RTL is handled by CSS direction on the grid. */ function showTiles(): void { - const lang = useLanguageStore(); for (let i = 0; i < tiles.value.length; i++) { const tilesRow = tiles.value[i]; const classesRow = tileClasses.value[i]; if (!tilesRow || !classesRow) continue; - if (lang.rightToLeft) { - tilesVisual.value.splice(i, 1, [...tilesRow].reverse()); - tileClassesVisual.value.splice(i, 1, [...classesRow].reverse()); - } else { - tilesVisual.value.splice(i, 1, [...tilesRow]); - tileClassesVisual.value.splice(i, 1, [...classesRow]); - } + tilesVisual.value.splice(i, 1, [...tilesRow]); + tileClassesVisual.value.splice(i, 1, [...classesRow]); } } @@ -605,14 +626,14 @@ export const useGameStore = defineStore('game', () => { const boardEl = _getBoardEl?.() ?? null; return new Promise((resolve) => { - animateRevealRow(boardEl, rowIndex, lang.rightToLeft, { - onMidpoint(visualIdx, dataIdx) { - const finalClass = tileClasses.value[rowIndex]?.[dataIdx] || ''; + animateRevealRow(boardEl, rowIndex, { + onMidpoint(visualIdx) { + const finalClass = tileClasses.value[rowIndex]?.[visualIdx] || ''; tileClassesVisual.value[rowIndex]?.splice(visualIdx, 1, finalClass); - const tileChar = tiles.value[rowIndex]?.[dataIdx] || ''; + const tileChar = tiles.value[rowIndex]?.[visualIdx] || ''; tilesVisual.value[rowIndex]?.splice(visualIdx, 1, tileChar); - const keyUpdate = pendingKeyUpdates.value[dataIdx]; + const keyUpdate = pendingKeyUpdates.value[visualIdx]; if (keyUpdate) { updateKeyColor(keyUpdate.char, keyUpdate.state, keys); } @@ -644,10 +665,9 @@ export const useGameStore = defineStore('game', () => { const STAGGER = 150; const DURATION = 1000; const tileCount = WORD_LENGTH; - const lang = useLanguageStore(); for (let t = 0; t < tileCount; t++) { - const visualIdx = lang.rightToLeft ? tileCount - 1 - t : t; + const visualIdx = t; setTimeout(() => { const currentClass = tileClassesVisual.value[rowIndex]?.[visualIdx] || ''; tileClassesVisual.value[rowIndex]?.splice( @@ -718,6 +738,7 @@ export const useGameStore = defineStore('game', () => { time_to_complete_seconds: timeToComplete, }); analytics.trackStreakMilestone(lang.languageCode, statsStore.stats.current_streak); + analytics.updateUserProperties(statsStore.gameResults); // Show embed banner after game completion if (import.meta.client) { @@ -775,6 +796,7 @@ export const useGameStore = defineStore('game', () => { had_frustration: lossFrustrationState.hadFrustration, time_to_complete_seconds: lossTimeToComplete, }); + analytics.updateUserProperties(statsStore.gameResults); // Show embed banner after game completion if (import.meta.client) { @@ -885,9 +907,40 @@ export const useGameStore = defineStore('game', () => { } } + /** Reset all game state to defaults. Called before loading a new language's game. */ + function resetGameState(): void { + tiles.value = makeEmptyGrid(MAX_GUESSES, WORD_LENGTH, ''); + tileColors.value = makeEmptyGrid(MAX_GUESSES, WORD_LENGTH, 'empty'); + tileClasses.value = makeEmptyGrid(MAX_GUESSES, WORD_LENGTH, DEFAULT_TILE_CLASS); + tilesVisual.value = makeEmptyGrid(MAX_GUESSES, WORD_LENGTH, ''); + tileClassesVisual.value = makeEmptyGrid(MAX_GUESSES, WORD_LENGTH, DEFAULT_TILE_CLASS); + activeRow.value = 0; + activeCell.value = 0; + fullWordInputted.value = false; + gameOver.value = false; + gameWon.value = false; + attempts.value = '0'; + keyClasses.value = {}; + pendingKeyUpdates.value = []; + emojiBoard.value = ''; + communityPercentile.value = null; + communityIsTopScore.value = false; + communityTotal.value = 0; + communityStatsLink.value = null; + shareButtonState.value = 'idle'; + srAnnouncement.value = ''; + todayDefinition.value = null; + todayImageUrl.value = null; + todayImageLoading.value = false; + todayDefinitionLoading.value = false; + maxDifficultyUsed.value = 0; + notification.value = makeEmptyNotification(); + } + /** Restore game state from localStorage. */ function loadFromLocalStorage(): void { if (!import.meta.client) return; + resetGameState(); try { const lang = useLanguageStore(); const pageName = window.location.pathname.split('/').pop() || 'home'; @@ -1035,14 +1088,7 @@ export const useGameStore = defineStore('game', () => { const dayIdx = lang.todaysIdx; if (!langCode || isNaN(dayIdx)) return; - // Get or create client ID (same logic as useAnalytics.getOrCreateClientId) - let clientId = 'unknown'; - try { - clientId = localStorage.getItem('client_id') || crypto.randomUUID(); - localStorage.setItem('client_id', clientId); - } catch { - // localStorage unavailable - } + const clientId = getOrCreateId('client_id'); try { $fetch(`/api/${langCode}/word-stats`, { @@ -1081,10 +1127,10 @@ export const useGameStore = defineStore('game', () => { const { fetchDefinition } = useDefinitions(); fetchDefinition(word, langCode) .then((def) => { - if (def.definition) { + if (def.definition || def.definitionNative) { todayDefinition.value = { word: def.word, - definition: def.definition, + definition: def.definitionNative || def.definition, partOfSpeech: def.partOfSpeech, url: `/${langCode}/word/${dayIdx}`, }; @@ -1263,6 +1309,7 @@ export const useGameStore = defineStore('game', () => { communityTotal, communityStatsLink, shareButtonState, + srAnnouncement, todayDefinition, todayImageUrl, todayImageLoading, @@ -1292,6 +1339,7 @@ export const useGameStore = defineStore('game', () => { showNotification, getEmojiBoard, getShareText, + resetGameState, saveToLocalStorage, loadFromLocalStorage, getTimeUntilNextDay, diff --git a/utils/game/useGameAnimations.ts b/utils/game/useGameAnimations.ts index dafd0caf..8b0c1569 100644 --- a/utils/game/useGameAnimations.ts +++ b/utils/game/useGameAnimations.ts @@ -26,7 +26,7 @@ const BOUNCE_DURATION = 1000; export interface RevealCallbacks { /** Called at midpoint for each tile — swap visual color and character. */ - onMidpoint: (visualIdx: number, dataIdx: number) => void; + onMidpoint: (tileIdx: number) => void; /** Called when all tiles have finished animating. */ onComplete: () => void; } @@ -34,41 +34,50 @@ export interface RevealCallbacks { /** * Staggered flip animation for a completed row. * + * RTL is handled by CSS `direction: rtl` on the tile grid, so the animation + * always iterates in DOM order (0→4). CSS flips the visual direction. + * * @param boardEl - The `.game-board` DOM element (from a template ref) * @param rowIndex - Which row to animate (0-based) - * @param rightToLeft - Whether the language reads RTL * @param callbacks - Midpoint and completion callbacks */ export function animateRevealRow( boardEl: HTMLElement | null, rowIndex: number, - rightToLeft: boolean, callbacks: RevealCallbacks ): void { const rowEl = boardEl?.children[rowIndex] as HTMLElement | undefined; const tileCount = WORD_LENGTH; + const reduceMotion = + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; for (let t = 0; t < tileCount; t++) { - const visualIdx = rightToLeft ? tileCount - 1 - t : t; - const dataIdx = rightToLeft ? tileCount - 1 - visualIdx : visualIdx; + const delay = reduceMotion ? 0 : t * FLIP_STAGGER; setTimeout(() => { - const tileEl = rowEl?.children[visualIdx] as HTMLElement | undefined; - if (tileEl) { + const tileEl = rowEl?.children[t] as HTMLElement | undefined; + if (tileEl && !reduceMotion) { tileEl.style.animation = `flipReveal ${FLIP_DURATION}ms ease-in-out`; } - // At midpoint: callback to swap visual state - setTimeout(() => { - callbacks.onMidpoint(visualIdx, dataIdx); - }, FLIP_MIDPOINT); + // At midpoint (or immediately if reduced motion): swap visual state + setTimeout( + () => { + callbacks.onMidpoint(t); + }, + reduceMotion ? 0 : FLIP_MIDPOINT + ); // Clean up after animation - setTimeout(() => { - if (tileEl) tileEl.style.animation = ''; - if (t === tileCount - 1) callbacks.onComplete(); - }, FLIP_DURATION); - }, t * FLIP_STAGGER); + setTimeout( + () => { + if (tileEl) tileEl.style.animation = ''; + if (t === tileCount - 1) callbacks.onComplete(); + }, + reduceMotion ? 0 : FLIP_DURATION + ); + }, delay); } } diff --git a/utils/storage.ts b/utils/storage.ts index 424a6102..8978ccba 100644 --- a/utils/storage.ts +++ b/utils/storage.ts @@ -109,3 +109,16 @@ export function isDismissedWithCooldown(key: string, durationMs: number): boolea export function dismissWithCooldown(key: string): void { writeLocal(key, Date.now().toString()); } + +// --------------------------------------------------------------------------- +// Platform detection +// --------------------------------------------------------------------------- + +/** Check if running as installed PWA (standalone mode). SSR-safe. */ +export function isStandalone(): boolean { + if (!import.meta.client) return false; + return ( + window.matchMedia('(display-mode: standalone)').matches || + (navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} From c4cc3606d1cf4ea9daa764cd1b859e661a777676 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Thu, 19 Mar 2026 00:12:00 +0000 Subject: [PATCH 3/8] style: format with prettier --- .mcp.json | 8 + components/game/GameKeyboard.vue | 6 +- components/game/KeyboardKey.vue | 6 +- components/game/Tile.vue | 15 +- composables/useAnalytics.ts | 1 - data/default_language_config.json | 20 +- data/languages/ar/language_config.json | 12 +- data/languages/az/language_config.json | 12 +- data/languages/bg/language_config.json | 12 +- data/languages/bn/language_config.json | 6 +- data/languages/br/language_config.json | 6 +- data/languages/ca/language_config.json | 12 +- data/languages/ckb/language_config.json | 6 +- data/languages/cs/language_config.json | 6 +- data/languages/da/language_config.json | 12 +- data/languages/de/language_config.json | 12 +- data/languages/el/language_config.json | 6 +- data/languages/en/language_config.json | 12 +- data/languages/eo/language_config.json | 6 +- data/languages/es/language_config.json | 12 +- data/languages/et/language_config.json | 12 +- data/languages/eu/language_config.json | 6 +- data/languages/fa/language_config.json | 6 +- data/languages/fi/language_config.json | 12 +- data/languages/fo/language_config.json | 6 +- data/languages/fr/language_config.json | 12 +- data/languages/fur/language_config.json | 6 +- data/languages/fy/language_config.json | 6 +- data/languages/ga/language_config.json | 6 +- data/languages/gd/language_config.json | 6 +- data/languages/gl/language_config.json | 12 +- data/languages/ha/language_config.json | 6 +- data/languages/he/language_config.json | 12 +- data/languages/hi/language_config.json | 8 +- data/languages/hr/language_config.json | 12 +- data/languages/hu/language_config.json | 12 +- data/languages/hy/language_config.json | 4 +- data/languages/hyw/language_config.json | 4 +- data/languages/ia/language_config.json | 6 +- data/languages/id/language_config.json | 18 +- data/languages/ie/language_config.json | 6 +- data/languages/is/language_config.json | 6 +- data/languages/it/language_config.json | 12 +- data/languages/ja/language_config.json | 20 +- data/languages/ka/language_config.json | 6 +- data/languages/ko/language_config.json | 260 ++++++++++++++++++------ data/languages/la/language_config.json | 12 +- data/languages/lb/language_config.json | 6 +- data/languages/lt/language_config.json | 6 +- data/languages/ltg/language_config.json | 6 +- data/languages/lv/language_config.json | 12 +- data/languages/mi/language_config.json | 8 +- data/languages/mk/language_config.json | 12 +- data/languages/mn/language_config.json | 6 +- data/languages/mr/language_config.json | 6 +- data/languages/ms/language_config.json | 12 +- data/languages/nb/language_config.json | 6 +- data/languages/nds/language_config.json | 6 +- data/languages/ne/language_config.json | 6 +- data/languages/nl/language_config.json | 6 +- data/languages/nn/language_config.json | 6 +- data/languages/oc/language_config.json | 6 +- data/languages/pa/language_config.json | 6 +- data/languages/pau/language_config.json | 6 +- data/languages/pl/language_config.json | 12 +- data/languages/pt/language_config.json | 12 +- data/languages/qya/language_config.json | 8 +- data/languages/ro/language_config.json | 12 +- data/languages/ru/language_config.json | 12 +- data/languages/rw/language_config.json | 6 +- data/languages/sk/language_config.json | 12 +- data/languages/sl/language_config.json | 6 +- data/languages/sq/language_config.json | 6 +- data/languages/sr/language_config.json | 12 +- data/languages/sv/language_config.json | 12 +- data/languages/sw/language_config.json | 12 +- data/languages/tk/language_config.json | 6 +- data/languages/tl/language_config.json | 8 +- data/languages/tlh/language_config.json | 6 +- data/languages/tr/language_config.json | 12 +- data/languages/uk/language_config.json | 12 +- data/languages/ur/language_config.json | 8 +- data/languages/uz/language_config.json | 6 +- data/languages/vi/language_config.json | 100 +++++++-- data/languages/yo/language_config.json | 6 +- pages/accessibility.vue | 12 +- scripts/cleanup_language_configs.py | 100 +++++++++ stores/game.ts | 16 +- 88 files changed, 663 insertions(+), 555 deletions(-) create mode 100644 .mcp.json create mode 100644 scripts/cleanup_language_configs.py diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..041989a7 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "posthog": { + "type": "http", + "url": "https://mcp-eu.posthog.com/mcp" + } + } +} diff --git a/components/game/GameKeyboard.vue b/components/game/GameKeyboard.vue index af55d06c..79bcb782 100644 --- a/components/game/GameKeyboard.vue +++ b/components/game/GameKeyboard.vue @@ -1,5 +1,9 @@