Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
bcfa8ef
feat: Postgres cache tables + pgvector schema for semantic embeddings
Hugo0 Apr 12, 2026
eb3127e
feat: DB-backed caches + pgvector semantic operations
Hugo0 Apr 12, 2026
6ce2b74
fix: handle wrapped JSON formats in seed script
Hugo0 Apr 12, 2026
6164adf
feat: update all consumers to use DB-backed caches + semantic-db
Hugo0 Apr 12, 2026
0e28c6d
fix: compass graceful fallback when embeddings not in memory
Hugo0 Apr 12, 2026
4e9708d
fix: add await to loadWordStats callers (sync→async change)
Hugo0 Apr 12, 2026
ee70a74
fix: deduplicate words across game modes and speed streak sessions
Hugo0 Apr 12, 2026
9cd70d7
style: format TS/Vue files with prettier
Hugo0 Apr 12, 2026
3fa6555
refactor: clean up viewport/layout CSS
Hugo0 Apr 12, 2026
5918726
feat: remove legacy in-memory path, DB-only semantic, add tests
Hugo0 Apr 12, 2026
e37f78d
fix: address CodeRabbit review — normalize projections, guard imports…
Hugo0 Apr 12, 2026
62edc53
fix: responsive MeaningMap — no inline sizes, CSS-driven scaling
Hugo0 Apr 12, 2026
82c62de
fix: restore SVG width/height attrs — pan/zoom math needs pixel-accur…
Hugo0 Apr 12, 2026
c6d7a1e
fix: polar mode pan/zoom as rigid viewport transform
Hugo0 Apr 12, 2026
420f2ac
revert: restore original MeaningMap — my CSS/projection changes intro…
Hugo0 Apr 12, 2026
b4b21b6
fix: enable recency window for all game modes
Hugo0 Apr 12, 2026
c219f48
feat: deduplicate concurrent LLM/embedding API calls
Hugo0 Apr 12, 2026
7e4f726
feat: MeaningMap camera transform architecture
Hugo0 Apr 12, 2026
b869872
feat: deduplicate DALL-E image + wiktionary calls, include agent fixes
Hugo0 Apr 12, 2026
0a77478
fix: await DB cache writes + log failures
Hugo0 Apr 12, 2026
573b25a
feat: DB-only semantic + cleanup — eliminate 98MB in-memory embedding…
Hugo0 Apr 12, 2026
0d9c38d
fix: get2dPosition — replace prisma.$raw with static column SELECT
Hugo0 Apr 12, 2026
29cce8b
fix: extend grid to cover max zoom-out range
Hugo0 Apr 12, 2026
f45ee63
refactor: remove dead code — kaikki in-memory cache + 750 lines from …
Hugo0 Apr 12, 2026
0e245bf
refactor: remove all disk fallback paths + untrack semantic data files
Hugo0 Apr 12, 2026
69f4431
feat: expand semantic valid words from 75K to 275K (wordfreq top 300K)
Hugo0 Apr 12, 2026
5b3b473
refactor: /simplify review — deduplicate, fix efficiency, clean inter…
Hugo0 Apr 12, 2026
44a3e48
fix: constrain map canvas to available height on short viewports
Hugo0 Apr 12, 2026
b2177b1
fix: remove inline width/height from canvas-wrap — CSS controls sizing
Hugo0 Apr 12, 2026
488efca
fix: map aspect ratio, input border, expanded overflow
Hugo0 Apr 12, 2026
d47c590
chore: save all in-progress work (DB migration + other agent changes)
Hugo0 Apr 12, 2026
51aa1bd
chore: gitignore .claude/ directory
Hugo0 Apr 12, 2026
2dcc60a
fix: CSS cascade — media query must come after default rule for --map…
Hugo0 Apr 12, 2026
cd6d198
fix: restrict LLM definition generation to game words only
Hugo0 Apr 12, 2026
0ce4a91
fix: remove SVG width/height attributes — viewBox-only for true respo…
Hugo0 Apr 12, 2026
409efc7
fix: re-apply map sizing constraints (lost in other agent's merge)
Hugo0 Apr 12, 2026
524d058
security: fix 6 findings from security audit
Hugo0 Apr 12, 2026
13a7fc0
fix: lock semantic-body to viewport height — eliminates double scrollbar
Hugo0 Apr 12, 2026
755b5df
fix: reject invalid attempt counts in incrementWordStats (CR feedback)
Hugo0 Apr 12, 2026
54e3cac
fix: disable auto-zoom in polar mode — was jarring on each guess
Hugo0 Apr 12, 2026
bd45768
fix: showInstallCta checks hasPrompt/isIOS instead of always truthy
Hugo0 Apr 12, 2026
1b12295
fix: label overlap accounts for camera zoom — labels no longer hidden…
Hugo0 Apr 12, 2026
5713eb0
perf: eliminate 12-request waterfall on word explorer page
Hugo0 Apr 12, 2026
7f88ea9
chore: save all in-progress work — DB migration + other agents
Hugo0 Apr 12, 2026
5936578
fix: cap absolute auto-zoom at 8x (was 30x) — prevents giant labels d…
Hugo0 Apr 12, 2026
5b17007
fix: remove counter-scale from dots — eliminates giant text flash
Hugo0 Apr 12, 2026
ac6b46d
fix: restore auto-zoom + counter-scale with animation-safe approach
Hugo0 Apr 12, 2026
5320a92
fix: don't restore corrupt semantic game state (gameOver without targ…
Hugo0 Apr 12, 2026
f2f0cf4
feat: SEO content for semantic, speed, and multiboard game modes
Hugo0 Apr 12, 2026
946871e
fix: shorten semantic title to fit 60-char limit (was 72, truncated)
Hugo0 Apr 12, 2026
48891ef
seo: semantic title targets 'contexto' keyword for competitive ranking
Hugo0 Apr 12, 2026
df6ab08
docs: add SEO TODOs — OG image, best starting words, useGameSeo refactor
Hugo0 Apr 12, 2026
77387cb
feat: leaderboard improvements, SEO content, semantic UX polish
Hugo0 Apr 12, 2026
288f8f7
style: format with Prettier
Hugo0 Apr 12, 2026
47ac014
feat: semantic 20 guesses, DRY maxGuesses, SEO content, llms.txt
Hugo0 Apr 12, 2026
9007bc7
refactor: extract LbList + LbPodium components from leaderboard page
Hugo0 Apr 12, 2026
f54fe1c
fix: leaderboard minor tweaks
Hugo0 Apr 12, 2026
d4f29c8
style: format leaderboard components
Hugo0 Apr 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .claude/scheduled_tasks.lock

This file was deleted.

1 change: 0 additions & 1 deletion .claude/worktrees/semantic-poc
Submodule semantic-poc deleted from f1236c
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
65 changes: 65 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 19 additions & 0 deletions assets/css/design-system.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions components/LbAvatar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<template>
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="username"
:class="[sizeClass, 'rounded-full object-cover flex-shrink-0']"
referrerpolicy="no-referrer"
/>
<div
v-else
:class="[
sizeClass,
fontSize,
'rounded-full bg-rule text-muted flex items-center justify-center font-display font-bold flex-shrink-0',
]"
>
{{ initial }}
</div>
</template>

