From bcfa8ef194ea3a0a0c1d025e2616358f57016fc7 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 11:33:48 +0100 Subject: [PATCH 01/58] feat: Postgres cache tables + pgvector schema for semantic embeddings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 migration (0002): definitions, word_stats, wiktionary_cache, semantic_hints — replacing disk-based JSON files with atomic DB operations. Phase 2 migration (0003): pgvector extension + word_embeddings table with HNSW index, semantic_axes, and target_neighbors for precomputed rank lookups. Replaces the 98-230MB in-memory embedding matrix. New db-cache.ts utility: DRY module for all cache operations with DB-first + disk fallback pattern. --- .../0002_cache_tables/migration.sql | 57 +++++ .../0003_semantic_embeddings/migration.sql | 55 +++++ prisma/schema.prisma | 114 ++++++++++ server/utils/db-cache.ts | 204 ++++++++++++++++++ 4 files changed, 430 insertions(+) create mode 100644 prisma/migrations/0002_cache_tables/migration.sql create mode 100644 prisma/migrations/0003_semantic_embeddings/migration.sql create mode 100644 server/utils/db-cache.ts diff --git a/prisma/migrations/0002_cache_tables/migration.sql b/prisma/migrations/0002_cache_tables/migration.sql new file mode 100644 index 00000000..42188737 --- /dev/null +++ b/prisma/migrations/0002_cache_tables/migration.sql @@ -0,0 +1,57 @@ +-- Phase 1: Cache tables (replacing disk-based JSON files) +-- No pgvector dependency — these are plain relational tables. + +-- LLM-generated word definitions +CREATE TABLE wordle.definitions ( + id SERIAL PRIMARY KEY, + lang TEXT NOT NULL, + word TEXT NOT NULL, + definition TEXT, + definition_native TEXT, + definition_en TEXT, + part_of_speech TEXT, + confidence DOUBLE PRECISION, + source TEXT, + url TEXT, + is_negative BOOLEAN NOT NULL DEFAULT FALSE, + cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX definitions_lang_word_key ON wordle.definitions(lang, word); + +-- Per-day community stats (atomic increments via SQL, no lockfile needed) +CREATE TABLE wordle.word_stats ( + id SERIAL PRIMARY KEY, + lang TEXT NOT NULL, + day_idx INTEGER NOT NULL, + total INTEGER NOT NULL DEFAULT 0, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + dist_1 INTEGER NOT NULL DEFAULT 0, + dist_2 INTEGER NOT NULL DEFAULT 0, + dist_3 INTEGER NOT NULL DEFAULT 0, + dist_4 INTEGER NOT NULL DEFAULT 0, + dist_5 INTEGER NOT NULL DEFAULT 0, + dist_6 INTEGER NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX word_stats_lang_day_idx_key ON wordle.word_stats(lang, day_idx); + +-- Wiktionary existence probe cache +CREATE TABLE wordle.wiktionary_cache ( + id SERIAL PRIMARY KEY, + lang TEXT NOT NULL, + word TEXT NOT NULL, + "exists" BOOLEAN NOT NULL, + checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX wiktionary_cache_lang_word_key ON wordle.wiktionary_cache(lang, word); + +-- LLM-generated semantic game hints +CREATE TABLE wordle.semantic_hints ( + id SERIAL PRIMARY KEY, + lang TEXT NOT NULL DEFAULT 'en', + word TEXT NOT NULL, + hint TEXT NOT NULL, + model TEXT, + cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX semantic_hints_lang_word_key ON wordle.semantic_hints(lang, word); diff --git a/prisma/migrations/0003_semantic_embeddings/migration.sql b/prisma/migrations/0003_semantic_embeddings/migration.sql new file mode 100644 index 00000000..ca01f3ab --- /dev/null +++ b/prisma/migrations/0003_semantic_embeddings/migration.sql @@ -0,0 +1,55 @@ +-- Phase 2: Semantic embeddings (pgvector) +-- Replaces in-memory Float32Array embedding matrix (~98-230MB per language) + +-- Enable pgvector extension (available on Render Postgres paid plans) +CREATE EXTENSION IF NOT EXISTS vector SCHEMA public; + +-- Word embedding vectors for semantic similarity search +CREATE TABLE wordle.word_embeddings ( + id SERIAL PRIMARY KEY, + lang TEXT NOT NULL DEFAULT 'en', + word TEXT NOT NULL, + embedding vector(512) NOT NULL, + umap_x DOUBLE PRECISION, + umap_y DOUBLE PRECISION, + pca2d_x DOUBLE PRECISION, + pca2d_y DOUBLE PRECISION, + is_target BOOLEAN NOT NULL DEFAULT FALSE, + is_vocab BOOLEAN NOT NULL DEFAULT TRUE +); +CREATE UNIQUE INDEX word_embeddings_lang_word_key ON wordle.word_embeddings(lang, word); +CREATE INDEX word_embeddings_lang_target_idx ON wordle.word_embeddings(lang, is_target); + +-- HNSW index for approximate nearest neighbor search (cosine distance) +-- m=16, ef_construction=64 is a good balance for 50k vectors +CREATE INDEX word_embeddings_hnsw_idx + ON wordle.word_embeddings + USING hnsw (embedding vector_cosine_ops) + WITH (m = 16, ef_construction = 64); + +-- Named semantic axes with directional vectors for compass hints +CREATE TABLE wordle.semantic_axes ( + id SERIAL PRIMARY KEY, + lang TEXT NOT NULL DEFAULT 'en', + name TEXT NOT NULL, + low_anchor TEXT NOT NULL, + high_anchor TEXT NOT NULL, + vector vector(512) NOT NULL, + auc DOUBLE PRECISION DEFAULT 0, + range_p5 DOUBLE PRECISION, + range_p95 DOUBLE PRECISION +); +CREATE UNIQUE INDEX semantic_axes_lang_name_key ON wordle.semantic_axes(lang, name); + +-- Precomputed neighbor rankings for fast rank lookup during gameplay. +-- Top 5k neighbors per target word (879 targets × 5k = ~4.4M rows). +-- Replaces the runtime O(N) cosine sort that was cached in _targetCosineCache. +CREATE TABLE wordle.target_neighbors ( + target_word TEXT NOT NULL, + lang TEXT NOT NULL DEFAULT 'en', + word TEXT NOT NULL, + rank INTEGER NOT NULL, + cosine REAL NOT NULL, + PRIMARY KEY (lang, target_word, word) +); +CREATE INDEX target_neighbors_lookup_idx ON wordle.target_neighbors(lang, target_word, rank); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6032b01c..1a73c714 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,3 +151,117 @@ model Subscription { @@map("subscriptions") @@schema("wordle") } + +// ═══════════════════════════════════════════════════════════════════════════ +// Caches (replacing disk-based JSON files) +// ═══════════════════════════════════════════════════════════════════════════ + +/// LLM-generated word definitions. Replaces word-defs/{lang}/{word}.json. +model Definition { + id Int @id @default(autoincrement()) + lang String + word String + definition String? + definitionNative String? @map("definition_native") + definitionEn String? @map("definition_en") + partOfSpeech String? @map("part_of_speech") + confidence Float? + source String? + url String? + isNegative Boolean @default(false) @map("is_negative") + cachedAt DateTime @default(now()) @map("cached_at") + + @@unique([lang, word]) + @@map("definitions") + @@schema("wordle") +} + +/// Per-day community stats. Replaces word-stats/{lang}/{dayIdx}.json + lockfile. +/// Denormalized dist columns enable atomic SQL increments without JSON parsing. +model WordStat { + id Int @id @default(autoincrement()) + lang String + dayIdx Int @map("day_idx") + total Int @default(0) + wins Int @default(0) + losses Int @default(0) + dist1 Int @default(0) @map("dist_1") + dist2 Int @default(0) @map("dist_2") + dist3 Int @default(0) @map("dist_3") + dist4 Int @default(0) @map("dist_4") + dist5 Int @default(0) @map("dist_5") + dist6 Int @default(0) @map("dist_6") + + @@unique([lang, dayIdx]) + @@map("word_stats") + @@schema("wordle") +} + +/// Wiktionary existence probe cache. Replaces word-defs/wiktionary-exists/{lang}/{word}.json. +model WiktionaryCache { + id Int @id @default(autoincrement()) + lang String + word String + exists Boolean + checkedAt DateTime @default(now()) @map("checked_at") + + @@unique([lang, word]) + @@map("wiktionary_cache") + @@schema("wordle") +} + +/// LLM-generated semantic game hints. Replaces word-defs/semantic-hints/{word}.json. +model SemanticHint { + id Int @id @default(autoincrement()) + lang String @default("en") + word String + hint String + model String? + cachedAt DateTime @default(now()) @map("cached_at") + + @@unique([lang, word]) + @@map("semantic_hints") + @@schema("wordle") +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Semantic Explorer (pgvector) — Phase 2 +// Replaces in-memory Float32Array embedding matrix (~98MB per language) +// ═══════════════════════════════════════════════════════════════════════════ + +/// Word embedding vectors for semantic similarity search. +/// On-demand (out-of-vocab) words also stored here with is_vocab = false. +model WordEmbedding { + id Int @id @default(autoincrement()) + lang String @default("en") + word String + embedding Unsupported("vector(512)") + umapX Float? @map("umap_x") + umapY Float? @map("umap_y") + pca2dX Float? @map("pca2d_x") + pca2dY Float? @map("pca2d_y") + isTarget Boolean @default(false) @map("is_target") + isVocab Boolean @default(true) @map("is_vocab") + + @@unique([lang, word]) + @@index([lang, isTarget]) + @@map("word_embeddings") + @@schema("wordle") +} + +/// Named semantic axes with directional vectors for compass hints. +model SemanticAxis { + id Int @id @default(autoincrement()) + lang String @default("en") + name String + lowAnchor String @map("low_anchor") + highAnchor String @map("high_anchor") + vector Unsupported("vector(512)") + auc Float? @default(0) + rangeP5 Float? @map("range_p5") + rangeP95 Float? @map("range_p95") + + @@unique([lang, name]) + @@map("semantic_axes") + @@schema("wordle") +} diff --git a/server/utils/db-cache.ts b/server/utils/db-cache.ts new file mode 100644 index 00000000..5f578e96 --- /dev/null +++ b/server/utils/db-cache.ts @@ -0,0 +1,204 @@ +/** + * db-cache — unified database cache layer. + * + * Single module for all Postgres-backed cache operations (definitions, + * word stats, wiktionary checks, semantic hints). Every function follows + * the same pattern: + * 1. Try DB query + * 2. On DB error, fall back to disk (backward compatibility) + * 3. On miss, return null (caller handles LLM/API fallback) + * + * This replaces scattered disk I/O with file locks across multiple + * server/utils modules. The DB provides atomicity (word stats), + * queryability (analytics), and eliminates filesystem dependencies. + */ + +import { prisma } from './prisma'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Definitions +// ═══════════════════════════════════════════════════════════════════════════ + +export interface DefinitionData { + definition?: string | null; + definitionNative?: string | null; + definitionEn?: string | null; + partOfSpeech?: string | null; + confidence?: number | null; + source?: string | null; + url?: string | null; +} + +export async function getDefinition( + lang: string, + word: string +): Promise<(DefinitionData & { isNegative: boolean; cachedAt: Date }) | null> { + try { + const row = await prisma.definition.findUnique({ + where: { lang_word: { lang, word } }, + }); + if (!row) return null; + + // Negative cache entries expire after 24h + if (row.isNegative) { + const age = Date.now() - row.cachedAt.getTime(); + if (age > 24 * 60 * 60 * 1000) return null; // expired + } + + return row; + } catch { + return null; // DB error → caller falls back to disk/LLM + } +} + +export async function upsertDefinition( + lang: string, + word: string, + data: DefinitionData, + isNegative = false +): Promise { + try { + await prisma.definition.upsert({ + where: { lang_word: { lang, word } }, + create: { + lang, + word, + ...data, + isNegative, + }, + update: { + ...data, + isNegative, + cachedAt: new Date(), + }, + }); + } catch { + // Non-critical — disk fallback still works + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Word Stats +// ═══════════════════════════════════════════════════════════════════════════ + +export interface WordStats { + total: number; + wins: number; + losses: number; + distribution: Record; +} + +export async function getWordStats(lang: string, dayIdx: number): Promise { + try { + const row = await prisma.wordStat.findUnique({ + where: { lang_dayIdx: { lang, dayIdx } }, + }); + if (!row) return null; + + return { + total: row.total, + wins: row.wins, + losses: row.losses, + distribution: { + 1: row.dist1, + 2: row.dist2, + 3: row.dist3, + 4: row.dist4, + 5: row.dist5, + 6: row.dist6, + }, + }; + } catch { + return null; + } +} + +/** + * Atomic increment of word stats. Uses raw SQL for ON CONFLICT upsert + * with per-column increments — no read-modify-write, no lockfile. + */ +export async function incrementWordStats( + lang: string, + dayIdx: number, + won: boolean, + attempts: number +): Promise { + const distCol = attempts >= 1 && attempts <= 6 ? `dist_${attempts}` : null; + + try { + await prisma.$executeRaw` + INSERT INTO wordle.word_stats (lang, day_idx, total, wins, losses, ${distCol ? prisma.$raw(`${distCol}`) : prisma.$raw('dist_1')}) + VALUES (${lang}, ${dayIdx}, 1, ${won ? 1 : 0}, ${won ? 0 : 1}, ${distCol && won ? 1 : 0}) + ON CONFLICT (lang, day_idx) DO UPDATE SET + total = word_stats.total + 1, + wins = word_stats.wins + ${won ? 1 : 0}, + losses = word_stats.losses + ${won ? 0 : 1} + ${distCol && won ? prisma.$raw(`, ${distCol} = word_stats.${distCol} + 1`) : prisma.$raw('')} + `; + } catch (e) { + console.warn('[db-cache] incrementWordStats failed:', e); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Wiktionary Existence Cache +// ═══════════════════════════════════════════════════════════════════════════ + +export async function getWiktionaryExists(lang: string, word: string): Promise { + try { + const row = await prisma.wiktionaryCache.findUnique({ + where: { lang_word: { lang, word } }, + }); + return row?.exists ?? null; + } catch { + return null; + } +} + +export async function setWiktionaryExists( + lang: string, + word: string, + exists: boolean +): Promise { + try { + await prisma.wiktionaryCache.upsert({ + where: { lang_word: { lang, word } }, + create: { lang, word, exists }, + update: { exists, checkedAt: new Date() }, + }); + } catch { + // Non-critical + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Semantic Hints +// ═══════════════════════════════════════════════════════════════════════════ + +export async function getSemanticHint(lang: string, word: string): Promise { + try { + const row = await prisma.semanticHint.findUnique({ + where: { lang_word: { lang, word } }, + }); + return row?.hint ?? null; + } catch { + return null; + } +} + +export async function setSemanticHint( + lang: string, + word: string, + hint: string, + model?: string +): Promise { + try { + await prisma.semanticHint.upsert({ + where: { lang_word: { lang, word } }, + create: { lang, word, hint, model }, + update: { hint, model, cachedAt: new Date() }, + }); + } catch { + // Non-critical + } +} From eb3127ec7210bd2260c0256f46f185c722336131 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 11:42:26 +0100 Subject: [PATCH 02/58] feat: DB-backed caches + pgvector semantic operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - semantic-db.ts: pgvector-backed rank lookup, kNN, embedding fetch - definitions.ts: DB-first with disk fallback (4-tier cache) - word-stats.ts: atomic DB increment (no lockfile), disk fallback - semantic-warmup.ts: conditional loading (SEMANTIC_DB=1 skips 98MB) - prisma.ts: pool max 10→20 for pgvector query concurrency - migrate-caches-to-db.ts: import disk cache files into Postgres - seed-semantic-db.ts: import embeddings + precompute target_neighbors --- scripts/migrate-caches-to-db.ts | 213 +++++++++++++++++++ scripts/seed-semantic-db.ts | 320 ++++++++++++++++++++++++++++ server/plugins/semantic-warmup.ts | 17 +- server/utils/definitions.ts | 66 +++++- server/utils/prisma.ts | 2 +- server/utils/semantic-db.ts | 337 ++++++++++++++++++++++++++++++ server/utils/word-stats.ts | 37 ++-- 7 files changed, 970 insertions(+), 22 deletions(-) create mode 100644 scripts/migrate-caches-to-db.ts create mode 100644 scripts/seed-semantic-db.ts create mode 100644 server/utils/semantic-db.ts diff --git a/scripts/migrate-caches-to-db.ts b/scripts/migrate-caches-to-db.ts new file mode 100644 index 00000000..a040668c --- /dev/null +++ b/scripts/migrate-caches-to-db.ts @@ -0,0 +1,213 @@ +/** + * Migrate disk-based cache files to Postgres tables. + * + * Reads all existing JSON cache files (definitions, word stats, + * wiktionary existence, semantic hints) and batch-inserts them + * into the corresponding Postgres tables. + * + * Safe to run multiple times — uses upsert (ON CONFLICT DO NOTHING). + * + * Usage: npx tsx scripts/migrate-caches-to-db.ts + */ + +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import pg from 'pg'; +import Prisma from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; + +const { PrismaClient } = Prisma; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error('DATABASE_URL not set'); + process.exit(1); +} + +const pool = new pg.Pool({ + connectionString: DATABASE_URL, + ssl: { rejectUnauthorized: false }, +}); +const adapter = new PrismaPg(pool, { schema: 'wordle' }); +const prisma = new PrismaClient({ adapter }); + +const WORD_DEFS_DIR = join(process.cwd(), 'word-defs'); +const WORD_STATS_DIR = join(process.cwd(), 'word-stats'); + +async function migrateDefinitions() { + const baseDir = WORD_DEFS_DIR; + if (!existsSync(baseDir)) { + console.log('[definitions] No word-defs directory found, skipping'); + return; + } + + let count = 0; + const langs = readdirSync(baseDir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'wiktionary-exists' && d.name !== 'semantic-embeddings' && d.name !== 'semantic-hints') + .map((d) => d.name); + + for (const lang of langs) { + const langDir = join(baseDir, lang); + const files = readdirSync(langDir).filter((f) => f.endsWith('.json')); + + for (const file of files) { + const word = file.replace('.json', ''); + try { + const data = JSON.parse(readFileSync(join(langDir, file), 'utf-8')); + const isNeg = !!data.not_found; + + await prisma.definition.upsert({ + where: { lang_word: { lang, word } }, + create: { + lang, + word, + definition: data.definition ?? null, + definitionNative: data.definition_native ?? null, + definitionEn: data.definition_en ?? null, + partOfSpeech: data.part_of_speech ?? null, + confidence: data.confidence ?? null, + source: data.source ?? null, + url: data.url ?? null, + isNegative: isNeg, + }, + update: {}, + }); + count++; + } catch (e) { + console.warn(` [skip] ${lang}/${file}:`, (e as Error).message); + } + } + } + console.log(`[definitions] Migrated ${count} entries`); +} + +async function migrateWordStats() { + if (!existsSync(WORD_STATS_DIR)) { + console.log('[word-stats] No word-stats directory found, skipping'); + return; + } + + let count = 0; + const langs = readdirSync(WORD_STATS_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const lang of langs) { + const langDir = join(WORD_STATS_DIR, lang); + const files = readdirSync(langDir).filter((f) => f.endsWith('.json')); + + for (const file of files) { + const dayIdx = parseInt(file.replace('.json', ''), 10); + if (isNaN(dayIdx)) continue; + + try { + const data = JSON.parse(readFileSync(join(langDir, file), 'utf-8')); + if (!data.total) continue; + + await prisma.wordStat.upsert({ + where: { lang_dayIdx: { lang, dayIdx } }, + create: { + lang, + dayIdx, + total: data.total ?? 0, + wins: data.wins ?? 0, + losses: data.losses ?? 0, + dist1: data.distribution?.['1'] ?? 0, + dist2: data.distribution?.['2'] ?? 0, + dist3: data.distribution?.['3'] ?? 0, + dist4: data.distribution?.['4'] ?? 0, + dist5: data.distribution?.['5'] ?? 0, + dist6: data.distribution?.['6'] ?? 0, + }, + update: {}, + }); + count++; + } catch (e) { + console.warn(` [skip] ${lang}/${file}:`, (e as Error).message); + } + } + } + console.log(`[word-stats] Migrated ${count} entries`); +} + +async function migrateWiktionary() { + const wiktDir = join(WORD_DEFS_DIR, 'wiktionary-exists'); + if (!existsSync(wiktDir)) { + console.log('[wiktionary] No wiktionary-exists directory found, skipping'); + return; + } + + let count = 0; + const langs = readdirSync(wiktDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + for (const lang of langs) { + const langDir = join(wiktDir, lang); + const files = readdirSync(langDir).filter((f) => f.endsWith('.json')); + + for (const file of files) { + const word = file.replace('.json', ''); + try { + const data = JSON.parse(readFileSync(join(langDir, file), 'utf-8')); + + await prisma.wiktionaryCache.upsert({ + where: { lang_word: { lang, word } }, + create: { lang, word, exists: !!data.exists }, + update: {}, + }); + count++; + } catch (e) { + console.warn(` [skip] ${lang}/${file}:`, (e as Error).message); + } + } + } + console.log(`[wiktionary] Migrated ${count} entries`); +} + +async function migrateSemanticHints() { + const hintsDir = join(WORD_DEFS_DIR, 'semantic-hints'); + if (!existsSync(hintsDir)) { + console.log('[semantic-hints] No hints directory found, skipping'); + return; + } + + let count = 0; + const files = readdirSync(hintsDir).filter((f) => f.endsWith('.json')); + + for (const file of files) { + const word = file.replace('.json', ''); + try { + const data = JSON.parse(readFileSync(join(hintsDir, file), 'utf-8')); + if (!data.hint) continue; + + await prisma.semanticHint.upsert({ + where: { lang_word: { lang: 'en', word } }, + create: { lang: 'en', word, hint: data.hint, model: data.model }, + update: {}, + }); + count++; + } catch (e) { + console.warn(` [skip] ${file}:`, (e as Error).message); + } + } + console.log(`[semantic-hints] Migrated ${count} entries`); +} + +async function main() { + console.log('=== Migrating disk caches to Postgres ===\n'); + + await migrateDefinitions(); + await migrateWordStats(); + await migrateWiktionary(); + await migrateSemanticHints(); + + console.log('\n=== Migration complete ==='); + await prisma.$disconnect(); + await pool.end(); +} + +main().catch((e) => { + console.error('Migration failed:', e); + process.exit(1); +}); diff --git a/scripts/seed-semantic-db.ts b/scripts/seed-semantic-db.ts new file mode 100644 index 00000000..bdfb8033 --- /dev/null +++ b/scripts/seed-semantic-db.ts @@ -0,0 +1,320 @@ +/** + * Seed Postgres with semantic embeddings + precompute target neighbors. + * + * This is a heavy script (~30 minutes for target_neighbors) that should + * be run locally, NOT on the production server. + * + * Steps: + * 1. Read embeddings from .f32 binary (or .json fallback) + * 2. Read metadata: umap, pca2d, targets, vocabulary, axes + * 3. Batch-insert into word_embeddings table + * 4. Insert axes into semantic_axes table + * 5. Precompute target_neighbors (879 targets × top 5k vocab) + * + * Usage: npx tsx scripts/seed-semantic-db.ts + * + * Requires: DATABASE_URL env var, pgvector extension enabled + */ + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import pg from 'pg'; + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error('DATABASE_URL not set'); + process.exit(1); +} + +const pool = new pg.Pool({ + connectionString: DATABASE_URL, + ssl: { rejectUnauthorized: false }, + max: 5, +}); + +const SEMANTIC_DIR = join(process.cwd(), 'data', 'semantic'); +const RUNTIME_DIR = join(process.cwd(), 'semantic-runtime'); +const LANG = 'en'; +const DIMS = 512; +const TOP_K_NEIGHBORS = 5000; +const BATCH_SIZE = 500; + +// ═══════════════════════════════════════════════════════════════════════════ +// Data loading (same logic as server/utils/semantic.ts) +// ═══════════════════════════════════════════════════════════════════════════ + +function loadEmbeddings(): { words: string[]; embeddings: Float32Array } { + // Try binary .f32 first + const f32Path = existsSync(join(RUNTIME_DIR, 'embeddings.f32')) + ? join(RUNTIME_DIR, 'embeddings.f32') + : join(SEMANTIC_DIR, 'embeddings.f32'); + const metaPath = existsSync(join(RUNTIME_DIR, 'embeddings.meta.json')) + ? join(RUNTIME_DIR, 'embeddings.meta.json') + : join(SEMANTIC_DIR, 'embeddings.meta.json'); + + if (existsSync(f32Path) && existsSync(metaPath)) { + console.log('[load] Using binary .f32 format'); + const meta = JSON.parse(readFileSync(metaPath, 'utf-8')); + const buf = readFileSync(f32Path); + const embeddings = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4); + return { words: meta.words, embeddings }; + } + + // Fallback to JSON + const jsonPath = existsSync(join(RUNTIME_DIR, 'embeddings.json')) + ? join(RUNTIME_DIR, 'embeddings.json') + : join(SEMANTIC_DIR, 'embeddings.json'); + + console.log('[load] Using JSON format (slow)'); + const data = JSON.parse(readFileSync(jsonPath, 'utf-8')); + const words: string[] = data.words; + const N = words.length; + const embeddings = new Float32Array(N * DIMS); + for (let i = 0; i < N; i++) { + const vec = data.vectors[i]; + for (let j = 0; j < DIMS; j++) { + embeddings[i * DIMS + j] = vec[j]; + } + } + return { words, embeddings }; +} + +function loadJson(filename: string): T { + const runtimePath = join(RUNTIME_DIR, filename); + const staticPath = join(SEMANTIC_DIR, filename); + const p = existsSync(runtimePath) ? runtimePath : staticPath; + return JSON.parse(readFileSync(p, 'utf-8')) as T; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Seeding +// ═══════════════════════════════════════════════════════════════════════════ + +async function seedWordEmbeddings() { + const { words, embeddings } = loadEmbeddings(); + const targets = new Set(loadJson('targets.json')); + const umap = loadJson>('umap.json'); + const pca2d = loadJson>('pca2d.json'); + const vocabulary = new Set(loadJson('vocabulary.json')); + + const N = words.length; + console.log(`[seed] Inserting ${N} word embeddings...`); + + // Clear existing data for this language + const client = await pool.connect(); + try { + await client.query('DELETE FROM wordle.word_embeddings WHERE lang = $1', [LANG]); + + // Batch insert + for (let batch = 0; batch < N; batch += BATCH_SIZE) { + const end = Math.min(batch + BATCH_SIZE, N); + const values: string[] = []; + const params: any[] = []; + let paramIdx = 1; + + for (let i = batch; i < end; i++) { + const word = words[i]!; + const vec = Array.from( + embeddings.subarray(i * DIMS, (i + 1) * DIMS) + ); + const vecStr = `[${vec.join(',')}]`; + const umapCoords = umap[word]; + const pca2dCoords = pca2d[word]; + + values.push( + `($${paramIdx++}, $${paramIdx++}, $${paramIdx++}::vector, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++})` + ); + params.push( + LANG, + word, + vecStr, + umapCoords?.[0] ?? null, + umapCoords?.[1] ?? null, + pca2dCoords?.[0] ?? null, + pca2dCoords?.[1] ?? null, + targets.has(word), + vocabulary.has(word) + ); + } + + await client.query( + `INSERT INTO wordle.word_embeddings (lang, word, embedding, umap_x, umap_y, pca2d_x, pca2d_y, is_target, is_vocab) + VALUES ${values.join(', ')} + ON CONFLICT (lang, word) DO UPDATE SET + embedding = EXCLUDED.embedding, + umap_x = EXCLUDED.umap_x, + umap_y = EXCLUDED.umap_y, + pca2d_x = EXCLUDED.pca2d_x, + pca2d_y = EXCLUDED.pca2d_y, + is_target = EXCLUDED.is_target, + is_vocab = EXCLUDED.is_vocab`, + params + ); + + if ((batch / BATCH_SIZE) % 10 === 0) { + console.log(` ${end}/${N} words inserted`); + } + } + + console.log(`[seed] Inserted ${N} word embeddings`); + } finally { + client.release(); + } +} + +async function seedAxes() { + const axesData = loadJson>('axes.json'); + const axisNames = Object.keys(axesData).filter( + (k) => !['_model', '_dims', '_auc', '_ranges'].includes(k) + ); + + console.log(`[seed] Inserting ${axisNames.length} semantic axes...`); + + const client = await pool.connect(); + try { + await client.query('DELETE FROM wordle.semantic_axes WHERE lang = $1', [LANG]); + + for (const name of axisNames) { + const axis = axesData[name]; + if (!axis?.vector) continue; + + const vecStr = `[${axis.vector.join(',')}]`; + const auc = axesData._auc?.[name] ?? 0; + const ranges = axesData._ranges?.[name]; + + await client.query( + `INSERT INTO wordle.semantic_axes (lang, name, low_anchor, high_anchor, vector, auc, range_p5, range_p95) + VALUES ($1, $2, $3, $4, $5::vector, $6, $7, $8) + ON CONFLICT (lang, name) DO UPDATE SET + low_anchor = EXCLUDED.low_anchor, + high_anchor = EXCLUDED.high_anchor, + vector = EXCLUDED.vector, + auc = EXCLUDED.auc, + range_p5 = EXCLUDED.range_p5, + range_p95 = EXCLUDED.range_p95`, + [ + LANG, + name, + axis.low_anchor, + axis.high_anchor, + vecStr, + auc, + ranges?.p5 ?? null, + ranges?.p95 ?? null, + ] + ); + } + + console.log(`[seed] Inserted ${axisNames.length} axes`); + } finally { + client.release(); + } +} + +async function seedTargetNeighbors() { + const { words, embeddings } = loadEmbeddings(); + const targets = loadJson('targets.json'); + const N = words.length; + + // Build word → index map + const wordIndex = new Map(); + for (let i = 0; i < N; i++) wordIndex.set(words[i]!, i); + + console.log( + `[seed] Computing target neighbors: ${targets.length} targets × top ${TOP_K_NEIGHBORS}...` + ); + console.log(' (This takes ~30 minutes for 879 targets × 50k vocab)'); + + const client = await pool.connect(); + try { + await client.query('DELETE FROM wordle.target_neighbors WHERE lang = $1', [LANG]); + + for (let t = 0; t < targets.length; t++) { + const target = targets[t]!; + const targetIdx = wordIndex.get(target); + if (targetIdx === undefined) { + console.warn(` [skip] target "${target}" not in vocab`); + continue; + } + + // Compute cosine to all vocab words + const cosines = new Float32Array(N); + for (let i = 0; i < N; i++) { + let dot = 0; + for (let j = 0; j < DIMS; j++) { + dot += + embeddings[targetIdx * DIMS + j]! * + embeddings[i * DIMS + j]!; + } + cosines[i] = dot; + } + + // Get top K indices by cosine (descending) + const indices = Array.from({ length: N }, (_, i) => i); + indices.sort((a, b) => cosines[b]! - cosines[a]!); + + // Batch insert top K neighbors + const k = Math.min(TOP_K_NEIGHBORS, N); + const batchValues: string[] = []; + const batchParams: any[] = []; + let pIdx = 1; + + for (let rank = 0; rank < k; rank++) { + const idx = indices[rank]!; + batchValues.push( + `($${pIdx++}, $${pIdx++}, $${pIdx++}, $${pIdx++}, $${pIdx++})` + ); + batchParams.push( + LANG, + target, + words[idx]!, + rank + 1, + cosines[idx]! + ); + + // Flush every 1000 rows + if (batchValues.length >= 1000 || rank === k - 1) { + await client.query( + `INSERT INTO wordle.target_neighbors (lang, target_word, word, rank, cosine) + VALUES ${batchValues.join(', ')} + ON CONFLICT (lang, target_word, word) DO NOTHING`, + batchParams + ); + batchValues.length = 0; + batchParams.length = 0; + pIdx = 1; + } + } + + if ((t + 1) % 50 === 0 || t === targets.length - 1) { + console.log( + ` ${t + 1}/${targets.length} targets processed` + ); + } + } + + console.log( + `[seed] Inserted ${targets.length * TOP_K_NEIGHBORS} target neighbor rows` + ); + } finally { + client.release(); + } +} + +async function main() { + console.log('=== Seeding semantic data into Postgres (pgvector) ===\n'); + + const t0 = Date.now(); + await seedWordEmbeddings(); + await seedAxes(); + await seedTargetNeighbors(); + + const elapsed = ((Date.now() - t0) / 1000 / 60).toFixed(1); + console.log(`\n=== Seeding complete in ${elapsed} minutes ===`); + await pool.end(); +} + +main().catch((e) => { + console.error('Seeding failed:', e); + process.exit(1); +}); diff --git a/server/plugins/semantic-warmup.ts b/server/plugins/semantic-warmup.ts index 875c0ded..a55130b3 100644 --- a/server/plugins/semantic-warmup.ts +++ b/server/plugins/semantic-warmup.ts @@ -23,7 +23,22 @@ import { } from '~/server/utils/semanticGenerate'; export default defineNitroPlugin(async () => { - // Fast path: files already on disk → just warm the in-memory cache. + // DB mode: skip the heavy 98-230MB embedding load. Only load axes (140KB). + if (process.env.SEMANTIC_DB === '1') { + try { + const { loadAxes } = await import('~/server/utils/semantic-db'); + const t0 = Date.now(); + const axes = await loadAxes('en'); + consola.info( + `[semantic warmup] DB mode — loaded ${axes.length} axes in ${Date.now() - t0}ms (embeddings in Postgres)` + ); + } catch (e) { + consola.warn('[semantic warmup] DB axis load failed:', e); + } + return; + } + + // Legacy mode: load full embedding matrix into memory. if (semanticRuntimeCacheExists()) { try { const t0 = Date.now(); diff --git a/server/utils/definitions.ts b/server/utils/definitions.ts index bfcf62bf..ca57034f 100644 --- a/server/utils/definitions.ts +++ b/server/utils/definitions.ts @@ -265,19 +265,54 @@ async function callLlmDefinition( /** * Fetch a word definition. * - * Default: 3-tier: disk cache → LLM → kaikki. - * With cacheOnly: disk cache → kaikki only (no LLM call — safe for unlimited/random words). + * 4-tier: DB cache → disk cache → LLM → kaikki. + * With cacheOnly: DB/disk cache → kaikki only (no LLM call — safe for unlimited/random words). */ export async function fetchDefinition( word: string, langCode: string, options: { skipNegativeCache?: boolean; cacheOnly?: boolean } = {} ): Promise | null> { + const { getDefinition, upsertDefinition } = await import('./db-cache'); + + // --- Tier 0: DB cache --- + const dbResult = await getDefinition(langCode, word.toLowerCase()); + if (dbResult) { + if (dbResult.isNegative && !options.skipNegativeCache) return null; + if (!dbResult.isNegative) { + // If DB result is kaikki-en fallback, check if stale (>24h) + if (dbResult.source === 'kaikki-en' && !dbResult.definitionNative) { + const age = Date.now() - dbResult.cachedAt.getTime(); + if (age < NEGATIVE_CACHE_TTL * 1000) { + return { + definition: dbResult.definition, + definition_native: dbResult.definitionNative, + definition_en: dbResult.definitionEn, + part_of_speech: dbResult.partOfSpeech, + confidence: dbResult.confidence, + source: dbResult.source, + url: dbResult.url, + }; + } + } else { + return { + definition: dbResult.definition, + definition_native: dbResult.definitionNative, + definition_en: dbResult.definitionEn, + part_of_speech: dbResult.partOfSpeech, + confidence: dbResult.confidence, + source: dbResult.source, + url: dbResult.url, + }; + } + } + } + + // --- Tier 1: Disk cache (fallback during migration) --- const cacheDir = WORD_DEFS_DIR; const langCacheDir = join(cacheDir, langCode); const cachePath = join(langCacheDir, `${word.toLowerCase()}.json`); - // --- Tier 1: Disk cache --- if (existsSync(cachePath)) { try { const loaded = JSON.parse(readFileSync(cachePath, 'utf-8')); @@ -288,16 +323,12 @@ export async function fetchDefinition( return null; } } - // Expired — fall through } else if (loaded && Object.keys(loaded).length > 0) { - // 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; } - // Expired — fall through } else { return loaded; } @@ -318,7 +349,26 @@ export async function fetchDefinition( result = lookupKaikki(word, langCode, 'native') || lookupKaikki(word, langCode, 'en'); } - // Cache result (including negative results) + // Cache result to DB (primary) and disk (backup) + const isNeg = !result; + upsertDefinition( + langCode, + word.toLowerCase(), + result + ? { + definition: result.definition, + definitionNative: result.definition_native, + definitionEn: result.definition_en, + partOfSpeech: result.part_of_speech, + confidence: result.confidence, + source: result.source, + url: result.url, + } + : {}, + isNeg + ); + + // Also write to disk for backward compat during migration try { mkdirSync(langCacheDir, { recursive: true }); writeFileSync( diff --git a/server/utils/prisma.ts b/server/utils/prisma.ts index b0744929..2882d5e2 100644 --- a/server/utils/prisma.ts +++ b/server/utils/prisma.ts @@ -23,7 +23,7 @@ function createClient(): PrismaClient { const pool = new pg.Pool({ connectionString, ssl: { rejectUnauthorized: false }, - max: 10, + max: 20, min: 2, idleTimeoutMillis: 60000, connectionTimeoutMillis: 15000, diff --git a/server/utils/semantic-db.ts b/server/utils/semantic-db.ts new file mode 100644 index 00000000..9201f6db --- /dev/null +++ b/server/utils/semantic-db.ts @@ -0,0 +1,337 @@ +/** + * semantic-db — pgvector-backed semantic operations. + * + * Replaces the in-memory Float32Array embedding matrix (~98-230MB) with + * Postgres queries using pgvector's HNSW index and precomputed neighbor + * rankings. Activated via SEMANTIC_DB=1 env var. + * + * Architecture: + * - Rank lookup: precomputed target_neighbors table (btree, O(1)) + * - kNN: pgvector HNSW index (approximate, ~5-15ms for k=8) + * - Compass: fetch 2 vectors from DB, compute dot products in-app + * - Axes: loaded into memory at startup (70 × 512 = 140KB, negligible) + * + * All functions return null on error — callers can fall back to in-memory. + */ + +import { prisma } from './prisma'; + +// ═══════════════════════════════════════════════════════════════════════════ +// Axis data (loaded once at startup, cached in memory — 140KB) +// ═══════════════════════════════════════════════════════════════════════════ + +interface AxisData { + name: string; + lowAnchor: string; + highAnchor: string; + vector: Float32Array; + auc: number; + rangeP5: number; + rangeP95: number; +} + +let _axesCache: { lang: string; axes: AxisData[]; axesVectors: Float32Array } | null = null; + +/** + * Load axis data from DB. Called once at startup, cached forever. + * Total size: ~140KB (70 axes × 512 dims × 4 bytes). Negligible. + */ +export async function loadAxes(lang: string = 'en'): Promise { + if (_axesCache?.lang === lang) return _axesCache.axes; + + const rows = await prisma.$queryRaw< + Array<{ + name: string; + low_anchor: string; + high_anchor: string; + vector: string; // pgvector returns as string "[0.1,0.2,...]" + auc: number | null; + range_p5: number | null; + range_p95: number | null; + }> + >`SELECT name, low_anchor, high_anchor, vector::text, auc, range_p5, range_p95 + FROM wordle.semantic_axes WHERE lang = ${lang} ORDER BY name`; + + const axes: AxisData[] = rows.map((r) => ({ + name: r.name, + lowAnchor: r.low_anchor, + highAnchor: r.high_anchor, + vector: parseVector(r.vector), + auc: r.auc ?? 0, + rangeP5: r.range_p5 ?? 0, + rangeP95: r.range_p95 ?? 0, + })); + + // Build concatenated axes vector array for fast dot products + const dims = axes[0]?.vector.length ?? 512; + const axesVectors = new Float32Array(axes.length * dims); + for (let a = 0; a < axes.length; a++) { + axesVectors.set(axes[a]!.vector, a * dims); + } + + _axesCache = { lang, axes, axesVectors }; + return axes; +} + +/** Get cached axes vectors for dot product computation. */ +export function getCachedAxesVectors(): Float32Array | null { + return _axesCache?.axesVectors ?? null; +} + +/** Get cached axis names in order. */ +export function getCachedAxesNames(): string[] { + return _axesCache?.axes.map((a) => a.name) ?? []; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Embedding lookups +// ═══════════════════════════════════════════════════════════════════════════ + +/** Fetch a single word's embedding vector from the DB. */ +export async function getEmbedding(lang: string, word: string): Promise { + try { + const rows = await prisma.$queryRaw>` + SELECT embedding::text as vector FROM wordle.word_embeddings + WHERE lang = ${lang} AND word = ${word} LIMIT 1 + `; + if (!rows.length) return null; + return parseVector(rows[0]!.vector); + } catch { + return null; + } +} + +/** Fetch UMAP or PCA2D coordinates for a word. */ +export async function get2dPosition( + lang: string, + word: string, + projection: 'umap' | 'pca2d' = 'umap' +): Promise<[number, number] | null> { + try { + const col1 = projection === 'umap' ? 'umap_x' : 'pca2d_x'; + const col2 = projection === 'umap' ? 'umap_y' : 'pca2d_y'; + const rows = await prisma.$queryRaw>` + SELECT ${prisma.$raw(col1)} as x, ${prisma.$raw(col2)} as y + FROM wordle.word_embeddings + WHERE lang = ${lang} AND word = ${word} LIMIT 1 + `; + if (!rows.length || rows[0]!.x == null) return null; + return [rows[0]!.x, rows[0]!.y]; + } catch { + return null; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Rank computation (via precomputed target_neighbors table) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Look up the rank of a guess word relative to a target. + * Uses the precomputed target_neighbors table (top 5k per target). + * + * For in-vocab words within the top 5k: O(1) btree lookup. + * For out-of-vocab or low-ranked words: fetch both vectors, compute + * cosine, then count how many top-5k neighbors have higher cosine. + */ +export async function computeGuessRank( + lang: string, + target: string, + guess: string, + guessVec?: Float32Array +): Promise { + if (guess === target) return 1; + + try { + // Fast path: precomputed rank lookup + const rows = await prisma.$queryRaw>` + SELECT rank FROM wordle.target_neighbors + WHERE lang = ${lang} AND target_word = ${target} AND word = ${guess} + LIMIT 1 + `; + if (rows.length) return rows[0]!.rank; + + // Slow path: word not in top 5k (or out-of-vocab) + // Fetch both vectors, compute cosine, count how many top-5k beat it + const gVec = guessVec ?? (await getEmbedding(lang, guess)); + const tVec = await getEmbedding(lang, target); + if (!gVec || !tVec) return null; + + const guessCos = dotProduct(gVec, tVec); + + // Count neighbors with higher cosine (all 5k are stored) + const countRows = await prisma.$queryRaw>` + SELECT COUNT(*) as cnt FROM wordle.target_neighbors + WHERE lang = ${lang} AND target_word = ${target} AND cosine > ${guessCos} + `; + const betterCount = Number(countRows[0]?.cnt ?? 0); + return betterCount + 1; + } catch (e) { + console.warn('[semantic-db] computeGuessRank failed:', e); + return null; + } +} + +/** + * Get the total number of ranked words for a target (vocab size). + */ +export async function getTotalRanked(lang: string): Promise { + try { + const rows = await prisma.$queryRaw>` + SELECT COUNT(*) as cnt FROM wordle.word_embeddings + WHERE lang = ${lang} AND is_vocab = true + `; + return Number(rows[0]?.cnt ?? 0); + } catch { + return 0; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// k-Nearest Neighbors (pgvector HNSW) +// ═══════════════════════════════════════════════════════════════════════════ + +export interface NeighborResult { + word: string; + similarity: number; + umapX?: number; + umapY?: number; + pca2dX?: number; + pca2dY?: number; +} + +/** + * Find the k nearest neighbors to a word using pgvector's HNSW index. + * Uses cosine distance operator (<=>). + */ +export async function knnNearest( + lang: string, + word: string, + k: number, + excludeWords: string[] = [] +): Promise { + try { + // Get the word's embedding first + const vec = await getEmbedding(lang, word); + if (!vec) return []; + + return knnNearestByVector(lang, vec, k, excludeWords); + } catch { + return []; + } +} + +/** + * Find k nearest neighbors by raw vector (for on-demand embeddings). + */ +export async function knnNearestByVector( + lang: string, + vec: Float32Array, + k: number, + excludeWords: string[] = [] +): Promise { + try { + const vecStr = `[${Array.from(vec).join(',')}]`; + + const rows = await prisma.$queryRaw< + Array<{ + word: string; + similarity: number; + umap_x: number | null; + umap_y: number | null; + pca2d_x: number | null; + pca2d_y: number | null; + }> + >` + SELECT word, + 1 - (embedding <=> ${vecStr}::vector) as similarity, + umap_x, umap_y, pca2d_x, pca2d_y + FROM wordle.word_embeddings + WHERE lang = ${lang} + AND is_vocab = true + AND word != ALL(${excludeWords}::text[]) + ORDER BY embedding <=> ${vecStr}::vector + LIMIT ${k} + `; + + return rows.map((r) => ({ + word: r.word, + similarity: r.similarity, + umapX: r.umap_x ?? undefined, + umapY: r.umap_y ?? undefined, + pca2dX: r.pca2d_x ?? undefined, + pca2dY: r.pca2d_y ?? undefined, + })); + } catch (e) { + console.warn('[semantic-db] knnNearest failed:', e); + return []; + } +} + +/** + * Store an on-demand embedding (out-of-vocab word) in the DB. + */ +export async function storeOnDemandEmbedding( + lang: string, + word: string, + vec: Float32Array +): Promise { + try { + const vecStr = `[${Array.from(vec).join(',')}]`; + await prisma.$executeRaw` + INSERT INTO wordle.word_embeddings (lang, word, embedding, is_target, is_vocab) + VALUES (${lang}, ${word}, ${vecStr}::vector, false, false) + ON CONFLICT (lang, word) DO UPDATE SET embedding = ${vecStr}::vector + `; + } catch { + // Non-critical — in-memory cache still works as fallback + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Vocab + target queries +// ═══════════════════════════════════════════════════════════════════════════ + +/** Get all target words for a language. */ +export async function getTargets(lang: string): Promise { + try { + const rows = await prisma.$queryRaw>` + SELECT word FROM wordle.word_embeddings + WHERE lang = ${lang} AND is_target = true + ORDER BY word + `; + return rows.map((r) => r.word); + } catch { + return []; + } +} + +/** Check if a word exists in the embedding vocabulary. */ +export async function wordExists(lang: string, word: string): Promise { + try { + const rows = await prisma.$queryRaw>` + SELECT COUNT(*) as cnt FROM wordle.word_embeddings + WHERE lang = ${lang} AND word = ${word} + `; + return Number(rows[0]?.cnt ?? 0) > 0; + } catch { + return false; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════ + +/** Parse pgvector's string representation "[0.1,0.2,...]" into Float32Array. */ +function parseVector(pgvectorStr: string): Float32Array { + const nums = pgvectorStr.replace(/^\[/, '').replace(/\]$/, '').split(',').map(Number); + return new Float32Array(nums); +} + +/** Dot product of two Float32Arrays (pre-normalized = cosine similarity). */ +function dotProduct(a: Float32Array, b: Float32Array): number { + let sum = 0; + for (let i = 0; i < a.length; i++) sum += a[i]! * b[i]!; + return sum; +} diff --git a/server/utils/word-stats.ts b/server/utils/word-stats.ts index 648535f0..44748718 100644 --- a/server/utils/word-stats.ts +++ b/server/utils/word-stats.ts @@ -15,9 +15,19 @@ const _statsSeenIps = new Set(); let _statsSeenDay: number | null = null; /** - * Load stats for a specific word/day. + * Load stats for a specific word/day. DB-first, disk fallback. */ -export function loadWordStats(langCode: string, dayIdx: number): WordStats | null { +export async function loadWordStats(langCode: string, dayIdx: number): Promise { + // Try DB first + try { + const { getWordStats } = await import('./db-cache'); + const dbStats = await getWordStats(langCode, dayIdx); + if (dbStats) return dbStats; + } catch { + // DB unavailable — fall through to disk + } + + // Disk fallback const statsPath = join(WORD_STATS_DIR, langCode, `${dayIdx}.json`); if (!existsSync(statsPath)) return null; try { @@ -28,7 +38,8 @@ export function loadWordStats(langCode: string, dayIdx: number): WordStats | nul } /** - * Atomically read-modify-write stats for a specific word/day. + * Atomically update stats for a specific word/day. + * DB-first (atomic SQL increment, no lockfile needed), disk fallback. */ export async function updateWordStats( langCode: string, @@ -36,34 +47,36 @@ export async function updateWordStats( won: boolean, attempts: number ): Promise { + // Primary: DB atomic increment (no read-modify-write, no lockfile) + try { + const { incrementWordStats } = await import('./db-cache'); + await incrementWordStats(langCode, dayIdx, won, attempts); + return; // Success — no disk write needed + } catch { + // DB unavailable — fall through to disk + } + + // Fallback: disk with lockfile (legacy behavior) const statsDir = join(WORD_STATS_DIR, langCode); const statsPath = join(statsDir, `${dayIdx}.json`); mkdirSync(statsDir, { recursive: true }); - // Use proper-lockfile for atomic updates let lockfile: typeof import('proper-lockfile'); try { lockfile = await import('proper-lockfile'); } catch { - // Fallback: write without locking _writeStats(statsPath, won, attempts); return; } - const lockPath = statsPath + '.lock'; - // Ensure lock target exists if (!existsSync(statsPath)) { writeFileSync(statsPath, '{}', 'utf-8'); } let release: (() => Promise) | undefined; try { - release = await lockfile.lock(statsPath, { - stale: 10000, - retries: 0, - }); + release = await lockfile.lock(statsPath, { stale: 10000, retries: 0 }); } catch { - // Another process holds the lock; skip this update return; } From 6ce2b7462a796d6c1b6d296d8b36c61efb4c8ee5 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 11:51:12 +0100 Subject: [PATCH 03/58] fix: handle wrapped JSON formats in seed script --- scripts/seed-semantic-db.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/scripts/seed-semantic-db.ts b/scripts/seed-semantic-db.ts index bdfb8033..84aa6141 100644 --- a/scripts/seed-semantic-db.ts +++ b/scripts/seed-semantic-db.ts @@ -92,10 +92,12 @@ function loadJson(filename: string): T { async function seedWordEmbeddings() { const { words, embeddings } = loadEmbeddings(); - const targets = new Set(loadJson('targets.json')); + const targetsData = loadJson<{ targets?: string[] } | string[]>('targets.json'); + const targets = new Set(Array.isArray(targetsData) ? targetsData : targetsData.targets ?? []); const umap = loadJson>('umap.json'); const pca2d = loadJson>('pca2d.json'); - const vocabulary = new Set(loadJson('vocabulary.json')); + const vocabData = loadJson<{ words?: string[] } | string[]>('vocabulary.json'); + const vocabulary = new Set(Array.isArray(vocabData) ? vocabData : vocabData.words ?? []); const N = words.length; console.log(`[seed] Inserting ${N} word embeddings...`); @@ -163,9 +165,13 @@ async function seedWordEmbeddings() { } async function seedAxes() { - const axesData = loadJson>('axes.json'); + const rawAxes = loadJson>('axes.json'); + // Axes file may be wrapped: { version, axes: {...}, coherence_auc, ranges } + const axesData = rawAxes.axes ?? rawAxes; + const aucData = rawAxes.coherence_auc ?? rawAxes._auc ?? {}; + const rangesData = rawAxes.ranges ?? rawAxes._ranges ?? {}; const axisNames = Object.keys(axesData).filter( - (k) => !['_model', '_dims', '_auc', '_ranges'].includes(k) + (k) => !['version', '_model', '_dims', '_auc', '_ranges', 'coherence_auc', 'ranges'].includes(k) ); console.log(`[seed] Inserting ${axisNames.length} semantic axes...`); @@ -179,8 +185,8 @@ async function seedAxes() { if (!axis?.vector) continue; const vecStr = `[${axis.vector.join(',')}]`; - const auc = axesData._auc?.[name] ?? 0; - const ranges = axesData._ranges?.[name]; + const auc = aucData[name] ?? 0; + const ranges = rangesData[name]; await client.query( `INSERT INTO wordle.semantic_axes (lang, name, low_anchor, high_anchor, vector, auc, range_p5, range_p95) @@ -213,7 +219,8 @@ async function seedAxes() { async function seedTargetNeighbors() { const { words, embeddings } = loadEmbeddings(); - const targets = loadJson('targets.json'); + const targetsRaw = loadJson<{ targets?: string[] } | string[]>('targets.json'); + const targets = Array.isArray(targetsRaw) ? targetsRaw : targetsRaw.targets ?? []; const N = words.length; // Build word → index map From 6164adfc89a6bb8178123c00448f09d05689eb2e Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 12:00:29 +0100 Subject: [PATCH 04/58] feat: update all consumers to use DB-backed caches + semantic-db - wiktionary.ts: DB-first with disk fallback - semantic/hint.post.ts: DB cache for LLM hints - semantic/guess.post.ts: SEMANTIC_DB=1 flag enables pgvector rank lookup via target_neighbors table, with in-memory fallback - semantic/reveal.post.ts: pgvector kNN for neighbor reveal - seed-semantic-db.ts: sanitize NaN/Infinity float coordinates --- assets/css/design-system.css | 19 +++++ components/app/AppHeader.vue | 8 +- components/semantic/SemanticLeaderboard.vue | 18 +--- components/shared/BaseModal.vue | 2 +- pages/[lang]/semantic.vue | 11 +-- scripts/seed-semantic-db.ts | 12 ++- server/api/[lang]/semantic/guess.post.ts | 94 +++++++++++++++++---- server/api/[lang]/semantic/hint.post.ts | 25 +++++- server/api/[lang]/semantic/reveal.post.ts | 35 ++++++++ server/utils/wiktionary.ts | 26 ++++-- 10 files changed, 192 insertions(+), 58 deletions(-) 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/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' }" > -
+
- -
-
- Next daily Dordle - 07:42:15 -
-
- -
After daily — streak is product-wide (14)
- -
-
-
9:41•••
-
-
Dordle
-
- - EN · Unlimited · 6/7 -
-
-
-
-
RIVER & GHOST
-
Nice one. Another round?
-
-
-
18
Rounds
-
72
Win %
-
Streak
-
Best
-
-
- - -
- -
-
-
After unlimited — 2 buttons + subtle text link for daily nudge
-
- - -
-

B. Keyboard Flip Panel

-

- After game over, the keyboard flips to reveal a discovery panel. Currently shows - mode suggestions. Add: "Keep playing: Unlimited Dordle" as a prominent - card alongside other mode suggestions. This surface reaches players who dismiss the - stats modal quickly. -

- -

C. Scroll-Down Section

-

- Below the game board, the SEO content area has FAQ, tips, recent words, and mode links. - The "More Game Modes" section should surface daily/unlimited variants - with contextual text: "Finished today's Dordle? Play unlimited rounds — same rules, - random words." This is the lowest-friction surface (always visible, no modal). -

-
- - - -
-
- -

Homepage

-
Mode cards adapt to player state
- -

- Each mode card shows available play types via small tags. Returning users see active - game state with contextual CTAs. Compare to - F1 (Homepage Hub) and - Screen 01. -

-
- -
-
-
-
9:41•••
-
-
Wordle.Global
- -
-
-
Game Modes
-
Classic

The original. 6 guesses.

Daily
🔥 14
-
▦▦
Multi-Board

2–32 boards at once

Daily·Unlim
-
Semantic

Navigate meaning space

Daily·Unlim
-
Speed Streak

Race the clock

Daily·Unlim
-
-
-
New visitor — click → daily by default
-
-
-
-
9:41•••
-
-
Wordle.Global
- -
-
-
Game Modes
-
Classic #1756

Solved ✓ · 3/6

🔥 14
-
▦▦
Dordle #142

In progress · 3/7

Continue →
-
Semantic #99

Solved ✓

Play Unlimited →
-
Speed Streak

Race the clock

Daily·Unlim
-
-
-
Returning — cards show game state. Solved modes nudge unlimited.
-
-
-
- - -
-
- -

Board Picker Modal

-
Repurposed from GameModePicker. Opens when clicking "Multi-Board" in sidebar.
- -

- The existing GameModePicker.vue modal evolves into a board picker. - When clicking "Multi-Board" in the sidebar, this modal opens showing the 5 board - options (Dordle through Duotrigordle). Each row shows the board name, board count, - and Daily / Unlim tags. - Clicking a tag navigates directly. -

-

- This is better than inline sidebar expansion because 5 modes × 2 play types = 10 - sub-items would bloat the sidebar. The modal gives each option breathing room. -

-
- -
-
-
-
9:41•••
-
- ← Back -
Multi-Board
- -
-
-
▦▦
Dordle

2 boards · 7 guesses

Daily·Unlim
-
▦×4
Quordle

4 boards · 9 guesses

Daily·Unlim
-
▦×8
Octordle

8 boards · 13 guesses

Daily·Unlim
-
▦16
Sedecordle

16 boards · 21 guesses

Daily·Unlim
-
▦32
Duotrigordle

32 boards · 37 guesses

Daily·Unlim
-
-
-
Board picker — click Daily or Unlim tag to navigate directly
-
-
-
- - -
-
- -

Language Picker Modal

-
Opens from sidebar or header flag. No page navigation.
- -

- When in a game, clicking the language item in the sidebar (or the flag icon in the - header subtitle) opens a modal showing the language grid. Selecting a language - navigates to the same mode + same play type in the new language. -

-

- The modal filters languages by what's available for the current mode (e.g. Semantic - only shows English). On the homepage/landing page, the language picker remains - inline (the flag grid is already there). -

-
- -
-
-
-
9:41•••
-
-   -
Choose Language
- -
-
-
- -
-
🇬🇧
English

Current language

-
🇫🇷
Français

French

-
🇩🇪
Deutsch

German

-
🇪🇸
Español

Spanish

-
🇮🇹
Italiano

Italian

-
+ 75 more languages
-
-
-
Language modal — search + grid. Selecting navigates to same mode in new language.
-
-
-
- - -
-
- -

Streaks

-
Product-wide. Any daily mode, any language.
- -

- One global streak. Playing ANY daily mode in ANY language continues your - streak. You don't need to play the same mode or language every day — just complete - at least one daily game per calendar day. -

- -
- Example:
- Day 12: Classic English → streak continues
- Day 13: Dordle Finnish + Semantic English → streak continues
- Day 14: Quordle German → streak continues
- Day 15: (nothing) → streak breaks -
- -

- The streak badge in the header (🔥 14) shows one global number. The streak dropdown - (see Streak exploration) can show a - calendar heatmap with sub-info per day: which modes and languages were played. -

- - - - - - - -
Play TypeStreakWin %Games PlayedBest Streak
Daily (any mode)🔥 Product-widePer-modePer-modeProduct-wide
UnlimitedPer-modePer-mode (rounds)
- -

- Per-mode stats still exist: win rate, games played, guess distribution. - They just don't have their own streak. The streak is the one number that represents - your overall engagement with Wordle Global. -

-
-
- - -
-
- -

Archive

-
Daily-only. Per-mode. At /en/archive.
- -

- URL: /en/archive is the canonical URL. /en/words - gets a 301 redirect (preserve SEO equity from existing indexed pages). -

-

- Mode filter tabs at the top: Classic, Dordle, Quordle, Octordle, Sedecordle, Semantic, Speed. - Each tab shows that mode's daily word history with the player's result. - Unlimited games don't appear — they're ephemeral. -

-

- The existing archive page (/en/words) already has a good design — - paginated word cards with mini tiles, definitions, AI art thumbnails, and community stats. - Don't redesign the Classic archive. Changes: -

-

- • URL rename: /en/archive canonical, 301 from /en/words
- • Mode filter tabs at the top: Classic, Dordle, Quordle, Semantic, Speed
- • When viewing "All" modes, each card gains a small mode label -

- -

Multi-board archive cards

-

- The Classic archive card shows 1 word with tiles + definition + art. That works for - Classic, Semantic, and Speed (1 word per day). Multi-board modes need a different - card format since one day has N words: -

- - - - - - - - - - - - - -
ModeWords/DayArchive Card Format
Classic1Existing card — word, tiles, definition, art. No change.
Semantic1Target word + best rank + guess count. Same card shape.
SpeedvariesSummary: "8 words solved in 5:00". No individual word cards.
Dordle2Two words side by side on one card. Compact tiles per word.
Quordle42×2 mini grid. Word name + result per cell. No full tiles.
Octordle8Summary card: "Solved 7/8 boards". Expandable to show individual words.
Sedecordle16Summary card only. "Solved 14/16". Click to expand word list.
Duotrigordle32Summary card only. "Solved 28/32". Click to expand. No tile rendering — just word names + solved/failed.
- -
- Principle: archive cards get progressively more summarized as board count increases. - 1–2 words: show everything. 4 words: mini grid. 8+: summary with expand. - The card never tries to render 32 sets of tiles — that's unreadable. -
-
- - -
-
- -

Word Pages

-
Cross-mode daily history on every word detail page
- -

- Each word detail page (/en/word/chase) already shows the word's definition, - part of speech, and AI illustration. With multi-mode dailies, a word can appear as - the daily target in multiple modes on different days. -

-

- New section: "Appeared in" — shows which daily modes featured this word - and on which day. This creates internal links between word pages and the archive, - and gives players context ("I solved CHASE in Classic on Day #1742, but it was also - in Dordle #98"). -

- -
- Example for the word "CHASE":

- - Appeared in:
- • Classic Daily #1742 — Apr 2, 2026
- • Dordle Daily #98 (Board 1) — Mar 15, 2026
- • Quordle Daily #41 (Board 3) — Feb 28, 2026 -
-
- -

- Each entry links to the archive page filtered to that mode + day. - Words that haven't appeared in any daily mode don't show this section. - This is SEO-valuable: creates a web of internal links between word pages, - archive pages, and mode pages. -

-
-
- - -
-
- -

Routing & URLs

-
One URL per mode. Play type is a query param.
- - - - - - - - - - - - - - -
URLResult
/en/dordleDaily Dordle (default)
/en/dordle?play=unlimitedUnlimited Dordle
/en/semanticDaily Semantic (default)
/en/semantic?play=unlimitedUnlimited Semantic
/en/speedDaily Speed (default)
/en/speed?play=unlimitedUnlimited Speed
/enDaily Classic (no change)
/en/unlimitedUnlimited Classic (no change)
/en/archiveArchive (301 from /en/words)
- -

- ?play=unlimited triggers a soft reset. Last choice saved to localStorage - per mode. Classic keeps /unlimited for backward compat and SEO. -

-
-
- - -
-
- -

All Entry Points

-
Every path to a game
- -
-
Sidebar
mode → daily/unlim
-
-
Game
-
-
-
Stats Modal CTA
"play unlimited" / "try daily"
-
-
Game
-
-
-
Keyboard Flip
discovery panel after game over
-
-
Game
-
-
-
Scroll-Down Section
"More Game Modes" below fold
-
-
Game
-
-
-
Homepage Card
click → daily default
-
-
Game
-
-
-
Header Subtitle Tap
hidden — opens sidebar expanded
-
-
Sidebar
-
-
Game
-
-
-
Board Picker Modal
from "Multi-Board" sidebar item
-
-
Game
-
-
-
Direct URL / Share Link
/en/dordle?play=unlimited
-
-
Game
-
-
-
- - -
-
- -

What We're NOT Doing

-
Explicitly rejected approaches
- -

No config bar. - F3 had persistent Daily/Unlimited/Custom - pills. Rejected — too much UI for a binary choice that's made once per session.

- -

No in-game toggle. - Option B toggled play type mid-game. - Confusing. You pick before you play.

- -

No separate sidebar items for daily/unlimited per mode. - Doubles the sidebar. Multi-Board uses a picker modal instead.

- -

No per-mode streaks. - Product-wide streak is simpler, more motivating, and easier to maintain. - Per-mode stats (win rate, games played) still exist.

- -

No full-screen mode picker modal for all modes. - Screen 09 was redundant with the sidebar. - The board picker modal only opens for Multi-Board (5 sub-modes need space).

-
-
- - - diff --git a/public/design-explorations/direction-a-editorial.html b/public/design-explorations/direction-a-editorial.html deleted file mode 100644 index 3b67f4f2..00000000 --- a/public/design-explorations/direction-a-editorial.html +++ /dev/null @@ -1,5995 +0,0 @@ - - - - - -Direction A: Editorial — Wordle Global Design Exploration - - - - - - - -
- DIRECTION A: EDITORIAL — Newspaper meets modern app - View C: Quiet Craft → -
- - - - -
- 01 / Homepage - -
-

Wordle.Global

-
The world's word game — 80 languages
-
-
- -

One word. Six guesses. Every language on Earth.

- -
-
-
- WORDS -
-

Daily Puzzle

-

One word per day, per language. The classic. Come back tomorrow for a new challenge.

- Classic -
-
-
- -
-

Unlimited

-

Keep playing after you solve the daily. Random words, no waiting. Practice makes perfect.

- New -
-
-
- Q - U - A - D -
-

Quordle

-

Four boards, one keyboard. Solve all four words in nine guesses or less.

- New -
-
- -
-

Choose your language

-

80 languages. Your streaks, your stats, your words.

- -
-
EnglishEnglish14
-
SuomiSuomi7
-
Arabicالعربية
-
DeutschDeutsch3
-
EspañolEspañol
-
FrançaisFrançais21
-
ItalianoItaliano
-
PortuguêsPortuguês5
-
РусскийРусский
-
日本語日本語
-
한국어한국어
-
עבריתעברית
-
-
-
- - - - - -
- 03 / End of Game - -
-
-
Solved in 3 guesses
-
CHASE
-
/tʃeɪs/ · verb
-
- To pursue in order to catch or catch up with. - "The dog chased the rabbit across the field." -
-
- -
-
-
247
-
Played
-
-
-
94
-
Win %
-
-
-
14
-
Streak
-
-
-
21
-
Best
-
-
- -
-

Guess Distribution

-
1
2
-
2
14
-
3
42
-
4
31
-
5
11
-
6
3
-
- -
- - -
- -
- Next Wordle - 07:42:15 -
-
-
- - -
- 04 / Badges & Profile - -
-
H
-
-

Hugo

-
Joined January 2025 · Free tier
-
-
12 languages
-
247 games won
-
21 best streak
-
7 badges
-
-
-
- -
-

Achievement Badges

-

7 of 24 earned

- -
-
-
-
Week Warrior
-
7-day streak in any language
-
Earned Feb 14, 2026
-
-
-
-
Polyglot
-
Win in 5 different languages
-
Earned Mar 2, 2026
-
-
-
-
First Try
-
Solve a puzzle in one guess
-
Earned Jan 28, 2026
-
-
-
-
Scholar
-
Read 50 word definitions
-
Earned Mar 10, 2026
-
-
-
-
Centurion
-
Win 100 games total
-
Earned Mar 15, 2026
-
-
-
-
Sharp Shooter
-
Average under 3.5 guesses (50+ games)
-
Earned Mar 18, 2026
-
-
-
-
Night Owl
-
Solve within 5 min of midnight
-
Earned Feb 8, 2026
-
-
-
-
Month Master
-
30-day streak in any language
-
-
-
-
-
World Traveler
-
Win in 10 different languages
-
-
-
-
-
Diamond Streak
-
100-day streak in any language
-
-
-
-
-
Semantic Sage
-
Win 10 Semantic Explorer games
-
-
-
-
-
Quad Master
-
Win 25 Quordle games
-
-
-
-
-
- - -
- 05 / Semantic Explorer - -
-
-
Semantic Explorer
-
Navigate meaning space. Find the hidden word. 10 guesses.
- -
- - Abstract - Concrete - Natural - Artificial - - -
- - -
-
-
-
-
- - -
- computer - -
- garden - -
- kitchen -
- -
- - -
-
7 of 10 guesses remaining
-
- -
-
-

Compass Hints

-
-
- - Think smaller and more domestic -
-
- - Something you'd find indoors -
-
-
- -
-

Proximity

-
-
-
-
-
- Cold - Warm - Hot - Burning -
-
- -
-

Your Guesses

-
- 3 - kitchen - 82% -
-
- 2 - garden - 45% -
-
- 1 - computer - 12% -
-
-
-
-
- - - -
- 01 / Dordle — 2 Boards - -
-
- - -
-
-

Dordle

- English · #42 · Guess 5/7 -
-
- - -
-
- -
-
-
Board 1Solved (4)
-
-
S
L
A
T
E
-
S
H
A
R
P
-
S
W
A
M
P
-
S
P
A
R
K
-
-
Solved ✓
-
-
-
Board 2
-
-
S
L
A
T
E
-
S
H
A
R
P
-
S
W
A
M
P
-
S
P
A
R
K
-
M
I
-
-
2 guesses remaining
-
-
- -
-
-
Q
-
W
-
E
-
R
-
T
-
Y
-
U
-
I
-
O
-
P
-
-
-
A
-
S
-
D
-
F
-
G
-
H
-
J
-
K
-
L
-
-
-
Enter
-
Z
-
X
-
C
-
V
-
B
-
N
-
M
-
-
-
-
- - - -
- 02 / Tridle — 3 Boards - -
-
- - -
-
-

Tridle

- English · #42 · Guess 4/8 -
-
- - -
-
- -
- -
-
- Board 1 - Solved in 3 - Tap to expand -
-
-
C
L
A
S
S
-
C
H
A
S
E
-
-
- - -
-
- Board 2 - Playing -
-
-
C
R
A
N
E
-
C
L
A
S
S
-
S
T
O
N
E
-
F
L
-
-
4 guesses remaining
-
- - -
-
- Board 3 - Tap to focus -
-
-
S
T
O
N
E
-
F
L
-
-
-
- - -
-
-
Q
-
W
-
E
-
R
-
T
-
Y
-
U
-
I
-
O
-
P
-
-
-
A
-
S
-
D
-
F
-
G
-
H
-
J
-
K
-
L
-
-
-
Enter
-
Z
-
X
-
C
-
V
-
B
-
N
-
M
-
-
-
-
- - -
- 03 / Quordle — 4 Boards - -
-
- - -
-
-

Quordle

- English · #42 · Guess 5/9 -
-
- - -
-
- -
-
-
Board 1Solved (3)
-
-
C
R
A
N
E
-
C
L
A
S
S
-
C
H
A
S
E
-
-
Solved ✓
-
-
-
Board 2Solved (4)
-
-
C
R
A
N
E
-
C
L
A
S
S
-
S
T
O
N
E
-
O
T
H
E
R
-
-
Solved ✓
-
-
-
Board 3
-
-
C
R
A
N
E
-
C
L
A
S
S
-
S
T
O
N
E
-
D
R
I
V
E
-
F
L
-
-
4 remaining
-
-
-
Board 4
-
-
C
R
A
N
E
-
C
L
A
S
S
-
S
T
O
N
E
-
D
R
I
V
E
-
F
L
-
-
4 remaining
-
-
- -
-
-
Q
-
W
-
E
-
R
-
T
-
Y
-
U
-
I
-
O
-
P
-
-
-
A
-
S
-
D
-
F
-
G
-
H
-
J
-
K
-
L
-
-
-
Enter
-
Z
-
X
-
C
-
V
-
B
-
N
-
M
-
-
-
-
- - - -
- 04 / Language Selection - -
-

Choose Your Language

-

80 languages. Your progress follows you everywhere.

-
- -
- -
- - - - - - - -
-
- -
-
Active — Languages you're playing4 languages
- -
-
English
-
-
English
-
English
-
- Daily - Unlimited - Quordle - Semantic -
-
-
-
14
-
day streak
-
94% win rate
-
-
- -
-
Français
-
-
French
-
Français
-
- Daily - Unlimited - Quordle - Semantic -
-
-
-
21
-
day streak
-
89% win rate
-
-
- -
-
Suomi
-
-
Finnish
-
Suomi
-
- Daily - Unlimited - Quordle - Semantic -
-
-
-
7
-
day streak
-
76% win rate
-
-
- -
-
Deutsch
-
-
German
-
Deutsch
-
- Daily - Unlimited - Quordle - Semantic -
-
-
-
3
-
games played
-
67% win rate
-
-
- -
Popular12 languages
- -
-
Español
-
-
Spanish
-
Español
-
- Daily - Unlimited - Quordle - Semantic -
-
-
-
- -
-
Arabic
-
-
Arabic
-
العربية
-
- Daily - Unlimited - Quordle - Semantic -
-
-
-
- -
-
Hebrew
-
-
Hebrew
-
עברית
-
- Daily - Unlimited - Quordle - Semantic -
-
-
-
-
-
- - - -
- 05 / Speed Streak - -
-

Speed Streak

-
Solve as many as you can in 5 minutes
-
- -
-
-
- 0:00 - 3:24 - 5:00 -
-
- -
-
4 solved
-
3.2 avg guesses
-
28s avg time
-
- -
-
S
L
A
T
E
-
B
R
-
-
-
-
-
- -
- Crane - Light - Brisk - Flame -
-
- - - -
- 06 / Archive - -
-

Archive

-

Replay any past daily puzzle. See how your history stacks up.

- Premium Feature -
- -
-
-
-

March 2026

-
- - -
-
-
-
- MonTueWedThuFriSatSun -
-
-
-
1
-
2
3
4
5
6
7
8
-
9
10
11
12
13
14
15
-
16
17
18
19
20
21
22
-
23
24
25
26
27
28
29
-
30
31
-
-
-
- -
-
-

March Stats

-
Played19
-
Won17
-
Win Rate89%
-
Avg Guesses3.4
-
Best Streak8
-
-
-
-
- - - - - - - -
- 08 / Leaderboard - -
-

English Leaderboard

-
- -
- - - -
- -
-
- PlayerGuessesWin %Streak -
-
- 1 - SSannaK - 298%47 -
-
- 2 - MMarcDP - 396%33 -
-
- 3 - JJWright - 395%28 -
-
- 14 - HHugo YOU - 394%14 -
-
- 15 - AAnnaB - 493%12 -
-
- 16 - TTinaM - 491%9 -
-
-
- - - -
- 09 / Game Mode Selection - -
-
-

Choose a Game Mode

-

Different ways to play — same language, new challenges.

-
English English
-
- -
-
-
-
-

Daily Puzzle

-

One word per day. 6 guesses. The classic.

-
-
-
Solved ✓
-
14
-
-
- -
-
-
-

Unlimited

-

Random words, no limit. Play as much as you want.

-
-
-
Play →
-
-
- -
-
-
-

Dordle

-

2 boards, 1 keyboard, 7 guesses.

-
-
-
Play →
-
-
- -
-
-
-

Tridle

-

3 boards, 1 keyboard, 8 guesses.

-
-
-
Play →
-
-
- -
-
-
-

Quordle

-

4 boards, 1 keyboard, 9 guesses.

-
-
-
Play →
-
-
- -
-
-
-

Semantic Explorer

-

Navigate meaning space. 10 guesses.

-
-
-
Play →
-
-
- -
-
-
-

Speed Streak

-

5 minutes. Solve as many as you can.

-
-
-
Play →
-
-
-
-
-
- - - -
- 10 / Challenge a Friend - -
-
-

Challenge a Friend

-

Pick a word and send a link. See how many guesses your friend needs.

-
- -
-
-
C
-
H
-
A
-
S
-
E
-
-
Your friend will see: _ _ _ _ _
-
- - - -
-
- - - -
-
-
-
- - - - -
-

Exploratory

-

How should users navigate the game mode matrix? Five approaches, each with tradeoffs.

-

BOARD TYPE × PLAY TYPE × SOCIAL × LANGUAGE

-
- -
-
Option A
-
The Configurator
-
A "New Game" screen where you set all dimensions before playing. Like creating a custom match in a video game.
- -
-
-

Pros

-
    -
  • All options visible and discoverable
  • -
  • Clear mental model — set it and play
  • -
  • Easy to add new dimensions later
  • -
-
-
-

Cons

-
    -
  • Friction before play — too many choices
  • -
  • Intimidating for casual users
  • -
  • Daily puzzle doesn't need configuration
  • -
-
-
- -
-
New Game
-
-

New Game

- -
Board Type
-
- Classic - Dordle - Tridle - Quordle - Semantic - Speed -
- -
Play Type
-
- Daily - Unlimited - Custom Word -
- -
Social
-
- Solo - Party -
- -
Language
-
- English - Français - Deutsch - + 77 more -
- -
Word Length
-
- 4 - 5 - 6 - 7 - 8 -
- -
- -
-
The button label summarizes all selections
-
-
-
- -
-
Option B
-
Smart Defaults + In-Game Toggles
-
Start playing instantly with defaults (Classic · Daily · Solo · your language). Change any dimension from within the game via the header bar. No pre-game configuration.
- -
-
-

Pros

-
    -
  • Zero friction — tap and play immediately
  • -
  • Discoverable through exploration
  • -
  • Feels lightweight, not overwhelming
  • -
-
-
-

Cons

-
    -
  • Dimensions hidden behind icons — less discoverable
  • -
  • Switching mid-game could be confusing
  • -
  • Hard to represent Party mode as a toggle
  • -
-
-
- -
-
- - ℹ️ - - - Wordle - - 📊 - - -
-
- Classic - Dordle - Tridle - Quordle - + -
-
- Daily - Unlimited - 🇬🇧 EN - Party -
-
-
-
-
-
Board type tabs + play type pills + language/party in sub-bar
-
-
-
- -
-
Option C
-
Mode-First Sidebar
-
The sidebar is the primary navigation hub. Board types are top-level items. Play type, social, and language are settings within each mode. Like Discord's server/channel hierarchy.
- -
-
-

Pros

-
    -
  • Familiar pattern (Discord, Slack, Notion)
  • -
  • Each mode feels like its own "space"
  • -
  • Room for per-mode stats and streaks
  • -
-
-
-

Cons

-
    -
  • Sidebar takes screen real estate on mobile
  • -
  • Deep nesting: Mode → Play Type → Social
  • -
  • Switching language requires digging
  • -
-
-
- -
-
Wordle Global
-
-
-
Play
-
Classic
-
Daily · Solo
-
Unlimited
-
Party ●
-
Dordle
-
Tridle
-
Quordle
-
Semantic
-
Speed Streak
-
-
Language
-
🇬🇧 English
-
Change →
-
-
-
CLASSIC · DAILY · SOLO · ENGLISH
-
[game board here]
-
-
-
-
- -
-
Option D
-
Homepage Hub + Quick Actions
-
The homepage shows your active games, streaks, and quick-launch cards. Each card is a pre-configured combo. "Continue" buttons for in-progress games. Party and Custom are actions, not modes.
- -
-
-

Pros

-
    -
  • Personalized — shows what matters to you
  • -
  • Quick resume for daily players
  • -
  • Party/Custom as actions feels natural
  • -
-
-
-

Cons

-
    -
  • Complex homepage to build
  • -
  • Empty state problem for new users
  • -
  • May hide modes you haven't tried
  • -
-
-
- -
-
Wordle Global
-
-
Today's Puzzles
- -
-
-
English Daily
Classic · 5 letters
-
Solved ✓
-
-
-
-
Français Daily
Classic · 5 letters
-
Play →
-
-
-
-
English Quordle
4 boards · Daily
-
Play →
-
- -
- -
Quick Play
-
-
-
Unlimited
-
English · Classic
-
-
-
Speed Streak
-
5 min challenge
-
-
- -
- -
Social
-
- - -
-
-
-
- -
-
Option E
-
Two-Step: Pick Board → Configure
-
Step 1: Choose your board type (the primary decision). Step 2: A compact config bar for play type, social, and language. Separates the "what" from the "how".
- -
-
-

Pros

-
    -
  • Progressive disclosure — one choice at a time
  • -
  • Board type is the hero — most visual impact
  • -
  • Config bar is reusable across all modes
  • -
  • Party/Custom are first-class but not overwhelming
  • -
-
-
-

Cons

-
    -
  • Two steps to start playing (vs one tap)
  • -
  • Config bar adds a row of UI to every game
  • -
-
-
- -
-
-
Step 1: Pick board type
-
-
Wordle Global
-
-
-
-
Classic
1 board · 6 guesses
-
-
-
-
-
Dordle
2 boards · 7 guesses
-
-
-
-
-
Tridle
3 boards · 8 guesses
-
-
-
-
-
Quordle
4 boards · 9 guesses
-
-
-
-
-
Semantic
Meaning space · 10 guesses
-
-
-
-
-
Speed Streak
5 min timer
-
-
-
-
-
- -
-
Step 2: Configure and play
-
-
- ← Classic - 📊 ⚙ -
-
- Daily - Unlimited - Custom - - Solo - Party - - EN -
-
-
-
-
-
Config bar stays visible during play
Tap Party to create lobby from here
-
-
-
-
-
- -
-
Party Mode Screens
-
Party: Lobby → In-Game → Results
-
These screens work regardless of which navigation option is chosen. Party is an overlay that wraps any game mode.
- -
- - -
-
1. Create & share lobby
-
-
Party Lobby
-
-
-
Classic · English
-
Daily puzzle · 5 letters
-
- -
-
Players (2/6)
-
-
-
H
- Hugo - Ready ● -
-
-
S
- Sanna - Ready ● -
-
-
?
- Waiting for players... -
-
-
- -
- -
Copy
-
- - -
Everyone plays the same word simultaneously
-
-
-
- - -
-
2. Play with live progress
-
-
Party · Classic2 players
-
-
-
H
-
-
-
-
-
-
-
-
-
-
-
S
-
-
-
-
-
-
-
-
-
-
-
-
-
C
R
A
N
E
-
C
L
A
S
S
-
C
H
-
-
Progress strip shows guess # per player
No letter spoilers — just dots
-
-
-
- - -
-
3. Results & rematch
-
-
Party Results
-
-
-
CHASE
-
/tʃeɪs/ · verb
-
- -
-
-
1
-
Sanna
Solved in 2 · 0:34
-
2 guesses
-
🏆
-
-
-
2
-
Hugo
Solved in 3 · 1:12
-
3 guesses
-
-
-
- -
- - -
-
Rematch keeps the same lobby, new word
-
-
-
-
-
- - -
-

Refined: The Hybrid

-

Combining the Homepage Hub (D) with Two-Step Flow (E). The recommended architecture.

-
- -
-
F1 / Returning User Homepage
-
Homepage Hub
-
Returning users see their active games, languages, and social actions. One tap to continue. "New Game" for exploration.
- -
-
9:41●●● WiFi 🔋
-
-
Wordle.Global
-
- - -
-
-
- -
-
English
-
-
English Daily
-
Classic · 5 letters · #247
-
-
Solved ✓
-
-
-
French
-
-
Français Daily
-
Classic · 5 letters · 🔥 21
-
-
Play →
-
-
-
-
-
English Quordle
-
4 boards · Daily · #42
-
-
Play →
-
- -
- -
- - -
- English - French - Finnish - German -
+
-
- - -
-
Create Party
-
Custom Word
-
- -
- 247 won - 14 streak - 12 langs -
-
-
-
- -
-
F2 / New Game → Board Type Picker
-
Pick Your Board
-
The hero choice. Each board type is visually distinct. Tap to proceed to the game with the config bar.
- -
-
9:41●●● WiFi 🔋
-
- ← Back - New Game - -
-
-
-
-
-
Classic
-
1 board · 6 guesses · The original
-
- -
-
-
-
-
Dordle
-
2 boards · 7 guesses
-
- -
-
-
-
-
Tridle
-
3 boards · 8 guesses
-
- -
-
-
-
-
Quordle
-
4 boards · 9 guesses
-
- -
-
-
-
-
Semantic Explorer
-
Navigate meaning · 10 guesses
-
- -
-
-
-
-
Speed Streak
-
5 minutes · Solve as many as you can
-
- -
-
More modes coming soon
-
-
-
- -
-
F3 / In-Game with Config Bar
-
Play with Persistent Config
-
The config bar stays visible during gameplay. Switch between Daily/Unlimited, toggle Party, or change language without leaving the game.
- -
-
9:41●●● WiFi 🔋
-
- - Classic -
- - -
-
-
- Daily - Unlimited - Custom - - Solo - Party - - EN -
-
-
#247 · March 20, 2026
-
-
C
R
A
N
E
-
C
L
A
S
S
-
C
H
-
-
-
-
-
-
Q
W
E
R
T
Y
U
I
O
P
-
A
S
D
F
G
H
J
K
L
-
ENTER
Z
X
C
V
B
N
M
-
-
-
-
- -
-
F4 / Party — Triggered from Config Bar
-
Party as an Overlay
-
Tapping "Party" in the config bar opens a bottom sheet. Share the link, wait for friends, start when ready. Works for any board type.
- -
-
9:41●●● WiFi 🔋
-
- - Classic -
- - -
-
-
- Daily - Unlimited - Custom - - Solo - Party ● - - EN -
-
-
-
-
-

Party Mode

-
Play Classic · English · Daily together
- -
-
Players (2/6)
-
-
-
H
- Hugo - Host ● -
-
-
S
- Sanna - Joined ● -
-
-
?
- Waiting... -
-
-
- -
- -
Copy
-
- - -
Everyone plays the same word simultaneously
-
-
-
-
- - - - - - - - - - - - - - - - - -
- Streak / Navbar -
-

Streak in the Header

-

- The flame sits to the left of the stats icon — visible but not loud. - The badge changes color and intensity with streak length. Inspired by Duolingo but adapted to our editorial restraint. -

- -
- - - - - -
- -
-
No active streak
-
-
-
- - -
-
-
-
English
-
#1737
-
-
-
- - - -
-
-
-

Gray flame, no count — invites the user to start a streak.

-
- -
-
Dev / Product Notes
-
    -
  • Data source: statsStore.stats.current_streak (already computed from localStorage game results)
  • -
  • Placement: Between info (i) and stats (bar chart) icons in GameHeader.vue
  • -
  • States: No streak (gray, 0.5 opacity) / Active 1-6 (orange, static) / Hot 7+ (orange, gentle pulse animation) / Frozen (blue, snowflake accent)
  • -
  • Animation — "Catch fire": When the user completes a game and their streak increments, the flame should briefly ignite — scale up with a glow burst (600ms), then settle. CSS keyframe: scale(1) -> scale(1.6) + drop-shadow glow -> scale(1). Only on the transition from streak N to N+1, not on page load.
  • -
  • Animation — "Freeze": When a freeze is consumed, the flame transitions from orange to blue with a brief frost particle effect (CSS radial gradient burst). The badge gets a subtle crystalline shimmer.
  • -
  • Click target: Opens the streak dropdown (see next section). On mobile, opens as a bottom sheet instead of dropdown.
  • -
  • Speed mode: Streak badge hidden — speed is session-based, not daily.
  • -
-
-
-
- - -
- Streak / Dropdown -
-

Click the Flame

-

- Tapping the streak badge opens a dropdown: hero number, calendar heatmap of the past month, streak stats, and freeze status. -

- -
- -
-
Active — 12 days
-
-
- -
12
-
Day streak
-
You're on fire! Keep it going.
-
-
-
March 2026
-
MTWTFSS
-
-
1
-
2
3
4
5
6
7
8
-
9
10
11
12
13
14
15
-
16
17
18
19
20
21
22
-
-
-
-
-
12
Current
-
34
Longest
-
-
-
-
1 Streak Freeze
Protects your streak if you miss a day
-
- -
-
- - -
-
Frozen — used a freeze
-
-
- -
12
-
Day streak — frozen
-
Your streak was saved! Play today to keep going.
-
-
-
-
No Freezes Left
Get more with Wordle Global+
- -
- -
-
-
- -
-
Dev / Product Notes
-
    -
  • Trigger: Click/tap the streak badge in the navbar. Opens as dropdown (desktop) or bottom sheet (mobile).
  • -
  • Calendar data: Built from gameResults in localStorage — each entry has a date. Map dates to played/missed/frozen grid cells.
  • -
  • Frozen days: Detected when a day has no game result but the streak didn't break. Requires storing freeze usage timestamps.
  • -
  • "View full stats" link: Navigates to /stats page (already exists).
  • -
  • Freeze count: Free users: 1 freeze, replenishes every Monday. Premium: unlimited. Stored in localStorage, validated server-side for premium.
  • -
  • Auto-dismiss: Dropdown closes on outside click or Escape key (same as BaseModal pattern).
  • -
-
-
-
- - -
- Streak / Milestones -
-

Streak Milestones

-

- Celebrations at 7, 30, 100, and 365 days. Each progressively more dramatic. Click to preview. -

- -
-
- -
7
Days
"First Week"
-
-
- -
30
Days
"Monthly Master"
-
-
- -
100
Days
"Century Club"
-
-
- -
365
Days
"Year of Words"
-
-
-

Click a milestone to preview the celebration

- -
-
Dev / Product Notes
-
    -
  • When it appears: Celebration overlay triggers immediately after the stats modal auto-shows, IF the user just crossed a milestone threshold. Sequence: tiles reveal -> bounce -> keyboard flip -> stats modal -> milestone celebration on top.
  • -
  • Haptic feedback: haptic.success() pattern on milestone hit. Sound: ascending 4-note chime (reuse win sound but extended).
  • -
  • Confetti: The dotted confetti pattern in the card is CSS-only (background radial gradients). For the overlay entrance, consider a lightweight canvas confetti burst (200ms, 30 particles, gravity fall).
  • -
  • Share prompt: After dismissing the celebration, optionally prompt "Share your streak?" with a pre-formatted share text: "I just hit a 30-day streak on Wordle Global! wordle.global"
  • -
  • Persistence: Track which milestones the user has seen in localStorage to avoid re-showing on refresh. Key: streak_milestones_seen: [7, 30]
  • -
  • Future milestones: Could extend to 500, 1000. Consider language-specific milestones ("Play 7 languages in a week").
  • -
-
-
-
- - -
- Streak / Freeze & Premium -
-

Streak Freeze

-

- Miss a day, your freeze auto-activates to save the streak. - Free users get 1 freeze (replenishes weekly). Premium gets unlimited + streak recovery. -

- -
-
-
- - Wordle Global+ -
-
Never Lose Your Streak
-
Keep your momentum with unlimited freezes and more
-
-
-
-
-
Unlimited Streak Freezes
Miss a day without losing your streak. Free users get 1 per week.
-
-
-
-
Streak Recovery
Broke your streak? Restore it within 48 hours.
-
-
-
-
No Ads, Ever
Clean, distraction-free gameplay.
-
-
-
- -
7 days free, then $2.99/month
-
-
-
-
Dev / Product Notes
-
    -
  • Freeze mechanic: If the user doesn't play on a given day AND has a freeze available, the freeze auto-consumes at midnight UTC. The next time they open the app, the flame shows blue (frozen state) with "Your streak was saved!" messaging.
  • -
  • Free tier: 1 freeze, replenishes every Monday at 00:00 UTC. Stored in localStorage: { freezes_available: 1, last_replenish: "2026-03-17" }
  • -
  • Premium tier: Unlimited freezes + streak recovery (restore a broken streak within 48 hours). Revenue model: $2.99/month or $24.99/year.
  • -
  • Premium gate UX: When free user runs out of freezes, the dropdown shows "Get more" button -> opens premium upsell modal. Also accessible from Settings.
  • -
  • Server validation: Premium status and freeze usage should be validated server-side to prevent localStorage tampering. But streak itself stays client-side (privacy, offline play).
  • -
  • Implementation order: 1) Streak badge in navbar (free, no backend). 2) Streak dropdown with calendar (free, no backend). 3) Milestone celebrations (free, no backend). 4) Streak freeze (needs freeze storage). 5) Premium (needs auth + payments).
  • -
