diff --git a/.gitignore b/.gitignore index f9c3a357..fe4fd06e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ scripts/.freq_data # Curation review temp files scripts/.curation_review/ +.mcp.json diff --git a/README.md b/README.md index 379c170e..303cec0a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # 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) ![Wordle Global](public/images/og-image.png) -**[Play now at wordle.global](https://wordle.global/)** — the daily word guessing game in 78 languages. Open source, community-driven, no ads. +**[Play now at wordle.global](https://wordle.global/)** — the daily word guessing game in 79 languages. Open source, community-driven, no ads. ## Languages 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..aaa786b3 100644 --- a/components/game/KeyboardKey.vue +++ b/components/game/KeyboardKey.vue @@ -1,24 +1,352 @@ + + 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..b6a08a61 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 } @@ -93,9 +96,10 @@ const shareResult = route.query.r as string | undefined; const validResults = ['1', '2', '3', '4', '5', '6', 'x']; const isShareLink = shareResult !== undefined && validResults.includes(shareResult); -// Dynamic share image: use result-specific image for share links, default to _1 otherwise -const shareImageSuffix = isShareLink ? shareResult : '1'; -const shareImageUrl = `https://wordle.global/images/share/${lang}_${shareImageSuffix}.png`; +// Dynamic share image: result-specific for share links, generic OG image otherwise +const shareImageUrl = isShareLink + ? `https://wordle.global/images/share/${lang}_${shareResult}.png` + : 'https://wordle.global/images/og-image.png'; // Override title/description for share links (matches Flask behavior) const configText = gameData.value.config.text || {}; @@ -241,8 +245,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..62658cd6 --- /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/scripts/cleanup_language_configs.py b/scripts/cleanup_language_configs.py new file mode 100644 index 00000000..c65a7e75 --- /dev/null +++ b/scripts/cleanup_language_configs.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Clean up language_config.json files across all languages. + +1. Remove dead keys (replaced by newer keys during Nuxt migration) +2. Rename kebab-case keys to snake_case for consistency +""" + +import json +from pathlib import Path + +LANGUAGES_DIR = Path(__file__).parent.parent / "data" / "languages" + +# Keys that are no longer referenced in any Vue template or TypeScript code +DEAD_KEYS = { + "ui": { + "easy_mode", + "easy_mode_label", + "haptic_feedback", + "haptic_feedback_desc", + "show_definitions", + "show_definitions_desc", + "sound_effects", + "sound_effects_desc", + "word_art", + "word_art_desc", + }, +} + +# Kebab-case → snake_case renames +RENAMES = { + "text": { + "notification-copied": "notification_copied", + "notification-word-not-valid": "notification_word_not_valid", + "notification-partial-word": "notification_partial_word", + }, +} + + +def cleanup_config(config: dict) -> tuple[dict, int, int]: + """Clean a single config dict. Returns (cleaned_config, keys_removed, keys_renamed).""" + removed = 0 + renamed = 0 + + for section, dead_keys in DEAD_KEYS.items(): + if section in config: + for key in dead_keys: + if key in config[section]: + del config[section][key] + removed += 1 + + for section, rename_map in RENAMES.items(): + if section in config: + for old_key, new_key in rename_map.items(): + if old_key in config[section]: + config[section][new_key] = config[section].pop(old_key) + renamed += 1 + + return config, removed, renamed + + +def main(): + # Clean default config + default_path = LANGUAGES_DIR.parent / "default_language_config.json" + with open(default_path, encoding="utf-8") as f: + default_config = json.load(f) + + default_config, d_removed, d_renamed = cleanup_config(default_config) + with open(default_path, "w", encoding="utf-8") as f: + json.dump(default_config, f, ensure_ascii=False, indent=4) + f.write("\n") + print(f"default: removed {d_removed}, renamed {d_renamed}") + + # Clean all language configs + total_removed = d_removed + total_renamed = d_renamed + + for lang_dir in sorted(LANGUAGES_DIR.iterdir()): + config_path = lang_dir / "language_config.json" + if not config_path.exists(): + continue + + with open(config_path, encoding="utf-8") as f: + config = json.load(f) + + config, removed, renamed = cleanup_config(config) + + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=4) + f.write("\n") + + if removed or renamed: + print(f" {lang_dir.name}: removed {removed}, renamed {renamed}") + total_removed += removed + total_renamed += renamed + + print(f"\nTotal: {total_removed} keys removed, {total_renamed} keys renamed") + + +if __name__ == "__main__": + main() 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..3156fa06 --- /dev/null +++ b/server/routes/t/[...path].ts @@ -0,0 +1,24 @@ +/** + * 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 || ''; + + const hostname = path.startsWith('static/') ? 'eu-assets.i.posthog.com' : 'eu.i.posthog.com'; + const targetUrl = `https://${hostname}/${path}${search}`; + + // Use server-observed IP only (ignore client x-forwarded-for to prevent spoofing) + const clientIp = getRequestIP(event, { xForwardedFor: true }); + + 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..71fcff29 100644 --- a/server/utils/definitions.ts +++ b/server/utils/definitions.ts @@ -59,6 +59,7 @@ function lookupKaikki( part_of_speech: null, source, url: wiktionaryUrl(word, langCode), + ts: Math.floor(Date.now() / 1000), }; } return null; @@ -138,10 +139,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 +285,17 @@ 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 + // But only retry once per 24h to avoid hammering LLM + if (loaded.source === 'kaikki-en' && !loaded.definition_native) { + const cachedTs = loaded.ts || 0; + if (Date.now() / 1000 - cachedTs < NEGATIVE_CACHE_TTL) { + return loaded; // Too soon to retry, serve cached English + } + // Expired — fall through to LLM + } else { + return loaded; + } } } catch { // Fall through diff --git a/stores/game.ts b/stores/game.ts index 13bc08ef..762f1686 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'; @@ -987,7 +1040,12 @@ export const useGameStore = defineStore('game', () => { if (color === 'correct') { if (!charsMatch(guess[c] || '', letter, nMap)) { - return `Hard mode: ${letter.toUpperCase()} must be in position ${c + 1}`; + const tmpl = + lang.config?.text?.hard_mode_position || + 'Hard mode: {letter} must be in position {position}'; + return tmpl + .replace('{letter}', letter.toUpperCase()) + .replace('{position}', String(c + 1)); } } else if (color === 'semicorrect') { const normalizedLetter = normalizeChar(letter, nMap).toLowerCase(); @@ -995,7 +1053,10 @@ export const useGameStore = defineStore('game', () => { (g) => normalizeChar(g, nMap).toLowerCase() === normalizedLetter ); if (!guessHasLetter) { - return `Hard mode: guess must contain ${letter.toUpperCase()}`; + const tmpl2 = + lang.config?.text?.hard_mode_contains || + 'Hard mode: guess must contain {letter}'; + return tmpl2.replace('{letter}', letter.toUpperCase()); } } } @@ -1035,14 +1096,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 +1135,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 +1317,7 @@ export const useGameStore = defineStore('game', () => { communityTotal, communityStatsLink, shareButtonState, + srAnnouncement, todayDefinition, todayImageUrl, todayImageLoading, @@ -1292,6 +1347,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..036325c7 100644 --- a/utils/storage.ts +++ b/utils/storage.ts @@ -82,16 +82,11 @@ export function writeJson(key: string, value: unknown): void { */ export function getOrCreateId(key: string): string { if (!import.meta.client) return 'unknown'; - try { - let id = localStorage.getItem(key); - if (!id) { - id = crypto.randomUUID(); - localStorage.setItem(key, id); - } - return id; - } catch { - return 'unknown'; - } + const existing = readLocal(key); + if (existing) return existing; + const id = crypto.randomUUID(); + writeLocal(key, id); + return id; } /** @@ -109,3 +104,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 + ); +}