<script setup lang="ts">
const props = withDefaults(
defineProps<{
username: string;
avatarUrl?: string | null;
size?: 'sm' | 'md' | 'lg';
}>(),
{
avatarUrl: null,
size: 'md',
}
);

const sizeClass = computed(() => {
if (props.size === 'sm') return 'w-7 h-7';
if (props.size === 'lg') return 'w-12 h-12';
return 'w-8 h-8';
});
const fontSize = computed(() => (props.size === 'lg' ? 'text-base' : 'text-xs'));
const initial = computed(() => props.username.charAt(0).toUpperCase());
</script>
98 changes: 98 additions & 0 deletions components/LbList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<template>
<ol class="lb-list">
<li
v-for="entry in entries"
:key="entry.rank"
class="lb-row"
:class="{ 'lb-row-you': showYou && you && entry.username === you.username }"
>
<div class="lb-rank" :class="rankClass(entry.rank)">{{ entry.rank }}</div>
<LbAvatar :username="entry.username" :avatar-url="entry.avatarUrl" />
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-ink truncate">
{{ entry.username }}
<span
v-if="showYou && you && entry.username === you.username"
class="font-mono text-[8px] tracking-[0.1em] uppercase bg-ink text-paper px-1 py-px ms-1.5 align-middle"
>YOU</span
>
</div>
<div v-if="isAgg && entry.daysPlayed" class="font-mono text-[9px] text-muted">
{{ entry.daysPlayed }} days
</div>
</div>
<div class="text-end flex-shrink-0">
<div class="font-mono text-sm font-bold flex items-center gap-1">
<Flame v-if="isStreaks" :size="14" class="text-flame" />
{{ formatScore(entry) }}
</div>
<div v-if="formatScoreSub(entry)" class="font-mono text-[9px] text-muted">
{{ formatScoreSub(entry) }}
</div>
</div>
</li>
</ol>
</template>

