Skip to content

feat: streak badge, multi-board modes, and game polish#185

Merged
Hugo0 merged 30 commits into
mainfrom
feat/streak-multiboard-polish
Mar 29, 2026
Merged

feat: streak badge, multi-board modes, and game polish#185
Hugo0 merged 30 commits into
mainfrom
feat/streak-multiboard-polish

Conversation

@Hugo0
Copy link
Copy Markdown
Owner

@Hugo0 Hugo0 commented Mar 29, 2026

Summary

  • Streak badge in header — Lucide flame icon with superscript count. Organic flicker animation (7+ days), ignite burst with spark particles on win. Click opens streak modal with calendar heatmap, stats, and per-language breakdown.
  • Streak calculation rewrite — Now uses consecutive LOCAL calendar days (DST-safe). Win overrides same-day loss. Today skipped if not yet played.
  • Multi-board modes — Octordle (8 boards), Sedecordle (16), Duotrigordle (32) with BoardMinimap component.
  • PWA install — Platform-aware prompts (Android every win, Desktop/iOS after 2nd game with cooldown).
  • Design system--color-flame token, all flame icons consolidated from text-correct to text-flame.
  • Debug helpersdebug.streak.set(n), .ignite(), .reset() for visual testing.
  • Housekeeping — Removed ROADMAP.md and TODO.md from git tracking (strategic docs, kept on disk). Added llms.txt. Trailing-slash redirect middleware.

