diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index a2cec532..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"f2210cba-a155-40b7-87ba-f76b37114205","pid":12996,"acquiredAt":1775866616902} \ No newline at end of file diff --git a/.claude/worktrees/semantic-poc b/.claude/worktrees/semantic-poc deleted file mode 160000 index f1236cc0..00000000 --- a/.claude/worktrees/semantic-poc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f1236cc0216d1ee66c60497ec8e021ba2c100884 diff --git a/.gitignore b/.gitignore index 4eb02ade..4b359265 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Working session docs (internal notes) docs/ +# Design exploration HTML mockups (local only) +public/design-explorations/ + # Node.js / Frontend node_modules/ .pnpm-store/ @@ -55,3 +58,4 @@ scripts/.fonts/ # OG mode images: regenerate with `uv run python scripts/generate_mode_og_images.py --all` # Committed to git (~5MB) since Render build env lacks Python/uv. +.claude diff --git a/TODO.md b/TODO.md index ae84d4e0..82d395cf 100644 --- a/TODO.md +++ b/TODO.md @@ -448,3 +448,68 @@ const stats = computed(() => { }); ``` Then `calculateStats(key, max)` becomes `setStatsKey(key, max)` — just sets the refs, Vue handles the rest. Eliminates the entire class of "forgot to recalculate" bugs. Touches: `stores/stats.ts`, `composables/useGamePage.ts`, `pages/profile.vue`. + +### 16. Semantic Explorer: viewport-locked layout (like other game modes) +The semantic page uses a scrollable layout (`overflow-y: auto` on `.semantic-body`) while every other game mode uses `h-[100dvh]` viewport-locked layout via `PageShell`. This causes: +- Double scrollbar on short desktops (page scroll + browser scroll) +- Map SVG overflows its `max-height` container because it renders at intrinsic 520px +- `min-height: 280px` fights `max-height: calc(100dvh - 310px)` on short viewports +- Input gets pushed below the fold + +**Proper fix:** Refactor `semantic.vue` to use viewport-locked layout like `PageShell`: +- Left column (map + input): flex column, map grows to fill, input pinned to bottom +- Right column (compass + hint + leaderboard): flex column with overflow scroll +- No page-level scroll — everything fits in viewport +- Expand button goes truly fullscreen (overlay), not just "fill the column" + +Current band-aid: `min-height: min(200px, calc(100dvh - 310px))` prevents `min-height` from exceeding viewport, but the SVG still overflows on short desktops. `:deep()` CSS hacks were tried and reverted because they broke the expanded map aspect ratio. + +--- + +## DB Migration — Remove Disk Fallback Paths + +**Added**: 2026-04-12 +**Status**: Monitoring — disk fallback paths emit console.warn when hit + +Data has been migrated from Render's persistent disk to Postgres: +- 253K definitions (77K kaikki native + 98K kaikki-en + 77K LLM, source/model provenance tracked) +- 2.8K word stats +- 50K embeddings with UMAP/PCA2D coordinates, 70 axes, 4.4M neighbor ranks +- `model` column added to definitions table (gpt-5.2, wiktionary-kaikki-2024, legacy-unknown) + +### Phase 1: Remove disk fallback code (after 2 weeks stable, ~2026-04-26) + +- [ ] `server/utils/definitions.ts` — remove Tier 1 disk read, disk write, kaikki in-memory cache (`_kaikkiCache`, `loadKaikkiFile`, `lookupKaikki`, `resolveDefinitionsDir`, `DEFINITIONS_DIR`). Kaikki data is now in the `definitions` table with source='kaikki'/'kaikki-en'. +- [ ] `server/utils/word-stats.ts` — remove disk read/write fallback + `proper-lockfile` dependency +- [ ] `server/utils/wiktionary.ts` — remove `readCache`/`writeCache` disk functions +- [ ] `server/api/[lang]/semantic/hint.post.ts` — remove disk read/write for hints +- [ ] `server/utils/data-loader.ts` — remove `WORD_DEFS_DIR`, `WORD_STATS_DIR` exports +- [ ] Remove `proper-lockfile` from package.json +- [ ] Remove fs imports (`existsSync`, `readFileSync`, `writeFileSync`, `mkdirSync`) from all above files + +### Phase 2: Migrate remaining disk-dependent features + +- [ ] **Word history** → new DB table `(lang, day_idx, word)`. ~136K rows (80 langs × 1700 days). Eliminates 546MB of `.txt` files on Render disk and disk reads in `word-selection.ts`. Algorithm is deterministic but cache is a safety net against word list changes. +- [ ] **Word images** → decide: keep on Render persistent disk ($0.40/month for 1.5GB), or move to Cloudflare R2 (free egress, ~$0.003/month for 204MB). Only feature still requiring the persistent disk. No urgency — current setup works. +- [ ] **`semantic.ts` legacy in-memory loader** — `start.post.ts` and `word/[slug].get.ts` still import `loadSemanticData` which loads the 98MB embedding matrix. Migrate these two endpoints to use `_semantic-db.ts` (DB-backed), then delete `loadSemanticData`/`loadSemanticDataSafe`/`loadEmbeddings` and the entire in-memory path. + +### Phase 3: Remove committed heavy files from git + +- [ ] `data/semantic/embeddings.f32` + `embeddings.meta.json` (~99MB) — in pgvector +- [ ] `data/semantic/embeddings.json` (~230MB if present) — in pgvector +- [ ] `data/semantic/axes.json` — in `semantic_axes` table +- [ ] `data/semantic/umap.json`, `pca2d.json` — in `word_embeddings` columns +- [ ] `data/semantic/targets.json`, `vocabulary.json` — queryable from `word_embeddings` +- [ ] Keep `data/semantic/valid_words.json` (loaded into memory for spellcheck, no DB table) +- [ ] Keep `data/definitions/` as archive (kaikki data now in DB, but files are small and useful for re-seeding) + +### 17. Semantic Explorer OG image +Design and add `public/images/og-semantic.png` (1200x630) showing the meaning map with dots, compass needle, and the editorial aesthetic. Currently falls back to generic `og-image.png`. + +### 18. Semantic best starting words +Add semantic-specific content to the `/en/best-starting-words` page — tips for first guesses in semantic mode (broad category words, high-information starters). Could be a separate section or tab. + +### 19. useGameSeo refactor +- Silent 60-char truncation: configured titles are dropped without warning when too long. Should either warn at build time or use the configured title regardless. +- Hardcoded `| Wordle English` suffix: not all modes benefit from Wordle brand. Add configurable suffix per mode. +- No length validation at config time — easy to write titles/descriptions that get silently truncated. diff --git a/assets/css/design-system.css b/assets/css/design-system.css index 2ff1e696..db2cf680 100644 --- a/assets/css/design-system.css +++ b/assets/css/design-system.css @@ -230,6 +230,25 @@ border-color: var(--color-ink); } +/* Thin editorial scrollbar — used on modals, leaderboards, and any scrollable panel */ +.editorial-scroll { + scrollbar-width: thin; + scrollbar-color: var(--color-rule) transparent; +} +.editorial-scroll::-webkit-scrollbar { + width: 4px; +} +.editorial-scroll::-webkit-scrollbar-track { + background: transparent; +} +.editorial-scroll::-webkit-scrollbar-thumb { + background: var(--color-rule); + border-radius: 2px; +} +.editorial-scroll::-webkit-scrollbar-thumb:hover { + background: var(--color-muted); +} + /* Flag icon — consistent circular flag display */ .flag-icon { width: 24px; diff --git a/components/LbAvatar.vue b/components/LbAvatar.vue new file mode 100644 index 00000000..bc941818 --- /dev/null +++ b/components/LbAvatar.vue @@ -0,0 +1,41 @@ + + + diff --git a/components/LbList.vue b/components/LbList.vue new file mode 100644 index 00000000..4adb0c0f --- /dev/null +++ b/components/LbList.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/components/LbPodium.vue b/components/LbPodium.vue new file mode 100644 index 00000000..991d2bf3 --- /dev/null +++ b/components/LbPodium.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/components/app/AppHeader.vue b/components/app/AppHeader.vue index 1134dbd6..d23a3db4 100644 --- a/components/app/AppHeader.vue +++ b/components/app/AppHeader.vue @@ -4,9 +4,9 @@ :style="{ viewTransitionName: logoMode ? 'landing-header' : 'header' }" > -
+
-
Guest
-
Sync stats, earn badges
+
+ {{ ui?.guest || 'Guest' }} +
+
+ {{ ui?.sync_stats_earn_badges || 'Sync stats, earn badges' }} +
@@ -347,6 +363,7 @@ import { import { useFlag } from '~/composables/useFlag'; import { GAME_MODES_UI, getModeLabel } from '~/composables/useGameModes'; import { GAME_MODE_CONFIG } from '~/utils/game-modes'; +import { NEW_MODES_START_IDX } from '~/utils/day-index'; import SidebarItem from './SidebarItem.vue'; import { useAutoHeight } from '~/composables/useAutoHeight'; @@ -424,7 +441,18 @@ const langStore = useLanguageStore(); // Expand/collapse state for sidebar sections const expandedSection = ref<'classic' | 'speed' | 'multiboard' | 'semantic' | null>(null); -const dayIdx = computed(() => langStore.todaysIdx ?? ''); +const classicDayIdx = computed(() => langStore.todaysIdx ?? 0); + +const subPanelDayLabel = computed(() => { + const idx = classicDayIdx.value; + if (!idx) return ''; + const mode = subPanelMode.value; + if (mode && mode !== 'classic') { + const modeIdx = idx - NEW_MODES_START_IDX + 1; + return modeIdx >= 1 ? `#${modeIdx}` : ''; + } + return `#${idx}`; +}); // Sub-panel state: which mode's daily/unlimited panel is showing const subPanelMode = ref(null); @@ -503,7 +531,7 @@ const multiboardModes = computed( return { id, label: getModeLabel(mode, ui.value), - boards: `${def.boardCount} boards`, + boards: `${def.boardCount} ${ui.value?.boards || 'boards'}`, icon: mode.icon, }; }).filter(Boolean) as Array<{ id: string; label: string; boards: string; icon: any }> diff --git a/components/app/BoardPickerModal.vue b/components/app/BoardPickerModal.vue index 18dd6ff3..8558f344 100644 --- a/components/app/BoardPickerModal.vue +++ b/components/app/BoardPickerModal.vue @@ -12,12 +12,19 @@ align="top" no-padding no-close-button - aria-label="Choose a multi-board mode" + :aria-label="props.ui?.multi_board || 'Choose a multi-board mode'" @close="$emit('close')" >
-

Multi-Board

-

Same rules, more boards. Pick your challenge.

+

+ {{ props.ui?.multi_board || 'Multi-Board' }} +

+

+ {{ + props.ui?.board_picker_subtitle || + 'Same rules, more boards. Pick your challenge.' + }} +

@@ -37,7 +44,8 @@
{{ mode.label }}
- {{ mode.boards }} boards · {{ mode.maxGuesses }} guesses + {{ mode.boards }} {{ props.ui?.boards || 'boards' }} · + {{ mode.maxGuesses }} {{ props.ui?.guesses || 'guesses' }}
@@ -48,7 +56,7 @@ class="text-btn text-xs" @click="$emit('close')" > - Daily + {{ props.ui?.mode_daily_label || 'Daily' }} · - Unlimited + {{ props.ui?.unlimited_mode || 'Unlimited' }}
diff --git a/components/app/GameModePicker.vue b/components/app/GameModePicker.vue index e6cf080c..e60e9b29 100644 --- a/components/app/GameModePicker.vue +++ b/components/app/GameModePicker.vue @@ -4,14 +4,19 @@ size="lg" align="top" no-padding - :aria-label="`Choose a game mode for ${languageName}`" + :aria-label="ui?.choose_game_mode || 'Choose a Game Mode'" @close="$emit('close')" >
-

Choose a Game Mode

+

+ {{ ui?.choose_game_mode || 'Choose a Game Mode' }} +

- Different ways to play — same language, new challenges. + {{ + ui?.game_mode_subtitle || + 'Different ways to play — same language, new challenges.' + }}

@@ -101,6 +106,7 @@ const flagFailed = ref(false); const flagSrc = computed(() => (flagFailed.value ? null : useFlag(props.langCode))); const langStore = useLanguageStore(); +const ui = computed(() => langStore.config?.ui); const modes = computed(() => { const ui = langStore.config?.ui; return GAME_MODES_UI.map((mode) => ({ diff --git a/components/app/LanguagePickerModal.vue b/components/app/LanguagePickerModal.vue index bc2679e6..c01de308 100644 --- a/components/app/LanguagePickerModal.vue +++ b/components/app/LanguagePickerModal.vue @@ -12,17 +12,19 @@ size="lg" align="top" no-padding - aria-label="Choose a language" + :aria-label="ui?.choose_language || 'Choose a language'" @close="$emit('close')" >
-

Choose Language

+

+ {{ ui?.choose_language || 'Choose Language' }} +

@@ -47,7 +49,7 @@ >
- No languages match "{{ searchQuery }}" + {{ ui?.no_languages_match || 'No languages match' }} "{{ searchQuery }}"
@@ -63,11 +65,16 @@ const props = withDefaults( currentLangCode: string; /** Current mode route suffix (e.g., 'dordle', 'semantic', ''). Used to try same mode in new language. */ currentModeSuffix?: string; + /** If true, emits 'select' with the language code instead of navigating. */ + selectOnly?: boolean; }>(), - { currentModeSuffix: '' } + { currentModeSuffix: '', selectOnly: false } ); -const emit = defineEmits<{ close: [] }>(); +const emit = defineEmits<{ close: []; select: [code: string] }>(); + +const langStore = useLanguageStore(); +const ui = computed(() => langStore.config?.ui); const searchRef = ref(null); const searchQuery = ref(''); @@ -113,10 +120,12 @@ const filteredLanguages = computed(() => { }); function selectLanguage(code: string) { + if (props.selectOnly) { + emit('select', code); + emit('close'); + return; + } emit('close'); - // Try to stay in the same game mode in the new language. - // Modes that are language-restricted (e.g., semantic = English-only) - // have server-side redirects that will send the user to the right place. if (props.currentModeSuffix) { navigateTo(`/${code}/${props.currentModeSuffix}`); } else { diff --git a/components/app/SidebarItem.vue b/components/app/SidebarItem.vue index 1ff2ed70..821a0421 100644 --- a/components/app/SidebarItem.vue +++ b/components/app/SidebarItem.vue @@ -62,6 +62,7 @@ import { Settings, PenLine, Users, + Trophy, } from 'lucide-vue-next'; const props = withDefaults( @@ -99,6 +100,7 @@ const iconMap: Record = { Settings, PenLine, Users, + Trophy, }; // Accept either a string (looked up in iconMap) or a component directly diff --git a/components/game/BestStartingWordsPanel.vue b/components/game/BestStartingWordsPanel.vue index 274e33d8..8cf6211f 100644 --- a/components/game/BestStartingWordsPanel.vue +++ b/components/game/BestStartingWordsPanel.vue @@ -47,7 +47,7 @@ const copy = computed(() => { heading: interpolate(m.panel_heading ?? '', vars), subtitle: interpolate(m.panel_subtitle ?? '', vars), linkText: m.panel_link ?? '', - coverageLabel: d.ui?.coverage_label || 'Coverage', + coverageLabel: d.ui?.coverage_label, }; }); diff --git a/components/game/CopyFallbackModal.vue b/components/game/CopyFallbackModal.vue index da214ead..d476b15b 100644 --- a/components/game/CopyFallbackModal.vue +++ b/components/game/CopyFallbackModal.vue @@ -1,19 +1,23 @@