-
-
-
- - - - - - - - - - - - - - -
- Streak / Delight Effects -
-

Delight Prototypes

-

- Interactive prototypes of every streak effect. Each panel is a working demo — click the buttons to trigger. - Dev notes explain implementation. All effects respect prefers-reduced-motion. -

- -
- - -
-
Living Flame
-
Clean Lucide icon + CSS transforms + canvas embers
- -
- - -
-
Organic Flicker
-
- -
-
- - -
-
- - -
-
Flicker + Embers
-
- - -
-
- - - -
-
- - -
-
Win Sequence
-
- - -
-
- - -
-
- -
- - -
-
Size Variants — badge / header / celebration
-
-
- -
18px
-
-
- -
36px
-
-
- -
64px
-
-
-
- -
- When this appears - Same Lucide flame icon throughout. At 30+ days it flickers; at 50+ embers detach from the tip. - Win Sequence shows the full chain: gray dormant icon -> warms to orange -> catches ablaze with embers -> settles to gentle flicker. -
-
- - -
-
Glow Escalation
-
CSS only — zero JS
-
-
- - 3 -
-
-
- - - - -
-
- When this appears - Always visible in header when streak >= 1. Glow tier set on page load — no entrance animation. -
-
- - -
-
Fire Particles
-
Canvas2D — ~30 particles
-
- -
- - 47 -
-
-
- - - -
-
- When this appears - Simmer: ambient at 30+ day streaks. Catch ablaze: ~2s burst on game win, then settles back to simmer. -
-
- - -
-
Heat Distortion
-
SVG feTurbulence filter
-
-
-
- - 100 -
-
-
- - -
-
- When this appears - 100+ day streaks only. Stacks with fire particles for the full hot streak look. -
-
- - -
-
Frost & Snowfall
-
CSS + Canvas2D
-
- -
- - 12 -
-
-
-
- - -
-
- When this appears - Freeze auto-activates at midnight if user missed today. Next visit: frozen badge + snowfall. Playing thaws it back to warm. -
-
- - -
-
Tile Ember Sweep
-
CSS keyframes — staggered per tile
-
-
F
-
L
-
A
-
M
-
E
-
- -
- When this appears - After final tile-flip on win. Sweeps the winning row, then badge does catch ablaze. Chain: tiles catch fire -> badge ignites. -
-
- - -
- -
Milestone Confetti
-
Canvas2D — gravity physics
-
- -
7
-
-
First Week Complete
-
- - - - -
-
- When this appears - Fires once when milestone modal opens. Scales with milestone: 7-day = 50 pieces, 365-day = 150 piece eruption. -
-
- -
- - -
-