Test plan

  • Fresh user (clear localStorage): no flame in header
  • Win daily puzzle: flame appears with ignite burst + sparks
  • Refresh after win: flame shows correct count, no animation
  • debug.streak.set(7) — flame flickers with sparks
  • debug.streak.set(0) — flame hidden
  • Click flame → modal opens with calendar, stats, per-language breakdown
  • Animations toggle OFF → no flicker, no sparks
  • RTL language (/ar) → badge positioned correctly
  • Dark mode → flame color reads well (#fd7e14)
  • Speed mode → flame badge still visible (daily streak unrelated)
  • Multi-board modes (octordle, sedecordle, duotrigordle) load and play correctly
  • CI passes (tests + lint)

Summary by CodeRabbit

Release Notes

  • New Features

    • Added three new game modes: Octordle (8 boards), Sedecordle (16 boards), and Duotrigordle (32 boards).
    • Introduced streak tracking system with flame animations and dedicated streak modal.
    • Enhanced multi-board gameplay with focus mode, minimap navigation, and scrollable grid layouts.
    • Improved PWA installation prompting with idle-based triggering.
    • Expanded SEO infrastructure for better search visibility and accessibility.
  • Improvements

    • Refined analytics to reduce event volume while maintaining key insights.
    • Enhanced flag rendering with fallback handling across the app.

Hugo0 added 2 commits March 29, 2026 11:04
Streak system:
- Streak flame badge in header (Lucide Flame icon + superscript count)
- Streak modal with calendar heatmap, stats, per-language breakdown
- Organic flicker animation (7+ days), ignite burst on win, CSS spark particles
- Streak calculation rewritten: consecutive LOCAL calendar days, DST-safe
- Win overrides same-day loss; today skipped if not yet played
- --color-flame design token (#e8590c light, #fd7e14 dark)
- Debug helpers: debug.streak.set(n), .ignite(), .reset()
- All flame icons consolidated to text-flame (was text-correct)

Multi-board modes (from other agents):
- Octordle (8 boards), Sedecordle (16), Duotrigordle (32)
- BoardMinimap component for large board counts
- MultiBoardLayout + MultiBoardPanel enhancements
- Tridle page restructured

Game polish:
- PWA install: platform-aware prompts (Android aggressive, Desktop/iOS conservative)
- Keyboard diacritic popup improvements
- StatsModal responsive sizing with scoped styles
- Definition system improvements
- Sitemap updates for new modes
- generate_favicon.py updated with size-specific rendering
- Design exploration: streak effects section (Living Flame, sparks, milestones)
- Remove docs/ROADMAP.md and docs/TODO.md from git tracking
  (files remain on disk, already covered by docs/ in .gitignore)
- Multi-board mode page fixes from parallel agents
- Add useMultiBoardModePage composable
- Add llms.txt for LLM-friendly site description
- Analytics and SEO composable updates
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 29, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR introduces three new multi-board game modes (octordle, sedecordle, duotrigordle) across 60+ language localizations, replaces per-mode page routes with a unified dynamic [lang]/[mode].vue pattern, refactors SEO generation into a centralized composable system, consolidates analytics event tracking, adds streak and flame visual features with accompanying modal and badge components, implements a scrollable multi-board layout with focus mode for high board counts, and shifts PWA installation prompting from win-triggered to idle-triggered behavior.

Changes

Cohort / File(s) Summary
Flame & Streak Visual Features
assets/css/design-system.css, assets/css/main.css, components/game/StreakBadge.vue, components/game/StreakModal.vue, plugins/debug.client.ts
Added semantic color tokens (--color-flame, --color-flame-soft) for light/dark themes; introduced @keyframes for flame wobble/ignite and spark burst/float animations; created new streak badge component with ignition animation, frozen state, and previous streak display; added streak modal with 28-day calendar heatmap, current/longest streak display, and wins-by-language breakdown; extended debug API with streak control methods.
Game Mode Architecture & Routes
composables/useGameModes.ts, pages/[lang]/[mode].vue, pages/[lang]/dordle.vue, pages/[lang]/quordle.vue, pages/[lang]/tridle.vue, pages/[lang]/index.vue, pages/[lang]/unlimited.vue, pages/[lang]/speed.vue
Removed tridle mode and added octordle, sedecordle, duotrigordle to mode registry with updated badge assignments; created unified dynamic [lang]/[mode].vue route supporting five multi-board modes with server-side data validation; converted tridle.vue to redirect-only page; replaced per-mode SEO implementations with centralized useGameSeo calls in classic/unlimited/speed pages.
Multi-Board Layout & Focus Mode
components/game/MultiBoardLayout.vue, components/game/MultiBoardPanel.vue, components/game/BoardMinimap.vue, composables/useMultiBoardLayout.ts
Refactored multi-board container from fixed grid to responsive ResizeObserver-based measurement; introduced focus mode that expands single board when boardCount > 4; added scrollable layout with smooth auto-scroll to first unsolved board; implemented minimap navigation component for board jumping; created useMultiBoardLayout composable computing grid dimensions and scroll requirements based on board count.
SEO System Refactor
composables/useGameSeo.ts, composables/useGameModeSeo.ts, components/game/SeoNoscript.vue, pages/[lang]/best-starting-words.vue, pages/[lang]/word/[id].vue, CONTRIBUTING.md
Removed old useGameModeSeo composable; added comprehensive useGameSeo with title/description/OG/Twitter/canonical/JSON-LD generation, FAQ/HowTo interpolation, and hreflang alternates; created SeoNoscript component rendering full SEO content blocks for crawlers; added best-starting-words page with strategy guide; updated word page to use new UI label helpers.
Analytics Event Consolidation
composables/useAnalytics.ts, composables/useGameShare.ts, composables/useGamePage.ts, nuxt.config.ts
Consolidated share tracking from four separate calls into single trackShare event with result/method/error metadata; removed low-volume event suppression workarounds; added PWA analytics source tracking; expanded GA4 with PWA events and whitelist-based parameter forwarding; moved frustration counters to module-scoped state; removed per-guess/word tracking methods; disabled PostHog capture_pageleave and enable_web_vitals.
Localization & Language Data
data/default_language_config.json, data/languages/*/language_config.json (60+ files)
Added new seo top-level object to default and multiple language configs containing gameplay copy, tips, FAQ, mode descriptions, how-to steps, value propositions, and stat labels with template variable support; added octordle, sedecordle, duotrigordle mode metadata (title/description) to all 60+ language configs; updated Spanish/Italian/Portuguese mode titles (unlimited variants).
Page Shell & Game Layout
components/game/PageShell.vue, components/game/GameHeader.vue, layouts/game.vue, components/game/GameBoard.vue
Updated game header to include GameStreakBadge component with streakCount and justWon props plus @streak event handler; added streak modal display binding; replaced fixed page height with scrollable container featuring CSS scroll snapping and programmatic snap-back behavior; refactored GameBoard to use GameMultiBoardPanel instead of inline tile grid; introduced panelRef proxy for boardEl exposure.
Component Enhancements
components/game/KeyboardKey.vue, components/game/StatsModal.vue, components/app/AppSidebar.vue, components/app/GameModePicker.vue
Generalized keyboard key gradient rendering from fixed 2/3/4-board splits to grid-based layout with getKeyGridDimensions function; paginated multi-board definitions in stats modal (showing 4 per page) with previous/next controls and consolidated analytics; added error handlers to flag images in sidebar and mode picker to hide on load failure.
PWA & Installation
plugins/pwa.client.ts
Transitioned prompting from game-win/banner-driven to idle-triggered (20s inactivity); replaced dismissal cooldown with exponential backoff (24h doubling up to 30 days); added session-limited prompting and bridged beforeinstallprompt event to web component; updated install/dismiss analytics with new dialog/settings sources; removed showBanner/hideBanner API exports; cached isStandalone per session.
Utilities & Composables
composables/useFlag.ts, composables/useDefinitions.ts
Replaced LANG_TO_COUNTRY mapping with LANG_TO_FLAG supporting circle-flag icon codes for countries, sub-nationals, and constructed languages; added cacheOnly option to fetchDefinition for API requests.
Documentation & Config
docs/ROADMAP.md, docs/TODO.md, pages/index.vue, pages/stats.vue
Deleted entire ROADMAP.md file; updated TODO with analytics/git-history cleanup tasks and bot-filter implementation status; updated homepage description to mention streak/modes; changed stats page flame icons from text-correct to text-flame for streak indicators; added image error handlers to language flags.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🔥 Eight boards ignite, sixteen flame and dance,
Thirty-two streaks shimmer in their trance,
New modes hop and leap where modes once stood,
A hoppy refactor—chef's kiss, 'twas good! 🐰✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/streak-multiboard-polish

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
plugins/debug.client.ts (1)

130-140: ⚠️ Potential issue | 🟡 Minor

Array size mismatch for console styling.

The help text now has 13 command lines (each needs 2 style strings = 26 total), but Array(10) only produces 20 style strings. This causes the last few lines to display undefined or lose formatting.

Proposed fix
-                ...Array(10)
+                ...Array(13)
                     .fill(null)
                     .flatMap(() => [
                         'color: `#6aaa63`; font-weight: bold',
                         'color: `#999`; font-weight: normal',
                     ])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/debug.client.ts` around lines 130 - 140, The console style array
length is wrong: the help text prints 13 command lines but the styles are
generated with Array(10) (20 entries) causing undefined styles; update the
generation that uses Array(10).fill(null).flatMap(...) to produce 26 style
strings (one pair per command) — e.g. derive the repeat count from the number of
command lines or change Array(10) to Array(13) so the styles align with the
commands like debug.pwa.reset, debug.streak.set, debug.streak.ignite,
debug.streak.reset.
🧹 Nitpick comments (4)
server/middleware/trailing-slash.ts (1)

12-12: Consider stripping all trailing slashes at once.

slice(0, -1) removes only one trailing slash. If a URL like /foo// is requested, it becomes /foo/, triggering a second redirect. While rare, this can be handled in a single pass.

♻️ Suggested improvement
-    const cleanPath = url.pathname.slice(0, -1) + url.search;
+    const cleanPath = url.pathname.replace(/\/+$/, '') + url.search;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/middleware/trailing-slash.ts` at line 12, The current logic in
trailing-slash.ts uses url.pathname.slice(0, -1) to remove only a single
trailing slash (assigned to cleanPath), causing double-slash paths like "/foo//"
to require multiple redirects; update the cleanPath calculation to strip all
trailing slashes from url.pathname in one pass (but preserve a single "/" for
the root) — e.g., trim any consecutive trailing "/" characters from
url.pathname, then append url.search; change references to cleanPath accordingly
so a single redirect handles paths like "/foo//".
components/game/KeyboardKey.vue (1)

105-113: Optional: Hoist colWidth outside the row loop.

colWidth is invariant across rows since it depends only on cols. Moving it before the loop removes redundant recalculation and slightly improves clarity.

♻️ Suggested refactor
     const layers: string[] = [];
     const sizeH = (100 / rows).toFixed(4);
+    const colWidth = 100 / cols;

     for (let r = 0; r < rows; r++) {
         const stops: string[] = [];
-        const colWidth = 100 / cols;
         for (let c = 0; c < cols; c++) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/KeyboardKey.vue` around lines 105 - 113, The colWidth
calculation is invariant per row; move the const colWidth = 100 / cols;
declaration out of the outer for (let r = 0; r < rows; r++) loop so it’s
computed once, then keep using colWidth inside the loop that builds stops;
ensure references to cols, rows, stateColor, props.boardStates and
DEFAULT_KEY_COLOR remain unchanged and behavior of the nested loop that computes
boardIdx and pushes color stops is preserved.
tests/stores/stats.test.ts (1)

141-162: Please add the two new streak edge cases explicitly.

This suite now covers consecutive-day wins and loss breaks, but it still doesn’t lock down the two special branches added in calculateTotalStats(): “win overrides same-day loss” and “today is skipped if not yet played.” Those are the easiest regressions to miss.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/stores/stats.test.ts` around lines 141 - 162, Add two focused tests
covering the two edge branches in calculateTotalStats: (1) "same-day win
overrides same-day loss" — create gameResults entries on the same date for the
classic mode with a loss and a win, call calculateTotalStats() on the
useStatsStore() instance, and assert the win is counted (n_victories
increments), loss is not counted (n_losses unchanged) and current_overall_streak
reflects the win; (2) "today skipped if not yet played" — populate consecutive
prior-day classic wins but leave today empty (no entry for today), call
calculateTotalStats(), and assert current_overall_streak does not include today
(streak equals consecutive prior days) and totals (n_victories/n_losses) reflect
only existing entries; reference stats.gameResults, calculateTotalStats(),
useStatsStore(), and
total.current_overall_streak/total.n_victories/total.n_losses when adding
assertions.
components/game/BoardMinimap.vue (1)

49-70: Consider memoizing miniRows for performance with large board counts.

For duotrigordle (32 boards), miniRows is called for each board on every render. The function iterates through tileClassesVisual and creates new arrays each time. With 32 boards × potential re-renders, this could cause unnecessary allocations.

Consider using a computed property or memoization pattern:

♻️ Optional: Memoize miniRows results
+const miniRowsCache = computed(() => {
+    const cache: Record<number, { index: number; colors: string[] }[]> = {};
+    for (let boardIndex = 0; boardIndex < boardCount.value; boardIndex++) {
+        cache[boardIndex] = computeMiniRows(boardIndex);
+    }
+    return cache;
+});

-function miniRows(boardIndex: number) {
+function computeMiniRows(boardIndex: number) {
     // ... existing logic
 }

+function miniRows(boardIndex: number) {
+    return miniRowsCache.value[boardIndex] ?? [];
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/BoardMinimap.vue` around lines 49 - 70, miniRows rebuilds
rows on every render which causes heavy allocations for many boards; replace it
with a memoized solution (e.g., a Vue computed or a simple Map cache) keyed by
boardIndex and a quick change-token (length + join or a version/hash of
board.tileClassesVisual and board.tilesVisual) so results are returned from
cache when inputs are unchanged; implement the memoization where miniRows is
defined/used (function miniRows / BoardMinimap.vue) and ensure cache
invalidation when board.tileClassesVisual or board.tilesVisual change so colors
are recomputed only when needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@assets/css/main.css`:
- Around line 237-244: Rename all keyframes from camelCase to kebab-case to
satisfy Stylelint: change flameWobble → flame-wobble, flameIgnite →
flame-ignite, sparkFloat1 → spark-float-1, sparkFloat2 → spark-float-2,
sparkBurst1 → spark-burst-1, sparkBurst2 → spark-burst-2 in the `@keyframes`
declarations (e.g., `@keyframes` flame-wobble) and update every place they are
referenced (animation, animation-name, or shorthand in classes/selectors that
currently reference flameWobble, flameIgnite, sparkFloat1, sparkFloat2,
sparkBurst1, sparkBurst2) so names match exactly.

In `@components/game/MultiBoardLayout.vue`:
- Around line 40-57: The toolbar buttons (the icon-only buttons using
ChevronLeft, ChevronRight and X) lack accessible names; update the button
elements (class "page-nav-btn") that call prevPage, nextPage and set
focusedBoard = null to include clear accessible labels by adding aria-label
attributes (e.g., "Previous page", "Next page", "Close board focus") or
visible/visually-hidden text inside the buttons so screen readers can announce
them; ensure disabled state remains and labels reflect action/state where
appropriate.

In `@components/game/MultiBoardPanel.vue`:
- Around line 12-15: Replace the hardcoded English labels in the aria-label and
any other visible text (e.g., "Wordle game grid", `Board ${boardIndex + 1}`, and
"left") with lookups into the app's localization strings using the project's
i18n helper (e.g., this.$t or i18n.t). For the aria-label expression that checks
singleBoard and boardIndex, call the appropriate locale keys (for example a
singular grid label key and a board label key that accepts a boardIndex param)
instead of the literal strings; do the same for the "left" string referenced on
lines 27-29 so all usages use the existing language UI string keys rather than
hardcoded English.

In `@components/game/PageShell.vue`:
- Around line 160-175: wasGameOverOnLoad is captured during script setup and can
become stale if loadFromLocalStorage() runs later on the client; declare
wasGameOverOnLoad (e.g., let wasGameOverOnLoad = false) at top-level but move
the assignment that reads game.gameOver into an onMounted() callback so it
captures the post‑hydration/localStorage state, leaving the existing watch on ()
=> game.gameOver unchanged; this ensures the watcher logic that checks
wasGameOverOnLoad, game.gameWon and isSpeedMode.value behaves correctly for
returning users.

In `@composables/useGameModeSeo.ts`:
- Around line 40-43: The current logic swaps to `${wordleBase} — ${modeLabel}`
when the constructed title exceeds 60 chars, but that fallback can also exceed
60; instead, compute title as you do (using translatedMode?.title ?
`${wordleBase} — ${translatedMode.title}` : `${wordleBase} — ${modeLabel}`),
then if title.length > 60 truncate the final string to 60 characters (e.g.,
title = title.slice(0, 57) + '...') so the resulting title variable always
respects the 60-char limit; update the code that sets title (referencing title,
wordleBase, translatedMode?.title, and modeLabel) to perform this truncation
rather than swapping to the fallback.

In `@composables/useMultiBoardModePage.ts`:
- Around line 15-18: The code currently converts any fetch error into a 404
inside the block that checks error.value or !gameData.value; change this so you
only throw a 404 when the fetch succeeded but the payload is missing (i.e.,
error.value is falsy and !gameData.value), and when error.value exists propagate
the upstream status by throwing createError using the upstream status
(error.value.status or error.value.statusCode) or defaulting to 500 and include
the original error message; locate the logic around useFetch, gameData, error
and createError to implement this conditional behavior.

In `@pages/`[lang]/unlimited.vue:
- Around line 183-194: In the noscript block of pages/[lang]/unlimited.vue the
anchor tags use Vue bindings like :href="`/${lang}`" which won't be processed
when JS is disabled; change those to static href attributes (e.g., href="/en"
style strings) or render the correct absolute path at build/SSR time so the
markup contains plain href values for the links (`/${lang}`, `/${lang}/speed`,
`/${lang}/dordle`, `/${lang}/tridle`, `/${lang}/quordle`) instead of using :href
bindings.

In `@plugins/pwa.client.ts`:
- Around line 59-62: The code snapshots the dismissal state into the variable
dismissed once at startup, causing stale behavior; change checks to call
isDismissed() dynamically instead of using the frozen dismissed value (update
the initialization and all usages, including inside status() and any logic
around the prompt flow), and add logic to re-evaluate and update state when a
cooldown expires (e.g., schedule a timer or recompute when the stored expiry
timestamp passes) so status() and prompt conditions always reflect the current
cooldown; specifically remove reliance on the module-level dismissed snapshot,
replace with direct calls to isDismissed() (or refresh dismissed via a scheduled
check), and ensure places referenced in the diff (the top-level dismissed
initialization and functions/methods that read it such as status() and prompt
handlers around promptedThisSession/idleTimer) use the fresh value.
- Around line 230-239: Watcher currently arms idle detection immediately because
useGameStore().gameConfig.mode defaults to 'classic'; change the watcher so it
only starts idle detection when the user is actually on a game page: inside the
watcher for gameConfig.mode (or replace it) check the current route (useRoute())
or a route meta flag (e.g., route.name === 'Game' or route.meta.isGame) before
calling startIdleDetection(), and avoid arming on plugin init by removing
immediate: true or gating the immediate branch with the same route check;
continue to call stopIdleDetection() when leaving the game route or when mode
becomes 'speed'.

In `@public/llms.txt`:
- Around line 7-15: The shipped mode list in llms.txt is incomplete (stops at
Quordle) so consumers will miss new routes; update the "Game Modes" section to
include the newly added modes (Octordle, Sedecordle, Duotrigordle) and add their
corresponding example URLs (e.g., /{lang}/octordle, /{lang}/sedecordle,
/{lang}/duotrigordle), and also update any later example URL lists in the file
to reflect these new modes so the file matches the actual route set.

In `@scripts/generate_favicon.py`:
- Around line 183-188: The PNG conversion currently flattens alpha by
compositing onto PAPER in the block used by make_favicon and make_large; change
the logic so you only convert RGBA->RGB when the output must be opaque (e.g.,
formats that don't support alpha), and for the large PNG/preview path leave the
image in RGBA to preserve rounded-corner transparency. Locate the RGBA check
(img.mode == "RGBA") and the compositing that creates rgb = Image.new("RGB",
img.size, PAPER) and make it conditional: if the target is an opaque export
convert and composite to PAPER, otherwise keep img as RGBA so make_large and the
large PNG/preview retain their alpha channel.
- Around line 198-203: The ICO writer is using img32 so the hand-tuned 16px
raster (img16) never gets embedded; change the save call to use img16 (i.e.,
call img16.save with ico_path and sizes=[(16,16),(32,32)]) so the dedicated 16px
renderer produced by make_favicon is included in favicon.ico; update the
reference to img32.save(...) to img16.save(...) while keeping ico_path and the
sizes list the same.
- Around line 108-129: The code computes W width using w_right - 50 but then
draws the glyph at start_x (ignoring the glyph’s left side bearing), which
mis-centers marks when the left bearing ≠ 50; change to use the ink left bound
consistently: set w_ink_width = w_right - w_left (and ensure dot_ink_width =
dot_right - dot_left), compute total_width from those ink widths, and either
offset the draw x by the glyph left ink bound (draw W at start_x + (w_left - 50)
and draw the dot at start_x + w_ink_width + gap + (dot_left - 50)) or adjust
start_x accordingly so you always position from the glyph’s left ink edge; make
the identical fix inside make_maskable().

In `@stores/game.ts`:
- Around line 1391-1396: The code uses a truthy check on dayIdx which skips
valid zero values; update the checks to explicitly test for null/undefined
(e.g., dayIdx != null) when setting todayDefinition.value.url and wherever
dayIdx is used (including the similar cases around lines 1404-1405) so that
dayIdx === 0 is treated as a valid index and the URL/image logic runs when
dayIdx is 0; adjust the conditional expressions that currently read like "dayIdx
? `/${langCode}/word/${dayIdx}` : undefined" to use an explicit null/undefined
check instead.
- Around line 1382-1398: The previous definition can persist on cache-only
misses; before calling fetchDefinition in the block using useDefinitions() and
fetchDefinition(word, langCode, { cacheOnly: !isDaily }), clear
todayDefinition.value (set to undefined/null) so the UI doesn't show an old
definition, and in the promise .then handler ensure you explicitly set
todayDefinition.value = undefined when def is falsy or has no
definition/definitionNative; also ensure loading flags
(todayDefinitionLoading.value and todayImageLoading.value) are still set/cleared
appropriately after the fetch.

In `@stores/stats.ts`:
- Around line 201-265: The streak logic in this file (toLocalDay, stepBack,
seenDays, and the loops that set current_overall_streak and
longest_overall_streak) must be extracted into a reusable helper (e.g.,
computeDayBasedStreaks) and invoked wherever classic-daily GameStats are
produced so both current_overall_streak and longest_overall_streak use identical
day-based rules; update calculateStats() / game_stats[key] to call that helper
for classic-daily keys, preserving the semantics: bucket by toLocalDay, treat
any same-day win as overriding a loss, walk backwards with stepBack for current
streak, and compute longest streak from sorted days as shown. Ensure the helper
returns both current_overall_streak and longest_overall_streak and replace any
older streak logic in computeStats()/calculateStats() with calls to this shared
function.

In `@tests/stores/stats.test.ts`:
- Around line 143-150: The test fixtures build "consecutive days" by subtracting
24h in milliseconds from new Date(), which is DST-sensitive; instead, freeze the
clock (use a fixed Date) and create calendar-day offsets by calling setDate() on
a noon-anchored Date to step days reliably; update the arrays assigned to
stats.gameResults['en'] (the three-day classic wins fixture) and the other
similar fixtures in this file to construct dates via a baseNoon = new
Date(frozenDate); baseNoon.setHours(12,0,0,0); then for each entry use new
Date(baseNoon) and base.setDate(baseNoon.getDate() - N) to produce prior days so
tests are stable across DST.

---

Outside diff comments:
In `@plugins/debug.client.ts`:
- Around line 130-140: The console style array length is wrong: the help text
prints 13 command lines but the styles are generated with Array(10) (20 entries)
causing undefined styles; update the generation that uses
Array(10).fill(null).flatMap(...) to produce 26 style strings (one pair per
command) — e.g. derive the repeat count from the number of command lines or
change Array(10) to Array(13) so the styles align with the commands like
debug.pwa.reset, debug.streak.set, debug.streak.ignite, debug.streak.reset.

---

Nitpick comments:
In `@components/game/BoardMinimap.vue`:
- Around line 49-70: miniRows rebuilds rows on every render which causes heavy
allocations for many boards; replace it with a memoized solution (e.g., a Vue
computed or a simple Map cache) keyed by boardIndex and a quick change-token
(length + join or a version/hash of board.tileClassesVisual and
board.tilesVisual) so results are returned from cache when inputs are unchanged;
implement the memoization where miniRows is defined/used (function miniRows /
BoardMinimap.vue) and ensure cache invalidation when board.tileClassesVisual or
board.tilesVisual change so colors are recomputed only when needed.

In `@components/game/KeyboardKey.vue`:
- Around line 105-113: The colWidth calculation is invariant per row; move the
const colWidth = 100 / cols; declaration out of the outer for (let r = 0; r <
rows; r++) loop so it’s computed once, then keep using colWidth inside the loop
that builds stops; ensure references to cols, rows, stateColor,
props.boardStates and DEFAULT_KEY_COLOR remain unchanged and behavior of the
nested loop that computes boardIdx and pushes color stops is preserved.

In `@server/middleware/trailing-slash.ts`:
- Line 12: The current logic in trailing-slash.ts uses url.pathname.slice(0, -1)
to remove only a single trailing slash (assigned to cleanPath), causing
double-slash paths like "/foo//" to require multiple redirects; update the
cleanPath calculation to strip all trailing slashes from url.pathname in one
pass (but preserve a single "/" for the root) — e.g., trim any consecutive
trailing "/" characters from url.pathname, then append url.search; change
references to cleanPath accordingly so a single redirect handles paths like
"/foo//".

In `@tests/stores/stats.test.ts`:
- Around line 141-162: Add two focused tests covering the two edge branches in
calculateTotalStats: (1) "same-day win overrides same-day loss" — create
gameResults entries on the same date for the classic mode with a loss and a win,
call calculateTotalStats() on the useStatsStore() instance, and assert the win
is counted (n_victories increments), loss is not counted (n_losses unchanged)
and current_overall_streak reflects the win; (2) "today skipped if not yet
played" — populate consecutive prior-day classic wins but leave today empty (no
entry for today), call calculateTotalStats(), and assert current_overall_streak
does not include today (streak equals consecutive prior days) and totals
(n_victories/n_losses) reflect only existing entries; reference
stats.gameResults, calculateTotalStats(), useStatsStore(), and
total.current_overall_streak/total.n_victories/total.n_losses when adding
assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0f6f7c82-faf3-4298-8611-3658636dfcd2

📥 Commits

Reviewing files that changed from the base of the PR and between 6864f97 and 91967cd.

⛔ Files ignored due to path filters (1)
  • .claude/scheduled_tasks.lock is excluded by !**/*.lock
📒 Files selected for processing (46)
  • assets/css/design-system.css
  • assets/css/main.css
  • components/game/BoardMinimap.vue
  • components/game/GameBoard.vue
  • components/game/GameHeader.vue
  • components/game/KeyboardKey.vue
  • components/game/MultiBoardLayout.vue
  • components/game/MultiBoardPanel.vue
  • components/game/PageShell.vue
  • components/game/StatsModal.vue
  • components/game/StreakBadge.vue
  • components/game/StreakModal.vue
  • composables/useAnalytics.ts
  • composables/useDefinitions.ts
  • composables/useGameModeSeo.ts
  • composables/useGameModes.ts
  • composables/useGamePage.ts
  • composables/useMultiBoardModePage.ts
  • docs/ROADMAP.md
  • docs/TODO.md
  • nuxt.config.ts
  • pages/[lang]/dordle.vue
  • pages/[lang]/duotrigordle.vue
  • pages/[lang]/index.vue
  • pages/[lang]/octordle.vue
  • pages/[lang]/quordle.vue
  • pages/[lang]/sedecordle.vue
  • pages/[lang]/tridle.vue
  • pages/[lang]/unlimited.vue
  • pages/index.vue
  • pages/stats.vue
  • plugins/debug.client.ts
  • plugins/pwa.client.ts
  • public/design-explorations/direction-a-editorial.html
  • public/llms.txt
  • public/manifest.json
  • scripts/generate_favicon.py
  • server/api/[lang]/definition/[word].get.ts
  • server/middleware/trailing-slash.ts
  • server/routes/sitemap-main.xml.ts
  • server/utils/definitions.ts
  • stores/game.ts
  • stores/stats.ts
  • tests/stores/stats.test.ts
  • utils/game-modes.ts
  • utils/types.ts
💤 Files with no reviewable changes (3)
  • docs/ROADMAP.md
  • pages/[lang]/index.vue
  • docs/TODO.md

Comment thread assets/css/main.css Outdated
Comment thread components/game/MultiBoardLayout.vue Outdated
Comment on lines +12 to +15
:aria-label="singleBoard ? 'Wordle game grid' : `Board ${boardIndex + 1}`"
class="game-board grid relative w-full h-full box-border"
:class="singleBoard ? 'gap-1 p-3' : 'gap-0.5 p-1'"
:style="{ gridTemplateRows: gridRowsStyle }"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Localize the new board labels before shipping.

Wordle game grid, Board ${boardIndex + 1}, and left bypass the language packs, so multi-board mode will still render/announce English in every locale. Please source these from the existing language UI strings instead.

Also applies to: 27-29

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/MultiBoardPanel.vue` around lines 12 - 15, Replace the
hardcoded English labels in the aria-label and any other visible text (e.g.,
"Wordle game grid", `Board ${boardIndex + 1}`, and "left") with lookups into the
app's localization strings using the project's i18n helper (e.g., this.$t or
i18n.t). For the aria-label expression that checks singleBoard and boardIndex,
call the appropriate locale keys (for example a singular grid label key and a
board label key that accepts a boardIndex param) instead of the literal strings;
do the same for the "left" string referenced on lines 27-29 so all usages use
the existing language UI string keys rather than hardcoded English.

Comment on lines +160 to +175
// Ignite animation only on live wins — not page load with completed game.
// Read gameOver immediately (before any watcher fires) to detect returning users.
const wasGameOverOnLoad = game.gameOver;

watch(
() => game.gameOver,
(isOver) => {
if (isOver && game.gameWon && !isSpeedMode.value && !wasGameOverOnLoad) {
justWon.value = true;
if (justWonTimeout) clearTimeout(justWonTimeout);
justWonTimeout = setTimeout(() => {
justWon.value = false;
}, 800);
}
}
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if game store's gameOver is accessed elsewhere during SSR
rg -n 'game\.gameOver' -g '*.vue' -B2 -A2 | head -50

Repository: Hugo0/wordle

Length of output: 3256


🏁 Script executed:

# Find the game store/state definition
fd -t f -e 'ts' -e 'js' -e 'vue' | xargs grep -l 'gameOver' | grep -E '(store|state)' | head -20

Repository: Hugo0/wordle

Length of output: 96


🏁 Script executed:

# Look for where game state is imported in PageShell.vue
rg -n 'import.*game' components/game/PageShell.vue -A5

Repository: Hugo0/wordle

Length of output: 38


🏁 Script executed:

# Check if PageShell is a layout component
git ls-files | grep -i 'layout\|PageShell'

Repository: Hugo0/wordle

Length of output: 159


🏁 Script executed:

# Read the game store to see how gameOver is initialized
cat stores/game.ts

Repository: Hugo0/wordle

Length of output: 50368


🏁 Script executed:

# Check if there's localStorage-based hydration logic anywhere
rg -n 'localStorage' -g '*.ts' -g '*.vue' -B2 -A2 | head -80

Repository: Hugo0/wordle

Length of output: 4046


🏁 Script executed:

# Check how PageShell.vue uses the game store
sed -n '1,50p' components/game/PageShell.vue

Repository: Hugo0/wordle

Length of output: 2214


Terminology clarification: wasGameOverOnLoad doesn't cause a hydration mismatch, but may become stale if loadFromLocalStorage() runs after component setup.

The variable wasGameOverOnLoad captures a value during script setup, which executes on both server and client. Since the game store properly guards localStorage access with import.meta.client, game.gameOver starts at false on both sides. However, if the app calls loadFromLocalStorage() after this component's script setup completes, wasGameOverOnLoad will hold a stale value while game.gameOver has already been updated from localStorage—causing the watcher to behave unexpectedly on returning users.

Consider moving this initialization to onMounted() to ensure it captures the actual client-side state after any localStorage restoration.

Proposed fix
+import { onMounted } from 'vue';
+
 // Ignite animation only on live wins — not page load with completed game.
-// Read gameOver immediately (before any watcher fires) to detect returning users.
-const wasGameOverOnLoad = game.gameOver;
+// Track whether game was already over when component mounted (after store initialization).
+const wasGameOverOnLoad = ref(false);
+
+onMounted(() => {
+    wasGameOverOnLoad.value = game.gameOver;
+});

 watch(
     () => game.gameOver,
     (isOver) => {
-        if (isOver && game.gameWon && !isSpeedMode.value && !wasGameOverOnLoad) {
+        if (isOver && game.gameWon && !isSpeedMode.value && !wasGameOverOnLoad.value) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/PageShell.vue` around lines 160 - 175, wasGameOverOnLoad is
captured during script setup and can become stale if loadFromLocalStorage() runs
later on the client; declare wasGameOverOnLoad (e.g., let wasGameOverOnLoad =
false) at top-level but move the assignment that reads game.gameOver into an
onMounted() callback so it captures the post‑hydration/localStorage state,
leaving the existing watch on () => game.gameOver unchanged; this ensures the
watcher logic that checks wasGameOverOnLoad, game.gameWon and isSpeedMode.value
behaves correctly for returning users.

Comment thread components/game/StreakModal.vue
Comment on lines +198 to +203
# ICO (contains 16 + 32)
img16 = make_favicon(16, font_path)
img32 = make_favicon(32, font_path)
ico_path = os.path.join(FAVICON_DIR, "favicon.ico")
img32.save(ico_path, "ICO", sizes=[(16, 16), (32, 32)])
print(f" ✓ favicon.ico (16+32)")
print(f"\nDone! Favicon deployed as option {key}.")
print(" ✓ favicon.ico (16+32)")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

favicon.ico still never consumes the hand-tuned 16px raster.

Line 199 renders img16, but Line 202 only passes img32 into the ICO writer. That means the dedicated 16px renderer added in this refactor cannot make it into favicon.ico, which is still the first icon some browsers read.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/generate_favicon.py` around lines 198 - 203, The ICO writer is using
img32 so the hand-tuned 16px raster (img16) never gets embedded; change the save
call to use img16 (i.e., call img16.save with ico_path and
sizes=[(16,16),(32,32)]) so the dedicated 16px renderer produced by make_favicon
is included in favicon.ico; update the reference to img32.save(...) to
img16.save(...) while keeping ico_path and the sizes list the same.

Comment thread stores/game.ts
Comment thread stores/game.ts
Comment thread stores/stats.ts Outdated
Comment on lines +143 to +150
const day = 24 * 60 * 60 * 1000;
const today = new Date();

// Classic wins on 3 consecutive days
stats.gameResults['en'] = [
{ won: true, attempts: 3, date: new Date(today.getTime() - 2 * day) },
{ won: true, attempts: 2, date: new Date(today.getTime() - 1 * day) },
{ won: true, attempts: 4, date: today },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

These fixtures are still DST-sensitive.

All three tests build “consecutive days” by subtracting 24h in milliseconds from new Date(), which is exactly the pitfall the production code is avoiding here. Around DST transitions that can land on the wrong local calendar day. Please freeze the clock and step calendar days with setDate() (ideally from a noon anchor) instead.

Also applies to: 269-279, 287-301

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/stores/stats.test.ts` around lines 143 - 150, The test fixtures build
"consecutive days" by subtracting 24h in milliseconds from new Date(), which is
DST-sensitive; instead, freeze the clock (use a fixed Date) and create
calendar-day offsets by calling setDate() on a noon-anchored Date to step days
reliably; update the arrays assigned to stats.gameResults['en'] (the three-day
classic wins fixture) and the other similar fixtures in this file to construct
dates via a baseNoon = new Date(frozenDate); baseNoon.setHours(12,0,0,0); then
for each entry use new Date(baseNoon) and base.setDate(baseNoon.getDate() - N)
to produce prior days so tests are stable across DST.

- Extract useMultiBoardModePage composable (shared setup for all multi-board pages)
- Add shellMaxWidth to GameModeDefinition (data-driven page shell width)
- Reduce 5 near-identical ~90-line pages to 5 identical 33-line pages
- Prettier formatting fixes across analytics, game share, tests
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
composables/useGamePage.ts (2)

152-153: ⚠️ Potential issue | 🟡 Minor

Same localStorage concern applies here.

The localStorage.setItem call should also be wrapped in try-catch for consistency with the rest of the codebase.

🛡️ Proposed fix
-            localStorage.setItem('last_played_date', new Date().toISOString().split('T')[0]!);
+            try {
+                localStorage.setItem('last_played_date', new Date().toISOString().split('T')[0]!);
+            } catch {
+                // Private browsing or localStorage unavailable
+            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useGamePage.ts` around lines 152 - 153, Wrap the
localStorage.setItem call that updates 'last_played_date' inside a try-catch in
the same way other storage operations are handled in useGamePage; specifically,
in the function or block where localStorage.setItem('last_played_date', ...) is
called, catch any exceptions and log/handle the error (e.g., via the existing
logger or a no-op fallback) instead of letting it throw. Ensure you reference
the same storage key 'last_played_date' and preserve the current value logic
(new Date().toISOString().split('T')[0]) inside the try block.

130-139: ⚠️ Potential issue | 🟡 Minor

Wrap localStorage access in try-catch for consistency.

Other parts of the codebase (e.g., identifyUser in useAnalytics.ts) wrap localStorage access in try-catch to handle private browsing mode or quota errors. This bare localStorage.getItem call could throw in those environments.

🛡️ Proposed fix
-            const lastPlayed = localStorage.getItem('last_played_date');
+            let lastPlayed: string | null = null;
+            try {
+                lastPlayed = localStorage.getItem('last_played_date');
+            } catch {
+                // Private browsing or localStorage unavailable
+            }
             const daysSinceLast = analytics.daysSince(lastPlayed ?? undefined);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useGamePage.ts` around lines 130 - 139, Wrap the localStorage
access in useGamePage (the call reading 'last_played_date' into lastPlayed) in a
try-catch so it won’t throw in private browsing/quota error scenarios; on error
fall back to undefined for lastPlayed (so analytics.daysSince(lastPlayed ??
undefined) behaves the same) and optionally log or swallow the exception, then
continue to call analytics.daysSince and conditionally
analytics.trackReturningPlayer as before (references: lastPlayed variable,
analytics.daysSince, analytics.trackReturningPlayer).
🧹 Nitpick comments (2)
composables/useGamePage.ts (1)

120-128: Good optimization to reduce redundant PostHog $identify calls.

Comparing ph?.get_distinct_id() against the stored clientId avoids wasteful identify/set events when the user is already identified. Using computeUserProperties for already-identified users is efficient.

However, usePostHog() could return undefined if the plugin hasn't initialized yet. Consider adding a null check before calling get_distinct_id().

💡 Optional: Explicit null handling
 const ph = usePostHog();
 const clientId = getOrCreateId('client_id');
-const alreadyIdentified = ph?.get_distinct_id() === clientId;
+const alreadyIdentified = ph != null && ph.get_distinct_id() === clientId;

This makes the intent clearer and ensures alreadyIdentified is false (not undefined === clientId) when PostHog isn't available.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useGamePage.ts` around lines 120 - 128, The null-check is missing
before calling get_distinct_id on the PostHog instance: ensure usePostHog()
result (ph) is checked for existence before accessing ph.get_distinct_id so
alreadyIdentified becomes false if PostHog isn't initialized; update the logic
around usePostHog(), ph, clientId and alreadyIdentified so you only call
ph.get_distinct_id() when ph is truthy and otherwise fall back to treating the
user as not-identified (then call analytics.identifyUser or
analytics.computeUserProperties with stats.gameResults accordingly).
composables/useAnalytics.ts (1)

455-479: Consolidated share tracking with emoji pattern parsing looks good.

The regex patterns correctly use the Unicode flag (/gu) for matching emoji codepoints. One minor note: \u2B1C is the white square (⬜), which matches the "incorrect" emoji used in buildEmojiBoard. The high-contrast mode uses different emojis (🟦/🟧), so these counts may be inaccurate for high-contrast users.

💡 Optional: Consider passing highContrast flag for accurate emoji counting

If accurate emoji counts matter for analytics, you could pass the high-contrast setting and adjust the regex patterns accordingly. However, this may be unnecessary complexity if the counts are just directional metrics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useAnalytics.ts` around lines 455 - 479, trackShare currently
parses emojiPattern assuming standard green/yellow/gray emojis; add support for
high-contrast variants by extending the params with an optional highContrast
boolean (or game_mode flag if preferred) and update parsing to count both
standard and high-contrast codepoints. Specifically, update the trackShare
signature to include highContrast?: boolean, then when computing
props.green_count and props.yellow_count count both U+1F7E9 and U+1F7E6 for
green, and both U+1F7E8 and U+1F7E7 for yellow (keep gray_count as U+2B1C), and
ensure all callers of trackShare are updated to pass the highContrast flag where
available. Ensure regexes keep the /gu flags and the rest of the props logic is
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@composables/useGamePage.ts`:
- Around line 152-153: Wrap the localStorage.setItem call that updates
'last_played_date' inside a try-catch in the same way other storage operations
are handled in useGamePage; specifically, in the function or block where
localStorage.setItem('last_played_date', ...) is called, catch any exceptions
and log/handle the error (e.g., via the existing logger or a no-op fallback)
instead of letting it throw. Ensure you reference the same storage key
'last_played_date' and preserve the current value logic (new
Date().toISOString().split('T')[0]) inside the try block.
- Around line 130-139: Wrap the localStorage access in useGamePage (the call
reading 'last_played_date' into lastPlayed) in a try-catch so it won’t throw in
private browsing/quota error scenarios; on error fall back to undefined for
lastPlayed (so analytics.daysSince(lastPlayed ?? undefined) behaves the same)
and optionally log or swallow the exception, then continue to call
analytics.daysSince and conditionally analytics.trackReturningPlayer as before
(references: lastPlayed variable, analytics.daysSince,
analytics.trackReturningPlayer).

---

Nitpick comments:
In `@composables/useAnalytics.ts`:
- Around line 455-479: trackShare currently parses emojiPattern assuming
standard green/yellow/gray emojis; add support for high-contrast variants by
extending the params with an optional highContrast boolean (or game_mode flag if
preferred) and update parsing to count both standard and high-contrast
codepoints. Specifically, update the trackShare signature to include
highContrast?: boolean, then when computing props.green_count and
props.yellow_count count both U+1F7E9 and U+1F7E6 for green, and both U+1F7E8
and U+1F7E7 for yellow (keep gray_count as U+2B1C), and ensure all callers of
trackShare are updated to pass the highContrast flag where available. Ensure
regexes keep the /gu flags and the rest of the props logic is unchanged.

In `@composables/useGamePage.ts`:
- Around line 120-128: The null-check is missing before calling get_distinct_id
on the PostHog instance: ensure usePostHog() result (ph) is checked for
existence before accessing ph.get_distinct_id so alreadyIdentified becomes false
if PostHog isn't initialized; update the logic around usePostHog(), ph, clientId
and alreadyIdentified so you only call ph.get_distinct_id() when ph is truthy
and otherwise fall back to treating the user as not-identified (then call
analytics.identifyUser or analytics.computeUserProperties with stats.gameResults
accordingly).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2c273160-f255-42e4-8c70-cd9024ade414

📥 Commits

Reviewing files that changed from the base of the PR and between 91967cd and 081d923.

📒 Files selected for processing (12)
  • composables/useAnalytics.ts
  • composables/useGamePage.ts
  • composables/useGameShare.ts
  • composables/useMultiBoardModePage.ts
  • pages/[lang]/dordle.vue
  • pages/[lang]/duotrigordle.vue
  • pages/[lang]/octordle.vue
  • pages/[lang]/quordle.vue
  • pages/[lang]/sedecordle.vue
  • pages/[lang]/unlimited.vue
  • stores/game.ts
  • tests/stores/game.test.ts
✅ Files skipped from review due to trivial changes (3)
  • pages/[lang]/octordle.vue
  • composables/useMultiBoardModePage.ts
  • pages/[lang]/dordle.vue
🚧 Files skipped from review as they are similar to previous changes (4)
  • pages/[lang]/sedecordle.vue
  • pages/[lang]/duotrigordle.vue
  • pages/[lang]/unlimited.vue
  • stores/game.ts

- Rename keyframe animations to kebab-case (Stylelint compliance)
- Add aria-labels to icon-only toolbar buttons (accessibility)
- Distinguish network errors from 404s in useMultiBoardModePage
- Update llms.txt with new game modes (octordle, sedecordle, duotrigordle)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
composables/useMultiBoardModePage.ts (1)

23-28: ⚠️ Potential issue | 🟠 Major

Preserve upstream fetch status instead of collapsing most failures to 404.

At Line 23 and Line 26, non-network fetch errors (including upstream 5xx) still get rewritten to 404, which masks outages and misclassifies crawler responses.

✅ Suggested fix
-    if (error.value || !gameData.value) {
-        const isNetworkError = error.value && !error.value.statusCode;
-        throw createError({
-            statusCode: isNetworkError ? 500 : 404,
-            message: isNetworkError ? 'Failed to load game data' : 'Language not found',
-        });
-    }
+    if (error.value) {
+        const statusCode = error.value.statusCode || error.value.status || 500;
+        throw createError({
+            statusCode,
+            statusMessage: error.value.statusMessage || error.value.message || 'Failed to load game data',
+        });
+    }
+    if (!gameData.value) {
+        throw createError({ statusCode: 404, statusMessage: 'Language not found' });
+    }
In Nuxt 3, for `useFetch`, what fields are guaranteed on `error.value` (e.g., `status`, `statusCode`, `statusMessage`, `message`) and which should be used when rethrowing with `createError`?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useMultiBoardModePage.ts` around lines 23 - 28, The current error
handling in the useMultiBoardModePage composable collapses non-network failures
to 404; update the block that checks error.value || !gameData.value to preserve
and rethrow the original upstream status and message when available (use
error.value.status || error.value.statusCode as the statusCode, and prefer
error.value.statusMessage || error.value.message for the message), only falling
back to 500/"Failed to load game data" when no upstream status/message exists;
locate the logic around error.value, gameData.value and createError and replace
the hardcoded 404/500 mapping with these preserved fields so upstream 5xx/4xx
are propagated instead of being converted to 404.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@composables/useMultiBoardModePage.ts`:
- Around line 23-28: The current error handling in the useMultiBoardModePage
composable collapses non-network failures to 404; update the block that checks
error.value || !gameData.value to preserve and rethrow the original upstream
status and message when available (use error.value.status ||
error.value.statusCode as the statusCode, and prefer error.value.statusMessage
|| error.value.message for the message), only falling back to 500/"Failed to
load game data" when no upstream status/message exists; locate the logic around
error.value, gameData.value and createError and replace the hardcoded 404/500
mapping with these preserved fields so upstream 5xx/4xx are propagated instead
of being converted to 404.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b984e3c-024b-44d9-a9aa-836325ffeca6

📥 Commits

Reviewing files that changed from the base of the PR and between 081d923 and be4fe01.

📒 Files selected for processing (4)
  • assets/css/main.css
  • components/game/MultiBoardLayout.vue
  • composables/useMultiBoardModePage.ts
  • public/llms.txt
✅ Files skipped from review due to trivial changes (1)
  • public/llms.txt
🚧 Files skipped from review as they are similar to previous changes (2)
  • assets/css/main.css
  • components/game/MultiBoardLayout.vue

- Replace useGameModeSeo with useGameSeo (simpler API, handles hreflang)
- Add SeoNoscript.vue shared component for noscript fallback content
- Add seo-content.ts for mode descriptions and structured data
- Simplify all game page files (dordle, quordle, octordle, etc.)
- Update useMultiBoardModePage to synchronous-only pattern
- Add GameData type to utils/types.ts
- Game layout: add scroll-snap for multi-board scrolling
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/game/SeoNoscript.vue`:
- Around line 71-84: The shared "More Game Modes" nav omits newer multi-board
modes; update the template in SeoNoscript.vue to include links for the new modes
(octordle, sedecordle, duotrigordle) using the same pattern as existing links:
add anchor tags with :href="`/${lang}/octordle`", :href="`/${lang}/sedecordle`",
and :href="`/${lang}/duotrigordle`" and guard each with v-if="mode !==
'octordle'" (and similarly for 'sedecordle' and 'duotrigordle') so the current
page link is not shown, matching the existing logic used for 'dordle'/'quordle'
and 'unlimited'/'speed'.

In `@composables/useAnalytics.ts`:
- Around line 120-123: The PWAParams.type union is missing 'unknown' which the
code emits (see the emit using source: params?.source || 'unknown'); update the
PWAParams interface to include 'unknown' in the source union (PWAParams.source?:
'settings' | 'auto' | 'dialog' | 'appinstalled' | 'unknown') so callers and
downstream handlers match the actual payload from the emission; ensure any
references to PWAParams or usages of source accept the new 'unknown' member.

In `@composables/useGamePage.ts`:
- Around line 119-125: The current logic only calls analytics.identifyUser(...)
for new users and uses analytics.computeUserProperties(...) (which is pure) for
returning users, so persisted profile totals/languages never get updated after
first session; change the flow so returning sessions also persist updates—either
always call analytics.identifyUser(stats.gameResults) instead of
computeUserProperties, or implement and call a lighter persistent update method
(e.g., analytics.patchUserProperties or analytics.updateUserProperties) for
returning users using stats.gameResults, ensuring persisted profile fields are
refreshed even when localStorage.getItem('first_seen_date') is present.
- Around line 87-90: The per-mode stats are being initialized too early using
buildStatsKey(game.gameConfig) and stats.calculateStats(...)—move or defer this
work until after the page-specific mode setup has run (i.e., after the component
that replaces game.gameConfig finishes mounting). Fix by removing the early
calls to buildStatsKey / stats.calculateStats / stats.calculateTotalStats from
useGamePage.ts and instead invoke them once the real game.gameConfig is applied
(either by calling them from that component’s mounted hook or by adding a
watcher on game.gameConfig that calls buildStatsKey and then
stats.calculateStats(...) and stats.calculateTotalStats() only when
game.gameConfig is no longer the default). Ensure you reference the same
buildStatsKey and stats.calculateStats/stat.calculateTotalStats symbols so the
correct stats bucket is used.

In `@pages/index.vue`:
- Line 46: Update the SEO meta string on pages/index.vue (the template literal
that currently reads `Play Wordle in ${langCount.value}+ languages — daily
puzzle, unlimited mode, speed streak, dordle & quordle.`) to mention the new
multi-board modes instead of only “dordle & quordle” — e.g., replace that ending
with something like “multi-board modes up to 32 boards” or “multi-board modes
(up to 32 boards)” so the meta copy stays accurate after the release.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 27762d60-ba8f-442e-aee5-202a0509391d

📥 Commits

Reviewing files that changed from the base of the PR and between be4fe01 and 1501908.

📒 Files selected for processing (19)
  • components/game/PageShell.vue
  • components/game/SeoNoscript.vue
  • composables/useAnalytics.ts
  • composables/useGameModeSeo.ts
  • composables/useGamePage.ts
  • composables/useGameSeo.ts
  • composables/useMultiBoardModePage.ts
  • layouts/game.vue
  • pages/[lang]/dordle.vue
  • pages/[lang]/duotrigordle.vue
  • pages/[lang]/index.vue
  • pages/[lang]/octordle.vue
  • pages/[lang]/quordle.vue
  • pages/[lang]/sedecordle.vue
  • pages/[lang]/speed.vue
  • pages/[lang]/unlimited.vue
  • pages/index.vue
  • utils/seo-content.ts
  • utils/types.ts
💤 Files with no reviewable changes (1)
  • composables/useGameModeSeo.ts
✅ Files skipped from review due to trivial changes (1)
  • layouts/game.vue
🚧 Files skipped from review as they are similar to previous changes (8)
  • pages/[lang]/quordle.vue
  • utils/types.ts
  • pages/[lang]/unlimited.vue
  • pages/[lang]/duotrigordle.vue
  • pages/[lang]/dordle.vue
  • pages/[lang]/octordle.vue
  • pages/[lang]/sedecordle.vue
  • components/game/PageShell.vue

Comment thread components/game/SeoNoscript.vue Outdated
Comment on lines 120 to 123
interface PWAParams {
platform?: 'ios' | 'android' | 'desktop' | 'unknown';
source?: 'banner' | 'settings' | 'auto';
source?: 'settings' | 'auto' | 'dialog' | 'appinstalled';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, check if the file exists and get its size
fd -t f "useAnalytics.ts" | head -5

Repository: Hugo0/wordle

Length of output: 83


🏁 Script executed:

# Get the total line count to understand file structure
wc -l composables/useAnalytics.ts

Repository: Hugo0/wordle

Length of output: 87


🏁 Script executed:

# Read lines 115-130 to see the PWAParams interface and surrounding context
sed -n '115,130p' composables/useAnalytics.ts

Repository: Hugo0/wordle

Length of output: 409


🏁 Script executed:

# Read lines 510-520 to check for the fallback behavior mentioned at line 516
sed -n '510,520p' composables/useAnalytics.ts

Repository: Hugo0/wordle

Length of output: 354


🏁 Script executed:

# Search for all usages of PWAParams in the file
rg "PWAParams" composables/useAnalytics.ts -n

Repository: Hugo0/wordle

Length of output: 145


🏁 Script executed:

# Search for trackPWAInstall calls to see how PWAParams is passed
rg "trackPWAInstall" composables/useAnalytics.ts -n

Repository: Hugo0/wordle

Length of output: 148


🏁 Script executed:

# Check if there's any handler or consumer of the 'source' field downstream
rg "pwa_install" composables/useAnalytics.ts -n -A 3 -B 3

Repository: Hugo0/wordle

Length of output: 491


🏁 Script executed:

# Check if other source values are used or validated elsewhere
rg "'unknown'" composables/useAnalytics.ts -n

Repository: Hugo0/wordle

Length of output: 334


Keep PWAParams.source aligned with the payload emitted.

Line 516 emits source: params?.source || 'unknown', but the interface at lines 120-122 declares source?: 'settings' | 'auto' | 'dialog' | 'appinstalled', excluding 'unknown'. This breaks the type contract for callers and downstream handlers.

Add 'unknown' to the union:

Fix
 interface PWAParams {
     platform?: 'ios' | 'android' | 'desktop' | 'unknown';
-    source?: 'settings' | 'auto' | 'dialog' | 'appinstalled';
+    source?: 'settings' | 'auto' | 'dialog' | 'appinstalled' | 'unknown';
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
interface PWAParams {
platform?: 'ios' | 'android' | 'desktop' | 'unknown';
source?: 'banner' | 'settings' | 'auto';
source?: 'settings' | 'auto' | 'dialog' | 'appinstalled';
}
interface PWAParams {
platform?: 'ios' | 'android' | 'desktop' | 'unknown';
source?: 'settings' | 'auto' | 'dialog' | 'appinstalled' | 'unknown';
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useAnalytics.ts` around lines 120 - 123, The PWAParams.type union
is missing 'unknown' which the code emits (see the emit using source:
params?.source || 'unknown'); update the PWAParams interface to include
'unknown' in the source union (PWAParams.source?: 'settings' | 'auto' | 'dialog'
| 'appinstalled' | 'unknown') so callers and downstream handlers match the
actual payload from the emission; ensure any references to PWAParams or usages
of source accept the new 'unknown' member.

Comment on lines 87 to 90
const statsKey = buildStatsKey(game.gameConfig);
stats.calculateStats(statsKey, game.gameConfig.maxGuesses);
stats.calculateTotalStats(); // Overall streak for header badge

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Defer per-mode stats until the page-specific mode setup has run.

buildStatsKey(game.gameConfig) here can still see the default config on first load. For example, pages/[lang]/speed.vue only replaces game.gameConfig in its own mounted hook at Lines 156-165, so this seeds stats.stats from the wrong bucket before the speed page finishes initializing.

Suggested fix
-        const statsKey = buildStatsKey(game.gameConfig);
-        stats.calculateStats(statsKey, game.gameConfig.maxGuesses);
-        stats.calculateTotalStats(); // Overall streak for header badge
+        nextTick(() => {
+            const statsKey = buildStatsKey(game.gameConfig);
+            stats.calculateStats(statsKey, game.gameConfig.maxGuesses);
+        });
+        stats.calculateTotalStats(); // Overall streak for header badge
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useGamePage.ts` around lines 87 - 90, The per-mode stats are
being initialized too early using buildStatsKey(game.gameConfig) and
stats.calculateStats(...)—move or defer this work until after the page-specific
mode setup has run (i.e., after the component that replaces game.gameConfig
finishes mounting). Fix by removing the early calls to buildStatsKey /
stats.calculateStats / stats.calculateTotalStats from useGamePage.ts and instead
invoke them once the real game.gameConfig is applied (either by calling them
from that component’s mounted hook or by adding a watcher on game.gameConfig
that calls buildStatsKey and then stats.calculateStats(...) and
stats.calculateTotalStats() only when game.gameConfig is no longer the default).
Ensure you reference the same buildStatsKey and
stats.calculateStats/stat.calculateTotalStats symbols so the correct stats
bucket is used.

Comment on lines +119 to +125
// Only identify new users — returning users are already identified
// from a previous session (PostHog persists distinct_id in localStorage).
// This saves ~$identify + $set events for every returning-user page load.
const isNewUser = !localStorage.getItem('first_seen_date');
const userProps = isNewUser
? analytics.identifyUser(stats.gameResults)
: analytics.computeUserProperties(stats.gameResults);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Returning sessions stop refreshing the persisted analytics profile.

computeUserProperties() is pure; only identifyUser() actually writes the derived totals and language list back out. After the first session, those persisted profile fields freeze even as the user plays more games or languages.

Safest short-term fix
-            const isNewUser = !localStorage.getItem('first_seen_date');
-            const userProps = isNewUser
-                ? analytics.identifyUser(stats.gameResults)
-                : analytics.computeUserProperties(stats.gameResults);
+            const userProps = analytics.identifyUser(stats.gameResults);
If the extra identify volume is too expensive, add a lighter update path instead of skipping persistence entirely.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Only identify new users — returning users are already identified
// from a previous session (PostHog persists distinct_id in localStorage).
// This saves ~$identify + $set events for every returning-user page load.
const isNewUser = !localStorage.getItem('first_seen_date');
const userProps = isNewUser
? analytics.identifyUser(stats.gameResults)
: analytics.computeUserProperties(stats.gameResults);
// Only identify new users — returning users are already identified
// from a previous session (PostHog persists distinct_id in localStorage).
// This saves ~$identify + $set events for every returning-user page load.
const userProps = analytics.identifyUser(stats.gameResults);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useGamePage.ts` around lines 119 - 125, The current logic only
calls analytics.identifyUser(...) for new users and uses
analytics.computeUserProperties(...) (which is pure) for returning users, so
persisted profile totals/languages never get updated after first session; change
the flow so returning sessions also persist updates—either always call
analytics.identifyUser(stats.gameResults) instead of computeUserProperties, or
implement and call a lighter persistent update method (e.g.,
analytics.patchUserProperties or analytics.updateUserProperties) for returning
users using stats.gameResults, ensuring persisted profile fields are refreshed
even when localStorage.getItem('first_seen_date') is present.

Comment thread pages/index.vue
const description = computed(
() =>
`Play Wordle today in ${langCount.value}+ languages \u2014 free, daily 5-letter word puzzle. Guess the word in 6 tries. No account needed. Available in English, Spanish, German, Arabic, Hebrew, Finnish and more.`
`Play Wordle in ${langCount.value}+ languages \u2014 daily puzzle, unlimited mode, speed streak, dordle & quordle. Free word game, no account needed.`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Refresh SEO copy to reflect newly shipped multi-board modes.

Line 46 still highlights only “dordle & quordle,” while this PR ships additional multi-board modes. Consider broader wording (e.g., “multi-board modes up to 32 boards”) to avoid stale metadata immediately after release.

Suggested copy tweak
-        `Play Wordle in ${langCount.value}+ languages — daily puzzle, unlimited mode, speed streak, dordle & quordle. Free word game, no account needed.`
+        `Play Wordle in ${langCount.value}+ languages — daily puzzle, unlimited mode, speed streak, and multi-board modes (Dordle to Duotrigordle). Free word game, no account needed.`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`Play Wordle in ${langCount.value}+ languages \u2014 daily puzzle, unlimited mode, speed streak, dordle & quordle. Free word game, no account needed.`
`Play Wordle in ${langCount.value}+ languages daily puzzle, unlimited mode, speed streak, and multi-board modes (Dordle to Duotrigordle). Free word game, no account needed.`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/index.vue` at line 46, Update the SEO meta string on pages/index.vue
(the template literal that currently reads `Play Wordle in ${langCount.value}+
languages — daily puzzle, unlimited mode, speed streak, dordle & quordle.`) to
mention the new multi-board modes instead of only “dordle & quordle” — e.g.,
replace that ending with something like “multi-board modes up to 32 boards” or
“multi-board modes (up to 32 boards)” so the meta copy stays accurate after the
release.

Hugo0 added 2 commits March 29, 2026 14:59
Calendar heatmap:
- Show rolling 28 days instead of current month only
- Add loss state (red/accent) distinct from miss (gray)
- Add legend (Won / Lost / Missed)
- Always shows full streak context across month boundaries

Per-language section:
- Show "Wins by Language" with win count + games played
- Replaces misleading "Per Language" streak numbers
- Per-language streaks were game-based not day-based — confusing

Footer copy:
- "classic daily puzzles in any language" (was "any daily puzzle")
- Clarifies that multi-board unlimited modes don't count

Streak badge:
- Frozen state when streak breaks (blue flame, 5s duration)
- Shows previous streak count during frozen animation
- Blue glow pulse animation instead of instant disappear

Cross-tab sync:
- Stats store listens for StorageEvent on 'game_results'
- Streak updates immediately when another tab saves a result
Replace 5 near-identical page files (dordle, quordle, octordle,
sedecordle, duotrigordle) with one dynamic [mode].vue that validates
the mode param. Fixes Nuxt composable context error (useFetch in
extracted async composable lost context after await).

Delete useMultiBoardModePage.ts — the async composable pattern doesn't
work in Nuxt 3 (context transform only applies to <script setup>).
The synchronous setup (useGamePage, useGameSeo, useMultiBoardPage)
stays in the page's setup where Nuxt preserves context.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
stores/stats.ts (1)

212-212: Consider locale fallback for date formatting.

toLocaleDateString('en-CA') is a clever way to get ISO format, but it relies on the en-CA locale being available. Most browsers support it, but for extra robustness, consider a manual formatter:

const toLocalDay = (d: Date) => {
    const y = d.getFullYear();
    const m = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    return `${y}-${m}-${day}`;
};

This avoids any locale dependency while achieving the same result.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@stores/stats.ts` at line 212, The toLocalDay helper currently uses
toLocaleDateString('en-CA') which depends on that locale being available;
replace it with a deterministic ISO date formatter inside toLocalDay that builds
the string from d.getFullYear(), d.getMonth()+1 and d.getDate(), padding
month/day to two digits (e.g., padStart) to produce YYYY-MM-DD so the code does
not rely on locale availability.
components/game/StreakBadge.vue (1)

6-6: Improve aria-label to include streak count.

The current static label doesn't convey the streak value to screen reader users. Consider making it dynamic:

-        aria-label="Streak"
+        :aria-label="`${frozen ? prevStreak : streak} day streak`"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/StreakBadge.vue` at line 6, The static aria-label="Streak"
should be changed to a dynamic bound label that includes the numeric value so
screen readers hear the count; update the template attribute (aria-label on the
element in StreakBadge.vue) to a bound expression (e.g., use :aria-label or
v-bind:aria-label) that composes a string with the component's streak prop/state
(e.g., streak or streakCount) so it reads like "Streak: 5"; ensure you reference
the existing streak prop/computed used in this component when constructing the
label.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/game/StreakBadge.vue`:
- Around line 56-69: The watch callback sets a 5s setTimeout that can run after
the component unmounts; store the timeout id (from setTimeout) in a local
ref/variable and clear it in an onUnmounted handler to avoid state mutation
after unmount. Update the watch() block that references props.streak,
prevStreak, and frozen to assign the timeout id when scheduling frozen.value =
false, and add an onUnmounted() that calls clearTimeout on that id (and clear
any previous timeout before setting a new one).

---

Nitpick comments:
In `@components/game/StreakBadge.vue`:
- Line 6: The static aria-label="Streak" should be changed to a dynamic bound
label that includes the numeric value so screen readers hear the count; update
the template attribute (aria-label on the element in StreakBadge.vue) to a bound
expression (e.g., use :aria-label or v-bind:aria-label) that composes a string
with the component's streak prop/state (e.g., streak or streakCount) so it reads
like "Streak: 5"; ensure you reference the existing streak prop/computed used in
this component when constructing the label.

In `@stores/stats.ts`:
- Line 212: The toLocalDay helper currently uses toLocaleDateString('en-CA')
which depends on that locale being available; replace it with a deterministic
ISO date formatter inside toLocalDay that builds the string from
d.getFullYear(), d.getMonth()+1 and d.getDate(), padding month/day to two digits
(e.g., padStart) to produce YYYY-MM-DD so the code does not rely on locale
availability.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3d91d5a4-ab1a-4084-be2f-6848a8f001d3

📥 Commits

Reviewing files that changed from the base of the PR and between 1501908 and 1095c6b.

📒 Files selected for processing (3)
  • components/game/StreakBadge.vue
  • components/game/StreakModal.vue
  • stores/stats.ts
✅ Files skipped from review due to trivial changes (1)
  • components/game/StreakModal.vue

Comment thread components/game/StreakBadge.vue
Hugo0 added 4 commits March 29, 2026 15:24
- Add circle-flags next to each language in "Wins by Language"
- Cap list at 5 languages with "+N more" toggle button
- Prevents unbounded growth for multilingual players
… height

- Dordle/Quordle: flex centering restored for non-scrollable modes
  (overflow-y-auto only for scrollable, flex center for fit-in-view)
- Remove row truncation (visibleRows always 0) — all rows shown by default
- Explicit pixel height on grid container fixes empty tile collapse
  (CSS grid 1fr rows need a resolved height, not auto)
- Cap grid at 4 columns max to align with keyboard width
- Hydration mismatch: use data-allow-mismatch on scroll container
- Remove CLASSIC badge from Daily Puzzle, remove NEW from Unlimited
- Speed Streak → NEW, all multi-board modes → BETA
- GameBoard: responsive max sizing with min(350px,90vw) / min(420px,50vh)
- MultiBoardLayout: grid sizing improvements
- BoardMinimap: layout fixes
- SeoNoscript: expanded noscript content
- Language configs: minor translation fixes (es, it, pt)
- Sitemap: additional routes
- Word page: layout improvements
@Hugo0
Copy link
Copy Markdown
Owner Author

Hugo0 commented Mar 29, 2026

@coderabbitai full review

Please review this PR across all dimensions with extra attention to these highest risk areas:

Critical Risk Areas

1. Tile sizing stability (MultiBoardLayout.vue + MultiBoardPanel.vue)

We had persistent bugs where tiles loaded at 1px height and jumped to correct size on first keypress. The root cause was CSS grid 1fr rows collapsing when tiles have no content. The fix uses JS-computed pixel height/maxHeight on the grid container via ResizeObserver. Verify the sizing logic is stable and doesn't create feedback loops between measurement and rendering.

2. Row collapsing for 8+ board modes (MultiBoardPanel.vue)

Boards with >7 guesses show only 7 rows + "N left" label. The visibleRows computed uses a sliding window. Check that boardEl.children[rowIndex] in animation code still works correctly when rows are truncated — we skip DOM animations for 5+ boards to avoid this, but verify the skip logic is complete.

3. GameBoard.vue consolidation

Classic single-board mode now wraps MultiBoardPanel with singleBoard=true. Verify this doesn't regress the daily puzzle, unlimited, or speed mode layouts. The wrapper uses max-w-[min(350px,90vw)] max-h-[min(420px,50vh)].

4. Streak system (StreakBadge.vue + StreakModal.vue + stores/stats.ts)

  • Calendar heatmap changed from current-month to rolling 28 days
  • Per-language section changed from "streaks" (misleading) to "wins by language"
  • Streak broken effect: frozen blue flame for 5s
  • Cross-tab sync via StorageEvent
  • Verify streak calculation handles DST transitions, month boundaries, and win-overrides-loss-on-same-day correctly.

5. Keyboard split gradients (KeyboardKey.vue)

Extended from 4-board max to N-board grid. Uses CSS background-position with corrected percentage formula + top/bottom keywords for edge rows. Check that gradient layers don't clip or leave gaps on the last row.

6. Performance with 32 boards (stores/game.ts)

showTilesAllBoards was optimized to only sync the active row. Animation stagger disabled for 5+ boards. Key shake disabled for multi-board. Verify no reactive cascade issues remain.

7. Page deduplication (useMultiBoardModePage.ts)

5 near-identical pages consolidated via shared composable. definePageMeta stays in each page file (compiler macro). Verify SSR works correctly — the composable was changed from async to sync to avoid Nuxt context loss.

Also review

  • shellMaxWidth added to GameModeDefinition — used by all multi-board pages
  • Tridle deprecation (301 redirect to quordle)
  • Sitemap updates for new modes
  • Badge labels (BETA for multi-board, NEW for speed, nothing for classic/unlimited)
  • llms.txt updated with new modes

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 29, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Gather critical files for review

echo "=== MultiBoardLayout.vue ==="
cat components/game/MultiBoardLayout.vue

echo ""
echo "=== MultiBoardPanel.vue ==="
cat components/game/MultiBoardPanel.vue

echo ""
echo "=== GameBoard.vue ==="
cat components/game/GameBoard.vue

echo ""
echo "=== StreakBadge.vue ==="
cat components/game/StreakBadge.vue

echo ""
echo "=== StreakModal.vue ==="
cat components/game/StreakModal.vue

echo ""
echo "=== stores/stats.ts (streak calculation section) ==="
cat stores/stats.ts

echo ""
echo "=== KeyboardKey.vue ==="
cat components/game/KeyboardKey.vue

echo ""
echo "=== stores/game.ts (performance sections) ==="
cat stores/game.ts

echo ""
echo "=== useMultiBoardModePage.ts ==="
cat composables/useMultiBoardModePage.ts

echo ""
echo "=== useGameModes.ts ==="
cat composables/useGameModes.ts

echo ""
echo "=== utils/game-modes.ts ==="
cat utils/game-modes.ts

echo ""
echo "=== pages/[lang]/tridle.vue ==="
cat "pages/[lang]/tridle.vue"

echo ""
echo "=== server/routes/sitemap-main.xml.ts ==="
cat server/routes/sitemap-main.xml.ts

echo ""
echo "=== public/llms.txt ==="
cat public/llms.txt

echo ""
echo "=== BoardMinimap.vue ==="
cat components/game/BoardMinimap.vue

echo ""
echo "=== PageShell.vue ==="
cat components/game/PageShell.vue

echo ""
echo "=== plugins/debug.client.ts ==="
cat plugins/debug.client.ts

echo ""
echo "=== server/middleware/trailing-slash.ts ==="
cat server/middleware/trailing-slash.ts

echo ""
echo "=== tests/stores/stats.test.ts ==="
cat tests/stores/stats.test.ts

Length of output: 189597


@Hugo0, I've reviewed the full PR with close attention to all seven critical risk areas. Here's my complete analysis:


Critical Risk Areas

✅ 1. Tile Sizing Stability — Stable, no feedback loop

The explicit pixel height on the grid container is the right fix. The ResizeObserver targets containerRef (the <main> flex container), and gridStyle writes explicit width/height back to the inner grid <div> — not to the observed element — so there's no feedback loop.

One edge case to be aware of:

// MultiBoardLayout.vue
const containerHeight = ref(600); // hard-coded initial value

For non-scrollable modes (dordle, quordle), tileSize is constrained by both width and height using containerHeight. If the ResizeObserver fires asynchronously (which it does, after onMounted), the first computed gridStyle uses 600 as height. This produces a correct-looking grid, but on an unusually short viewport the first paint could be slightly over-sized before the observer fires. The data-allow-mismatch on the grid wrapper suppresses the hydration warning, which is good — but consider initialising containerHeight from window.innerHeight the same way containerWidth is initialised from window.innerWidth:

// before
const containerHeight = ref(600);

// after
const containerHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 600);

✅ 2. Row Collapsing for 8+ Boards — Skip logic is complete

revealRowForBoard correctly short-circuits for all 5+ board cases:

// stores/game.ts
if (boards.value.length > 4 || !boardEl) {
    // skip DOM animation, update data layer directly
    return Promise.resolve();
}

The boardEl.children[rowIndex] access in animateRevealRow is never reached for octordle/sedecordle/duotrigordle. For dordle/quordle (≤4 boards), shouldCollapse in MultiBoardPanel returns false because maxGuesses (7/9) is never greater than the maxVisibleRows that the layout composable passes (which appears to be 0 = show all). ✅


⚠️ 3. GameBoard.vue Consolidation — Possible regression on small landscape screens

The single-board wrapper uses:

<div class="w-full h-full max-w-[min(350px,90vw)] max-h-[min(420px,50vh)]">

50vh on iPhone SE in landscape is ~190px — not enough to comfortably display 6 rows. The old GameBoard had no explicit max-h at all, relying on the flex parent for containment. This is a layout regression risk on small landscape viewports. Consider raising the cap or making it conditional:

-class="w-full h-full max-w-[min(350px,90vw)] max-h-[min(420px,50vh)]"
+class="w-full h-full max-w-[min(350px,90vw)] max-h-[min(420px,65vh)]"

The boardEl computed proxy panelRef.value?.boardEl ?? null is clean and should work correctly for all animation calls. ✅


🐛 4. Streak System — One Vue warning bug found

DST handling ✅stepBack uses T12:00:00 (noon) to avoid DST-boundary edge cases. The overall streak walk-back and the seenDays win-overrides-loss logic are both correct.

Cross-tab sync ⚠️ (minor) — The storage event listener only calls calculateTotalStats(), not calculateStats(). The streak badge will update correctly, but the per-language stats ref (used by StatsModal) stays stale until the user navigates. This is low impact since the stats modal is user-initiated, but worth noting.

🐛 Duplicate v-for keys in StreakModal.vue — This is a real Vue warning that will appear in the console for every user:

<!-- StreakModal.vue — will emit "Duplicate keys found" warning -->
<span v-for="d in ['M', 'T', 'W', 'T', 'F', 'S', 'S']" :key="d">

'T' (Tue/Thu) and 'S' (Sat/Sun) are duplicated. Fix:

-<span v-for="d in ['M', 'T', 'W', 'T', 'F', 'S', 'S']" :key="d" class="text-center">
+<span v-for="(d, i) in ['M', 'T', 'W', 'T', 'F', 'S', 'S']" :key="i" class="text-center">

🐛 StreakBadge frozen timeout — no unmount cleanup:

// StreakBadge.vue — timeout is never cleared if component unmounts before 5s
frozen.value = true;
setTimeout(() => { frozen.value = false; }, 5000);
// Fix: use onBeforeUnmount
import { ref, watch, nextTick, onBeforeUnmount } from 'vue';
let frozenTimeout: ReturnType<typeof setTimeout> | null = null;

// in the watcher:
if (frozenTimeout) clearTimeout(frozenTimeout);
frozenTimeout = setTimeout(() => { frozen.value = false; }, 5000);

onBeforeUnmount(() => {
    if (frozenTimeout) clearTimeout(frozenTimeout);
});

✅ 5. Keyboard Split Gradients — Formula is correct

The middle-row background-position percentage formula uses CSS's relative positioning:

position% = r/(rows-1) * 100% applied to remaining space (H - backgroundSize)

For 4 rows, sizeH=25%: row 1 → 0.3333 × (100%-25%) = 25% from top — seamlessly adjacent to row 0. Row 2 → 0.6667 × 75% = 50% from top. ✅ No gaps.

Using top/bottom keywords for edge rows eliminates sub-pixel rounding. The DEFAULT_KEY_COLOR fill for cells beyond boardStates.length correctly pads the 32-board grid. ✅


✅ 6. Performance with 32 Boards — Optimizations are complete

The three-level optimisation is correct and complete:

  1. showTilesAllBoards(activeRow) during typing — only 32 × 1 row updates
  2. boards.value = [...boards.value] after keyStates mutation — triggers mergedKeyStates computed once per guess
  3. revealRowForBoard instant path for 5+ boards — no DOM reads/writes
  4. _nudgeKey no-op for boards.length > 4
  5. BOARD_STAGGER = 0 for 5+ boards ✅

No reactive cascade issues apparent. ✅


⚠️ 7. Page Deduplication — File not found by script; signature mismatch to verify

The script could not locate composables/useMultiBoardModePage.ts. The new page files (octordle.vue, sedecordle.vue, duotrigordle.vue) appear to call it with only one argument (useMultiBoardModePage('octordle')), while the AI summary shows a 4-parameter signature (mode, lang, gameData, allLangCodes?). If lang/gameData are resolved internally via useRoute/useFetch (Nuxt context), SSR should be fine since the composable would be synchronous. Please confirm the composable uses useRoute() internally rather than receiving lang as a parameter, otherwise the 1-arg callers will have a runtime type error.


Additional Items

Area Status Notes
Tridle 301 redirect layout: false + await navigateTo with redirectCode: 301 is correct Nuxt pattern
Sitemap tridle removed, octordle/sedecordle/duotrigordle added
Badge labels BETA for multi-board, NEW for speed, nothing for classic/unlimited — consistent with GAME_MODES_UI
Trailing-slash middleware Correctly preserves url.search, excludes /api/ and root
llms.txt ⚠️ References "GPT-5.2" (fictional model name — will mislead LLM consumers of this file)
debug.streak.ignite() ⚠️ Brief invalid intermediate state: gameWon=true, gameOver=false. The wasGameOverOnLoad guard in PageShell correctly prevents this from triggering the frozen state, but the invalid state exists for ~50ms
BoardMinimap props ℹ️ :current-page="0" :boards-per-page="boardCount" always marks all boards as minimap-current when unfocused — if pagination is not planned, the props could be simplified

Summary of Action Items

Priority Issue File
🔴 Bug Duplicate v-for :key for day-of-week headers StreakModal.vue
🟠 Risk max-h-[min(420px,50vh)] clips on small landscape screens GameBoard.vue
🟠 Bug Frozen timeout has no onBeforeUnmount cleanup StreakBadge.vue
🟡 Minor containerHeight initial 600 stale on first paint for non-scrollable modes MultiBoardLayout.vue
🟡 Minor Cross-tab sync doesn't refresh per-language stats ref stores/stats.ts
🟡 Minor "GPT-5.2" is a non-existent model name public/llms.txt
🔵 Info Verify useMultiBoardModePage signature vs 1-arg callers composables/useMultiBoardModePage.ts
✅ Actions performed

Full review triggered.

Layout composable rewrite:
- Mobile (< 640px): always 2 columns for 8+ boards
- Desktop: score-based column selection targeting 18-44px tile range
- Remove hardcoded maxTile caps — composable handles sizing via column choice
- Grid uses width-constrained tiles for scrollable, both axes for fit-in-view

Minimap bug fix:
- "incorrect".includes("correct") was true — all gray tiles showed green
- Fixed by checking incorrect BEFORE correct in the color mapping

Other fixes:
- Disable click-to-focus (buggy, boards always visible now)
- GameBoard: add min-h-0 for proper flex centering in speed mode
- Revert GameBoard to original 350x420 (was changed to 420x504)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
components/app/AppSidebar.vue (1)

58-66: ⚠️ Potential issue | 🟡 Minor

Globe fallback not shown when flag image fails to load.

When flagSrc exists but the image fails to load, the @error handler hides the image, but the Globe icon won't render because v-if="!flagSrc" only checks if the path exists, not whether the image loaded successfully. This leaves an empty space in the UI.

Consider using a reactive ref to track load failure and show the Globe as fallback.

🛠️ Suggested fix using reactive fallback

In the <script setup> section, add:

const flagLoadFailed = ref(false);

// Reset on langCode change
watch(() => props.langCode, () => {
    flagLoadFailed.value = false;
});

Then update the template:

 <span class="item-icon">
     <img
-        v-if="flagSrc"
+        v-if="flagSrc && !flagLoadFailed"
         :src="flagSrc"
         :alt="languageName"
         class="flag-icon flag-icon-sm"
-        `@error`="($event.target as HTMLImageElement).style.display = 'none'"
+        `@error`="flagLoadFailed = true"
     />
-    <Globe v-if="!flagSrc" :size="18" class="text-muted" />
+    <Globe v-if="!flagSrc || flagLoadFailed" :size="18" class="text-muted" />
 </span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/app/AppSidebar.vue` around lines 58 - 66, The flag image fallback
currently hides the <img> on error but still checks only flagSrc to show the
Globe; introduce a reactive ref (e.g., flagLoadFailed) in the <script setup>,
set flagLoadFailed.value = true in the image `@error` handler, and reset
flagLoadFailed to false when props.langCode (or the flagSrc source) changes via
a watch; then update the template logic to show the Globe when either !flagSrc
or flagLoadFailed is true (e.g., v-if="!flagSrc || flagLoadFailed") so the Globe
appears when the image fails to load.
components/game/StatsModal.vue (1)

384-394: ⚠️ Potential issue | 🟡 Minor

Reset and gate the definition-view analytics.

This event can fire while Word Info is disabled, and after the first successful send it never fires again because definitionTracked is never cleared.

💡 Proposed fix
 watch(
-    () => [props.visible, game.todayDefinition, game.boardDefinitions, game.todayImageUrl] as const,
-    ([visible]) => {
-        if (!visible || definitionTracked.value) return;
+    () =>
+        [
+            props.visible,
+            settings.wordInfoEnabled,
+            game.todayDefinition,
+            game.boardDefinitions,
+            game.todayImageUrl,
+        ] as const,
+    ([visible, wordInfoEnabled]) => {
+        if (!visible || !wordInfoEnabled || definitionTracked.value) return;
         const hasDefinition = game.todayDefinition || game.boardDefinitions?.some((d: any) => d);
         if (hasDefinition || game.todayImageUrl) {
             analytics.trackDefinitionView(lang.languageCode, 'stats_modal', !!game.todayImageUrl);
             definitionTracked.value = true;
         }
@@
 watch(
     () => props.visible,
     (v) => {
+        if (!v) definitionTracked.value = false;
         if (v) defPage.value = 0;
     }
 );

Also applies to: 465-471

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/StatsModal.vue` around lines 384 - 394, The watcher fires
definition-view analytics even when Word Info is disabled and never resets
definitionTracked, so add a guard that checks the Word Info enabled flag (e.g.,
props.wordInfoEnabled or game.wordInfoEnabled) before calling
analytics.trackDefinitionView, and reset definitionTracked.value = false when
the modal is closed (props.visible becomes false) so the event can fire again on
subsequent opens; apply the same changes to the other watcher referencing
definitionTracked (the block around lines 465-471) and keep references to watch,
props.visible, definitionTracked, game.todayDefinition, game.boardDefinitions,
game.todayImageUrl, and analytics.trackDefinitionView.
♻️ Duplicate comments (5)
composables/useGamePage.ts (1)

126-133: ⚠️ Potential issue | 🟠 Major

Returning sessions may still skip persisted profile refresh.

This still follows the previously flagged pattern: returning users use computeUserProperties(...) instead of a persist/update path. If computeUserProperties is pure, totals/language profile fields won’t be refreshed after first session.

Suggested patch
-            const isNewUser = !localStorage.getItem('first_seen_date');
-            const userProps = isNewUser
-                ? analytics.identifyUser(stats.gameResults)
-                : analytics.computeUserProperties(stats.gameResults);
+            const userProps = analytics.identifyUser(stats.gameResults);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useGamePage.ts` around lines 126 - 133, The current logic skips
refreshing persisted profile for returning users by calling
computeUserProperties(...) instead of performing an update/identify; change the
branch so that after computing properties for returning users you persist them
(e.g., call analytics.identifyUser or an explicit
analytics.updateUserProperties) so totals/language/profile fields are refreshed
even when localStorage has 'first_seen_date'; specifically adjust the isNewUser
branch around localStorage.getItem('first_seen_date') and replace or augment the
computeUserProperties(stats.gameResults) path to invoke the persistence/update
function (identifyUser or updateUserProperties) with the computed props so
returning sessions refresh stored profile data.
components/game/StreakBadge.vue (1)

42-43: ⚠️ Potential issue | 🟡 Minor

Clear the frozen-state timeout on unmount (still unresolved).

The setTimeout in this watcher can still fire after unmount and mutate state on a destroyed instance.

Suggested patch
-import { ref, watch, nextTick } from 'vue';
+import { ref, watch, nextTick, onUnmounted } from 'vue';
@@
 const igniting = ref(false);
 const frozen = ref(false);
 const prevStreak = ref(0);
+let frozenTimeout: ReturnType<typeof setTimeout> | null = null;
+
+onUnmounted(() => {
+    if (frozenTimeout) clearTimeout(frozenTimeout);
+});
@@
         if (oldVal > 0 && newVal === 0) {
             prevStreak.value = oldVal;
             frozen.value = true;
             // Clear frozen state after 5 seconds
-            setTimeout(() => {
+            if (frozenTimeout) clearTimeout(frozenTimeout);
+            frozenTimeout = setTimeout(() => {
                 frozen.value = false;
             }, 5000);
         }

Also applies to: 56-66

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/StreakBadge.vue` around lines 42 - 43, The watcher that uses
setTimeout (the watch(...) block using nextTick) can fire after the component is
unmounted and mutate state; store the timeout id returned by setTimeout (e.g.,
frozenTimeoutId), clear any existing timeout before creating a new one, and
clear it again in an onBeforeUnmount/onUnmounted hook to prevent callbacks
running after teardown; update the watcher(s) around the setTimeout calls (the
watch + nextTick sections) to use this timeout id and call clearTimeout when
appropriate.
composables/useAnalytics.ts (1)

107-110: ⚠️ Potential issue | 🟡 Minor

Allow 'unknown' in PWAParams.source.

trackPWAInstall() can emit 'unknown', but the public type disallows it. That leaves callers and downstream typings out of sync with the actual payload.

Suggested fix
 interface PWAParams {
     platform?: 'ios' | 'android' | 'desktop' | 'unknown';
-    source?: 'settings' | 'auto' | 'dialog' | 'appinstalled';
+    source?: 'settings' | 'auto' | 'dialog' | 'appinstalled' | 'unknown';
 }
#!/bin/bash
# Expect the interface union to omit 'unknown' while trackPWAInstall() emits it.
sed -n '107,110p' composables/useAnalytics.ts
sed -n '501,505p' composables/useAnalytics.ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useAnalytics.ts` around lines 107 - 110, The PWAParams.source
union omits 'unknown' while the emitter trackPWAInstall() can send 'unknown';
update the PWAParams interface (symbol: PWAParams) to include 'unknown' in the
source union so the public type matches the actual payload emitted by
trackPWAInstall(), ensuring callers and downstream typings stay in sync.
plugins/pwa.client.ts (2)

230-239: ⚠️ Potential issue | 🟠 Major

Gate idle detection on the active route, not just gameConfig.mode.

This watcher runs immediately in a global plugin, and the store defaults to a playable mode. Idle prompting can therefore arm on non-game pages and remain armed after navigation if the mode never changes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/pwa.client.ts` around lines 230 - 239, The watcher currently gates
idle detection only on gameStore.gameConfig.mode which can arm idle prompts on
non-game pages; update the logic to also check the active route before calling
startIdleDetection/stopIdleDetection. Modify the watcher (or add a derived
watcher) to depend on both gameStore.gameConfig.mode and the active route (e.g.,
router.currentRoute or useRoute()), and only call startIdleDetection when mode
indicates a playable game AND the current route is a game route; always call
stopIdleDetection when leaving a game route or mode becomes non-playable. Ensure
you update the watcher referencing watch, gameStore.gameConfig.mode,
startIdleDetection, and stopIdleDetection so idle detection is gated to active
game routes.

58-62: ⚠️ Potential issue | 🟠 Major

Stop treating the dismissal cooldown as a session-long constant.

The new idle flow still reads dismissed from a startup snapshot, so a 24h/48h cooldown never clears in a long-lived tab and status() can stay stale until reload. Read isDismissed() at each gate instead of relying on the cached boolean.

Also applies to: 80-82, 142-155, 162-171

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/pwa.client.ts` around lines 58 - 62, The code currently caches
dismissal state in the local variable dismissed (declared alongside
deferredPrompt, promptedThisSession, idleTimer) causing status() and idle flows
to use a stale 24/48h cooldown; instead remove reliance on the cached dismissed
variable and call isDismissed() wherever you check dismissal (including the
initial gates and the blocks around promptedThisSession, the idle timer logic,
and status() usages referenced in the review). Update any checks that read
dismissed (and any assignments that try to set it) to invoke isDismissed() at
the moment of decision so long-lived tabs reflect the current cooldown, and
ensure any code that previously mutated dismissed is adjusted to use persistent
storage access or re-run isDismissed() as needed.
🟡 Minor comments (20)
pages/[lang]/word/[id].vue-48-55 (1)

48-55: ⚠️ Potential issue | 🟡 Minor

Edge case: empty wordDate produces awkward SEO title.

If d.word_date is null, formatDateLong returns an empty string, resulting in a title like "Wordle #1 — — WORD | English Answer" with awkward double spacing. Consider handling this case:

🛠️ Suggested fix
 const titleStr = word
-    ? `Wordle #${dayIdx} \u2014 ${wordDate} \u2014 ${word.toUpperCase()} | ${d.lang_name} Answer`
+    ? `Wordle #${dayIdx}${wordDate ? ` \u2014 ${wordDate}` : ''} \u2014 ${word.toUpperCase()} | ${d.lang_name} Answer`
     : `Wordle ${langNameNative} #${dayIdx} \u2014 ${wordDate || 'Coming soon'}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/`[lang]/word/[id].vue around lines 48 - 55, The title/description
builders (titleStr and descriptionStr) use wordDate (from
formatDateLong(d.word_date)) directly and when d.word_date is null produce an
empty string and awkward double spaces; update the logic so titleStr and
descriptionStr substitute a fallback like 'Coming soon' when wordDate is falsy
(e.g. use wordDate || 'Coming soon' or conditionally include the " —
${wordDate}" segment) and ensure the templates for titleStr and the word branch
of descriptionStr only insert the date segment if wordDate is non-empty to avoid
extra spaces; adjust the expressions that reference titleStr/descriptionStr (and
formatDateLong/d.word_date) accordingly.
pages/[lang]/best-starting-words.vue-16-16 (1)

16-16: ⚠️ Potential issue | 🟡 Minor

Use statusMessage instead of message for createError.

Nuxt 3's createError uses statusMessage for the error message text that appears in error pages. The message property may not be rendered correctly by the default error handler.

🔧 Proposed fix
-    throw createError({ statusCode: 404, message: 'Language not found' });
+    throw createError({ statusCode: 404, statusMessage: 'Language not found' });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/`[lang]/best-starting-words.vue at line 16, Replace the createError
call that currently sets the "message" property with the "statusMessage"
property so the text appears on Nuxt 3 error pages; locate the createError
invocation (the throw createError(...) expression) and change the payload to use
statusMessage: 'Language not found' instead of message, preserving the
statusCode value.
data/languages/uk/language_config.json-33-42 (1)

33-42: ⚠️ Potential issue | 🟡 Minor

Fix Ukrainian plural form for board counts (grammar).

Line 33/34, Line 37/38, and Line 41/42 use дошки after 8/16/32; this should be дощок in Ukrainian.

✏️ Suggested text fix
-                "title": "Octordle — 8 дошки одночасно",
-                "description": "Грай у Octordle українською. Розгадай 8 дошки Wordle одночасно за 13 спроб."
+                "title": "Octordle — 8 дощок одночасно",
+                "description": "Грай у Octordle українською. Розгадай 8 дощок Wordle одночасно за 13 спроб."

-                "title": "Sedecordle — 16 дошки одночасно",
-                "description": "Грай у Sedecordle українською. Розгадай 16 дошки Wordle одночасно за 21 спроб."
+                "title": "Sedecordle — 16 дощок одночасно",
+                "description": "Грай у Sedecordle українською. Розгадай 16 дощок Wordle одночасно за 21 спроб."

-                "title": "Duotrigordle — 32 дошки одночасно",
-                "description": "Грай у Duotrigordle українською. Розгадай 32 дошки Wordle одночасно за 37 спроб."
+                "title": "Duotrigordle — 32 дощок одночасно",
+                "description": "Грай у Duotrigordle українською. Розгадай 32 дощок Wordle одночасно за 37 спроб."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/uk/language_config.json` around lines 33 - 42, The Ukrainian
plural for board counts is incorrect; update the strings for the keys
"octordle", "sedecordle", and "duotrigordle" by replacing "дошки" with the
correct form "дощок" wherever it appears in their "title" and "description"
values so the titles/descriptions read e.g. "8 дошок одночасно" and "Розгадай 8
дошок Wordle одночасно" (apply same change for 16 and 32 in the respective
keys).
data/languages/ne/language_config.json-33-43 (1)

33-43: ⚠️ Potential issue | 🟡 Minor

Translate the new mode metadata before release.

These meta.modes entries are still English, so the new Nepali mode pages will ship mixed-language titles and descriptions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/ne/language_config.json` around lines 33 - 43, The entries for
meta.modes "octordle", "sedecordle", and "duotrigordle" are still in English;
translate both the "title" and "description" values into Nepali for each key so
the Nepali locale is fully localized—update the values for the "octordle",
"sedecordle", and "duotrigordle" objects in language_config.json with
appropriate Nepali titles and descriptions that convey the same meaning and
guess counts.
data/languages/ms/language_config.json-36-46 (1)

36-46: ⚠️ Potential issue | 🟡 Minor

Translate these new mode strings.

The added meta.modes entries are still English, so the Malay multi-board pages will show mixed-language titles and descriptions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/ms/language_config.json` around lines 36 - 46, Translate the
three meta.modes entries "octordle", "sedecordle", and "duotrigordle" from
English into Malay by replacing the English "title" and "description" values
with Malay equivalents so the multi-board pages are fully localized; update the
values for the keys "octordle", "sedecordle", and "duotrigordle" in
language_config.json to provide Malay titles and descriptions consistent with
existing translations (e.g., retain the mode names but translate surrounding
text like "Play ... in Malay" and the guess counts).
data/languages/ca/language_config.json-56-66 (1)

56-66: ⚠️ Potential issue | 🟡 Minor

Localize the added mode copy.

These new meta.modes strings are still English, so the Catalan Octordle/Sedecordle/Duotrigordle pages will expose mixed-language metadata.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/ca/language_config.json` around lines 56 - 66, The new entries
"octordle", "sedecordle", and "duotrigordle" in the Catalan language_config.json
contain English title/description strings; replace them with proper Catalan
translations for both "title" and "description" under each key so the meta.modes
copy is fully localized (update the "octordle", "sedecordle", and "duotrigordle"
objects to use Catalan strings).
data/languages/pl/language_config.json-32-42 (1)

32-42: ⚠️ Potential issue | 🟡 Minor

Fix the Polish plural forms in the new mode copy.

8 and 16 require plansz, and the attempt counts here should be próbach; otherwise the new strings read ungrammatically.

✏️ Suggested wording
             "octordle": {
-                "title": "Octordle — 8 plansze naraz",
-                "description": "Graj w Octordle po polsku. Rozwiąż 8 plansze Wordle naraz w 13 prób."
+                "title": "Octordle — 8 plansz naraz",
+                "description": "Graj w Octordle po polsku. Rozwiąż 8 plansz Wordle naraz w 13 próbach."
             },
             "sedecordle": {
-                "title": "Sedecordle — 16 plansze naraz",
-                "description": "Graj w Sedecordle po polsku. Rozwiąż 16 plansze Wordle naraz w 21 prób."
+                "title": "Sedecordle — 16 plansz naraz",
+                "description": "Graj w Sedecordle po polsku. Rozwiąż 16 plansz Wordle naraz w 21 próbach."
             },
             "duotrigordle": {
                 "title": "Duotrigordle — 32 plansze naraz",
-                "description": "Graj w Duotrigordle po polsku. Rozwiąż 32 plansze Wordle naraz w 37 prób."
+                "description": "Graj w Duotrigordle po polsku. Rozwiąż 32 plansze Wordle naraz w 37 próbach."
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/pl/language_config.json` around lines 32 - 42, Update the
Polish strings for the "octordle", "sedecordle" (and also check "duotrigordle")
entries: change "plansze" to the correct plural "plansz" for counts 8 and 16,
and replace the incorrect attempt-word form "prób." with "próbach" (no trailing
period) in the description sentences (e.g., the values for keys "octordle",
"sedecordle", and "duotrigordle"). Ensure the resulting descriptions read like
"Rozwiąż 8 plansz Wordle naraz w 13 próbach.", "Rozwiąż 16 plansz Wordle naraz w
21 próbach.", and adjust the 32-board line similarly to "37 próbach" if needed.
data/languages/ru/language_config.json-32-44 (1)

32-44: ⚠️ Potential issue | 🟡 Minor

Russian grammar issue in mode titles - incorrect plural form.

Russian numbers 5+ require the genitive plural. The current text uses "поля" (genitive singular) instead of "полей" (genitive plural):

Mode Current Correct
octordle "8 поля" "8 полей"
sedecordle "16 поля" "16 полей"
duotrigordle "32 поля" "32 поля" or "32 полей"
📝 Proposed fix for Russian grammar
             "octordle": {
-                "title": "Octordle — 8 поля одновременно",
-                "description": "Играй в Octordle на русском. Разгадай 8 поля Wordle одновременно за 13 попыток."
+                "title": "Octordle — 8 полей одновременно",
+                "description": "Играй в Octordle на русском. Разгадай 8 полей Wordle одновременно за 13 попыток."
             },
             "sedecordle": {
-                "title": "Sedecordle — 16 поля одновременно",
-                "description": "Играй в Sedecordle на русском. Разгадай 16 поля Wordle одновременно за 21 попыток."
+                "title": "Sedecordle — 16 полей одновременно",
+                "description": "Играй в Sedecordle на русском. Разгадай 16 полей Wordle одновременно за 21 попытку."
             },
             "duotrigordle": {
-                "title": "Duotrigordle — 32 поля одновременно",
-                "description": "Играй в Duotrigordle на русском. Разгадай 32 поля Wordle одновременно за 37 попыток."
+                "title": "Duotrigordle — 32 поля одновременно",
+                "description": "Играй в Duotrigordle на русском. Разгадай 32 поля Wordle одновременно за 37 попыток."
             }

Note: "21 попыток" should also be "21 попытку" (accusative singular for numbers ending in 1 except 11).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/ru/language_config.json` around lines 32 - 44, Update the
Russian strings for the modes to use the correct genitive plural and correct
numeral agreement: change the "title" values for keys "octordle", "sedecordle",
and "duotrigordle" from "8 поля", "16 поля", "32 поля" to "8 полей", "16 полей",
"32 полей" respectively, and in the "description" for "sedecordle" change "21
попыток" to "21 попытку"; adjust the strings under those keys ("octordle",
"sedecordle", "duotrigordle") accordingly.
pages/[lang]/tridle.vue-1-11 (1)

1-11: ⚠️ Potential issue | 🟡 Minor

Add missing <template> section to prevent Vue warning.

Vue 3 requires every SFC to include a <template> section (or render function). The current file lacks this and will generate a runtime warning: [Vue warn] Component is missing template or render function. While the redirect executes server-side, adding an empty template resolves the warning and aligns with Nuxt/Vue best practices.

🛠️ Proposed fix
 await navigateTo(`/${lang}/quordle`, { redirectCode: 301 });
 </script>
+
+<template>
+  <div />
+</template>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/`[lang]/tridle.vue around lines 1 - 11, Add a minimal <template>
section to the SFC so Vue/Nuxt won't emit "Component is missing template or
render function" at runtime: modify the component that uses definePageMeta,
useRoute/route and await navigateTo(`/\${lang}/quordle`, { redirectCode: 301 })
by adding an empty or minimal <template> (e.g. a root <div> or comment) above or
below the <script setup> block; keep the redirect logic in the <script setup>
(using lang from route.params) unchanged so the redirect still runs server-side
while eliminating the Vue warning.
data/languages/fi/language_config.json-315-317 (1)

315-317: ⚠️ Potential issue | 🟡 Minor

Keep the Finnish Speed Streak timer consistent.

These new SEO strings say the mode starts with 3 minutes, but Line 18 still advertises 5 minutes. Cards/meta and the new SEO/noscript copy will disagree until both sources use the same duration.

Also applies to: 342-345, 408-412

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/fi/language_config.json` around lines 315 - 317, The Finnish
"Speed Streak" timer wording is inconsistent: update the "mode_desc_speed"
string (and the other Finnish strings that mention the starting timer at the
ranges noted) so the start duration matches the canonical value used elsewhere
(make all copies use the same minute value as the main card/meta copy), e.g.,
change "Aloita 3 minuutilla kellossa." to the consistent phrasing (or vice
versa) across mode_desc_speed and the other Finnish SEO/noscript strings so
every occurrence uses the identical start time.
data/languages/hr/language_config.json-219-220 (1)

219-220: ⚠️ Potential issue | 🟡 Minor

Keep the Croatian Speed Streak timer consistent.

These new SEO strings say the mode starts with 3 minutes, but Line 18 still advertises 5 minutes. Users will see conflicting duration copy depending on whether the page reads meta.modes.speed or the new SEO block.

Also applies to: 229-230, 253-254

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/hr/language_config.json` around lines 219 - 220, The Croatian
SEO strings for Speed Streak currently say "3 minute" while the canonical copy
(`meta.modes.speed`) advertises 5 minutes; update the SEO entries so the timer
is consistent by changing "3 minute" to "5 minute" wording in the
`mode_desc_speed` string and the other Speed Streak SEO strings in the same
block (the nearby SEO entries referenced in the comment), ensuring all Croatian
Speed Streak copy matches the canonical 5-minute start time.
data/languages/da/language_config.json-214-215 (1)

214-215: ⚠️ Potential issue | 🟡 Minor

Align speed-mode duration copy with the configured 5-minute mode.

meta.modes.speed says 5 minutes (Line 18), but SEO speed copy still says 3 minutes here, which creates conflicting user-facing guidance.

Suggested patch
-        "mode_desc_speed": "Start med 3 minutter på uret. Hvert løst ord giver bonustid — løs med færre gæt for større bonus (+60s ved 1 gæt, +10s ved 6). Mislykkede ord koster 30 sekunder. Byg komboer ved at løse ord i træk for op til 3x pointmultiplikator. Presset stiger — timeren accelererer hvert 3. ord.",
+        "mode_desc_speed": "Start med 5 minutter på uret. Hvert løst ord giver bonustid — løs med færre gæt for større bonus (+60s ved 1 gæt, +10s ved 6). Mislykkede ord koster 30 sekunder. Byg komboer ved at løse ord i træk for op til 3x pointmultiplikator. Presset stiger — timeren accelererer hvert 3. ord.",
@@
-                { "name": "Start timeren", "text": "Besøg wordle.global/{lang}/speed og tryk Start. Du begynder med 3 minutter på uret." },
+                { "name": "Start timeren", "text": "Besøg wordle.global/{lang}/speed og tryk Start. Du begynder med 5 minutter på uret." },

Also applies to: 249-250

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/da/language_config.json` around lines 214 - 215, The Danish
speed-mode description "mode_desc_speed" is inconsistent with configured
meta.modes.speed (5 minutes); update the string to say "Start med 5 minutter på
uret" instead of "3 minutter" and ensure the rest of the timing/bonus wording
remains unchanged; also update the duplicate SEO copy instance of
"mode_desc_speed" elsewhere in this file (the other occurrence referenced in the
review) so both entries match the configured 5-minute duration.
data/languages/hu/language_config.json-236-237 (1)

236-237: ⚠️ Potential issue | 🟡 Minor

Speed duration is inconsistent within the same locale config.

Line 18 describes Speed as 5 minutes, while these SEO sections describe 3 minutes. This should be unified to avoid conflicting instructions.

Suggested patch
-        "mode_desc_speed": "Kezdj 3 perccel az órán. Minden megoldott szó bónuszidőt ad — kevesebb tippel nagyobb bónusz (+60s 1 tippel, +10s 6 tippel). A sikertelen szavak 30 másodpercbe kerülnek. Építs kombót egymás utáni szavak megoldásával akár 3x pontszorzóért. A nyomás nő — az időzítő 3 szavanként gyorsul.",
+        "mode_desc_speed": "Kezdj 5 perccel az órán. Minden megoldott szó bónuszidőt ad — kevesebb tippel nagyobb bónusz (+60s 1 tippel, +10s 6 tippel). A sikertelen szavak 30 másodpercbe kerülnek. Építs kombót egymás utáni szavak megoldásával akár 3x pontszorzóért. A nyomás nő — az időzítő 3 szavanként gyorsul.",
@@
-                { "q": "Mi az a Speed Streak {langName} nyelven?", "a": "A Speed Streak egy időzített Wordle-mód {langName} nyelven. 3 perccel kezdesz és annyi szót oldasz meg, amennyit csak tudsz. Gyors megoldás = bónuszidő." },
+                { "q": "Mi az a Speed Streak {langName} nyelven?", "a": "A Speed Streak egy időzített Wordle-mód {langName} nyelven. 5 perccel kezdesz és annyi szót oldasz meg, amennyit csak tudsz. Gyors megoldás = bónuszidő." },
@@
-                { "name": "Indítsd az időzítőt", "text": "Látogass el a wordle.global/{lang}/speed oldalra és nyomd meg az Indítás gombot. 3 perccel kezdesz az órán." },
+                { "name": "Indítsd az időzítőt", "text": "Látogass el a wordle.global/{lang}/speed oldalra és nyomd meg az Indítás gombot. 5 perccel kezdesz az órán." },

Also applies to: 247-248, 271-272

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/hu/language_config.json` around lines 236 - 237, The locale
file has inconsistent Speed duration strings: keys like "mode_desc_speed" (and
the similar SEO strings at the other mentioned spots) say "3 perccel" while an
earlier string uses 5 minutes; update all Speed-related descriptions
("mode_desc_speed" and the other Speed/SEO entries around the noted areas) to
use the same duration (choose 5 minutes to match line 18) so every string
references "5 perccel" consistently, ensuring you update all occurrences
(including the multiboard/SEO variants referenced) to avoid conflicting
instructions.
data/languages/de/language_config.json-318-318 (1)

318-318: ⚠️ Potential issue | 🟡 Minor

Unify speed timer copy (3 vs 5 minutes) in German config.

The Speed mode metadata says 5 minutes (Line 32), but these SEO/how-to texts say 3 minutes.

Suggested patch
-        "mode_desc_speed": "Starte mit 3 Minuten auf der Uhr. Jedes gelöste Wort gibt Bonuszeit — löse mit weniger Versuchen für größere Boni (+60s bei einem Versuch, +10s bei sechs). Gescheiterte Wörter kosten 30 Sekunden. Baue Kombos auf, indem du aufeinanderfolgende Wörter für bis zu 3x Punktemultiplikator löst. Der Druck steigt — der Timer wird alle 3 Wörter schneller.",
+        "mode_desc_speed": "Starte mit 5 Minuten auf der Uhr. Jedes gelöste Wort gibt Bonuszeit — löse mit weniger Versuchen für größere Boni (+60s bei einem Versuch, +10s bei sechs). Gescheiterte Wörter kosten 30 Sekunden. Baue Kombos auf, indem du aufeinanderfolgende Wörter für bis zu 3x Punktemultiplikator löst. Der Druck steigt — der Timer wird alle 3 Wörter schneller.",
@@
-                    "a": "Speed Streak ist ein zeitgesteuerter Wordle-Modus auf Deutsch. Du startest mit 3 Minuten auf der Uhr und löst so viele Wörter wie möglich. Löse ein Wort schnell, um Bonuszeit zu verdienen."
+                    "a": "Speed Streak ist ein zeitgesteuerter Wordle-Modus auf Deutsch. Du startest mit 5 Minuten auf der Uhr und löst so viele Wörter wie möglich. Löse ein Wort schnell, um Bonuszeit zu verdienen."
@@
-                    "text": "Besuche wordle.global/de/speed und drücke Start. Du beginnst mit 3 Minuten auf der Uhr."
+                    "text": "Besuche wordle.global/de/speed und drücke Start. Du beginnst mit 5 Minuten auf der Uhr."

Also applies to: 346-346, 412-412

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/de/language_config.json` at line 318, The German speed-mode
descriptions are inconsistent with the mode metadata (which states 5 minutes);
update the text values for "mode_desc_speed" (and the other German speed-mode
description keys noted in the comment at the other occurrences) to use "5
Minuten" instead of "3 Minuten" so all SEO/how-to strings match the metadata,
keeping the rest of the sentence unchanged.
data/languages/pt/language_config.json-250-250 (1)

250-250: ⚠️ Potential issue | 🟡 Minor

Portuguese speed SEO copy conflicts with mode duration.

Line 49 states 5 minutes, but these SEO strings still describe 3 minutes.

Suggested patch
-        "mode_desc_speed": "Começa com 3 minutos no relógio. Cada palavra resolvida dá tempo bónus — resolve com menos tentativas para bónus maiores (+60s com 1 tentativa, +10s com 6). Palavras falhadas custam 30 segundos. Constrói combos resolvendo palavras consecutivas para um multiplicador até 3x. A pressão aumenta — o temporizador acelera a cada 3 palavras.",
+        "mode_desc_speed": "Começa com 5 minutos no relógio. Cada palavra resolvida dá tempo bónus — resolve com menos tentativas para bónus maiores (+60s com 1 tentativa, +10s com 6). Palavras falhadas custam 30 segundos. Constrói combos resolvendo palavras consecutivas para um multiplicador até 3x. A pressão aumenta — o temporizador acelera a cada 3 palavras.",
@@
-                { "q": "O que é Speed Streak em {langName}?", "a": "O Speed Streak é um modo Wordle cronometrado em {langName}. Começas com 3 minutos no relógio e resolves o máximo de palavras possível. Resolve rápido para ganhar tempo bónus." },
+                { "q": "O que é Speed Streak em {langName}?", "a": "O Speed Streak é um modo Wordle cronometrado em {langName}. Começas com 5 minutos no relógio e resolves o máximo de palavras possível. Resolve rápido para ganhar tempo bónus." },
@@
-                { "name": "Inicia o temporizador", "text": "Visita wordle.global/{lang}/speed e carrega em Iniciar. Começas com 3 minutos no relógio." },
+                { "name": "Inicia o temporizador", "text": "Visita wordle.global/{lang}/speed e carrega em Iniciar. Começas com 5 minutos no relógio." },

Also applies to: 261-261, 285-285

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/pt/language_config.json` at line 250, The Portuguese SEO
strings incorrectly state the mode starts with "3 minutos" while the mode
duration was changed to 5 minutes; update any Portuguese entries that mention "3
minutos" to "5 minutos" (e.g. the key "mode_desc_speed" and the other Portuguese
mode description keys flagged on lines 261 and 285) so the copy matches the
actual mode duration; keep all other phrasing and bonus/penalty numbers
unchanged unless they explicitly reference the starting time.
data/default_language_config.json-94-114 (1)

94-114: ⚠️ Potential issue | 🟡 Minor

Keep the Speed timer copy in one source of truth.

These new strings hardcode 3 minutes in multiple places, but the localized mode-card descriptions touched in this PR still say 5 minutes. That leaves the picker and the SEO/help copy out of sync. Please interpolate the duration from shared mode metadata, or update the related strings together.

Also applies to: 134-137

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/default_language_config.json` around lines 94 - 114, The speed-mode copy
hardcodes "3 minutes" in "mode_desc_speed" and the "speed" entries inside
"mode_faq"; change those strings to interpolate the canonical duration from the
shared mode metadata instead of hardcoding (e.g., replace the literal "3
minutes" with a placeholder like {durationMinutes} or {modeDuration} and use the
existing mode metadata when rendering). Update all occurrences in
"mode_desc_speed" and every object in the "mode_faq" -> "speed" array so they
use the same placeholder key, and ensure the renderer/template that displays
these strings reads the duration from the centralized mode config (the shared
mode metadata used for the picker/SEO/help) so copy is kept in sync.
data/languages/fr/language_config.json-64-66 (1)

64-66: ⚠️ Potential issue | 🟡 Minor

Fix the French preposition in the Octordle description.

Jouez au Octordle is ungrammatical. Please switch this one to à Octordle or à l’Octordle before shipping the new mode-card copy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@data/languages/fr/language_config.json` around lines 64 - 66, The French
description under the "octordle" key contains the incorrect preposition "Jouez
au Octordle"; update the "description" value for the "octordle" object to use
the correct form—either "Jouez à Octordle en français. Résolvez 8 grilles Wordle
en même temps en 13 essais." or "Jouez à l’Octordle en français. Résolvez 8
grilles Wordle en même temps en 13 essais."—so the JSON "octordle" ->
"description" string uses the correct preposition.
components/game/MultiBoardLayout.vue-99-100 (1)

99-100: ⚠️ Potential issue | 🟡 Minor

Initialize containerHeight from window.innerHeight to avoid first-paint mismatch.

containerWidth correctly initializes from window.innerWidth, but containerHeight is hardcoded to 600. On unusual viewports, this can cause a brief layout jump before ResizeObserver fires.

Proposed fix
 const containerWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1200);
-const containerHeight = ref(600);
+const containerHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 600);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/MultiBoardLayout.vue` around lines 99 - 100, containerHeight
is hardcoded to 600 causing a first-paint mismatch; initialize it from the
current viewport like containerWidth does. Change the ref initialization for
containerHeight (the ref named containerHeight in MultiBoardLayout.vue) to use
typeof window !== 'undefined' ? window.innerHeight : 600 so the initial height
matches the viewport, leaving containerWidth and the ResizeObserver logic
unchanged.
components/game/StreakModal.vue-30-36 (1)

30-36: ⚠️ Potential issue | 🟡 Minor

Duplicate v-for keys will emit Vue warnings.

The weekday abbreviations ['M', 'T', 'W', 'T', 'F', 'S', 'S'] contain duplicates ('T' twice, 'S' twice). Using the letter as the key causes Vue to warn about duplicate keys.

Proposed fix — use index as key
                     <span
-                        v-for="d in ['M', 'T', 'W', 'T', 'F', 'S', 'S']"
-                        :key="d"
+                        v-for="(d, i) in ['M', 'T', 'W', 'T', 'F', 'S', 'S']"
+                        :key="i"
                         class="text-center"
                     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/StreakModal.vue` around lines 30 - 36, The v-for in