<script setup lang="ts">
import { Flame } from 'lucide-vue-next';

defineProps<{
entries: any[];
you?: any;
showYou?: boolean;
isStreaks: boolean;
isAgg: boolean;
formatScore: (entry: any) => string;
formatScoreSub: (entry: any) => string | null;
}>();

function rankClass(rank: number): Record<string, boolean> {
return {
'lb-rank-gold': rank === 1,
'lb-rank-silver': rank === 2,
'lb-rank-bronze': rank === 3,
};
}
</script>

<style scoped>
.lb-list {
list-style: none;
margin: 0;
padding: 0;
}
.lb-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--color-rule);
transition: background 0.15s;
}
.lb-row:hover {
background: var(--color-paper-warm);
}
.lb-row-you {
background: var(--color-paper-warm);
border-inline-start: 3px solid var(--color-ink);
padding-inline-start: 1px;
}
.lb-rank {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 700;
width: 28px;
text-align: center;
flex-shrink: 0;
}
.lb-rank-gold {
color: #c9a930;
}
.lb-rank-silver {
color: #8a8a8a;
}
.lb-rank-bronze {
color: #a0622e;
}
</style>
132 changes: 132 additions & 0 deletions components/LbPodium.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<template>
<div class="podium">
<div
v-for="(place, idx) in orderedPlaces"
:key="idx"
class="podium-place"
:class="['second', 'first', 'third'][idx]"
>
<div class="podium-rank">{{ ['#2', '#1', '#3'][idx] }}</div>
<LbAvatar
:username="place.username"
:avatar-url="place.avatarUrl"
:size="idx === 1 ? 'lg' : 'md'"
/>
<div class="podium-name">
{{ place.username }}
<span v-if="showYou && you && place.username === you.username" class="podium-you"
>YOU</span
>
</div>
<div class="podium-score">
<Flame
v-if="isStreaks"
:size="14"
class="text-flame inline-block align-text-bottom"
/>
{{ formatScore(place) }}
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { Flame } from 'lucide-vue-next';

const props = defineProps<{
podium: { first: any; second: any; third: any };
you?: any;
showYou?: boolean;
isStreaks: boolean;
formatScore: (entry: any) => string;
}>();

const orderedPlaces = computed(() => [props.podium.second, props.podium.first, props.podium.third]);
</script>

<style scoped>
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 12px;
padding: 16px 8px 16px;
margin-bottom: 4px;
}
.podium-place {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.podium-place.first {
order: 2;
}
.podium-place.second {
order: 1;
}
.podium-place.third {
order: 3;
}
.podium-rank {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 700;
color: var(--color-muted);
letter-spacing: 0.04em;
margin-bottom: 2px;
}
.podium-place.first .podium-rank {
color: #c9a930;
}
.podium-place.second .podium-rank {
color: #8a8a8a;
}
.podium-place.third .podium-rank {
color: #a0622e;
}
.podium-name {
font-family: var(--font-body);
font-size: 12px;
font-weight: 600;
max-width: 80px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-ink);
}
.podium-you {
display: block;
font-family: var(--font-mono);
font-size: 7px;
letter-spacing: 0.1em;
text-transform: uppercase;
background: var(--color-ink);
color: var(--color-paper);
padding: 0 4px;
margin: 2px auto 0;
width: fit-content;
}
.podium-score {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 700;
color: var(--color-ink);
margin-top: 2px;
}

/* Avatar ring color for podium */
.podium-place.first :deep(img),
.podium-place.first :deep(.rounded-full) {
border: 2px solid #c9a930;
}
.podium-place.second :deep(img),
.podium-place.second :deep(.rounded-full) {
border: 2px solid #8a8a8a;
}
.podium-place.third :deep(img),
.podium-place.third :deep(.rounded-full) {
border: 2px solid #a0622e;
}
</style>
Loading
Loading