Effect Escalation Map

- - - - - - - - - - - - - - - - - -
StreakBadge EffectWin MomentTech
1-6 daysWarm glow (level 1)Tile bounce onlyCSS
7-29 daysGlow pulse (level 2-3)Ember sweep + bounceCSS
30-99 daysFire particles (simmer)Particles blaze + sweepCanvas
100+ daysParticles + heat distortionFull blaze + sweep + distortionCanvas + SVG
FrozenFrost overlay + snowfallThaw transition on winCSS + Canvas
Milestone--Confetti burst (scaled)Canvas
-

- All effects skip when prefers-reduced-motion is set or .reduce-animations is active. Falls back to static glow/tint only. -

-
-
-
- - -
-
- - -
7
-
Day Streak!
-
First Week Complete
-
You've played Wordle every day for a week. The habit is forming.
-
- -
-
- - - - - - - - -
- Sedecordle / Layout Approaches -
-

Sedecordle Layouts

-

- 16 boards, 21 guesses. The fundamental problem: 42 tile rows in ~480px = 11px tiles. - Three approaches to making this playable. All interactive — click to explore. -

-

- Constraint: 480px available height (100vh - header - keyboard) / 21 guess rows x 2 board rows = 11px tiles. Unplayable. -

- - -
-

A. Row Collapsing