StreakModal.vue uses the weekday labels array and sets :key="d", which produces
duplicate keys for 'T' and 'S'; update the span's v-for to capture the index
(e.g., v-for="(d, i) in ['M','T','W','T','F','S','S']") and set the key to the
index or a combined unique value like :key="i" or :key="d + '-' + i" so each
rendered span has a unique key.
plugins/pwa.client.ts-222-225 (1)

222-225: ⚠️ Potential issue | 🟡 Minor

Don't count the how-to action as another prompt impression.

pwa-install-how-to-event fires after the dialog is already open, and this hard-codes source: 'auto'. That double-counts pwa_prompt_shown and mislabels how-to opens that came from the settings entry point.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/pwa.client.ts` around lines 222 - 225, The listener for
'pwa-install-how-to-event' currently calls analytics.trackPWAPromptShown('auto')
which double-counts a prompt impression and mislabels the source; instead,
update the handler to NOT call trackPWAPromptShown with a hard-coded 'auto'. Use
the event detail to determine the true source (e.g., event.detail.source) and
either call a non-impression method like analytics.trackPWAHowToOpened(...) or
call analytics.trackPWAPromptShown(...) with the correct source and an explicit
flag/option to avoid incrementing the impression counter (e.g.,
increment=false); update the listener attached to 'pwa-install-how-to-event' to
use the correct method/params and pull source from the event rather than
hard-coding 'auto'.
🧹 Nitpick comments (11)
pages/[lang]/word/[id].vue (1)

27-29: Consider using nullish coalescing for more precise fallback behavior.

The || operator will return the fallback for any falsy value, including empty strings. If a UI label could legitimately be an empty string (unlikely but possible), use ?? instead.

♻️ Suggested refinement
 const ui = computed(() => langStore.config?.ui || {});
-const label = (key: string, fallback: string) => (ui.value as any)?.[key] || fallback;
+const label = (key: string, fallback: string) => (ui.value as any)?.[key] ?? fallback;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/`[lang]/word/[id].vue around lines 27 - 29, Replace the use of the
logical OR fallback with nullish coalescing to avoid treating valid falsy values
(like empty strings) as missing: update the ui computed expression (currently
using langStore.config?.ui || {}) to use ?? instead, and change the label helper
(label function using (ui.value as any)?.[key] || fallback) to use ?? for the
fallback so only null/undefined trigger the fallback.
pages/[lang]/best-starting-words.vue (2)