-

- Show recent guesses at full size. Collapse earlier rows into a tappable bar. - Tap "+N earlier" to expand and review, tap again to collapse. -

- -
-
Mobile — 2 boards, 21 guesses, rows collapsed
-
- -
-
Board 1
-
- -
- +12 earlier - -
- - -
S
T
A
R
E
-
C
L
O
U
D
-
M
I
G
H
T
-
B
R
I
N
T
-
W
A
G
E
T
- -
B
U
G
- -
-
-
3 guesses left
-
-
- -
-
Board 2
-
-
- +14 earlier - -
- -
P
L
A
N
E
-
P
O
I
N
T
-
P
A
I
N
T
- -
P
I
-
-
1 guess left
-
-
-
-
-
- How expand/collapse works - Tap "+N earlier" to smoothly expand all hidden rows (max-height transition). The board scrolls to keep the active row visible. Tap again to re-collapse. On desktop, hover shows a subtle preview. The collapsed bar doubles as a guess counter. The grid sizes for ~8 visible rows, giving 28px tiles on mobile. -
-
- - -
-

B. Scrollable Board Area

-

- All rows rendered. Board area scrolls vertically. Keyboard stays fixed at the bottom. - This is what sedecordle.com and duotrigordle.com do. -

- -
-
Desktop — 4x2 grid, scrollable, keyboard fixed
- -
-
- -
-
- -
-
-
-
Q
W
E
R
T
Y
U
I
O
P
-
-
-
A
S
D
F
G
H
J
K
L
-
-
-
ENT
Z
X
C
V
B
N
M
DEL
-
-
-
-
-
- How scrolling works - The board grid scrolls freely. Scroll-snap aligns to rows of boards. The keyboard is position: sticky at the bottom, always accessible. On mobile, the virtual keyboard replaces the on-screen one. After submitting a guess, auto-scroll to the first unsolved board that isn't visible. -
-
- - -
-

E. Focused Board + Thumbnails

-

- One board at full readable size. The rest as tiny colored thumbnails. - Click a thumbnail to swap the active board. -

- -
-
Mobile — 1 focused board, 16 thumbnails
- -
- -
- -
-
- -
-
-
Board 3 — Guess 6 of 21
-
-
- How focus swap works - Tap any thumbnail to swap it into the focus slot with a crossfade (opacity 0 -> swap content -> opacity 1, 200ms). The thumbnail strip scrolls horizontally. Solved boards get a green border and dimmed opacity. After a guess, if the focused board is solved, auto-advance to the next unsolved board. Keyboard colors show state for the focused board only. -
-
- - -
-

Comparison

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CriterionA. Row CollapseB. ScrollableE. Focus + Thumbs
Tile readability28px, readable16px, small but visible40px, best
Multi-board overview2-4 boards visible8 boards visible1 board + thumbnails
Guess history accessTap to expandAlways visible (scroll)Visible on focused board
Screen space usageGoodExcellentGood
Mobile UXGood (2 boards, big tiles)Scroll can conflict with keyboardGood (1 board, huge tiles)
Desktop UXGoodBest (all boards, scroll)Wastes space (1 board)
Implementation complexityMediumLow (just CSS overflow)High (swap animation, state)
RecommendationBest for mobileBest overall for desktopBest for single-board focus
-

- Recommended hybrid: B (scrollable) on desktop + A (row collapse) on mobile. Use E (focused) as an optional "zoom" interaction on any board. -

-
-
-
- - - - - - - - -
- Daily / Unlimited — Overview -
-

Daily vs Unlimited

-
Entry points, not toggles
-
Reframe
-
- Daily and unlimited aren't settings you toggle mid-game — they're - entry points. You choose which one before you start playing. - Once you're in, the game header tells you which type you're in, and that's it.

- The design problem isn't "how to switch" — it's how players discover - and navigate to both play types. Three surfaces matter: - 1. Sidebar, 2. Post-game CTAs, 3. Homepage mode cards. -
-
Prior Art
-
- Screen 02 — sidebar (daily-only), - Screen 03 — post-game "Play Unlimited" CTA, - Screen 09 — mode picker list, - F1 — hub with "Continue Playing" cards, - F3 — config bar with daily/unlimited pills (rejected — too heavy). -
-
Mode Matrix
- - - - - - - - - - - -
ModeTodayProposedNotes
ClassicDailyDailyStays daily. "Unlimited" is its own sidebar item.
UnlimitedUnlimUnlimClassic's unlimited variant. No change.
SpeedUnlimUnlimArcade survival. No daily variant.
DordleUnlimBothDaily: 2 deterministic words/day.
QuordleUnlimBothDaily: 4 deterministic words.
Octordle+UnlimBothSame for 8/16/32 boards.
SemanticDailyBothUnlimited: random target each game.
-
-
- - -
- Daily / Unlimited — In-Game Label -
-

In-Game: Just a Label

-
- The subtitle tells you which type you're in. "Dordle #142" = daily (the number says it). - "Dordle · Unlimited" = unlimited (accent color, no number). - Nothing interactive. No toggle. The subtitle is a label, not a control. -
-
-
-
9:41••• WiFi 🔋
-
-
English
-
Dordle #142
-
-
- 2 boards · game area -
-
Daily — the day number implies it
-
-
-
9:41••• WiFi 🔋
-
-
English
-
Dordle · Unlimited
-
-
- 2 boards · game area -
-
Unlimited — accent color signals the difference
-
-
-
-
- - -
- Daily / Unlimited — Sidebar -
-

Sidebar: Expandable Sub-Items

-
- Modes with both play types show a red dot on the right. Tapping expands to show - "Daily" and "Unlimited" as sub-items. Single-type modes (Classic, Unlimited, Speed) - navigate directly. Avoids doubling the sidebar length - (compare to Screen 02 which had no daily/unlimited distinction). -
-
- -
-
Play
-
Daily Puzzle
🔥 14
-
Unlimited
-
▦▦
Dordle
-
▦×4
Quordle
-
Semantic
-
Speed Streak
-
Collapsed — dot = has unlimited
-
- -
-
Play
-
Daily Puzzle
🔥 14
-
Unlimited
-
▦▦
Dordle
-
📅 Daily #142 🔥 3
-
Unlimited
-
▦×4
Quordle
-
Semantic
-
Speed Streak
-
Expanded — sub-items with streak
-
-
-
-
- - -
- Daily / Unlimited — Post-Game CTAs -
-

Post-Game: Cross-Pollination

-
- The stats modal is the highest-conversion moment. After daily, nudge into unlimited. - After unlimited, nudge into daily. Compare to Screen 03 which - already had a "Play Unlimited" button — this version is more prominent and contextual. -
-
- -
-
9:41••• WiFi 🔋
-
-
Dordle #142
-
Solved 5/7
-
-
-
-
CHASE & PLANT
-
Two down, infinite to go
-
-
-
42
Played
-
88
Win %
-
7
Streak
-
12
Best
-
- - -
- Next daily Dordle - 07:42:15 -
-
-
After daily: unlimited CTA in accent
-
- -
-
9:41••• WiFi 🔋
-
-
Dordle · Unlimited
-
Solved 6/7
-
-
-
-
RIVER & GHOST
-
Nice one. Another round?
-
-
-
18
Played
-
72
Win %
-
Streak
-
Best
-
- - - -
-
After unlimited: daily nudge in green
-
-
-
- After daily, "Play Unlimited" is accent-colored — "you're done for today, but there's more."
- After unlimited, "Play Again" is primary; "Try Daily" is a green nudge that only appears if today's daily is unplayed. -
-
-
- - -
- Daily / Unlimited — Homepage -
-

Homepage: Contextual Mode Cards

-
- Each card shows which play types are available via small labels. The card adapts - to the player's state — compare to F1's "Continue Playing" hub, - but applied to the mode grid instead of a separate section. -
-
- -
-
9:41••• WiFi 🔋
-
-
Wordle.Global
-
-
-
-
Game Modes
-
▦▦
Dordle

2 boards, 7 guesses

Daily·Unlim
🔥 7
-
▦×4
Quordle

4 boards, 9 guesses

Daily·Unlim
-
Semantic

Navigate meaning space

Daily·Unlim
-
Speed Streak

Race the clock

Unlim
-
-
New visitor — click card → daily by default
-
- -
-
9:41••• WiFi 🔋
-
-
Wordle.Global
-
-
-
-
Game Modes
-
▦▦
Dordle #142

In progress · 3/7

Continue →
-
▦×4
Quordle

4 boards, 9 guesses

Daily·Unlim
-
Semantic #99

Solved ✓

Play Unlimited →
-
Speed Streak

Race the clock

Unlim
-
-
Returning — cards adapt to game state
-
-
-
-
- - diff --git a/public/design-explorations/direction-c-quiet.html b/public/design-explorations/direction-c-quiet.html deleted file mode 100644 index e430b6c3..00000000 --- a/public/design-explorations/direction-c-quiet.html +++ /dev/null @@ -1,2354 +0,0 @@ - - - - - -Direction C: Quiet Craft — Wordle Global Design Exploration - - - - - - - -
- DIRECTION C: QUIET CRAFT — Warm, tactile, understated - ← View A: Editorial -
- - - - -
- 01 / Homepage - -
-

Wordle Global

-
The world's word game — eighty languages and counting
-
-
- -
-
-
-
- WORDS -
-
-

Daily Puzzle

-

One word per day, per language. The classic. Come back tomorrow for a new challenge.

-
-
-
-
- -
-
-

Unlimited

-

Keep playing after the daily. Random words from the full list, no waiting required.

- New -
-
-
-
- Q - U - A - D -
-
-

Quordle

-

Four boards at once. One keyboard. Nine guesses to solve them all.

- New -
-
- -
-

Choose your language

-

Your streaks, your stats, your words.

- -
-
EnglishEnglish 14
-
SuomiSuomi 7
-
العربيةالعربية
-
DeutschDeutsch 3
-
EspañolEspañol
-
FrançaisFrançais 21
-
ItalianoItaliano
-
PortuguêsPortuguês 5
-
РусскийРусский
-
日本語日本語
-
한국어한국어
-
עבריתעברית
-
-
-
- - - - - -
- 03 / End of Game - -
-
-
Solved in three guesses
-
CHASE
-
/tʃeɪs/ · verb
-
- To pursue in order to catch or catch up with. -
-
-
-
247
Played
-
94
Win %
-
14
Streak
-
21
Best
-
-
-

Guess Distribution

-
1
2
-
2
14
-
3
42
-
4
31
-
5
11
-
6
3
-
-
- - -
-
- Next Wordle - 07:42:15 -
-
-
- - -
- 04 / Badges & Profile - -
-
H
-
-

Hugo

-
Joined January 2025
-
-
12 languages
-
247 wins
-
21 best streak
-
7 badges
-
-
-
- -
-

Achievement Badges

-

7 of 24 earned

-
-
Week Warrior
7-day streak
Feb 14, 2026
-
Polyglot
Win in 5 languages
Mar 2, 2026
-
First Try
Solve in one guess
Jan 28, 2026
-
Scholar
Read 50 definitions
Mar 10, 2026
-
Centurion
Win 100 games
Mar 15, 2026
-
Sharp Shooter
Avg under 3.5 guesses
Mar 18, 2026
-
Night Owl
Solve at midnight
Feb 8, 2026
-
Month Master
30-day streak
-
World Traveler
Win in 10 languages
-
Diamond Streak
100-day streak
-
Semantic Sage
Win 10 Semantic games
-
Quad Master
Win 25 Quordle games
-
-
-
- - -
- 05 / Semantic Explorer - -
-
-
Semantic Explorer
-
Navigate the space of meaning. Ten guesses to find the hidden word.
- -
- Abstract - Concrete - Natural - Artificial - -
- -
-
-
-
- -
- computer - -
- garden - -
- kitchen -
- -
- - -
-
7 of 10 guesses remaining
-
- -
-
-

Compass Hints

-
- - Think smaller and more domestic -
-
- - Something you'd find indoors -
-
- -
-

Proximity

-
-
ColdWarmHotBurning
-
- -
-

Your Guesses

-
- 3 - kitchen - 82% -
-
- 2 - garden - 45% -
-
- 1 - computer - 12% -
-
-
-
-
- - - -
- 01 / Dordle — 2 Boards - -
-
- - -
-
-

Dordle

- English · #42 · Guess 5/7 -
-
- - -
-
- -
-
-
Board 1Solved (4)
-
-
S
L
A
T
E
-
S
H
A
R
P
-
S
W
A
M
P
-
S
P
A
R
K
-
-
Solved ✓
-
-
-
Board 2
-
-
S
L
A
T
E
-
S
H
A
R
P
-
S
W
A
M
P
-
S
P
A
R
K
-
M
I
-
-
2 guesses remaining
-
-
- -
-
-
Q
-
W
-
E
-
R
-
T
-
Y
-
U
-
I
-
O
-
P
-
-
-
A
-
S
-
D
-
F
-
G
-
H
-
J
-
K
-
L
-
-
-
Enter
-
Z
-
X
-
C
-
V
-
B
-
N
-
M
-
-
-
-
- - - -
- 02 / Tridle — 3 Boards - -
-
- - -
-
-

Tridle

- English · #42 · Guess 4/8 -
-
- - -
-
- -
- -
-
- Board 1 - Solved in 3 - Tap to expand -
-
-
C
L
A
S
S
-
C
H
A
S
E
-
-
- - -
-
- Board 2 - Playing -
-
-
C
R
A
N
E
-
C
L
A
S
S
-
S
T
O
N
E
-
F
L
-
-
4 guesses remaining
-
- - -
-
- Board 3 - Tap to focus -
-
-
S
T
O
N
E
-
F
L
-
-
-
- - -
-
-
Q
-
W
-
E
-
R
-
T
-
Y
-
U
-
I
-
O
-
P
-
-
-
A
-
S
-
D
-
F
-
G
-
H
-
J
-
K
-
L
-
-
-
Enter
-
Z
-
X
-
C
-
V
-
B
-
N
-
M
-
-
-
-
- - -
- 03 / Quordle — 4 Boards - -
-
- - -
-
-

Quordle

- English · #42 · Guess 5/9 -
-
- - -
-
- -
-
-
Board 1Solved (3)
-
-
C
R
A
N
E
-
C
L
A
S
S
-
C
H
A
S
E
-
-
Solved ✓
-
-
-
Board 2Solved (4)
-
-
C
R
A
N
E
-
C
L
A
S
S
-
S
T
O
N
E
-
O
T
H
E
R
-
-
Solved ✓
-
-
-
Board 3
-
-
C
R
A
N
E
-
C
L
A
S
S
-
S
T
O
N
E
-
D
R
I
V
E
-
F
L
-
-
4 remaining
-
-
-
Board 4
-
-
C
R
A
N
E
-
C
L
A
S
S
-
S
T
O
N
E
-
D
R
I
V
E
-
F
L
-
-
4 remaining
-
-
- -
-
-
Q
-
W
-
E
-
R
-
T
-
Y
-
U
-
I
-
O
-
P
-
-
-
A
-
S
-
D
-
F
-
G
-
H
-
J
-
K
-
L
-
-
-
Enter
-
Z
-
X
-
C
-
V
-
B
-
N
-
M
-
-
-
-
- - - -
- 04 / Language Selection - -
-

Choose your language

-

Eighty languages. Your progress follows you everywhere.

-
- -
-
- - 80 -
-
- - - - - - - -
-
- -
-
Your languages 4 active
-
-
-
English
-
-
English
-
English
-
- DailyUnlimitedQuordleSemantic -
-
-
-
14
-
day streak
-
94% win rate
-
-
-
-
French
-
-
French
-
Français
-
- DailyUnlimitedQuordleSemantic -
-
-
-
21
-
day streak
-
89% win rate
-
-
-
-
Finnish
-
-
Finnish
-
Suomi
-
- DailyUnlimitedQuordleSemantic -
-
-
-
7
-
day streak
-
76% win rate
-
-
-
- -
Popular 12 languages
-
-
-
Spanish
-
Spanish
Español
-
-
-
-
Arabic
-
Arabic
العربية
-
-
-
-
Hebrew
-
Hebrew
עברית
-
-
-
-
Japanese
-
Japanese
日本語
-
-
-
-
-
- - - -
- 05 / Speed Streak - -
-

Speed Streak

-
Solve as many as you can in five minutes
-
- -
-
3:24
-
-
- -
-
4 solved
-
3.2 avg guesses
-
28s avg time
-
- -
-
S
L
A
T
E
-
B
R
-
-
-
-
-
- -
- Crane - Light - Brisk - Flame -
-
- - - -
- 06 / Archive - -
-

Archive

-

Replay any past daily puzzle.

- Premium -
- -
-
-
-

March 2026

-
-
-
-
MTWTFSS
-
-
-
1
-
2
3
4
5
6
7
8
-
9
10
11
12
13
14
15
-
16
17
18
19
20
21
22
-
23
24
25
26
27
28
29
-
30
31
-
-
-
- -
-
-

March Stats

-
Played19
-
Won17
-
Win Rate89%
-
Avg Guesses3.4
-
-
-
-
- - - - - - - -
- 08 / Leaderboard - -

English Leaderboard

- -
- - - -
- -
-
PlayerGuessesWin %Streak
-
1SSannaK298%47
-
2MMarcDP396%33
-
3JJWright395%28
-
14HHugo YOU394%14
-
15AAnnaB493%12
-
-
- - - -
- 09 / Game Mode Selection - -
-
-

Choose a game mode

-

Different ways to play — same language, new challenges.

-
English English
-
- -
-
-
-

Daily Puzzle

One word per day. 6 guesses.

-
Solved
14
-
-
-
-

Unlimited

Random words, no limit.

-
Play →
-
-
-
-

Dordle

2 boards, 1 keyboard, 7 guesses.

-
Play →
-
-
-
-

Tridle

3 boards, 1 keyboard, 8 guesses.

-
Play →
-
-
-
-

Quordle

4 boards, 1 keyboard, 9 guesses.

-
Play →
-
-
-
-

Semantic Explorer

Navigate meaning space. 10 guesses.

-
Play →
-
-
-
-

Speed Streak

5 minutes. Solve as many as you can.

-
Play →
-
-
-
-
- - - -
- 10 / Challenge a Friend - -
-
-

Challenge a friend

-

Pick a word and send a link. See how many guesses your friend needs.

-
-
-
-
C
-
H
-
A
-
S
-
E
-
-
Your friend will see: _ _ _ _ _
-
- -
- -
-
-
- - - - - - - diff --git a/public/design-explorations/homepage-redesign.html b/public/design-explorations/homepage-redesign.html deleted file mode 100644 index 28b8149d..00000000 --- a/public/design-explorations/homepage-redesign.html +++ /dev/null @@ -1,392 +0,0 @@ - - - - - -Homepage Redesign — Wordle Global - - - - - - - -
- Homepage Redesign — Wordle Global - Editorial Exploration - Daily/Unlimited System -
- - -
- -

New Visitor vs Returning Player

-
Mode cards with daily/unlimited labels, game state, and a featured Semantic card
- -
-

- The homepage adapts to context. New visitors see mode cards with - "Daily · Unlimited" labels — click goes to daily by default. - Returning players see their active game state: in-progress games - get a "Continue" CTA, solved dailies nudge toward unlimited, and the streak badge - shows their product-wide streak. -

-

- Semantic Explorer gets a featured card with a larger icon, dark - background, and "NEW" badge to drive discovery of the newest mode. -

-
-
- - -
- - -
-
-
9:41••• WiFi
-
-

Wordle.Global

-
The world's word game — 80 languages
-
-
-
Game Modes
- - -
-
-
-

Wordle

-

One word per day. 6 guesses. The classic.

-
-
-
Daily·Unlim
-
-
- - - - - -
-
-
-

Speed Streak

-

Race the clock. Solve as many as you can.

-
-
-
Daily·Unlim
-
-
- - -
-
▦▦
-
-

Multi-Board

-

2–32 boards at once. Dordle, Quordle, and more.

-
-
-
Daily·Unlim
-
-
- -
Languages
-
-
English
-
Espanol
-
Deutsch
-
Francais
-
Italiano
-
Turkce
-
Suomi
-
+ 73 more
-
-
-
New visitor — click any card → daily by default
-
-
-
- - -
- - -
-
-
9:41••• WiFi
-
-

Wordle.Global

-
The world's word game — 80 languages
-
🔥 14 day streak
-
-
-
Your Games
- - -
-
-
-

Wordle #1756

-

Solved ✓ · 3/6

-
-
-
Play Unlimited →
-
-
- - -
-
▦▦
-
-

Dordle #142

-

In progress · 3/7

-
-
-
Continue →
-
-
- - - - - -
-
-
-

Speed Streak

-

Race the clock

-
-
-
Daily·Unlim
-
-
- - -
-
▦▦
-
-

Multi-Board

-

Quordle, Octordle, and more

-
-
-
Daily·Unlim
-
-
- -
Your Languages
-
-
-
-
-
+
-
-
-
Returning — streak badge, solved/in-progress states, featured Semantic
-
-
-
- - -
- -
-

4 mode cards, not 5. "Unlimited" is gone as a standalone card — it's Classic's unlimited variant, accessible via the sidebar or post-game CTA.

-

Semantic is featured. Larger card, dark icon bg, "NEW" badge. Positioned second after Classic to maximize discovery. Once it's no longer new, it becomes a regular card.

-

Multi-Board is one card. Clicking it opens the sidebar's Multi-Board section (or a picker modal) where you choose Dordle/Quordle/etc.

-

Streak badge on returning user. Product-wide streak (any daily mode, any language). Shows in the masthead area.

-

Solved dailies nudge unlimited. "Wordle #1756 — Solved" shows "Play Unlimited →" as the CTA, not a dead card.

-

Language flags as circles. Returning users see their played languages as flag circles with a "+" to add more. New visitors see the full grid.

-
-
- - - diff --git a/public/design-explorations/stats-redesign.html b/public/design-explorations/stats-redesign.html deleted file mode 100644 index 9161e1b4..00000000 --- a/public/design-explorations/stats-redesign.html +++ /dev/null @@ -1,453 +0,0 @@ - - - - - -Stats Redesign — Combined Daily + Unlimited View - - - - - - - -
- Stats Redesign — Combined Daily + Unlimited - Editorial - System Design -
- - -
- -

Combined View

-
Daily and unlimited stats in one place, per mode
-
-

- The stats modal and stats page show both play types for each mode. - A tab bar switches between Daily and Unlimited views. The product-wide streak - lives at the top — it's the one number that represents overall engagement. -

-

- Daily tab: games played, win %, guess distribution, streak (product-wide), - best streak. Unlimited tab: rounds played, win %, guess distribution. - No streak — unlimited is on-demand, not calendar-based. -

-
-
- - -
- - -
-
-
9:41•••
-
-
-
Wordle
-
English · #1756 · 3/6
-
- × -
-
- -
-
THETA
-
An angle or direction
-
- - -
-
14
-
Day Streak
-
Classic EN, Dordle EN, Semantic EN today
-
- - -
- - -
- - -
-
247
Played
-
94
Win %
-
14
Streak
-
21
Best
-
- - -
-
Guess Distribution
-
1
2
-
2
14
-
3
42
-
4
31
-
5
11
-
6
3
-
- - -
- - -
- -
- Next Wordle - 07:42:15 -
-
-
Daily tab active — streak + distribution + CTAs
-
- - -
-
9:41•••
-
-
-
Wordle
-
English · #1756 · 3/6
-
- × -
-
-
-
THETA
-
An angle or direction
-
- -
-
14
-
Day Streak
-
Streak is daily-only — any mode counts
-
- - -
- - -
- -
-
83
Rounds
-
78
Win %
-
Streak
-
Best
-
- -
-
Guess Distribution (Unlimited)
-
1
1
-
2
8
-
3
24
-
4
32
-
5
13
-
6
5
-
- -
- - -
-
-
Unlimited tab — rounds (not games), no streak, separate distribution
-
-
-
- - -
- - -
-
-
9:41•••
-
-
Statistics
- × -
-
- -
-
14
-
Day Streak
-
- -
-
412
Total Games
-
89
Win %
-
5
Languages
-
21
Best Streak
-
- - -
-
April 2026
-
-
-
-
1
-
2
-
3
-
4
-
5
-
6
-
7
-
8
-
9
-
10
-
-
-
-
-
1 mode
-
2+ modes
-
missed
-
-
- - -
-
-
-
-
Wordle
-
Daily: 247 played · 94%  |  Unlimited: 83 rounds · 78%
-
-
-
- -
-
-
▦▦
-
-
Dordle
-
Daily: 42 played · 88%  |  Unlimited: 18 rounds · 72%
-
-
-
- -
-
-
-
-
Semantic
-
Daily: 12 played · 75%  |  Unlimited: 5 rounds · 60%
-
-
-
- -
-
-
-
-
Speed Streak
-
Daily: 8 played · avg 12 words  |  Unlimited: 31 rounds
-
-
-
-
-
Stats page — global streak + calendar + per-mode daily/unlimited summary
-
-
-
- - -
- -
-

Streak is always visible. The product-wide streak hero sits above the tabs — it's the same regardless of which tab is active. It's the one motivating number.

-

Tabs switch stats, not streak. Daily tab shows daily stats + distribution. Unlimited tab shows unlimited stats + distribution. Streak doesn't change.

-

No streak for unlimited. The streak/best cells show "—" on the unlimited tab. Unlimited has rounds played and win %, but no consecutive-day concept.

-

Calendar heatmap. Shows which days you played (any daily mode). Green = 1 mode played. Dark green = 2+ modes. Red = missed. Expanding a day could show which modes/languages were played.

-

Per-mode breakdown. Each mode shows a one-line summary: "Daily: N played · X% | Unlimited: N rounds · X%". Clicking expands to full stats + distribution for that mode.

-

Stats modal vs stats page. The modal shows the current mode's stats (with tabs). The full stats page shows all modes with the calendar heatmap. Same tab pattern, different scope.