21-24: Consider extracting validated data to typed constants.

The non-null assertions (pageData!) on lines 119 and 228 are safe given the early error throw, but extracting validated properties alongside other constants would eliminate the assertions and improve type safety if the validation logic changes.

✨ Suggested extraction
 const langName = pageData.value.lang_name;
 const langNative = pageData.value.lang_name_native;
 const topWords = pageData.value.top_words;
 const letterFreqs = pageData.value.letter_frequency;
+const dailyWordCount = pageData.value.daily_word_count;

Then use dailyWordCount.toLocaleString() in the template instead of pageData!.daily_word_count.toLocaleString().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/`[lang]/best-starting-words.vue around lines 21 - 24, Extract validated
fields from pageData.value into typed constants (e.g., langName, langNative,
topWords, letterFreqs, dailyWordCount) immediately after the existing validation
logic so you can remove non-null assertions (pageData!) elsewhere; update code
that currently uses pageData!.daily_word_count in the template to use the new
dailyWordCount.toLocaleString() and replace other accesses with the
corresponding constants (langName, langNative, topWords, letterFreqs) to improve
type safety and prevent future nullable access.

187-192: Add ARIA attributes for accessibility.

The frequency bars are purely visual. Screen readers won't convey the percentage information without proper ARIA roles.

♿ Proposed accessibility improvement
-                        <div class="flex-1 h-4 bg-muted-soft rounded-sm overflow-hidden">
+                        <div
+                            class="flex-1 h-4 bg-muted-soft rounded-sm overflow-hidden"
+                            role="meter"
+                            :aria-valuenow="freq.percentage"
+                            aria-valuemin="0"
+                            aria-valuemax="100"
+                            :aria-label="`${freq.letter}: ${freq.percentage}%`"
+                        >
                             <div
                                 class="h-full bg-correct rounded-sm transition-all"
                                 :style="{ width: `${freq.percentage}%` }"
                             />
                         </div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/`[lang]/best-starting-words.vue around lines 187 - 192, The visual