-
-
- - - From 5713eb04696fe0e0dafb882ae008a9a2ca63f05b Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 16:34:45 +0100 Subject: [PATCH 43/58] perf: eliminate 12-request waterfall on word explorer page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The word explorer had a 3-phase data waterfall: Phase 1 (SSR): basic word data Phase 2 (mount): word-explore → 80 neighbors (muted dots appear) Phase 3 (watch): 12× word-explore → individual foreground dot data Phase 3 fired 12 parallel HTTP requests (36-60 DB queries) just to get axis projections for the foreground dots. The UMAP coords and similarity scores were already in the phase 2 response. Fix: include axis projections for the top 15 neighbors in the explore response. One batch embedding fetch (1 DB query) + pure math on cached axis vectors. Foreground dots now render instantly from phase 2 data. - Add getEmbeddings() batch fetch to _semantic-db.ts - word-explore endpoint includes projections for top 15 neighbors - NearbyInMeaning uses neighbor projections directly (no contextData wait) - loadContextData only fetches for user-added custom words Result: 59KB single request replaces 15KB + 12×15KB = 195KB waterfall. Map renders in 1 phase instead of 3. --- components/word/NearbyInMeaning.vue | 37 +++++++++++++----- composables/useWordData.ts | 3 ++ pages/[lang]/word/[slug].vue | 18 ++++++--- server/api/[lang]/word-explore/[slug].get.ts | 41 ++++++++++++++------ server/utils/_semantic-db.ts | 16 ++++++++ 5 files changed, 89 insertions(+), 26 deletions(-) diff --git a/components/word/NearbyInMeaning.vue b/components/word/NearbyInMeaning.vue index 9ad70444..986dcb63 100644 --- a/components/word/NearbyInMeaning.vue +++ b/components/word/NearbyInMeaning.vue @@ -93,17 +93,34 @@ const mapDots = computed(() => { const seen = new Set([p.basic!.word]); + // Foreground dots: use neighbor data from explore response (instant, no extra fetches). + // Fall back to contextData for user-added words not in the neighbor list. + const neighbors = p.explore.nearest ?? []; + const neighborMap = new Map(neighbors.map((n) => [n.word, n])); + for (const w of props.contextWords) { - const cd = props.contextData[w]; - if (!cd?.explore || !cd.basic?.word) continue; - const projs = Object.fromEntries(cd.explore.projections.map((x) => [x.axis, x.normalized])); - dots.push({ - word: cd.basic.word, - pos2d: cd.explore.umap ?? [0.5, 0.5], - projections: projs, - role: 'foreground', - }); - seen.add(cd.basic.word); + const neighbor = neighborMap.get(w); + if (neighbor) { + dots.push({ + word: neighbor.word, + pos2d: neighbor.umap ?? [0.5, 0.5], + projections: neighbor.projections ?? {}, + role: 'foreground', + }); + seen.add(neighbor.word); + } else { + // User-added word not in neighbor list — use separately fetched data + const cd = props.contextData[w]; + if (!cd?.explore || !cd.basic?.word) continue; + const projs = Object.fromEntries(cd.explore.projections.map((x) => [x.axis, x.normalized])); + dots.push({ + word: cd.basic.word, + pos2d: cd.explore.umap ?? [0.5, 0.5], + projections: projs, + role: 'foreground', + }); + seen.add(cd.basic.word); + } } const backgroundPool = (p.explore.nearest ?? []) diff --git a/composables/useWordData.ts b/composables/useWordData.ts index ba19207e..f3436691 100644 --- a/composables/useWordData.ts +++ b/composables/useWordData.ts @@ -58,6 +58,9 @@ export type NeighborEntry = { word: string; similarity: number; umap: [number, number] | null; + /** Normalized axis projections — included for top ~15 foreground candidates + * so the map can render lens/slice views without per-word fetches. */ + projections?: Record; }; export type WordExploreData = { diff --git a/pages/[lang]/word/[slug].vue b/pages/[lang]/word/[slug].vue index b0ce455d..8d88f84c 100644 --- a/pages/[lang]/word/[slug].vue +++ b/pages/[lang]/word/[slug].vue @@ -181,26 +181,34 @@ const contextWords = computed(() => { return neighbors.slice(0, AUTO_CONTEXT_COUNT).map((n) => n.word); }); +// Context data — only fetched for user-added custom words that aren't in the +// primary's neighbor list (which already includes projections for top 15). +// Auto-selected context words render instantly from explore.nearest. const contextData = ref>({}); async function loadContextData() { + if (!context.isCustom.value) { + // Auto-mode: all context words come from explore.nearest — no extra fetches + contextData.value = {}; + return; + } const words = contextWords.value; const pw = primaryWord.value; if (!words.length || !pw) { contextData.value = {}; return; } + // Only fetch words not already in the explore neighbor list + const neighborWords = new Set( + (primary.value?.explore?.nearest ?? []).map((n) => n.word) + ); const next: Record = { ...contextData.value }; - const missing = words.filter((w) => !next[w]); + const missing = words.filter((w) => !next[w] && !neighborWords.has(w)); await Promise.all( missing.map(async (w) => { - // Pass `relativeTo` so the server includes cosine similarity - // between this context word and the primary — used by the map - // for faithful polar layout instead of UMAP bounding-box hacks. const d = await fetchAll(lang, w, pw).catch(() => null); if (d?.basic?.word) next[w] = d; }) ); - // Prune stale entries for (const k of Object.keys(next)) { if (!words.includes(k)) delete next[k]; } diff --git a/server/api/[lang]/word-explore/[slug].get.ts b/server/api/[lang]/word-explore/[slug].get.ts index a44869ba..a505424a 100644 --- a/server/api/[lang]/word-explore/[slug].get.ts +++ b/server/api/[lang]/word-explore/[slug].get.ts @@ -2,18 +2,23 @@ * GET /api/[lang]/word-explore/[slug] * * Semantic exploration data for a word: normalized axis projections, - * nearest neighbors, UMAP coordinates. For out-of-vocab words, - * fetches an embedding on-demand via OpenAI (stored in DB). - * Non-English languages return `available: false`. + * nearest neighbors with UMAP + projections, cosine similarity. + * + * The top FOREGROUND_COUNT neighbors include axis projections so the + * client can render foreground dots AND lens/slice views from a single + * request — no per-word follow-up fetches needed. */ import { cosineSimilarity } from '../../../utils/semantic'; import * as semanticDb from '~/server/utils/_semantic-db'; import { resolveWordSlug } from '../../../utils/word-selection'; import { loadAllData } from '../../../utils/data-loader'; +const NEIGHBOR_COUNT = 80; +const FOREGROUND_COUNT = 15; + const EMPTY_RESPONSE = { projections: [] as Array, - nearest: [] as Array<{ word: string; similarity: number }>, + nearest: [] as Array, umap: null as [number, number] | null, similarityTo: null as number | null, available: false, @@ -54,17 +59,31 @@ export default defineEventHandler(async (event) => { const projections = semanticDb.projectAxesDetailed(vec, 0.8); - // Parallel: neighbors + position (vec already in hand, use knnNearestByVector) const [neighbors, umap] = await Promise.all([ - semanticDb.knnNearestByVector(lang, vec, 80, [word]), + semanticDb.knnNearestByVector(lang, vec, NEIGHBOR_COUNT, [word]), semanticDb.get2dPosition(lang, word), ]); - const nearest = neighbors.map((n) => ({ - word: n.word, - similarity: n.similarity, - umap: n.umapX != null ? [n.umapX, n.umapY] as [number, number] : null, - })); + // Batch-fetch embeddings for foreground neighbors to compute their projections. + // Pure math on cached axis vectors — no extra DB round-trips beyond the batch fetch. + const foregroundWords = neighbors.slice(0, FOREGROUND_COUNT).map((n) => n.word); + const foregroundVecs = await semanticDb.getEmbeddings(lang, foregroundWords); + + const nearest = neighbors.map((n, i) => { + const entry: Record = { + word: n.word, + similarity: n.similarity, + umap: n.umapX != null ? [n.umapX, n.umapY] : null, + }; + // Include projections for foreground candidates + if (i < FOREGROUND_COUNT) { + const nVec = foregroundVecs.get(n.word); + if (nVec) { + entry.projections = semanticDb.projectAxes(nVec); + } + } + return entry; + }); let similarityTo: number | null = null; if (relativeTo) { diff --git a/server/utils/_semantic-db.ts b/server/utils/_semantic-db.ts index 6330b4a2..a13a9683 100644 --- a/server/utils/_semantic-db.ts +++ b/server/utils/_semantic-db.ts @@ -161,6 +161,22 @@ export function projectAxesDetailed( // Embedding lookups // ═══════════════════════════════════════════════════════════════════════════ +/** Batch-fetch embeddings for multiple words (1 query). */ +export async function getEmbeddings(lang: string, words: string[]): Promise> { + if (!words.length) return new Map(); + try { + const rows = await prisma.$queryRaw>` + SELECT word, embedding::text as vector FROM wordle.word_embeddings + WHERE lang = ${lang} AND word = ANY(${words}::text[]) + `; + const result = new Map(); + for (const r of rows) result.set(r.word, parseVector(r.vector)); + return result; + } catch { + return new Map(); + } +} + export async function getEmbedding(lang: string, word: string): Promise { try { const rows = await prisma.$queryRaw>` From 7f88ea918cba1a5b59c340f89c4135d7640fbece Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 16:37:38 +0100 Subject: [PATCH 44/58] =?UTF-8?q?chore:=20save=20all=20in-progress=20work?= =?UTF-8?q?=20=E2=80=94=20DB=20migration=20+=20other=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes other agents' SEO content, component updates, and language config changes alongside DB migration branch work. --- .gitignore | 3 + components/app/AppSidebar.vue | 65 ++- components/app/BoardPickerModal.vue | 12 +- components/app/GameModePicker.vue | 11 +- components/app/LanguagePickerModal.vue | 11 +- components/app/SidebarItem.vue | 2 + components/game/CopyFallbackModal.vue | 8 +- components/game/HelpModal.vue | 49 +- components/game/PostGamePanel.vue | 43 +- components/semantic/SemanticCompassHints.vue | 62 ++- components/semantic/SemanticInput.vue | 21 +- components/semantic/SemanticLeaderboard.vue | 65 ++- components/semantic/SemanticStatsModal.vue | 32 +- components/shared/StreakCalendar.vue | 17 +- data/default_language_config.json | 72 ++- data/languages/ar/language_config.json | 115 ++-- data/languages/bg/language_config.json | 129 +++-- data/languages/ca/language_config.json | 102 ++-- data/languages/ckb/language_config.json | 12 +- data/languages/cs/language_config.json | 119 ++-- data/languages/da/language_config.json | 119 ++-- data/languages/de/language_config.json | 125 +++-- data/languages/el/language_config.json | 119 ++-- data/languages/en/language_config.json | 75 +++ data/languages/es/language_config.json | 113 ++-- data/languages/et/language_config.json | 116 ++-- data/languages/eu/language_config.json | 102 ++-- data/languages/fa/language_config.json | 129 +++-- data/languages/fi/language_config.json | 113 ++-- data/languages/fr/language_config.json | 111 ++-- data/languages/ga/language_config.json | 102 ++-- data/languages/he/language_config.json | 129 +++-- data/languages/hr/language_config.json | 113 ++-- data/languages/hu/language_config.json | 107 ++-- data/languages/id/language_config.json | 107 ++-- data/languages/is/language_config.json | 102 ++-- data/languages/it/language_config.json | 113 ++-- data/languages/ja/language_config.json | 115 ++-- data/languages/ka/language_config.json | 104 ++-- data/languages/ko/language_config.json | 129 +++-- data/languages/lt/language_config.json | 104 ++-- data/languages/lv/language_config.json | 104 ++-- data/languages/mk/language_config.json | 104 ++-- data/languages/nb/language_config.json | 107 ++-- data/languages/nl/language_config.json | 113 ++-- data/languages/pl/language_config.json | 113 ++-- data/languages/pt/language_config.json | 113 ++-- data/languages/ro/language_config.json | 119 ++-- data/languages/ru/language_config.json | 127 +++-- data/languages/sk/language_config.json | 139 +++-- data/languages/sl/language_config.json | 141 +++-- data/languages/sq/language_config.json | 104 ++-- data/languages/sr/language_config.json | 141 +++-- data/languages/sv/language_config.json | 127 +++-- data/languages/tr/language_config.json | 125 +++-- data/languages/uk/language_config.json | 139 +++-- data/languages/uz/language_config.json | 10 +- data/languages/vi/language_config.json | 119 ++-- error.vue | 26 +- pages/[lang]/leaderboard.vue | 552 +++++++++++++++++++ pages/[lang]/speed.vue | 26 +- pages/profile.vue | 10 + scripts/validate_i18n.py | 346 ++++++++++++ server/api/[lang]/leaderboard.get.ts | 384 +++++++++++++ tests/test_i18n_validation.py | 129 +++++ utils/types.ts | 72 +++ 66 files changed, 4491 insertions(+), 2296 deletions(-) create mode 100644 pages/[lang]/leaderboard.vue create mode 100644 scripts/validate_i18n.py create mode 100644 server/api/[lang]/leaderboard.get.ts create mode 100644 tests/test_i18n_validation.py diff --git a/.gitignore b/.gitignore index 8009698c..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/ diff --git a/components/app/AppSidebar.vue b/components/app/AppSidebar.vue index 98c33f8a..88b1aeb7 100644 --- a/components/app/AppSidebar.vue +++ b/components/app/AppSidebar.vue @@ -23,7 +23,7 @@ :class="isRtl ? 'right-0' : 'left-0'" @keydown.escape="close" > -
+ @@ -503,7 +503,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 }> @@ -544,6 +544,25 @@ const bugReportUrl = computed(() => { diff --git a/pages/[lang]/speed.vue b/pages/[lang]/speed.vue index f07f86d7..51f6b3d9 100644 --- a/pages/[lang]/speed.vue +++ b/pages/[lang]/speed.vue @@ -77,7 +77,7 @@ function startCountdown() { countdownNumber.value = 1; }, 2000); setTimeout(() => { - countdownNumber.value = 'GO!'; + countdownNumber.value = langStore.config?.ui?.speed_go || 'GO!'; }, 2700); } @@ -188,11 +188,11 @@ onMounted(() => { :lang="lang" :language-name="config?.name_native || config?.name || lang" current-mode="speed" - title="Speed Streak" + :title="langStore.config?.ui?.speed_streak || 'Speed Streak'" :subtitle=" isDaily ? `${config?.name_native || lang} · #${gameData?.mode_day_idx ?? gameData?.todays_idx}` - : `${config?.name_native || lang} · Unlimited` + : `${config?.name_native || lang} · ${langStore.config?.ui?.unlimited_mode || 'Unlimited'}` " :sidebar-open="sidebarOpen ?? false" :visible="!!gameData" @@ -246,22 +246,34 @@ onMounted(() => { class="flex flex-auto items-center justify-center px-4" >
-
Speed Streak
+
+ {{ langStore.config?.ui?.speed_streak || 'Speed Streak' }} +

- Tap for rules + {{ + (langStore.config?.ui?.speed_tap_for_rules || 'Tap {icon} for rules').split( + '{icon}' + )[0] + }}{{ + (langStore.config?.ui?.speed_tap_for_rules || 'Tap {icon} for rules').split( + '{icon}' + )[1] + }}

{{ countdownNumber }} diff --git a/pages/profile.vue b/pages/profile.vue index c8601070..e0c057e7 100644 --- a/pages/profile.vue +++ b/pages/profile.vue @@ -246,6 +246,9 @@ const overallDist = ref>(createEmptyDistribution(6)); // Per-language (classic daily) const perLang = ref([]); +// Most-played language code — used for leaderboard links +const topLang = computed(() => perLang.value[0]?.code || 'en'); + // Per-mode (all modes that have results) const modeStats = ref([]); @@ -759,6 +762,13 @@ const languagesConquered = computed(() => { · avg {{ m.avgAttempts }} + + View today's ranking → +
2x English length that may overflow UI + 4. JSON validity — every language_config.json must parse + +Usage: + uv run python scripts/validate_i18n.py + uv run python scripts/validate_i18n.py --verbose + uv run python scripts/validate_i18n.py --strict # also fail on untranslated keys +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +LANGUAGES_DIR = DATA_DIR / "languages" +DEFAULT_CONFIG_PATH = DATA_DIR / "default_language_config.json" + +# Sections whose string keys are compared against the default +CHECKED_SECTIONS = ("text", "ui", "help") + +# Languages exempt from untranslated-key checks (English-based or conlangs) +UNTRANSLATED_EXEMPT_LANGS = {"en", "tlh", "qya", "pau"} + +# UI keys used in tight spaces — warn if translation exceeds this char count +BUTTON_KEYS = {"text.share", "text.copied", "text.shared"} +BUTTON_MAX_CHARS = 15 + +STAT_LABEL_KEYS = { + "ui.games", + "ui.win_percent", + "ui.streak", + "ui.best", + "ui.wins", + "ui.solved", + "ui.combo", + "ui.avg_guesses", + "ui.failed", + "ui.points", +} +STAT_LABEL_MAX_CHARS = 12 + +# General max length before warning (for non-description strings) +GENERAL_MAX_CHARS = 80 + +# Minimum string length to consider for untranslated check — very short strings +# (1-2 chars) are often legitimately identical across languages. +MIN_UNTRANSLATED_CHECK_LEN = 3 + + +# --------------------------------------------------------------------------- +# Core validation logic (importable from tests) +# --------------------------------------------------------------------------- + + +def load_default_config() -> dict: + with open(DEFAULT_CONFIG_PATH, encoding="utf-8") as f: + return json.load(f) + + +def get_language_dirs() -> list[Path]: + """Return sorted list of language directories that contain language_config.json.""" + if not LANGUAGES_DIR.exists(): + return [] + return sorted( + d for d in LANGUAGES_DIR.iterdir() if d.is_dir() and (d / "language_config.json").exists() + ) + + +def load_lang_config(lang_dir: Path) -> dict: + with open(lang_dir / "language_config.json", encoding="utf-8") as f: + return json.load(f) + + +def _flat_string_keys(section: dict, prefix: str = "") -> dict[str, str]: + """Extract flat key->value pairs for string values only (skip dicts/lists).""" + result = {} + for k, v in section.items(): + full_key = f"{prefix}.{k}" if prefix else k + if isinstance(v, str): + result[full_key] = v + return result + + +def check_missing_keys(lang: str, lang_config: dict, default_config: dict) -> dict[str, list[str]]: + """Return {section: [missing_keys]} for sections the language overrides.""" + missing: dict[str, list[str]] = {} + for section in CHECKED_SECTIONS: + if section not in lang_config: + # Language doesn't override this section — falls back entirely to default. + continue + default_keys = set(default_config.get(section, {}).keys()) + lang_keys = set(lang_config[section].keys()) + diff = sorted(default_keys - lang_keys) + if diff: + missing[section] = diff + return missing + + +def check_untranslated_keys(lang: str, lang_config: dict, default_config: dict) -> list[str]: + """Return list of 'section.key' strings identical to English default.""" + if lang in UNTRANSLATED_EXEMPT_LANGS: + return [] + + untranslated = [] + for section in CHECKED_SECTIONS: + if section not in lang_config: + continue + default_flat = _flat_string_keys(default_config.get(section, {}), section) + lang_flat = _flat_string_keys(lang_config.get(section, {}), section) + + for key, lang_val in lang_flat.items(): + default_val = default_flat.get(key) + if default_val is None: + continue + if len(default_val) < MIN_UNTRANSLATED_CHECK_LEN: + continue + if lang_val == default_val: + untranslated.append(key) + return untranslated + + +def check_string_lengths( + lang: str, lang_config: dict, default_config: dict +) -> list[tuple[str, int, int]]: + """Return list of (key, lang_len, default_len) for oversized translations.""" + warnings = [] + for section in CHECKED_SECTIONS: + if section not in lang_config: + continue + default_flat = _flat_string_keys(default_config.get(section, {}), section) + lang_flat = _flat_string_keys(lang_config.get(section, {}), section) + + for key, lang_val in lang_flat.items(): + default_val = default_flat.get(key) + if default_val is None or not default_val: + continue + + lang_len = len(lang_val) + default_len = len(default_val) + qualified_key = key # already has section prefix + + # Button keys: hard cap + if qualified_key in BUTTON_KEYS and lang_len > BUTTON_MAX_CHARS: + warnings.append((qualified_key, lang_len, default_len)) + continue + + # Stat label keys: hard cap + if qualified_key in STAT_LABEL_KEYS and lang_len > STAT_LABEL_MAX_CHARS: + warnings.append((qualified_key, lang_len, default_len)) + continue + + # General: warn if >2x default AND >80 chars (skip short strings + # where 2x is still fine, e.g. "Share" -> "Teilen" is 2x but fine) + if lang_len > 2 * default_len and lang_len > GENERAL_MAX_CHARS: + warnings.append((qualified_key, lang_len, default_len)) + + return warnings + + +def check_json_validity(lang_dir: Path) -> str | None: + """Return error message if language_config.json is invalid JSON, else None.""" + try: + with open(lang_dir / "language_config.json", encoding="utf-8") as f: + json.load(f) + return None + except (json.JSONDecodeError, UnicodeDecodeError) as e: + return str(e) + + +def validate_all() -> dict: + """Run all checks on all languages. Returns a results dict.""" + default_config = load_default_config() + lang_dirs = get_language_dirs() + + results = { + "total_languages": len(lang_dirs), + "json_errors": {}, # lang -> error string + "missing_keys": {}, # lang -> {section: [keys]} + "untranslated": {}, # lang -> [keys] + "length_warnings": {}, # lang -> [(key, lang_len, default_len)] + } + + for lang_dir in lang_dirs: + lang = lang_dir.name + + # JSON validity + err = check_json_validity(lang_dir) + if err: + results["json_errors"][lang] = err + continue # can't check further if JSON is broken + + lang_config = load_lang_config(lang_dir) + + # Missing keys + missing = check_missing_keys(lang, lang_config, default_config) + if missing: + results["missing_keys"][lang] = missing + + # Untranslated keys + untranslated = check_untranslated_keys(lang, lang_config, default_config) + if untranslated: + results["untranslated"][lang] = untranslated + + # Length warnings + length_warns = check_string_lengths(lang, lang_config, default_config) + if length_warns: + results["length_warnings"][lang] = length_warns + + return results + + +# --------------------------------------------------------------------------- +# CLI output +# --------------------------------------------------------------------------- + + +def print_results(results: dict, verbose: bool = False, strict: bool = False) -> int: + """Print results and return exit code (0 = pass, 1 = fail). + + Default mode: only JSON errors are critical (exit 1). + --strict mode: missing keys and untranslated keys also cause exit 1. + + Missing keys fall back to English defaults at runtime, so they don't break + the app — but they indicate incomplete translations. + """ + has_critical = False + + print(f"\n{'=' * 60}") + print(f"i18n Validation — {results['total_languages']} languages checked") + print(f"{'=' * 60}\n") + + # JSON errors (always critical) + if results["json_errors"]: + has_critical = True + print("FAIL: Invalid JSON files") + for lang, err in sorted(results["json_errors"].items()): + print(f" {lang}: {err}") + print() + + # Missing keys (critical only with --strict) + if results["missing_keys"]: + label = "FAIL" if strict else "WARN" + if strict: + has_critical = True + print(f"{label}: Missing keys in {len(results['missing_keys'])} languages") + for lang, sections in sorted(results["missing_keys"].items()): + total = sum(len(keys) for keys in sections.values()) + if verbose: + for section, keys in sorted(sections.items()): + for key in keys: + print(f" {lang}: {section}.{key}") + else: + section_summary = ", ".join(f"{s} ({len(k)})" for s, k in sorted(sections.items())) + print(f" {lang}: {total} missing — {section_summary}") + print() + + # Untranslated keys (critical only with --strict) + if results["untranslated"]: + label = "FAIL" if strict else "WARN" + if strict: + has_critical = True + + # Sort by count descending, show top 10 + by_count = sorted(results["untranslated"].items(), key=lambda x: -len(x[1])) + print(f"{label}: Untranslated keys (identical to English default)") + for shown, (lang, keys) in enumerate(by_count): + if not verbose and shown >= 10: + remaining = len(by_count) - 10 + print(f" ... and {remaining} more languages") + break + if verbose: + print(f" {lang} ({len(keys)} keys):") + for key in keys: + print(f" {key}") + else: + print(f" {lang}: {len(keys)} untranslated keys") + print() + + # Length warnings (never critical, informational) + if results["length_warnings"]: + print(f"WARN: String length issues in {len(results['length_warnings'])} languages") + for lang, warns in sorted(results["length_warnings"].items()): + if verbose: + for key, lang_len, default_len in warns: + print(f" {lang}: {key} — {lang_len} chars (default: {default_len})") + else: + print(f" {lang}: {len(warns)} strings may overflow UI") + print() + + # Summary + issues_count = len(results["json_errors"]) + if strict: + issues_count += len(results["missing_keys"]) + ok_count = results["total_languages"] - issues_count + print(f"{'=' * 60}") + if has_critical: + print(f"RESULT: FAIL — {ok_count}/{results['total_languages']} languages OK") + else: + print(f"RESULT: PASS — {ok_count}/{results['total_languages']} languages OK") + warnings = [] + if results["missing_keys"]: + total_missing = sum( + sum(len(keys) for keys in sections.values()) + for sections in results["missing_keys"].values() + ) + warnings.append( + f"{total_missing} missing keys in {len(results['missing_keys'])} languages" + ) + if results["untranslated"]: + total_untranslated = sum(len(v) for v in results["untranslated"].values()) + warnings.append( + f"{total_untranslated} untranslated keys in " + f"{len(results['untranslated'])} languages" + ) + if warnings: + print(f" Warnings: {'; '.join(warnings)}") + print(" Run with --strict to enforce") + print(f"{'=' * 60}\n") + + return 1 if has_critical else 0 + + +def main(): + parser = argparse.ArgumentParser(description="Validate i18n translation files") + parser.add_argument("--verbose", action="store_true", help="Show detailed per-language output") + parser.add_argument( + "--strict", action="store_true", help="Fail on untranslated keys (not just warn)" + ) + args = parser.parse_args() + + results = validate_all() + exit_code = print_results(results, verbose=args.verbose, strict=args.strict) + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/server/api/[lang]/leaderboard.get.ts b/server/api/[lang]/leaderboard.get.ts new file mode 100644 index 00000000..1b95591a --- /dev/null +++ b/server/api/[lang]/leaderboard.get.ts @@ -0,0 +1,384 @@ +/** + * GET /api/[lang]/leaderboard — Daily leaderboard rankings. + * + * Query params: + * mode — game mode (default: "classic") + * period — "today" (default), "week", "month" + * day — day index for "today" period (default: today) + * offset — pagination offset (default: 0) + * limit — page size (default: 50, max 100) + * + * Returns: { entries, total, you?, period, day_idx } + * + * Privacy: only exposes username + avatarUrl. Never email or displayName. + */ +import { prisma } from '~/server/utils/prisma'; +import { requireLang, langResponseFields } from '~/server/utils/data-loader'; +import { getTodaysIdx } from '~/server/lib/day-index'; +import { GAME_MODE_CONFIG } from '~/utils/game-modes'; + +// In-memory cache: key → { data, expiresAt } +const cache = new Map(); +const CACHE_TTL_TODAY = 60_000; // 60s +const CACHE_TTL_AGG = 300_000; // 5min for week/month + +interface LeaderboardEntry { + rank: number; + username: string; + avatarUrl: string | null; + attempts: number; // for today: actual attempts; for week/month: avg (×10 for precision) + daysPlayed?: number; // week/month only + playedAt: string; +} + +type Period = 'today' | 'week' | 'month'; +const VALID_PERIODS: Period[] = ['today', 'week', 'month']; +const MIN_DAYS: Record = { today: 1, week: 3, month: 10 }; + +export default defineEventHandler(async (event) => { + const { lang, config } = requireLang(event); + const query = getQuery(event); + + const mode = (query.mode as string) || 'classic'; + if (!(mode in GAME_MODE_CONFIG)) { + throw createError({ statusCode: 400, message: `Invalid mode: ${mode}` }); + } + + const period = ((query.period as string) || 'today') as Period; + if (!VALID_PERIODS.includes(period)) { + throw createError({ statusCode: 400, message: `Invalid period: ${period}` }); + } + + const tz = config.timezone || 'UTC'; + const todaysIdx = getTodaysIdx(tz); + const dayIdx = query.day ? parseInt(query.day as string, 10) : todaysIdx; + if (isNaN(dayIdx) || dayIdx < 0 || dayIdx > todaysIdx) { + throw createError({ statusCode: 400, message: 'Invalid day index' }); + } + + const offset = Math.max(0, parseInt(query.offset as string, 10) || 0); + const limit = Math.min(100, Math.max(1, parseInt(query.limit as string, 10) || 50)); + + // Compute day range for week/month + const { startIdx, endIdx } = getDayRange(period, dayIdx, todaysIdx); + + const cacheTtl = period === 'today' ? CACHE_TTL_TODAY : CACHE_TTL_AGG; + const cacheKey = `${lang}:${mode}:${period}:${startIdx}-${endIdx}:${offset}:${limit}`; + const now = Date.now(); + const cached = cache.get(cacheKey); + let publicData: { entries: LeaderboardEntry[]; total: number }; + + if (cached && cached.expiresAt > now) { + publicData = cached.data; + } else { + publicData = + period === 'today' + ? await fetchToday(lang, mode, dayIdx, offset, limit) + : await fetchAggregate(lang, mode, startIdx, endIdx, MIN_DAYS[period], offset, limit); + cache.set(cacheKey, { data: publicData, expiresAt: now + cacheTtl }); + } + + // Optional auth: include caller's rank + let you: (LeaderboardEntry & { percentile: number }) | null = null; + try { + const session = await getUserSession(event); + const userId = (session?.user as any)?.id; + if (userId) { + you = + period === 'today' + ? await fetchYourRankToday(userId, lang, mode, dayIdx, publicData.total) + : await fetchYourRankAggregate( + userId, + lang, + mode, + startIdx, + endIdx, + MIN_DAYS[period], + publicData.total + ); + } + } catch { + // Not logged in + } + + return { + ...langResponseFields(lang, config), + day_idx: dayIdx, + todays_idx: todaysIdx, + mode, + period, + min_days: MIN_DAYS[period], + entries: publicData.entries, + total: publicData.total, + you, + }; +}); + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function getDayRange(period: Period, dayIdx: number, todaysIdx: number) { + if (period === 'today') return { startIdx: dayIdx, endIdx: dayIdx }; + + if (period === 'week') { + // Current week: go back to the most recent Monday (dayIdx % 7 alignment) + // Simple approach: last 7 days ending today + const startIdx = Math.max(0, todaysIdx - 6); + return { startIdx, endIdx: todaysIdx }; + } + + // month: last 30 days + const startIdx = Math.max(0, todaysIdx - 29); + return { startIdx, endIdx: todaysIdx }; +} + +// ─── Today queries ────────────────────────────────────────────────────────── + +async function fetchToday( + lang: string, + mode: string, + dayIdx: number, + offset: number, + limit: number +): Promise<{ entries: LeaderboardEntry[]; total: number }> { + const isSpeed = mode === 'speed'; + const where = { lang, mode, playType: 'daily' as const, dayIdx, won: true }; + + const [total, results] = await Promise.all([ + prisma.result.count({ where }), + prisma.result.findMany({ + where, + orderBy: isSpeed + ? [{ wordsSolved: 'desc' }, { maxCombo: 'desc' }, { totalGuesses: 'asc' }] + : [{ attempts: 'asc' }, { playedAt: 'asc' }], + skip: offset, + take: limit, + select: { + attempts: true, + wordsSolved: true, + maxCombo: true, + playedAt: true, + user: { select: { username: true, avatarUrl: true } }, + }, + }), + ]); + + const entries: LeaderboardEntry[] = results.map( + (r: (typeof results)[number], i: number) => ({ + rank: offset + i + 1, + username: r.user.username, + avatarUrl: r.user.avatarUrl, + attempts: isSpeed ? (r.wordsSolved ?? 0) : (r.attempts ?? 0), + playedAt: r.playedAt.toISOString(), + }) + ); + + return { entries, total }; +} + +async function fetchYourRankToday( + userId: string, + lang: string, + mode: string, + dayIdx: number, + total: number +): Promise<(LeaderboardEntry & { percentile: number }) | null> { + const isSpeed = mode === 'speed'; + const myResult = await prisma.result.findFirst({ + where: { userId, lang, mode, playType: 'daily', dayIdx, won: true }, + select: { + attempts: true, + wordsSolved: true, + maxCombo: true, + totalGuesses: true, + playedAt: true, + user: { select: { username: true, avatarUrl: true } }, + }, + }); + if (!myResult) return null; + + let rankAbove: number; + if (isSpeed) { + const ws = myResult.wordsSolved ?? 0; + const mc = myResult.maxCombo ?? 0; + const tg = myResult.totalGuesses ?? 999; + rankAbove = await prisma.result.count({ + where: { + lang, + mode, + playType: 'daily', + dayIdx, + won: true, + OR: [ + { wordsSolved: { gt: ws } }, + { wordsSolved: ws, maxCombo: { gt: mc } }, + { wordsSolved: ws, maxCombo: mc, totalGuesses: { lt: tg } }, + ], + }, + }); + } else { + const att = myResult.attempts ?? 999; + const pat = myResult.playedAt; + rankAbove = await prisma.result.count({ + where: { + lang, + mode, + playType: 'daily', + dayIdx, + won: true, + OR: [{ attempts: { lt: att } }, { attempts: att, playedAt: { lt: pat } }], + }, + }); + } + + const rank = rankAbove + 1; + const percentile = total > 0 ? Math.round((rank / total) * 100) : 0; + + return { + rank, + username: myResult.user.username, + avatarUrl: myResult.user.avatarUrl, + attempts: isSpeed ? (myResult.wordsSolved ?? 0) : (myResult.attempts ?? 0), + playedAt: myResult.playedAt.toISOString(), + percentile, + }; +} + +// ─── Week/Month aggregate queries ─────────────────────────────────────────── + +interface AggRow { + user_id: string; + username: string; + avatar_url: string | null; + avg_attempts: number; + days_played: number; +} + +async function fetchAggregate( + lang: string, + mode: string, + startIdx: number, + endIdx: number, + minDays: number, + offset: number, + limit: number +): Promise<{ entries: LeaderboardEntry[]; total: number }> { + const isSpeed = mode === 'speed'; + + // Count qualifying players + const countResult = await prisma.$queryRawUnsafe<[{ count: bigint }]>( + `SELECT COUNT(*) as count FROM ( + SELECT r.user_id FROM wordle.results r + WHERE r.lang = $1 AND r.mode = $2 AND r.play_type = 'daily' + AND r.day_idx >= $3 AND r.day_idx <= $4 AND r.won = true + GROUP BY r.user_id HAVING COUNT(*) >= $5 + ) sub`, + lang, + mode, + startIdx, + endIdx, + minDays + ); + const total = Number(countResult[0]?.count ?? 0); + + const orderCol = isSpeed ? 'AVG(r.words_solved)' : 'AVG(r.attempts)'; + const orderDir = isSpeed ? 'DESC' : 'ASC'; + + const rows = await prisma.$queryRawUnsafe( + `SELECT r.user_id, u.username, u.avatar_url, + ${isSpeed ? 'AVG(r.words_solved)' : 'AVG(r.attempts)'} as avg_attempts, + COUNT(*)::int as days_played + FROM wordle.results r + JOIN wordle.users u ON r.user_id = u.id + WHERE r.lang = $1 AND r.mode = $2 AND r.play_type = 'daily' + AND r.day_idx >= $3 AND r.day_idx <= $4 AND r.won = true + GROUP BY r.user_id, u.username, u.avatar_url + HAVING COUNT(*) >= $5 + ORDER BY ${orderCol} ${orderDir}, MIN(r.played_at) ASC + OFFSET $6 LIMIT $7`, + lang, + mode, + startIdx, + endIdx, + minDays, + offset, + limit + ); + + const entries: LeaderboardEntry[] = rows.map((r: AggRow, i: number) => ({ + rank: offset + i + 1, + username: r.username, + avatarUrl: r.avatar_url, + attempts: Math.round(Number(r.avg_attempts) * 10) / 10, // 1 decimal place + daysPlayed: Number(r.days_played), + playedAt: '', + })); + + return { entries, total }; +} + +async function fetchYourRankAggregate( + userId: string, + lang: string, + mode: string, + startIdx: number, + endIdx: number, + minDays: number, + total: number +): Promise<(LeaderboardEntry & { percentile: number }) | null> { + const isSpeed = mode === 'speed'; + const avgCol = isSpeed ? 'AVG(r.words_solved)' : 'AVG(r.attempts)'; + + // Get my aggregate + const myRows = await prisma.$queryRawUnsafe( + `SELECT r.user_id, u.username, u.avatar_url, + ${avgCol} as avg_attempts, + COUNT(*)::int as days_played + FROM wordle.results r + JOIN wordle.users u ON r.user_id = u.id + WHERE r.user_id = $1 AND r.lang = $2 AND r.mode = $3 AND r.play_type = 'daily' + AND r.day_idx >= $4 AND r.day_idx <= $5 AND r.won = true + GROUP BY r.user_id, u.username, u.avatar_url + HAVING COUNT(*) >= $6`, + userId, + lang, + mode, + startIdx, + endIdx, + minDays + ); + + if (myRows.length === 0) return null; + const my = myRows[0]!; + const myAvg = Number(my.avg_attempts); + + // Count players who rank above me + const orderOp = isSpeed ? '>' : '<'; + const rankResult = await prisma.$queryRawUnsafe<[{ count: bigint }]>( + `SELECT COUNT(*) as count FROM ( + SELECT r.user_id, ${avgCol} as avg_att + FROM wordle.results r + WHERE r.lang = $1 AND r.mode = $2 AND r.play_type = 'daily' + AND r.day_idx >= $3 AND r.day_idx <= $4 AND r.won = true + GROUP BY r.user_id HAVING COUNT(*) >= $5 + ) sub WHERE sub.avg_att ${orderOp} $6`, + lang, + mode, + startIdx, + endIdx, + minDays, + myAvg + ); + + const rank = Number(rankResult[0]?.count ?? 0) + 1; + const percentile = total > 0 ? Math.round((rank / total) * 100) : 0; + + return { + rank, + username: my.username, + avatarUrl: my.avatar_url, + attempts: Math.round(myAvg * 10) / 10, + daysPlayed: Number(my.days_played), + playedAt: '', + percentile, + }; +} diff --git a/tests/test_i18n_validation.py b/tests/test_i18n_validation.py new file mode 100644 index 00000000..9cad5176 --- /dev/null +++ b/tests/test_i18n_validation.py @@ -0,0 +1,129 @@ +""" +Tests for i18n validation — ensures translation files are complete and correct. + +Runs the same checks as scripts/validate_i18n.py but integrated with pytest +so they execute as part of the standard `uv run pytest tests/` suite. +""" + +import json + +# Import validation logic from the script +import sys +from pathlib import Path + +import pytest + +from tests.conftest import ALL_LANGUAGES, LANGUAGES_DIR + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +from validate_i18n import ( + check_json_validity, + check_missing_keys, + check_string_lengths, + check_untranslated_keys, + load_default_config, +) + +# Languages that have a language_config.json +LANGUAGES_WITH_CONFIG = [ + lang for lang in ALL_LANGUAGES if (LANGUAGES_DIR / lang / "language_config.json").exists() +] + + +@pytest.fixture(scope="module") +def default_config(): + return load_default_config() + + +class TestJsonValidity: + """Every language_config.json must be valid JSON.""" + + @pytest.mark.parametrize("lang", LANGUAGES_WITH_CONFIG) + def test_valid_json(self, lang): + lang_dir = LANGUAGES_DIR / lang + err = check_json_validity(lang_dir) + assert err is None, f"{lang}: Invalid JSON — {err}" + + +class TestMissingKeys: + """Languages that override a section should include all keys from the default. + + Missing keys fall back to English at runtime, so this is a warning (skip) + rather than a hard failure. It surfaces gaps for translators to fill. + """ + + @pytest.mark.parametrize("lang", LANGUAGES_WITH_CONFIG) + def test_no_missing_text_keys(self, lang, default_config): + lang_dir = LANGUAGES_DIR / lang + with open(lang_dir / "language_config.json", encoding="utf-8") as f: + lang_config = json.load(f) + + missing = check_missing_keys(lang, lang_config, default_config) + missing_text = missing.get("text", []) + if missing_text: + pytest.skip(f"{lang}: {len(missing_text)} missing text keys: {missing_text}") + + @pytest.mark.parametrize("lang", LANGUAGES_WITH_CONFIG) + def test_no_missing_ui_keys(self, lang, default_config): + lang_dir = LANGUAGES_DIR / lang + with open(lang_dir / "language_config.json", encoding="utf-8") as f: + lang_config = json.load(f) + + missing = check_missing_keys(lang, lang_config, default_config) + missing_ui = missing.get("ui", []) + if missing_ui: + pytest.skip(f"{lang}: {len(missing_ui)} missing ui keys: {missing_ui}") + + @pytest.mark.parametrize("lang", LANGUAGES_WITH_CONFIG) + def test_no_missing_help_keys(self, lang, default_config): + lang_dir = LANGUAGES_DIR / lang + with open(lang_dir / "language_config.json", encoding="utf-8") as f: + lang_config = json.load(f) + + missing = check_missing_keys(lang, lang_config, default_config) + missing_help = missing.get("help", []) + if missing_help: + pytest.skip(f"{lang}: {len(missing_help)} missing help keys: {missing_help}") + + +class TestStringLengths: + """Translations should not be excessively longer than the English default. + + Length issues are warnings (skip), not hard failures — some languages + legitimately need longer strings (e.g. Gaelic, Bengali). + """ + + @pytest.mark.parametrize("lang", LANGUAGES_WITH_CONFIG) + def test_no_length_overflow(self, lang, default_config): + lang_dir = LANGUAGES_DIR / lang + with open(lang_dir / "language_config.json", encoding="utf-8") as f: + lang_config = json.load(f) + + warnings = check_string_lengths(lang, lang_config, default_config) + if warnings: + details = "; ".join(f"{k} ({ll} chars, default {dl})" for k, ll, dl in warnings) + pytest.skip(f"{lang}: String length overflow — {details}") + + +class TestUntranslatedKeys: + """Informational: flag languages with many untranslated keys. + + This test does NOT fail by default — it uses xfail to surface the data + without blocking CI. Use --strict in the standalone script to enforce. + """ + + # Threshold: languages with more than this many untranslated keys get flagged + UNTRANSLATED_THRESHOLD = 50 + + @pytest.mark.parametrize("lang", LANGUAGES_WITH_CONFIG) + def test_untranslated_below_threshold(self, lang, default_config): + lang_dir = LANGUAGES_DIR / lang + with open(lang_dir / "language_config.json", encoding="utf-8") as f: + lang_config = json.load(f) + + untranslated = check_untranslated_keys(lang, lang_config, default_config) + if len(untranslated) > self.UNTRANSLATED_THRESHOLD: + pytest.skip( + f"{lang}: {len(untranslated)} untranslated keys " + f"(threshold: {self.UNTRANSLATED_THRESHOLD})" + ) diff --git a/utils/types.ts b/utils/types.ts index 975cd510..b6477416 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -267,6 +267,9 @@ export interface UiStrings { streak_footer?: string; // Speed mode speed_streak?: string; + speed_start?: string; + speed_go?: string; + speed_tap_for_rules?: string; time_up?: string; points?: string; solved?: string; @@ -283,6 +286,23 @@ export interface UiStrings { copy_your_results?: string; copy_instructions?: string; done?: string; + // Modals & pickers + choose_game_mode?: string; + game_mode_subtitle?: string; + play_arrow?: string; + choose_language?: string; + search_languages?: string; + no_languages_match?: string; + multi_board?: string; + board_picker_subtitle?: string; + boards?: string; + guesses?: string; + badges?: string; + archive?: string; + best_starting_words?: string; + guest?: string; + sign_in?: string; + sync_stats_earn_badges?: string; // Error page error_404?: string; error_500?: string; @@ -322,6 +342,58 @@ export interface UiStrings { mode_custom_desc?: string; mode_party_label?: string; mode_party_desc?: string; + // Semantic Explorer + semantic_title?: string; + semantic_won?: string; + semantic_lost?: string; + semantic_explore_link?: string; + semantic_guesses?: string; + semantic_best_rank?: string; + semantic_oracle?: string; + semantic_neighbours_label?: string; + semantic_compass?: string; + semantic_compass_empty?: string; + semantic_compass_no_bearing?: string; + semantic_compass_no_bearing_sub?: string; + semantic_compass_think_more?: string; + semantic_hint?: string; + semantic_hint_used?: string; + semantic_hint_ready?: string; + semantic_hint_guess_more?: string; + semantic_hint_stuck?: string; + semantic_hint_nudge?: string; + semantic_hint_divining?: string; + semantic_hint_locked?: string; + semantic_hint_ask?: string; + semantic_hint_note?: string; + semantic_hint_map?: string; + semantic_hint_slice?: string; + semantic_input_placeholder?: string; + semantic_game_over?: string; + semantic_guess_button?: string; + semantic_guesses_used?: string; + semantic_closest?: string; + semantic_cta_headline?: string; + semantic_cta_body?: string; + semantic_your_guesses?: string; + semantic_show_all?: string; + semantic_tier_found?: string; + semantic_tier_scorching?: string; + semantic_tier_burning?: string; + semantic_tier_hot?: string; + semantic_tier_warm?: string; + semantic_tier_cool?: string; + semantic_tier_cold?: string; + semantic_tier_awaiting?: string; + semantic_meaning_map?: string; + semantic_slice_label?: string; + semantic_distance_rank?: string; + semantic_find_hidden?: string; + semantic_loading?: string; + semantic_guesses_left?: string; + semantic_unavailable?: string; + semantic_retry?: string; + semantic_unlimited?: string; } export interface LanguageConfig { From 5936578366cdba38d69661ba4cc661dd6740b7b3 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 16:40:28 +0100 Subject: [PATCH 45/58] =?UTF-8?q?fix:=20cap=20absolute=20auto-zoom=20at=20?= =?UTF-8?q?8x=20(was=2030x)=20=E2=80=94=20prevents=20giant=20labels=20duri?= =?UTF-8?q?ng=20transitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also early-return zoom=1 when <2 foreground dots, preventing huge zoom from single-dot min-distance calculation. --- components/shared/MeaningMap.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/shared/MeaningMap.vue b/components/shared/MeaningMap.vue index 85dc5028..bdbc9c39 100644 --- a/components/shared/MeaningMap.vue +++ b/components/shared/MeaningMap.vue @@ -173,9 +173,11 @@ const autoZoom = computed(() => { return 1.0; } - // Absolute: dynamic zoom so closest dot pair has ~50px separation + // Absolute: dynamic zoom so closest foreground dot pair has ~50px separation. + // Capped at 8x to prevent giant labels during word transitions. const MIN_SPACING = 50; const fg = props.dots.filter((d) => d.role !== 'muted'); + if (fg.length < 2) return 1.0; const pts = fg.map((d) => d.pos2d); let minDist = Infinity; for (let i = 0; i < pts.length; i++) { @@ -186,7 +188,7 @@ const autoZoom = computed(() => { } if (minDist < 1e-6 || !isFinite(minDist)) return 1.0; const z = MIN_SPACING / (minDist * canvasSize.value); - return Math.min(30, Math.max(1.0, z)); + return Math.min(8, Math.max(1.0, z)); }); const totalZoom = computed(() => autoZoom.value * props.userZoom); From 5b1700707a9d710fd1bd39694daba623cc1eb6aa Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 16:44:59 +0100 Subject: [PATCH 46/58] =?UTF-8?q?fix:=20remove=20counter-scale=20from=20do?= =?UTF-8?q?ts=20=E2=80=94=20eliminates=20giant=20text=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counter-scaling (scale(1/zoom) on each dot) created timing bugs where the camera transform updated before the dot transforms, causing one frame of giant text. Without counter-scale, dots scale naturally with the camera. Labels and circles get bigger when zoomed in, smaller when zoomed out — standard map behavior (like Google Maps labels). --- components/shared/MeaningMap.vue | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/components/shared/MeaningMap.vue b/components/shared/MeaningMap.vue index bdbc9c39..9a4706b9 100644 --- a/components/shared/MeaningMap.vue +++ b/components/shared/MeaningMap.vue @@ -340,11 +340,10 @@ const labelPlacements = computed>(() => { const placed: { x: number; y: number; w: number; h: number; word: string }[] = []; const result = new Map(); - // Labels are counter-scaled (constant visual size), so their world-space - // footprint depends on the camera zoom. Scale label dimensions accordingly. - const labelScale = invCameraScale.value; - const charW = CHAR_W * labelScale; - const labelH = LABEL_H * labelScale; + // Labels scale with the camera (no counter-scale), so overlap + // detection uses raw world-space dimensions. + const charW = CHAR_W; + const labelH = LABEL_H; // Priority: primary first, then foreground, then neighbour, then muted const order = ['primary', 'foreground', 'neighbour', 'muted']; @@ -353,10 +352,10 @@ const labelPlacements = computed>(() => { ); for (const d of sorted) { - const defaultY = (d.role === 'muted' || d.role === 'neighbour' ? -6 : -12) * labelScale; + const defaultY = d.role === 'muted' || d.role === 'neighbour' ? -6 : -12; const compassFlip = d.word === props.compassWord && compassLabelBelow.value; - const preferredY = compassFlip ? 18 * labelScale : defaultY; - const altY = compassFlip ? defaultY : defaultY < 0 ? 18 * labelScale : -12 * labelScale; + const preferredY = compassFlip ? 18 : defaultY; + const altY = compassFlip ? defaultY : defaultY < 0 ? 18 : -12; const w = d.word.length * charW; @@ -791,7 +790,7 @@ const targetScreenPos = computed(() => { v-if="showTarget" class="target-marker" :style="{ - transform: `translate(${targetScreenPos.x}px, ${targetScreenPos.y}px) scale(${invCameraScale})`, + transform: `translate(${targetScreenPos.x}px, ${targetScreenPos.y}px)`, }" > @@ -819,7 +818,7 @@ const targetScreenPos = computed(() => { :style="{ '--dx': d.x + 'px', '--dy': d.y + 'px', - transform: `translate(${d.x}px, ${d.y}px) scale(${invCameraScale})`, + transform: `translate(${d.x}px, ${d.y}px)`, }" @click="clickable ? onDotClick($event, d.word) : undefined" @mouseenter="clickable ? onDotMouseEnter(d.word) : undefined" From ac6b46d3d53abd2e7e06cb4d520c121b60bf7993 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 16:53:31 +0100 Subject: [PATCH 47/58] fix: restore auto-zoom + counter-scale with animation-safe approach Counter-scale via CSS var --inv-zoom on camera . WAAPI animations include invCameraScale in their keyframes so the counter-scale is never lost during bounce-in or FLIP transitions. Auto-zoom restored for absolute mode (capped at 8x). Label overlap accounts for scale. --- components/shared/MeaningMap.vue | 34 +++++++++++++++++--------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/components/shared/MeaningMap.vue b/components/shared/MeaningMap.vue index 9a4706b9..a9ace17d 100644 --- a/components/shared/MeaningMap.vue +++ b/components/shared/MeaningMap.vue @@ -173,8 +173,7 @@ const autoZoom = computed(() => { return 1.0; } - // Absolute: dynamic zoom so closest foreground dot pair has ~50px separation. - // Capped at 8x to prevent giant labels during word transitions. + // Absolute: dynamic zoom so closest foreground pair has ~50px separation. const MIN_SPACING = 50; const fg = props.dots.filter((d) => d.role !== 'muted'); if (fg.length < 2) return 1.0; @@ -340,10 +339,11 @@ const labelPlacements = computed>(() => { const placed: { x: number; y: number; w: number; h: number; word: string }[] = []; const result = new Map(); - // Labels scale with the camera (no counter-scale), so overlap - // detection uses raw world-space dimensions. - const charW = CHAR_W; - const labelH = LABEL_H; + // Labels are counter-scaled (constant visual size). Scale overlap + // dimensions by invCameraScale to match actual visual footprint. + const labelScale = invCameraScale.value; + const charW = CHAR_W * labelScale; + const labelH = LABEL_H * labelScale; // Priority: primary first, then foreground, then neighbour, then muted const order = ['primary', 'foreground', 'neighbour', 'muted']; @@ -352,10 +352,10 @@ const labelPlacements = computed>(() => { ); for (const d of sorted) { - const defaultY = d.role === 'muted' || d.role === 'neighbour' ? -6 : -12; + const defaultY = (d.role === 'muted' || d.role === 'neighbour' ? -6 : -12) * labelScale; const compassFlip = d.word === props.compassWord && compassLabelBelow.value; - const preferredY = compassFlip ? 18 : defaultY; - const altY = compassFlip ? defaultY : defaultY < 0 ? 18 : -12; + const preferredY = compassFlip ? 18 * labelScale : defaultY; + const altY = compassFlip ? defaultY : defaultY < 0 ? 18 * labelScale : -12 * labelScale; const w = d.word.length * charW; @@ -477,13 +477,14 @@ watch( if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue; // no movement const newTx = `${curr.x}px`; const newTy = `${curr.y}px`; + const invZ = invCameraScale.value; trackAnimation( el.animate( [ { - transform: `translate(${curr.x + dx}px, ${curr.y + dy}px)`, + transform: `translate(${curr.x + dx}px, ${curr.y + dy}px) scale(${invZ})`, }, - { transform: `translate(${newTx}, ${newTy})` }, + { transform: `translate(${newTx}, ${newTy}) scale(${invZ})` }, ], { duration: 500, @@ -511,6 +512,7 @@ watch( const tx = el.style.getPropertyValue('--dx'); const ty = el.style.getPropertyValue('--dy'); if (!tx || !ty) continue; + const invZ = invCameraScale.value; trackAnimation( el.animate( [ @@ -519,12 +521,12 @@ watch( opacity: 0, }, { - transform: `translate(${tx}, ${ty}) scale(1.2)`, + transform: `translate(${tx}, ${ty}) scale(${1.2 * invZ})`, opacity: 1, offset: 0.6, }, { - transform: `translate(${tx}, ${ty}) scale(1)`, + transform: `translate(${tx}, ${ty}) scale(${invZ})`, opacity: 1, }, ], @@ -675,7 +677,7 @@ const targetScreenPos = computed(() => { Dots, grid, target, connectors all move as a unit. --> { v-if="showTarget" class="target-marker" :style="{ - transform: `translate(${targetScreenPos.x}px, ${targetScreenPos.y}px)`, + transform: `translate(${targetScreenPos.x}px, ${targetScreenPos.y}px) scale(var(--inv-zoom))`, }" > @@ -818,7 +820,7 @@ const targetScreenPos = computed(() => { :style="{ '--dx': d.x + 'px', '--dy': d.y + 'px', - transform: `translate(${d.x}px, ${d.y}px)`, + transform: `translate(${d.x}px, ${d.y}px) scale(var(--inv-zoom))`, }" @click="clickable ? onDotClick($event, d.word) : undefined" @mouseenter="clickable ? onDotMouseEnter(d.word) : undefined" From 5320a921d074fdfa88ba657c1544ee9e53a97137 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 17:12:06 +0100 Subject: [PATCH 48/58] fix: don't restore corrupt semantic game state (gameOver without target word) When a session expires mid-game, gameOver=true gets saved to localStorage but finalTargetWord stays null. On reload, restoreState loads this corrupt state and the stats modal shows 'Out of guesses' with '?' even though the user can still play. Now skips restore if gameOver=true but no target word. --- composables/useSemanticGame.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composables/useSemanticGame.ts b/composables/useSemanticGame.ts index eabbe577..9d4f2940 100644 --- a/composables/useSemanticGame.ts +++ b/composables/useSemanticGame.ts @@ -446,6 +446,9 @@ export function useSemanticGame(lang: string) { const saved = readJson(storageKey(lang, _currentPlay)); if (!saved || saved.dayIdx !== currentDayIdx) return false; if (!saved.guesses?.length) return false; + // If game ended but target word was never revealed (session expired + // before reveal API call), the state is corrupt — start fresh. + if (saved.gameOver && !saved.finalTargetWord) return false; guesses.value = saved.guesses; won.value = saved.won; From f2f0cf44657141b745565b36bfbc80ba52db554f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 17:24:16 +0100 Subject: [PATCH 49/58] feat: SEO content for semantic, speed, and multiboard game modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semantic Explorer: - Title: "Semantic Word Game — Find the Hidden Word by Meaning" - Description targeting "contexto", "semantle", "semantic word game" - 6 FAQ items (what is it, vs Contexto/Semantle, ranks, compass, daily, guesses) - 6 HowTo steps - Tips section - Mode description for on-page SEO content Speed Streak: - Title + description - 2 FAQ items, 5 HowTo steps, tips Multiboard (dordle through duotrigordle): - Descriptions for all 5 sizes - 2 shared FAQ items Missing: og-semantic.png (needs design — currently falls back to generic) --- data/languages/en/language_config.json | 121 ++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 4 deletions(-) diff --git a/data/languages/en/language_config.json b/data/languages/en/language_config.json index b2f8d3e5..287f6b64 100644 --- a/data/languages/en/language_config.json +++ b/data/languages/en/language_config.json @@ -13,8 +13,8 @@ "description": "Play unlimited Wordle in English. No waiting — get a new word every time." }, "speed": { - "title": "Speed Streak — race the clock", - "description": "Speed Streak: solve as many Wordle words in English as you can in 5 minutes." + "title": "Speed Streak — Race the Clock Word Game", + "description": "Solve as many words as you can before time runs out. Each correct guess adds bonus time. Build combos for higher scores. A fast-paced twist on the daily word puzzle — free to play." }, "dordle": { "title": "Dordle — 2 boards at once", @@ -39,6 +39,10 @@ "duotrigordle": { "title": "Duotrigordle — 32 boards at once", "description": "Play Duotrigordle in English. Solve 32 Wordle boards at once with 37 guesses." + }, + "semantic": { + "title": "Semantic Word Game — Find the Hidden Word by Meaning", + "description": "Guess the secret word using meaning, not letters. Each guess gets a rank showing how semantically close you are. With compass hints, a meaning map, and 50,000 words. Free daily puzzle — like Contexto and Semantle, but better." } } }, @@ -80,7 +84,20 @@ "speed_scoring": "Scoring", "speed_scoring_explanation": "Points = (7 − guesses) × 100 × combo multiplier. Consecutive solves build your combo up to 3x. A failed word resets your combo.", "speed_pressure": "Pressure ramp", - "speed_pressure_explanation": "Every 3 words solved, the timer speeds up. The longer you survive, the harder it gets." + "speed_pressure_explanation": "Every 3 words solved, the timer speeds up. The longer you survive, the harder it gets.", + "semantic_subtitle": "Find a hidden word by meaning, not letters.", + "semantic_rank_title": "Type any word — see its rank", + "semantic_rank_explanation": "Lower rank = closer in meaning.", + "semantic_rank_found": "you found it.", + "semantic_compass_title": "Read the compass", + "semantic_compass_explanation": "Hints point from your", + "semantic_compass_latest_guess": "latest guess", + "semantic_compass_toward": "toward the target.", + "semantic_map_title": "Watch the map", + "semantic_map_explanation": "Closer to the center = lower rank = closer in meaning.", + "semantic_oracle_title": "Ask the oracle (once)", + "semantic_oracle_explanation": "Unlocked after 5 guesses. One cryptic hint per game.", + "semantic_footer": "15 guesses. New word every day." }, "ui": { "coverage_label": "Coverage", @@ -291,6 +308,8 @@ "guest": "Guest", "sign_in": "Sign in", "sync_stats_earn_badges": "Sync stats, earn badges", + "animations": "Animations", + "animations_desc": "Tile flip, bounce, and pop effects", "sidebar_play": "Play", "sidebar_learn": "Learn", "sidebar_you": "You", @@ -304,5 +323,99 @@ "homepage_search": "Search languages...", "homepage_and_more": "& More", "homepage_and_more_desc": "Octordle, Semantic Explorer, Custom Word, Party Mode — and more coming soon." + }, + "seo": { + "mode_desc_semantic": "Semantic Explorer is a word-guessing game where you find a hidden word using meaning instead of letters. Each guess receives a rank from 1 to 50,001 based on how semantically close it is to the target. Use compass hints and a meaning map to navigate the space of words. A new puzzle every day — or play unlimited mode for endless practice.", + "mode_faq": { + "semantic": [ + { + "q": "What is Semantic Explorer?", + "a": "Semantic Explorer is a word-guessing game where you find a hidden word based on meaning. Unlike classic Wordle which uses letter positions, Semantic Explorer ranks your guesses by how close they are in meaning to the target word, using AI word embeddings." + }, + { + "q": "How does it differ from Contexto or Semantle?", + "a": "Semantic Explorer offers compass hints that tell you which semantic direction to think in (e.g., \"think more metallic\"), a visual meaning map showing where your guesses land in semantic space, and an oracle hint system. It also supports 50,000+ words including uncommon ones like \"defenestration\"." + }, + { + "q": "How is the rank calculated?", + "a": "Each word in the vocabulary has a position in a high-dimensional semantic space (computed by AI). Your guess is ranked by how close it is to the target word in this space. Rank #1 means you found the exact word. Lower ranks mean closer in meaning." + }, + { + "q": "What are compass hints?", + "a": "After 5 guesses, the compass analyzes the direction from your best guess toward the target and gives you two semantic axes to think along — like \"think more metallic\" or \"think more subordinate\". This helps narrow down the meaning space." + }, + { + "q": "Is there a daily puzzle?", + "a": "Yes! A new hidden word is chosen every day, the same for all players worldwide. You can also play unlimited mode for endless practice with random words." + }, + { + "q": "How many guesses do I get?", + "a": "You get 15 guesses to find the hidden word. After 5 guesses, you unlock the compass hints. You can also use the oracle hint once per game for an extra clue." + } + ], + "speed": [ + { + "q": "How does Speed Streak work?", + "a": "You start with 5 minutes on the clock. Each word you solve correctly adds bonus time. Build combos by solving consecutive words quickly for score multipliers. The game ends when the timer hits zero." + }, + { + "q": "Is there a daily Speed Streak?", + "a": "Yes! The daily Speed Streak uses the same word sequence for all players, so you can compare scores. There is also an unlimited mode with random words." + } + ], + "multiboard": [ + { + "q": "How do multi-board modes work?", + "a": "Each guess you type applies to ALL boards simultaneously. Green, yellow, and gray feedback appears independently on each board. You need to find all hidden words with a limited number of shared guesses." + }, + { + "q": "What multi-board sizes are available?", + "a": "Dordle (2 boards, 7 guesses), Quordle (4 boards, 9 guesses), Octordle (8 boards, 13 guesses), Sedecordle (16 boards, 21 guesses), and Duotrigordle (32 boards, 37 guesses). Each has daily and unlimited modes." + } + ] + }, + "mode_howto": { + "semantic": [ + { + "text": "Type any English word and press Guess." + }, + { + "text": "Check your rank — lower numbers mean you're closer in meaning." + }, + { + "text": "Watch the meaning map to see where your guesses land relative to the target." + }, + { + "text": "After 5 guesses, use compass hints to find the right semantic direction." + }, + { + "text": "Use the oracle hint once per game if you're stuck." + }, + { + "text": "Find the hidden word (rank #1) in 15 guesses or fewer!" + } + ], + "speed": [ + { + "text": "Start the countdown and get ready." + }, + { + "text": "Guess the 5-letter word in as few attempts as possible." + }, + { + "text": "Correct words add bonus time to your clock." + }, + { + "text": "Solve consecutive words quickly to build combos for higher scores." + }, + { + "text": "Keep going until the timer runs out!" + } + ] + }, + "tips_semantic": "Start with broad category words (animal, food, emotion) to narrow the semantic space. Pay attention to compass hints — they point you in the right direction. Words with similar meanings cluster together on the map. Try synonyms of your closest guess. The oracle hint can save you when you're stuck in the wrong corner of meaning space.", + "mode_desc_speed": "Speed Streak is an arcade-style timed word game. Solve 5-letter words as fast as you can — each correct answer adds bonus seconds to your timer. Build combos for multiplied scores. How many can you solve before time runs out?", + "tips_speed": "Start with high-frequency starting words. Speed is more important than finding the perfect guess — a good guess quickly beats a perfect guess slowly. Watch the timer and prioritize common letter patterns.", + "mode_desc_multiboard": "Multi-board Wordle challenges you to solve {boardCount} word puzzles simultaneously with a shared set of guesses. From Dordle (2 boards) to Duotrigordle (32 boards) — each guess applies to every board at once. Strategy is key: maximize information across all boards with each attempt." } -} +} \ No newline at end of file From 946871ed040193fafc8a7cfd45c410c1290938c9 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 17:31:29 +0100 Subject: [PATCH 50/58] fix: shorten semantic title to fit 60-char limit (was 72, truncated) --- data/languages/en/language_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/languages/en/language_config.json b/data/languages/en/language_config.json index 287f6b64..49c4f336 100644 --- a/data/languages/en/language_config.json +++ b/data/languages/en/language_config.json @@ -41,7 +41,7 @@ "description": "Play Duotrigordle in English. Solve 32 Wordle boards at once with 37 guesses." }, "semantic": { - "title": "Semantic Word Game — Find the Hidden Word by Meaning", + "title": "Semantic Word Game — Guess by Meaning", "description": "Guess the secret word using meaning, not letters. Each guess gets a rank showing how semantically close you are. With compass hints, a meaning map, and 50,000 words. Free daily puzzle — like Contexto and Semantle, but better." } } From 48891ef3250bd1198dd50896168211090bd00fb0 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 17:33:07 +0100 Subject: [PATCH 51/58] seo: semantic title targets 'contexto' keyword for competitive ranking --- data/languages/en/language_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/languages/en/language_config.json b/data/languages/en/language_config.json index 49c4f336..79500740 100644 --- a/data/languages/en/language_config.json +++ b/data/languages/en/language_config.json @@ -41,7 +41,7 @@ "description": "Play Duotrigordle in English. Solve 32 Wordle boards at once with 37 guesses." }, "semantic": { - "title": "Semantic Word Game — Guess by Meaning", + "title": "Semantic Word Game — Like Contexto", "description": "Guess the secret word using meaning, not letters. Each guess gets a rank showing how semantically close you are. With compass hints, a meaning map, and 50,000 words. Free daily puzzle — like Contexto and Semantle, but better." } } From df6ab087ebaddc66d4bf9bb479da0f0f2e97d5a7 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 17:34:20 +0100 Subject: [PATCH 52/58] =?UTF-8?q?docs:=20add=20SEO=20TODOs=20=E2=80=94=20O?= =?UTF-8?q?G=20image,=20best=20starting=20words,=20useGameSeo=20refactor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/TODO.md b/TODO.md index ee5d17a2..82d395cf 100644 --- a/TODO.md +++ b/TODO.md @@ -502,3 +502,14 @@ Data has been migrated from Render's persistent disk to Postgres: - [ ] `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. From 77387cb48135322caeda952b33c2c01f91389e0e Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Sun, 12 Apr 2026 17:46:37 +0100 Subject: [PATCH 53/58] feat: leaderboard improvements, SEO content, semantic UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From other agents: - Leaderboard: refactored API (streaks, records, aggregate rankings), removed standalone page (embedded in sidebar) - SEO: semantic mode meta templates in default_language_config.json, best-starting-words semantic section, llms.txt update - Semantic: max guesses 15→20, compass/input/leaderboard polish - Speed: results display improvements - Profile: simplified install CTA, streak calendar fixes - Game modes: label updates, type additions --- components/app/AppSidebar.vue | 33 +- components/app/LanguagePickerModal.vue | 14 +- components/game/BestStartingWordsPanel.vue | 2 +- components/game/HelpModal.vue | 46 +- components/game/PostGamePanel.vue | 6 +- components/game/SeoNoscript.vue | 39 +- components/game/SettingsModal.vue | 40 +- components/game/SpeedResults.vue | 22 +- components/game/StatsModal.vue | 24 +- components/semantic/SemanticCompassHints.vue | 32 +- components/semantic/SemanticInput.vue | 8 +- components/semantic/SemanticLeaderboard.vue | 26 +- components/semantic/SemanticStatsModal.vue | 24 +- components/shared/StreakCalendar.vue | 14 +- composables/useSemanticGame.ts | 4 +- data/default_language_config.json | 19 +- data/languages/en/language_config.json | 8 +- pages/[lang]/best-starting-words.vue | 2 +- pages/[lang]/leaderboard.vue | 552 -------------- pages/[lang]/semantic.vue | 20 +- pages/[lang]/speed.vue | 20 +- pages/index.vue | 61 +- pages/leaderboard.vue | 749 +++++++++++++++++++ pages/profile.vue | 20 +- public/llms.txt | 2 +- server/api/[lang]/leaderboard.get.ts | 299 +++++++- server/api/[lang]/semantic/start.post.ts | 2 +- stores/game.ts | 20 +- utils/game-modes.ts | 4 +- utils/interpolate.ts | 6 +- utils/types.ts | 2 + 31 files changed, 1273 insertions(+), 847 deletions(-) delete mode 100644 pages/[lang]/leaderboard.vue create mode 100644 pages/leaderboard.vue diff --git a/components/app/AppSidebar.vue b/components/app/AppSidebar.vue index 88b1aeb7..da80c76c 100644 --- a/components/app/AppSidebar.vue +++ b/components/app/AppSidebar.vue @@ -189,12 +189,12 @@ :class="{ active: subPanelMode === currentMode && currentPlayType === 'daily', }" - :aria-label="`Daily #${dayIdx}`" - :title="`Daily #${dayIdx}`" + :aria-label="`Daily ${subPanelDayLabel}`" + :title="`Daily ${subPanelDayLabel}`" @click="close()" > - #{{ dayIdx }} + {{ subPanelDayLabel }} -
- +
-
Compete
+
Explore
+
@@ -424,7 +428,20 @@ 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); + +// New modes use 1-based day index from April 11, 2026 (dayIdx 1757) +const NEW_MODES_EPOCH_IDX = 1757; +const subPanelDayLabel = computed(() => { + const idx = classicDayIdx.value; + if (!idx) return ''; + const mode = subPanelMode.value; + if (mode && mode !== 'classic') { + const modeIdx = idx - NEW_MODES_EPOCH_IDX + 1; + return modeIdx >= 1 ? `#${modeIdx}` : ''; + } + return `#${idx}`; +}); // Sub-panel state: which mode's daily/unlimited panel is showing const subPanelMode = ref(null); diff --git a/components/app/LanguagePickerModal.vue b/components/app/LanguagePickerModal.vue index 9feed5e0..f2f08718 100644 --- a/components/app/LanguagePickerModal.vue +++ b/components/app/LanguagePickerModal.vue @@ -63,11 +63,13 @@ 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); @@ -116,10 +118,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/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/HelpModal.vue b/components/game/HelpModal.vue index 2c784c1c..9c9d5de2 100644 --- a/components/game/HelpModal.vue +++ b/components/game/HelpModal.vue @@ -3,18 +3,18 @@
diff --git a/components/game/PostGamePanel.vue b/components/game/PostGamePanel.vue index 81b38a20..f0ff0095 100644 --- a/components/game/PostGamePanel.vue +++ b/components/game/PostGamePanel.vue @@ -43,7 +43,7 @@ class="text-muted shrink-0 transition-colors duration-150 group-hover:text-paper" />
- {{ lang.config?.ui?.keep_playing || 'Keep Playing' }} + {{ lang.config?.ui?.keep_playing }}
@@ -110,7 +110,7 @@ const isSingleBoard = computed( () => game.gameConfig.mode === 'classic' || game.gameConfig.mode === 'unlimited' ); -const nextWordLabel = computed(() => lang.config?.text?.next_word || 'Next Wordle'); +const nextWordLabel = computed(() => lang.config?.text?.next_word); // Cross-pollination routes const modeDef = computed(() => GAME_MODE_CONFIG[game.gameConfig.mode]); @@ -129,7 +129,7 @@ const dailyRoute = computed(() => { }); const leaderboardRoute = computed( - () => `/${lang.languageCode}/leaderboard?mode=${game.gameConfig.mode}` + () => `/leaderboard?lang=${lang.languageCode}&mode=${game.gameConfig.mode}` ); // Preferred mode order for post-game discovery: dordle first, then speed diff --git a/components/game/SeoNoscript.vue b/components/game/SeoNoscript.vue index edb342d8..0bb9fff9 100644 --- a/components/game/SeoNoscript.vue +++ b/components/game/SeoNoscript.vue @@ -85,27 +85,26 @@ function confirmReveal() { wordRevealed.value = true; } -// ── Translated section headings with English fallbacks ── +// ── Translated section headings (defaults merged by data-loader) ── const h = computed(() => ({ - howToPlay: s.value.how_to_play || 'How to Play', - tipsStrategy: s.value.tips_strategy || 'Tips & Strategy', - moreModes: s.value.more_modes || 'More Game Modes', - playInLanguages: s.value.play_in_languages || 'Play in 80+ Languages', - playInLanguagesSub: - s.value.play_in_languages_sub || 'Every language is free. No account needed.', - whyWordleGlobal: s.value.why_wordle_global || 'Why Wordle Global', - faqTitle: s.value.faq_title || 'Frequently Asked Questions', - browseAll: s.value.browse_all_languages || 'Browse all 80+ languages', - recentWords: s.value.recent_words || 'Recent Words', - viewAllWords: s.value.view_all_words || 'View all words', - footer: s.value.footer || 'wordle.global — the free daily word game in 80+ languages', + howToPlay: s.value.how_to_play, + tipsStrategy: s.value.tips_strategy, + moreModes: s.value.more_modes, + playInLanguages: s.value.play_in_languages, + playInLanguagesSub: s.value.play_in_languages_sub, + whyWordleGlobal: s.value.why_wordle_global, + faqTitle: s.value.faq_title, + browseAll: s.value.browse_all_languages, + recentWords: s.value.recent_words, + viewAllWords: s.value.view_all_words, + footer: s.value.footer, })); // ── Tile example descriptions (translated) ── const tileDescs = computed(() => ({ - correct: s.value.tile_correct || 'is in the word and in the correct spot.', - semicorrect: s.value.tile_semicorrect || 'is in the word but in the wrong spot.', - incorrect: s.value.tile_incorrect || 'is not in the word at all.', + correct: s.value.tile_correct, + semicorrect: s.value.tile_semicorrect, + incorrect: s.value.tile_incorrect, })); const EXAMPLES = computed(() => [ @@ -128,10 +127,10 @@ const EXAMPLES = computed(() => [ // ── Social proof stats (translated labels) ── const SOCIAL_STATS = computed(() => [ - { value: '800K+', label: s.value.stat_players || 'Players' }, - { value: '1.7M+', label: s.value.stat_guesses || 'Guesses' }, - { value: '80+', label: s.value.stat_languages || 'Languages' }, - { value: '8', label: s.value.stat_modes || 'Game Modes' }, + { value: '800K+', label: s.value.stat_players }, + { value: '1.7M+', label: s.value.stat_guesses }, + { value: '80+', label: s.value.stat_languages }, + { value: '8', label: s.value.stat_modes }, ]); // ── Strategy tips (mode-aware, translated) ── diff --git a/components/game/SettingsModal.vue b/components/game/SettingsModal.vue index 317a6459..dcdc8a81 100644 --- a/components/game/SettingsModal.vue +++ b/components/game/SettingsModal.vue @@ -2,7 +2,7 @@

- {{ lang.config?.ui?.settings || 'Settings' }} + {{ lang.config?.ui?.settings }}

@@ -10,7 +10,7 @@

- {{ lang.config?.ui?.dark_mode || 'Dark Mode' }} + {{ lang.config?.ui?.dark_mode }}

- {{ lang.config?.ui?.sound_and_haptics || 'Sound & Haptics' }} + {{ lang.config?.ui?.sound_and_haptics }}

- {{ lang.config?.ui?.difficulty || 'Difficulty' }} + {{ lang.config?.ui?.difficulty }}

- {{ lang.config?.ui?.easy || 'Easy' }} + {{ lang.config?.ui?.easy }}

- {{ lang.config?.ui?.easy_desc || 'Any word accepted as a guess' }} + {{ lang.config?.ui?.easy_desc }}

- {{ lang.config?.ui?.normal_desc || 'Only valid words accepted' }} + {{ lang.config?.ui?.normal_desc }}

- {{ lang.config?.ui?.hard_desc || 'Must use revealed hints' }} + {{ lang.config?.ui?.hard_desc }}

{{ settings.difficultyWarning }} @@ -103,12 +103,11 @@

- {{ lang.config?.ui?.word_info || 'Word Info' }} + {{ lang.config?.ui?.word_info }}

{{ - lang.config?.ui?.word_info_desc || - 'Show definition and image after solving' + lang.config?.ui?.word_info_desc }}

@@ -124,12 +123,11 @@

- {{ lang.config?.ui?.animations || 'Animations' }} + {{ lang.config?.ui?.animations }}

{{ - lang.config?.ui?.animations_desc || - 'Tile flip, bounce, and pop effects' + lang.config?.ui?.animations_desc }}

@@ -145,11 +143,11 @@

- {{ lang.config?.ui?.high_contrast || 'High Contrast' }} + {{ lang.config?.ui?.high_contrast }}

{{ - lang.config?.ui?.high_contrast_desc || 'Colorblind-friendly colors' + lang.config?.ui?.high_contrast_desc }}

@@ -164,7 +162,7 @@

- {{ lang.config?.ui?.keyboard_layout || 'Keyboard Layout' }} + {{ lang.config?.ui?.keyboard_layout }}