frequency bar lacks ARIA attributes so screen readers miss the percentage;
update the outer bar element (the div with class "flex-1 h-4 bg-muted-soft
rounded-sm overflow-hidden") to use role="progressbar" and set aria-valuenow to
the numeric freq.percentage, aria-valuemin="0", aria-valuemax="100", and a
descriptive aria-label or aria-describedby (e.g., include the word or rank from
the same data row). Mark the inner bar element (the div with class "h-full
bg-correct rounded-sm transition-all") aria-hidden="true" so only the
progressbar exposes the value to assistive tech.
plugins/debug.client.ts (1)

98-114: Nested setTimeout calls lack cleanup on component unmount.

If the user navigates away mid-animation, these timeouts will still fire and attempt to mutate the store. Since this is a debug-only tool, the impact is minimal, but consider using a flag or clearing timeouts if more robustness is desired.

💡 Optional: Add cancellation support
         ignite: () => {
             const game = useGameStore();
             const prevOver = game.gameOver;
             const prevWon = game.gameWon;
             game.gameOver = false;
             game.gameWon = true;
-            setTimeout(() => {
+            const t1 = setTimeout(() => {
                 game.gameOver = true;
-                setTimeout(() => {
+                const t2 = setTimeout(() => {
                     game.gameOver = prevOver;
                     game.gameWon = prevWon;
                 }, 900);
+                // Store t2 if cleanup needed
             }, 50);
+            // Could store t1 for cleanup if needed
             console.log('Ignite animation triggered');
         },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/debug.client.ts` around lines 98 - 114, The nested setTimeouts in the
ignite function (used with useGameStore) can fire after component unmount and
mutate the store; change ignite to capture the timeout IDs (for the outer and
inner setTimeout), store them in local variables or a cancellable context, and
clear them on unmount or when cancelling the debug animation (using clearTimeout
with those IDs) so the store is not mutated after the component using ignite is
destroyed.
composables/useFlag.ts (1)

98-103: Minor: Variable name countryCode is now a misnomer.

The variable holds icon codes (including sub-national and ethnic flags), not strictly country codes. Consider renaming to iconCode or flagCode for clarity.

♻️ Suggested rename
 export function useFlag(langCode: string): string | null {
-    const countryCode = LANG_TO_FLAG[langCode];
-    if (!countryCode) return null;
+    const flagCode = LANG_TO_FLAG[langCode];
+    if (!flagCode) return null;
     // circle-flags package stores SVGs at this path
-    return `/flags/${countryCode}.svg`;
+    return `/flags/${flagCode}.svg`;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useFlag.ts` around lines 98 - 103, Rename the misleading variable
in useFlag so it reflects that it may contain non-country identifiers: change
countryCode to a neutral name like flagCode or iconCode within the useFlag
function and its lookup from LANG_TO_FLAG, update the return path variable usage
to `/flags/${flagCode}.svg`, and adjust any local references or comments
accordingly to keep semantics identical while improving clarity.
components/game/BoardMinimap.vue (1)

34-38: Consider simplifying props if pagination is not used.

Per the PR comments, pagination is not currently used (all boards are rendered). The currentPage and boardsPerPage props are only consumed by isOnCurrentPage() for the minimap-current class. If pagination is not planned, these props add unnecessary complexity.

💡 Simplified props if pagination is removed

If pagination is definitively not needed:

 const props = defineProps<{
-    currentPage: number;
-    boardsPerPage: number;
     focusedBoard: number | null;
 }>();

And remove isOnCurrentPage() and the minimap-current class usage. However, if pagination may be added later, keeping these props is reasonable — consider adding a comment explaining the intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/BoardMinimap.vue` around lines 34 - 38, The component
currently defines pagination props (currentPage, boardsPerPage) and uses
isOnCurrentPage() only to toggle the minimap-current class; if pagination is not
used, remove currentPage and boardsPerPage from the defineProps declaration and
delete the isOnCurrentPage() helper and any usage of the "minimap-current" class
in BoardMinimap.vue so only focusedBoard remains, or if you want to keep them
for future pagination add a short comment above the props and isOnCurrentPage()
explaining they are reserved for future pagination to avoid confusion.
composables/useMultiBoardLayout.ts (2)

26-34: Unused parameter: maxGuesses is extracted but never used.

The mg variable (Line 33) is assigned from maxGuesses.value but is never referenced in the computed logic. This appears to be dead code or an incomplete feature.

🔧 Suggested fix: remove unused extraction or add TODO
 export function useMultiBoardLayout(
     boardCount: Ref<number>,
-    maxGuesses: Ref<number>,
+    _maxGuesses: Ref<number>, // Reserved for future row-based visibility logic
     containerWidth: Ref<number>
 ) {
     return computed<MultiBoardLayoutConfig>(() => {
         const bc = boardCount.value;
-        const mg = maxGuesses.value;
         const w = containerWidth.value;

Alternatively, if maxGuesses will be used for row truncation logic later, add a TODO comment explaining the intent.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useMultiBoardLayout.ts` around lines 26 - 34, The computed in
useMultiBoardLayout extracts maxGuesses into mg but never uses it; either remove
the unused extraction (delete the mg and maxGuesses.value read) or explicitly
mark intent if it's reserved for future row-truncation logic by adding a clear
TODO comment referencing maxGuesses/mg inside useMultiBoardLayout so linters
stop flagging it and readers know it's intentional.

46-55: Clarify the column selection logic for edge cases.

The condition on Line 47 (bc % cols !== 0 && cols !== bestCols) ensures non-evenly-divisible column counts are skipped, except for the fallback value (2). However, the logic is subtle:

  • For bc=8, cols=3: 8 % 3 !== 0 and 3 !== 2, so it skips (correct - 3 doesn't divide 8)
  • For bc=8, cols=2: 8 % 2 === 0, so it evaluates (correct)

The && cols !== bestCols guard seems intended to always evaluate the fallback, but if bestCols is updated mid-loop (Line 52), this guard becomes unreliable. Since the loop breaks after finding a valid cols, this currently works, but the intent is unclear.

💡 Consider clarifying with a comment or restructuring
         for (let cols = maxCols; cols >= 2; cols--) {
-            if (bc % cols !== 0 && cols !== bestCols) continue;
+            // Prefer column counts that evenly divide board count; always evaluate cols=2 as fallback
+            if (bc % cols !== 0 && cols > 2) continue;
             const totalTileCols = cols * TILES_PER_WORD;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useMultiBoardLayout.ts` around lines 46 - 55, The loop's guard
"bc % cols !== 0 && cols !== bestCols" is confusing and fragile because bestCols
may change mid-loop; remove the "cols !== bestCols" part and only skip when "bc
% cols !== 0" so column divisibility is the sole criterion, then handle the
fallback (default 2) outside the loop: keep bestCols initialized to 2 before the
loop, try to find a divisible cols that yields tileW >= minTile, break when
found, and if none found the pre-set bestCols (2) remains; alternatively add a
clarifying comment above the loop referencing bc, cols, bestCols,
TILES_PER_WORD, GAP_PX and minTile if you prefer to retain the current behavior.
components/game/StreakBadge.vue (1)

6-6: Localize the badge aria-label instead of hardcoding English.

This string bypasses your locale config and will remain English in non-English UIs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/game/StreakBadge.vue` at line 6, The aria-label="Streak" in
StreakBadge.vue is hardcoded; replace it with a localized string (e.g. use the
project's i18n helper such as $t or t) so the badge follows locale settings
(update the template attribute on the StreakBadge component to bind aria-label
to the translation key and add the corresponding "streak" entry to your locale
files); ensure you reference the exact attribute (aria-label on the StreakBadge
template) and the translation key you add/update in the locale resource so tests
and accessibility tools read the localized value.
assets/css/main.css (1)

246-344: Inherit the flame color instead of hard-coding spark oranges.

The icon is now themed via the flame token/text class, but these selectors still pin the glow and particles to fixed color literals. Using currentColor or the flame tokens here keeps the icon and effects synced across theme and palette changes.

pages/[lang]/[mode].vue (1)

39-45: Consider guarding the non-null assertion on config.value.

The config.value! assertion at line 42 is safe in practice because useGamePage initializes config from gameData, and lines 28-30 throw a 404 if gameData is missing. However, for defensive clarity, you could add an early return or explicit check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/`[lang]/[mode].vue around lines 39 - 45, The call to useGameSeo passes
config.value! with a non-null assertion; instead, explicitly guard config before
use: check that config.value exists (e.g., const cfg = config.value; if (!cfg)
return early or throw the same 404 as useGamePage) and then call useGameSeo({
lang, mode, config: cfg, langStore, allLangCodes: allLangs.value?.language_codes
}); also consider adding this guard near where useGamePage/gameData is validated
so useGameSeo never receives a possibly undefined config.

Comment on lines +19 to 29
async function fetchDefinition(
word: string,
lang: string,
options?: { cacheOnly?: boolean }
): Promise<WordDefinition> {
const key = `${lang}:${word.toLowerCase()}`;
const cached = _cache.get(key);
if (cached) return cached;

const params = options?.cacheOnly ? '?cache_only=1' : '';
try {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope cache keys by fetch mode to avoid cache-only poisoning full lookups.

cacheOnly changes backend behavior, but the cache key ignores it. A cache-only result can be returned for later non-cache-only requests, preventing richer definitions from ever being fetched for that word/lang in-session.

💡 Proposed fix
-        const key = `${lang}:${word.toLowerCase()}`;
-        const cached = _cache.get(key);
+        const normalizedWord = word.toLowerCase();
+        const baseKey = `${lang}:${normalizedWord}`;
+        const fullKey = `${baseKey}:full`;
+        const key = options?.cacheOnly ? `${baseKey}:cache_only` : fullKey;
+        const cached =
+            _cache.get(key) ??
+            (options?.cacheOnly ? _cache.get(fullKey) : undefined);
         if (cached) return cached;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useDefinitions.ts` around lines 19 - 29, The cache key in
fetchDefinition ignores the cache-only flag, causing cache-only responses to be
reused for full fetches; update the key construction in fetchDefinition to
include the options.cacheOnly state (e.g., append a marker like `:cacheOnly=1`
when options?.cacheOnly is true) and ensure both the _cache.get and the
corresponding _cache.set use this same scoped key so cache-only results cannot
poison normal lookups; reference the key variable and options?.cacheOnly inside
fetchDefinition to implement this change.

Comment on lines +97 to +140
const trackFail = (method: 'native' | 'clipboard' | 'fallback', errorType: string) => {
analytics.trackShare({
...baseParams,
method,
result: 'fail',
error_type: errorType,
});
};

// Try Web Share API
if (navigator.share) {
analytics.trackShareClick({ ...shareParams, method: 'native' });
try {
await navigator.share({ text: fullText });
opts.onNotify?.('Shared!');
handleSuccess('native');
trackSuccess('native');
return;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return;
analytics.trackShareFail(opts.langCode, 'native', 'share_api_failed');
trackFail('native', 'share_api_failed');
}
}

// Try Clipboard API
if (navigator.clipboard?.writeText && window.isSecureContext) {
analytics.trackShareClick({ ...shareParams, method: 'clipboard' });
try {
await navigator.clipboard.writeText(fullText);
opts.onNotify?.('Copied to clipboard!');
handleSuccess('clipboard');
trackSuccess('clipboard');
return;
} catch (error) {
if (error instanceof Error) {
analytics.trackShareFail(opts.langCode, 'clipboard', error.message);
trackFail('clipboard', error.message);
}
}
}

// Legacy execCommand fallback
analytics.trackShareClick({ ...shareParams, method: 'fallback' });
if (copyViaExecCommand(fullText)) {
opts.onNotify?.('Copied to clipboard!');
handleSuccess('fallback');
trackSuccess('fallback');
return;
}

analytics.trackShareFail(opts.langCode, 'fallback', 'all_methods_failed');
trackFail('fallback', 'all_methods_failed');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Defer failure analytics until the terminal path.

A native-share exception followed by a clipboard success now records both result: 'fail' and result: 'success' for one share attempt. That corrupts the consolidated metric and contradicts the Line 60 “single share event” contract.

Suggested fix
+        let pendingFailure:
+            | { method: 'native' | 'clipboard' | 'fallback'; errorType: string }
+            | null = null;
+
         const trackSuccess = (method: 'native' | 'clipboard' | 'fallback') => {
             analytics.trackShare({
                 ...baseParams,
                 method,
                 result: 'success',
                 emojiPattern: opts.emojiBoard,
             });
             opts.onSuccess?.();
         };
 
-        const trackFail = (method: 'native' | 'clipboard' | 'fallback', errorType: string) => {
-            analytics.trackShare({
-                ...baseParams,
-                method,
-                result: 'fail',
-                error_type: errorType,
-            });
-        };
+        const rememberFailure = (
+            method: 'native' | 'clipboard' | 'fallback',
+            errorType: string
+        ) => {
+            pendingFailure = { method, errorType };
+        };
 
         // Try Web Share API
         if (navigator.share) {
             try {
                 await navigator.share({ text: fullText });
                 opts.onNotify?.('Shared!');
                 trackSuccess('native');
                 return;
             } catch (error) {
                 if (error instanceof Error && error.name === 'AbortError') return;
-                trackFail('native', 'share_api_failed');
+                rememberFailure('native', 'share_api_failed');
             }
         }
 
         // Try Clipboard API
         if (navigator.clipboard?.writeText && window.isSecureContext) {
             try {
                 await navigator.clipboard.writeText(fullText);
                 opts.onNotify?.('Copied to clipboard!');
                 trackSuccess('clipboard');
                 return;
             } catch (error) {
                 if (error instanceof Error) {
-                    trackFail('clipboard', error.message);
+                    rememberFailure('clipboard', error.message);
                 }
             }
         }
 
         // Legacy execCommand fallback
         if (copyViaExecCommand(fullText)) {
             opts.onNotify?.('Copied to clipboard!');
             trackSuccess('fallback');
             return;
         }
 
-        trackFail('fallback', 'all_methods_failed');
+        rememberFailure('fallback', 'all_methods_failed');
+        analytics.trackShare({
+            ...baseParams,
+            method: pendingFailure?.method ?? 'fallback',
+            result: 'fail',
+            error_type: pendingFailure?.errorType ?? 'all_methods_failed',
+        });
         opts.onAllFailed?.(fullText);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useGameShare.ts` around lines 97 - 140, The current flow calls
trackFail inside intermediate catch blocks (e.g., catch for navigator.share and
navigator.clipboard.writeText), causing both fail and success events when a
later method succeeds; instead, stop calling trackFail inside those intermediate
catches and defer failure reporting until the terminal path: try navigator.share
(keep the AbortError early return), if it throws mark a local flag or just
continue to the next method; try navigator.clipboard.writeText, on throw
continue without emitting analytics; try copyViaExecCommand; only when all
methods have been attempted and none succeeded call
trackFail('fallback','all_methods_failed') once (and still call
trackSuccess(...) immediately on any successful path), keeping opts.onNotify?.
calls local to each success path.

Comment thread data/languages/fa/language_config.json
Comment thread layouts/game.vue Outdated
Comment on lines +14 to +33
onMounted(() => {
const el = scrollContainer.value;
if (!el) return;

let scrollTimer: ReturnType<typeof setTimeout> | null = null;
let hasSnappedBack = false;

el.addEventListener(
'scroll',
() => {
if (hasSnappedBack) return;
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
if (el.scrollTop <= 0) return;
hasSnappedBack = true;
el.scrollTo({ top: 0, behavior: 'smooth' });
}, 200);
},
{ passive: true }
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't force the first user scroll back to the top.

This handler runs after any 200 ms pause while scrollTop > 0, so the first attempt to reach lower snap sections gets canceled. It can also undo in-page scrolling inside the container. If the goal is only to correct restored scroll position, make that a one-shot mount check instead of a persistent scroll listener.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@layouts/game.vue` around lines 14 - 33, The scroll handler attached in
onMounted to scrollContainer currently forces the first user scroll back to top;
change it to a one-shot check instead of a persistent scroll listener:
onMounted, read scrollContainer.value (el) and if el.scrollTop > 0 and you
detect a restored scroll state, call el.scrollTo({ top: 0, behavior: 'smooth' })
once and do not attach the continuous el.addEventListener('scroll', ...) that
uses hasSnappedBack and scrollTimer; alternatively if you must keep a listener,
attach it with { once: true } or immediately remove it after the first automatic
snap so user-initiated scrolling isn't cancelled (refer to onMounted,
scrollContainer, hasSnappedBack, scrollTimer, and the el.addEventListener call).

Comment thread nuxt.config.ts
Comment on lines +16 to +17
capture_pageleave: false, // Disabled to reduce event volume (was ~61K/8 days)
enable_web_vitals: false, // Disabled to reduce event volume (was ~110K/8 days)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

What are the valid PostHog JavaScript client configuration options for web vitals and capture_pageleave?

💡 Result:

The valid PostHog JavaScript client configuration options related to web vitals and capture_pageleave are as follows: For web vitals (performance metrics including Web Vitals like LCP, CLS, FCP, INP): - Top-level: capture_performance (boolean | PerformanceCaptureConfig, default falls back to remote config) - PerformanceCaptureConfig options: - web_vitals (boolean): Enables Chrome's web vitals library. - web_vitals_allowed_metrics (SupportedWebVitalsMetrics[]): Restrict metrics, e.g. ['LCP', 'CLS'], default ['LCP', 'CLS', 'FCP', 'INP']. - web_vitals_delayed_flush_ms (number): Delay before flushing, default 5000 ms. - web_vitals_max_value (number): Max value filter, default 15 minutes (900000 ms). - network_timing (boolean): Captures network timing metrics. Example: posthog.init('token', { capture_performance: { web_vitals: true, web_vitals_allowed_metrics: ['LCP'] } }) Note: Enable web vitals autocapture in PostHog project settings first. These are client-side tuning options under capture_performance. For capture_pageleave: - capture_pageleave (boolean | 'if_capture_pageview', default 'if_capture_pageview'): - true: Capture $pageleave on all pages. - false: Disable. - 'if_capture_pageview': Capture only if capture_pageview is true or 'history_change'. Example: posthog.init('token', { capture_pageleave: true }) These are passed to posthog.init as part of PostHogConfig. Disabling autocapture does not affect pageview/pageleave.

Citations:


Fix enable_web_vitals configuration — invalid PostHog option.

capture_pageleave: false is correct and will reduce page leave events as intended.

However, enable_web_vitals is not a valid PostHog client configuration option. Based on PostHog's documentation, web vitals configuration is nested under capture_performance. To disable web vitals, use:

capture_performance: {
  web_vitals: false
}

or simply set capture_performance: false to disable all performance capture.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nuxt.config.ts` around lines 16 - 17, The PostHog option enable_web_vitals is
invalid; update the config where capture_pageleave is set by removing
enable_web_vitals and replacing it with the correct capture_performance setting
(either capture_performance: { web_vitals: false } to only disable web vitals or
capture_performance: false to disable all performance capture) so that PostHog
respects the intended behavior for capture_pageleave and performance telemetry.

Comment thread pages/index.vue
Comment on lines +656 to +667
<span
v-if="getCurrentStreak(language.language_code) > 0"
class="flex items-start gap-0 text-flame"
>
<Flame :size="14" />
<span
class="font-mono font-semibold tabular-nums"
style="font-size: 9px; line-height: 1; margin-top: 1px"
>
{{ getCurrentStreak(language.language_code) }}
</span>
</template>
</span>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t drive the new flame badge from the old trailing-wins helper.

This badge reads getCurrentStreak() from Line 417, but that helper only counts consecutive wins in storage order. After a skipped day or a same-day loss→win override, the homepage can disagree with the header/modal streak and show the wrong count.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/index.vue` around lines 656 - 667, The flame badge currently calls
getCurrentStreak(language.language_code) which uses the old trailing-wins helper
and can disagree with the header/modal streak; replace this call so the badge
uses the same canonical streak calculation used by the header/modal (the
authoritative streak getter used elsewhere in the app) instead of
getCurrentStreak, ensuring the new implementation accounts for skipped days and
same-day loss→win overrides; locate the badge span in pages/index.vue and swap
the helper call to the header/modal's streak function (or central streak
utility) so both UI locations render the identical streak value.

Comment thread plugins/pwa.client.ts
Comment on lines +241 to +248
watch(
() => gameStore.showStatsModal,
(showing, wasShowing) => {
if (wasShowing && !showing && gameStore.gameOver) {
resetIdleTimer();
}
}
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Pause idle prompting while the stats modal is visible.

Opening the stats modal only resets the timer. If the user stays on that modal for 20 seconds, the install dialog can stack on top of the post-game flow.

Suggested fix
 watch(
     () => gameStore.showStatsModal,
     (showing, wasShowing) => {
+        if (showing) {
+            stopIdleDetection();
+            return;
+        }
         if (wasShowing && !showing && gameStore.gameOver) {
-            resetIdleTimer();
+            startIdleDetection();
         }
     }
 );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
watch(
() => gameStore.showStatsModal,
(showing, wasShowing) => {
if (wasShowing && !showing && gameStore.gameOver) {
resetIdleTimer();
}
}
);
watch(
() => gameStore.showStatsModal,
(showing, wasShowing) => {
if (showing) {
stopIdleDetection();
return;
}
if (wasShowing && !showing && gameStore.gameOver) {
startIdleDetection();
}
}
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/pwa.client.ts` around lines 241 - 248, The watcher on
gameStore.showStatsModal currently only resets the idle timer when the modal
closes; change it so that when the modal opens (showing === true) you
pause/clear any pending idle prompt by calling resetIdleTimer() immediately, and
when the modal closes (showing === false) retain the existing behavior of
resetting the timer only if gameStore.gameOver is true; update the watch
callback for watch(() => gameStore.showStatsModal, (showing, wasShowing) => {
... }) to handle both showing === true (pause) and showing === false with
gameStore.gameOver (reset).

Hugo0 added 9 commits March 29, 2026 18:56
- Extract toLocalDay, stepBack, buildDailyResultMap to utils/streak-dates.ts
- Eliminates ~40 lines of duplicated date logic between stats.ts and StreakModal
- Add effectiveStreak computed to game store (respects debug override)
- PageShell and StreakModal now use game.effectiveStreak instead of
  repeating the debugStreakOverride ternary
- stores/game.ts: fix dayIdx falsy check (day 0 was skipped), use != null
- stores/game.ts: clear todayDefinition before fetch to prevent stale data
- StreakModal: fix duplicate v-for :key on weekday headers (T, S appear twice)
- StreakBadge: clean up frozen timeout on unmount (prevents detached ref write)
- useGameSeo: remove dead 'tridle' from MODES_WITH_PER_LANG_OG set
- fa/language_config.json: fix copy-paste errors in octordle/sedecordle/duotrigordle
  (was showing 4 boards/9 guesses instead of correct 8/13, 16/21, 32/37)
1. Replace boards.value = [...boards.value] with triggerRef(boards)
   Old: shallow-copied the entire 32-board array, forcing EVERY board-dependent
   computed to re-evaluate (mergedKeyStates, all 32 MultiBoardPanel board computeds).
   New: triggerRef is O(1) — just marks the ref as dirty without copying.

2. Convert boardKeyStates() from per-key function to single computed map
   Old: plain function called once per keyboard key during render.
   With 32 boards × 30 keys = 960 array allocations per re-render.
   New: single computed builds the full key→states map once per reactive change.

3. Skip solved boards in showTilesAllBoards during typing
   Old: synced visual state for all 32 boards on every keystroke.
   New: skips solved boards when doing single-row sync (onlyRow path).
   For a late game with 20 solved boards, this cuts splices from 64 to 24.
The biggest remaining perf bottleneck: saveMultiBoardToLocalStorage()
ran on EVERY keystroke, JSON.stringify'ing 32 boards × 37 rows = ~100KB
of synchronous serialization blocking the main thread.

Fix: debounce the save with 500ms delay during typing. Immediate save
still happens on guess submission (processMultiBoardGuess calls it directly).

Also skip tileColors splice for 5+ boards in addChar/removeChar — the pop
animation is already disabled for multi-board, so the color tracking is
unnecessary reactive overhead (~32 extra splices per keystroke eliminated).
Root cause of remaining lag: all 32 MultiBoardPanel components are rendered
simultaneously (no pagination). Every keystroke syncs tilesVisual for ALL
32 boards via showTilesAllBoards, causing 1,120 tile components to be
diffed by Vue even if most boards are scrolled off-screen.

Fix: MultiBoardLayout now tells the game store which board indices are
currently visible (gridCols × 2 rows ≈ 8-16 boards). During typing,
showTilesAllBoards only syncs those visible boards. When the user scrolls,
a debounced handler (200ms) does a full sync to catch up off-screen boards.

For 32-board mode: typing now syncs 8 boards instead of 32 = 4x fewer
reactive splices and component re-renders per keystroke.
…dals

- Flag @error handlers: use reactive Set/ref instead of style.display/classList
  manipulation (index.vue, AppSidebar, GameModePicker, SeoNoscript)
- Homepage About/Settings modals: replace hand-rolled v-show divs with
  SharedBaseModal (adds focus trap, Escape key, consistent styling)
- Remove unused X import from index.vue (BaseModal has its own)
…imation skip

Re-applied changes lost during git stash conflict:
- Measure scrollRef (padded inner div) instead of containerRef for accurate
  container width — fixes horizontal scroll overflow on mobile
- BoardMinimap: arrow buttons for scrolling when 16+ boards overflow
- useGameAnimations: skip tile flip for 8+ boards (speedMultiplier >= 2)
- gridTemplateRows on outer grid prevents tile resize when typing
- Skeleton loading state while measuring container
- PostGamePanel: hover effect on mode discovery buttons
- Toolbar overflow: min-width:0 + overflow:hidden
Re-apply changes lost when another agent checked out wrong branch:
- StreakModal: memoized useFlag() calls in v-for
- words.vue: remove langStore dependency, use API ui labels, reactive wordArt
- useGameShare: remove dead getShareText function
- settings.ts: remove redundant import.meta.client check
Hugo0 added 9 commits March 29, 2026 21:52
…ution

- useDefinitions: replace 7 `as any` casts with typed DefinitionApiResponse
  interface matching the server's actual response shape
- vitest.config: add @vue/reactivity resolve alias so game store tests
  can find the pnpm-nested package (fixes test import failure from perf PR)
- Add expand/collapse all button (Maximize2/Minimize2) to toolbar
- Toolbar centered with max-width: 600px
- Skeleton: 4 gray board placeholders instead of "Loading boards..." text
- Wire allExpanded to max-visible-rows (0 when expanded, layout.visibleRows when collapsed)
Compare filtered count against actual total instead of hardcoded 65.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant