Skip to content

feat: Vite build system, TypeScript, testing infrastructure, and CI/CD#109

Merged
Hugo0 merged 12 commits intomainfrom
feature/build-system-tests-ci
Jan 25, 2026
Merged

feat: Vite build system, TypeScript, testing infrastructure, and CI/CD#109
Hugo0 merged 12 commits intomainfrom
feature/build-system-tests-ci

Conversation

@Hugo0
Copy link
Owner

@Hugo0 Hugo0 commented Jan 25, 2026

Summary

  • Vite build system: Migrated from CDN-based JS to bundled TypeScript with Vite (~430KB → ~75KB gzipped)
  • TypeScript migration: Strict null checks, proper typing throughout
  • Testing infrastructure: pytest for data validation + vitest for game logic (16 unit tests)
  • CI/CD pipeline: GitHub Actions with lint, pytest, vitest, and build jobs
  • Code formatting: Prettier (TypeScript) + Black (Python)
  • Documentation: CLAUDE.md for AI assistants, updated README

Test plan

  • CI checks pass (lint, pytest, vitest, build)
  • Manual test: Play a game in multiple languages
  • Manual test: PWA install and offline mode
  • Manual test: Dark mode toggle
  • Manual test: Share functionality

@coderabbitai review

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Modern frontend app with interactive game, PWA install flow, offline play, dark mode, haptics, sounds, sharing, and keyboard layout selection.
  • Documentation
    • Added detailed developer guide, contributing guide, and expanded README with setup and testing instructions.
  • Tests
    • Comprehensive unit and integration tests for game logic, daily-word selection, word lists, and language configs.
  • Chores
    • CI/workflow, build/tooling configs, formatting rules, service worker, web manifest, many language data and keyboard updates.

✏️ Tip: You can customize this high-level summary in your review settings.

* Add keyboard layout selection and update keyboard configurations for multiple languages

* Refactor keyboard layout selector form for improved styling
@coderabbitai
Copy link

coderabbitai bot commented Jan 25, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Adds a new Vue 3 + TypeScript frontend with PWA support and service worker, replaces legacy JS frontend, integrates Vite-built assets into the Flask backend, introduces CI/tooling/tests, extensive language data and keyboard schema changes, and developer documentation and scripts.

Changes

Cohort / File(s) Change Summary
Frontend apps & entry
frontend/src/game.ts, frontend/src/index-app.ts, frontend/src/main.ts
New Vue 3 + TS apps and unified entry mounting logic; review game logic, persistence keys, window globals, and app mount selection.
Frontend types & runtime utilities
frontend/src/types/index.ts, frontend/src/debug.ts, frontend/src/pwa.ts, frontend/src/haptics.ts, frontend/src/sounds.ts
New type declarations and runtime helpers (PWA lifecycle, debug API, haptics, sound). Verify exports, side effects (window.debug), and PWA integration.
Frontend tests & logic unit tests
frontend/src/__tests__/game-logic.test.ts
Adds unit tests for color calculation and stats aggregation — check edge-case coverage and test assumptions.
Frontend styling & assets
frontend/src/style.css, webapp/static/style.css
New/updated CSS for tiles, keyboard, animations, PWA banner, dark mode and mobile refinements. Ensure class names match JS/TS usage.
Tooling & build configs
package.json, pnpm-workspace.yaml, tsconfig.json, vite.config.js, vitest.config.ts, .prettierrc, .prettierignore
New pnpm/Vite/TS/Vitest/Prettier configs and scripts; validate build output path, alias @ and CI script alignment.
CI / Python tooling
.github/workflows/test.yml, pyproject.toml, pytest.ini, requirements.txt, runtime.txt
Adds GitHub Actions workflow and Python tooling configs; note Python bumped to 3.14 and Black settings.
Backend integration & templates
webapp/app.py, webapp/templates/game.html, webapp/templates/index.html
Injects Vite assets via manifest loader, passes window-scoped data, and adds keyboard/layout selection + cookies; review manifest parsing, asset URLs, and cookie behavior.
Service worker & PWA assets
webapp/static/sw.js, webapp/static/manifest.json, webapp/static/offline.html, webapp/static/offline-game.html
New service worker with cache/network strategy and language-aware fallbacks; add manifest and offline pages — inspect caching rules and navigation fallback logic.
Removed legacy frontend
webapp/static/game.js
Legacy JS frontend removed; confirm all runtime responsibilities are served by new TS frontend and template references updated.
Tests & test scaffolding (Python)
tests/__init__.py, tests/conftest.py, tests/test_word_lists.py, tests/test_language_config.py, tests/test_daily_word.py
New pytest fixtures and comprehensive data/config validation tests — verify test assumptions about seed, word order, and paths.
Language data & keyboards
webapp/data/languages/*/*_5words.txt, webapp/data/languages/*/*_keyboard.json, webapp/data/languages/*/*_characters.txt
Many word-list edits and keyboard JSON schema changes (from array -> {default, layouts}); ensure backend/frontend load_keyboard changes align with new schema and verify language data integrity.
Docs & contributor guidance
CLAUDE.md, CONTRIBUTING.md, README.md, .cursorrules
New and expanded developer documentation and contributor guidelines, including build/test/process constraints and notes to preserve seed/ordering.
Utilities & scripts
scripts/download_github_data.sh
New script to download and summarize GitHub data; review shell robustness and jq usage.
Misc config / ignores
.gitignore, .prettierignore
New ignore patterns (docs/, node_modules/, webapp/static/dist/, pnpm store, etc.).

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Browser
    participant SW as "Service Worker"
    participant Cache as "Cache Storage"
    participant Network
    participant Backend

    User->>Browser: Navigate to /<lang>/game
    Browser->>Network: GET HTML (includes vite asset refs)
    Network->>Backend: Request page
    Backend->>Network: Respond HTML with window-provided data (word_list, config, todays_idx)
    Network->>Browser: HTML response
    Browser->>Browser: Load module JS (frontend main.ts)
    Browser->>SW: Register service worker (sw.js)
    SW->>Cache: Install -> cache STATIC_ASSETS

    alt Online & assets fetched
        Browser->>Network: Fetch vite JS/CSS modules
        Network->>Browser: Serve modules
        Browser->>Browser: Mount Vue app, init PWA, read window data
    else Offline or network failure
        Browser->>SW: Fetch intercepted
        SW->>Cache: Return `/static/offline-game.html` or `offline.html` based on path/language
        Cache-->>SW: Serve fallback
        SW->>Browser: Respond with offline fallback
        Browser->>User: Render offline UI
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through manifests and PWA dreams,
Tiles flipping, tests passing in my gleam,
Offline or online, the keys sing so bright,
A rabbit's small cheer for code that feels right,
Take a nibble, play on — delight!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and concisely summarizes the main changes: Vite build system migration, TypeScript adoption, testing infrastructure setup, and CI/CD pipeline integration.
Docstring Coverage ✅ Passed Docstring coverage is 80.56% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Major refactor of the frontend build system and developer tooling:

## Build System
- Migrate from CDN (Vue, Tailwind ~430KB) to Vite bundled (~75KB gzipped)
- Add TypeScript for type safety with strict null checks
- pnpm build / pnpm watch workflow

## Features
- PWA support: manifest, service worker, install prompt
- Dark mode toggle (persisted to localStorage)
- Guess distribution graph in stats modal
- Mobile improvements: 100dvh, touch-action, notch handling
- Share button fallback chain for better reliability

## Testing
- pytest: Data validation tests for word lists, configs, daily word algorithm
- vitest: TypeScript unit tests for game logic (color algorithm, stats)
- 1495+ test cases across 65 languages

## CI/CD
- GitHub Actions workflow: lint, pytest, vitest, build checks
- Prettier (TypeScript) + Black (Python) formatting
- Runs on every PR

## Dependencies
- Python 3.9.5 → 3.14.2
- Added: vitest, prettier, black, pytest
- All packages updated to latest stable

## Documentation
- CLAUDE.md: Architecture docs for AI assistants
- CONTRIBUTING.md: Contribution guidelines
- Updated README with test badge

## Cleanup
- Deleted dead code: webapp/static/game.js
@Hugo0 Hugo0 force-pushed the feature/build-system-tests-ci branch from d3edf54 to 886b124 Compare January 25, 2026 20:00
Hugo0 and others added 3 commits January 25, 2026 20:02
The PWA install functionality is handled by frontend/src/pwa.ts with native
beforeinstallprompt and iOS fallback. The bundle was never loaded in templates.
Integrate professor-eggs' multiple keyboard layouts feature:
- New keyboard JSON format with named layouts (qwerty, dvorak, alphabetical)
- Backward compatible with legacy array format
- User layout preference persisted via cookies
- Layout selector in Settings modal (only shows when multiple layouts exist)
- Auto-generated alphabetical fallback when no keyboard defined

Changes:
- load_keyboard() now returns normalized {default, layouts} structure
- Language class handles layout selection with fallback chain
- English keyboard: QWERTY, Dvorak, Alphabetical layouts
- Hebrew keyboard: Alphabetical, Hebrew QWERTY layouts
- Added dark mode styling to layout selector

Based on PR #105 by @professor-eggs, adapted for current codebase.

Co-Authored-By: professor-eggs <professor-eggs@users.noreply.github.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

@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: 20

🤖 Fix all issues with AI agents
In `@CLAUDE.md`:
- Line 9: Convert the bare URL "https://wordle.global" to a Markdown link to
satisfy MD034 and keep formatting consistent; replace the plain URL with a
Markdown link such as [wordle.global](https://wordle.global) (or a descriptive
label like [Live site](https://wordle.global)) in the CLAUDE.md content.
- Line 95: Update the wording of the note that currently reads "4. **145 pytest
failures are expected**" to avoid implying CI should fail; change it to
something like "4. **145 known pytest xfails**" or "4. **145 pytest xfails
expected**" and add a short clarifier that these are marked xfail in tests so CI
remains green; ensure the count remains accurate whenever test expectations
change and edit the same bullet containing the original text.
- Around line 13-39: The fenced code block containing the repository tree (the
triple-backtick block showing "wordle/ ├── webapp/ ...") lacks a language hint;
update that block by adding a language tag (e.g., change ``` to ```text) so
markdownlint MD040 passes — locate the repository tree fenced block in CLAUDE.md
and add the language identifier to the opening fence.

In `@frontend/src/__tests__/game-logic.test.ts`:
- Around line 6-71: The test file exports duplicated logic and types; extract
the calculateColors function, the calculateStats function, and the
GameResult/GameStats interfaces into a shared utility module and have both
game.ts and the test import them instead of defining them locally; update tests
to import calculateColors/calculateStats and the GameResult/GameStats types from
the new module and remove the local exports, and update game.ts to call the
shared calculateColors/calculateStats implementations so there is a single
source of truth.
- Around line 55-59: The local GameResult interface in game-logic.test.ts
conflicts with the canonical type—remove the local export and import the
canonical GameResult from the shared types module (frontend/src/types/index.ts);
update any test fixtures/usages in game-logic.test.ts to match the canonical
shape (make attempts a number, make date the required string type per the shared
definition) so all places use the same GameResult definition.

In `@frontend/src/game.ts`:
- Around line 730-756: The modal construction in showCopyFallbackModal uses
modal.innerHTML with only '<' escaped, leaving XSS risk; instead build the
dialog using DOM APIs or set the textarea's value/textContent rather than
interpolating into innerHTML: create the container and children via
document.createElement (or set modal.textContent for safe text) and assign the
untrusted string to the textarea.value (or textContent) so all characters are
safely handled, then attach the same focus/select and click handler to the
created modal element.

In `@frontend/src/index-app.ts`:
- Around line 99-110: Wrap the localStorage access in the created() lifecycle
hook (the block that reads/writes game_results using localStorage.getItem and
localStorage.setItem) in a try-catch so any exceptions (private browsing, quota,
or JSON parse errors) are caught; on error fall back to assigning
this.game_results = {} and avoid throwing, and still call this.total_stats =
this.calculateTotalStats(); ensure you also guard JSON.parse with try-catch or
use a safe parse so corrupt data doesn't break created().
- Around line 20-24: Replace the locally declared GameResult interface with the
shared type from types/index.ts: remove or rename the local interface GameResult
in frontend/src/index-app.ts and import the exported GameResult (or the
appropriate type name that includes attempts: string | number and date: Date |
string) from types/index.ts; then update any references in this file (e.g.,
functions, state variables, props) to use the imported type so attempts and date
match the canonical union types across the codebase.

In `@pyproject.toml`:
- Line 3: pyproject.toml sets target-version = ['py314'] but the requirements
pin (symbol: black) must be updated to a Black release that supports Python
3.14; change the Black constraint (in requirements.txt or wherever black is
declared) from black>=24.0.0 to black>=25.11.0, and regenerate any lock files if
present so the new constraint is applied.

In `@README.md`:
- Line 178: Update the README text string "All users, github issue raisers, and
PR creators! Thanks so much!" to capitalize "GitHub" (change "github" ->
"GitHub") so the line reads "All users, GitHub issue raisers, and PR creators!
Thanks so much!" and commit the change.

In `@scripts/download_github_data.sh`:
- Around line 1-10: The script uses a hardcoded OUTPUT_DIR and lacks error
handling and dependency checks; make OUTPUT_DIR configurable (respect an
environment variable or CLI arg, default to ./github_data), add robust shell
safety at the top (enable set -euo pipefail and ensure IFS is set safely),
validate required tools using command -v for "gh" and "jq" and exit with a clear
error if missing, and keep the mkdir -p "$OUTPUT_DIR" but fail fast on errors so
the script doesn't continue silently; refer to OUTPUT_DIR and the script
filename download_github_data.sh when applying changes.

In `@tests/test_daily_word.py`:
- Around line 139-159: The test fails because duplicate entries in some language
lists (e.g., 'pau') cause get_daily_word to return repeated words over the
sampled period; to fix, ensure the tests and selection operate on a
de-duplicated list: update load_word_list (or the test setup in
test_no_repeat_in_word_list_length_days) to remove duplicate words before
passing to get_daily_word (or before computing num_days), e.g., by creating a
stable deduped sequence from load_word_list(lang) and using that for num_days
and selection; reference load_word_list, get_daily_word and
test_no_repeat_in_word_list_length_days when making the change.
- Around line 14-39: Update the function type hints to reflect that date can be
None and get_daily_word can return None: change get_todays_idx signature to
accept date: Optional[datetime.date] = None and change get_daily_word signature
to date: Optional[datetime.date] = None with return type Optional[str]; add
"from typing import Optional" to imports so Optional is available. Keep all
runtime logic unchanged, only update the type annotations for get_todays_idx and
get_daily_word.

In `@webapp/app.py`:
- Around line 24-58: The code in get_vite_assets uses the wrong manifest key
("src/main.ts") causing KeyError; update get_vite_assets to read the entry and
recurse using the Vite entry key "main" instead of "src/main.ts" (i.e., set
entry = manifest["main"] and call collect_css("main")), and ensure collect_css
and the returned JS/CSS paths still reference the resolved entry['file'] and
collected css filenames from the manifest.

In `@webapp/static/offline-game.html`:
- Around line 45-65: The icon-only buttons lack accessible names; add aria-label
attributes to each button element that uses `@click` (e.g., the Help button that
toggles showHelpModal, the Settings button that toggles show_options_modal, and
the Stats button that toggles show_stats_modal) with concise labels like "Help",
"Settings", and "Stats"; also update the Backspace key button (the element that
performs the delete/backspace action) to include an aria-label such as
"Backspace" so screen readers can announce their purpose.
- Around line 15-27: offline-game.html currently loads Tailwind and Vue from
CDNs which the service worker will not cache; host vendored copies instead and
add them to the precache. Download the produced Tailwind CSS and
vue.global.prod.js into your static assets (e.g. /static/vendor/...), update
offline-game.html to reference those local files (replace the
cdn.tailwindcss.com script and the unpkg Vue script src with the local
href/src), and then add the exact local paths (e.g.
'/static/vendor/tailwind.css' and '/static/vendor/vue.global.prod.js') to the
precache list in your service worker (sw.js) so they are cached for offline use
(do not rely on cross-origin precaching/skipping behavior).

In `@webapp/static/offline.html`:
- Around line 151-159: The JSON.parse call in the offline.html block that
iterates cachedLangs is unguarded and can throw if localStorage[STORAGE_KEY] is
corrupted; fix by reusing a single safely-parsed object (or the existing
getCachedLanguages() result) instead of calling JSON.parse again, or wrap the
parse in a try-catch and fall back to an empty object; update the loop to read
data via that safe object (referencing STORAGE_KEY, cachedLangs, and
document.getElementById('language-list')) so rendering the language list never
throws on bad storage.

In `@webapp/static/sw.js`:
- Around line 95-110: The message handler for 'CLEAR_CACHE' can let the service
worker stop before asynchronous cache deletes and client notifications finish;
wrap the async work in event.waitUntil to keep the SW alive. Specifically, in
the message listener handling event.data === 'CLEAR_CACHE' (inside the
self.addEventListener('message', ...) callback), build a promise that performs
caches.keys(), Promise.all(names.map(n => caches.delete(n))), then calls
self.clients.matchAll() and posts 'CACHE_CLEARED' to each client, and pass that
promise into event.waitUntil(...); keep the existing self.skipWaiting() handling
for 'SKIP_WAITING' as-is.

In `@webapp/templates/game.html`:
- Around line 3-8: Wrap the dark-mode bootstrap localStorage access in a
try/catch to guard against exceptions from localStorage.getItem (e.g., Safari
Private Browsing); compute a safe boolean like hasStored = false then try to set
it from localStorage.getItem('darkMode') === 'true' and fall back to
window.matchMedia('(prefers-color-scheme: dark)').matches if any error occurs,
and only call document.documentElement.classList.add('dark') when the resolved
boolean is true; update the block using these symbols: localStorage.getItem,
window.matchMedia, and document.documentElement.classList.add('dark').
- Line 13: Remove the problematic attributes from the viewport meta tag that
disable zoom: locate the <meta name="viewport" ...> tag in the template (in
game.html) and in the offline-game.html static file and delete the
maximum-scale=1 and user-scalable=no parts, leaving width=device-width,
initial-scale=1 (and viewport-fit=cover if present) so users can zoom up to 200%
per WCAG SC 1.4.4.
🧹 Nitpick comments (22)
scripts/download_github_data.sh (2)

27-39: Hardcoded repository name and potential incomplete data.

  1. Hardcoded repo: Lines 29, 34, 38 use Hugo0/wordle directly, but line 24 uses implicit repo detection. This inconsistency could cause issues if the script is forked.
  2. Missing pagination: Line 38 doesn't use --paginate for contributors, which defaults to 30 results.
♻️ Proposed fix
+# Get repository owner/name from git remote
+REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
+
 # 4. Get list of forks
 echo "4. Downloading fork information..."
-gh api repos/Hugo0/wordle/forks --paginate > "$OUTPUT_DIR/forks.json"
+gh api "repos/$REPO/forks" --paginate > "$OUTPUT_DIR/forks.json"
 echo "   Done: $(jq length "$OUTPUT_DIR/forks.json") forks"
 
 # 5. Get stargazers (recent)
 echo "5. Downloading recent stargazers..."
-gh api repos/Hugo0/wordle/stargazers --paginate -H "Accept: application/vnd.github.star+json" > "$OUTPUT_DIR/stargazers.json" 2>/dev/null || echo "   Stargazers data limited"
+gh api "repos/$REPO/stargazers" --paginate -H "Accept: application/vnd.github.star+json" > "$OUTPUT_DIR/stargazers.json" 2>/dev/null || echo "   Stargazers data limited"
 
 # 6. Get contributors
 echo "6. Downloading contributors..."
-gh api repos/Hugo0/wordle/contributors > "$OUTPUT_DIR/contributors.json"
+gh api "repos/$REPO/contributors" --paginate > "$OUTPUT_DIR/contributors.json"
 echo "   Done"

43-55: Minor: Prefer redirecting input over piping from cat.

The cat file | jq pattern is a useless use of cat. Direct input redirection is more efficient.

♻️ Suggested change
-cat "$OUTPUT_DIR/issues.json" | jq -r '
+jq -r '
   .[] |
   ...
-' > "$OUTPUT_DIR/issues_readable.md"
+' "$OUTPUT_DIR/issues.json" > "$OUTPUT_DIR/issues_readable.md"

Same pattern applies to lines 60 and 78.

webapp/static/style.css (2)

20-36: Add focus-visible styles for keyboard and banner controls.

This preserves keyboard accessibility without changing visuals for mouse/touch users.

♻️ Proposed addition
 .key:active {
     transform: scale(0.95);
 }
+
+.key:focus-visible,
+.pwa-install-banner button:focus-visible,
+.pwa-install-banner .dismiss:focus-visible {
+    outline: 2px solid currentColor;
+    outline-offset: 2px;
+}

Also applies to: 106-123


72-81: Consider honoring reduced-motion preferences.

Optional, but helps users who disable motion effects.

♿ Proposed addition
 `@keyframes` pop {
     0% { transform: scale(1); }
     50% { transform: scale(1.1); }
     100% { transform: scale(1); }
 }
+
+@media (prefers-reduced-motion: reduce) {
+    .pop {
+        animation: none;
+    }
+}
.github/workflows/test.yml (1)

34-38: Use --frozen-lockfile in CI installs.

This prevents lockfile drift and makes builds reproducible.

♻️ Proposed change
-          pnpm install
+          pnpm install --frozen-lockfile
-        run: pnpm install
+        run: pnpm install --frozen-lockfile
-        run: pnpm install
+        run: pnpm install --frozen-lockfile

Also applies to: 84-86, 108-110

frontend/src/types/index.ts (2)

58-65: Align todays_idx type with injected value.

If the backend injects a numeric index, the current string type forces extra parsing and can lead to mismatched usage. Consider number (or a transitional number | string) and normalize once at load.

🔧 Possible adjustment
-    todays_idx: string;
+    todays_idx: number | string;

92-96: Consider making GameResult.date optional.

Tests define date as optional, and older persisted results may not include it. Marking it optional avoids unsafe assumptions.

🔧 Possible adjustment
-    date: Date | string;
+    date?: Date | string;
tests/conftest.py (2)

28-32: Sort language codes for deterministic test order.

iterdir() order is filesystem-dependent. Sorting keeps parametrized tests stable and predictable.

🔧 Suggested update
-    return [d.name for d in LANGUAGES_DIR.iterdir() if d.is_dir()]
+    return sorted(d.name for d in LANGUAGES_DIR.iterdir() if d.is_dir())

71-77: Normalize load_keyboard to always return a list.

Other loaders return [] when missing; returning None adds special-case handling for call sites and tests.

🔧 Suggested update
-def load_keyboard(lang_code: str) -> list | None:
+def load_keyboard(lang_code: str) -> list:
@@
-    if not keyboard_file.exists():
-        return None
+    if not keyboard_file.exists():
+        return []
vitest.config.ts (1)

11-16: Coverage exclude path misses the actual types directory.

The current exclude uses frontend/src/types.ts, which doesn't match the types directory. Type files are actually located at frontend/src/types/index.ts, so the exclude pattern fails to filter them from coverage. Update to frontend/src/types/** to exclude all type-only files.

🔧 Suggested update
-            exclude: ['frontend/src/**/*.{test,spec}.ts', 'frontend/src/types.ts'],
+            exclude: ['frontend/src/**/*.{test,spec}.ts', 'frontend/src/types/**'],
tests/test_word_lists.py (2)

77-114: Minor redundancy between character validation tests.

test_words_use_valid_characters and test_all_word_chars_in_character_set are logically equivalent—if any word contains a character not in the character set, that character will also appear in the "missing from character set" result. However, the different error messages provide complementary diagnostics (word-centric vs. character-centric), so this redundancy may be intentional for debugging purposes.


120-128: Consider pytest.mark.xfail or warnings for quality checks.

Using pytest.skip for quality warnings means these tests always show as "skipped" rather than providing visibility into which languages have fewer words. Consider using pytest.warns with a custom warning category or pytest.mark.xfail(reason=...) if you want these conditions to be tracked without failing the build.

tests/test_language_config.py (1)

41-63: Annotate mutable class attributes with ClassVar.

Per Ruff RUF012, mutable class attributes should be annotated with typing.ClassVar to indicate they're shared across instances and shouldn't be modified per-instance.

♻️ Proposed fix
+from typing import ClassVar
+
 class TestRequiredFields:
     """Tests for required configuration fields."""

-    REQUIRED_FIELDS = [
+    REQUIRED_FIELDS: ClassVar[list[str]] = [
         "language_code",
         "name",
         "name_native",
         "right_to_left",
     ]

-    REQUIRED_META_FIELDS = [
+    REQUIRED_META_FIELDS: ClassVar[list[str]] = [
         "locale",
         "title",
         "description",
     ]

-    REQUIRED_TEXT_FIELDS = [
+    REQUIRED_TEXT_FIELDS: ClassVar[list[str]] = [
         "subheader",
         "next_word",
         "share",
     ]

-    REQUIRED_HELP_FIELDS = [
+    REQUIRED_HELP_FIELDS: ClassVar[list[str]] = [
         "title",
         "close",
     ]
frontend/src/game.ts (3)

108-159: Consider generating tile arrays programmatically.

The 6x5 tile and tile_class arrays are verbose. This could be simplified:

♻️ Suggested simplification
const DEFAULT_TILE_CLASS = 'border-2 border-neutral-300';
// ...
tiles: Array.from({ length: 6 }, () => Array(5).fill('')),
tile_classes: Array.from({ length: 6 }, () => Array(5).fill(DEFAULT_TILE_CLASS)),

256-261: Add cleanup for keydown event listener.

The keydown listener added in created() is never removed. If this component is ever unmounted and remounted, listeners will accumulate.

♻️ Proposed fix
+        beforeUnmount() {
+            window.removeEventListener('keydown', this.keyDown);
+        },
+
         created() {
-            window.addEventListener('keydown', (e) => this.keyDown(e));
+            window.addEventListener('keydown', this.keyDown);
             this.loadGameResults();
             this.stats = this.calculateStats(this.config?.language_code);
             this.time_until_next_day = this.getTimeUntilNextDay();
         },

Note: You'll need to bind this.keyDown in the constructor or use an arrow function property for proper this context.


263-273: Clear interval on component unmount.

The setInterval for updating time_until_next_day is never cleared, which can cause memory leaks and errors if the component unmounts.

♻️ Proposed fix

Store the interval ID and clear it on unmount:

 data(): GameData {
     return {
         // ... existing properties
+        countdownInterval: null as number | null,
     };
 },

 mounted() {
-    setInterval(() => {
+    this.countdownInterval = window.setInterval(() => {
         this.time_until_next_day = this.getTimeUntilNextDay();
     }, 1000);
     // ...
 },

+beforeUnmount() {
+    if (this.countdownInterval) {
+        clearInterval(this.countdownInterval);
+    }
+},
frontend/src/index-app.ts (1)

112-114: Add cleanup for keydown listener.

Same issue as in game.ts—the listener is added but never removed on unmount.

vite.config.js (1)

31-37: Proxying '/' can swallow Vite dev assets/HMR.
If the intent is to proxy only API calls, scope this to a path prefix so Vite can still serve /@vite/client and module assets locally.

♻️ Suggested proxy narrowing
 server: {
   proxy: {
-    '/': {
+    '/api': {
       target: 'http://127.0.0.1:8000',
       changeOrigin: true,
     },
   },
 },
frontend/src/debug.ts (1)

6-29: Gate window.debug to dev-only.
Exposing debug hooks in production expands the global surface area and can collide with other globals.

♻️ Suggested guard
-// Expose to window for console access
-window.debug = debug;
+// Expose to window for console access (dev only)
+if (import.meta.env.DEV) {
+    window.debug = debug;
+}
frontend/src/main.ts (1)

21-28: Consider limiting service‑worker init to production.
Registering SWs during local dev can cause stale assets/HMR confusion; a prod-only guard helps.

♻️ Suggested guard
-// Initialize PWA (service worker, install prompt listeners)
-pwa.init();
+// Initialize PWA (service worker, install prompt listeners)
+if (import.meta.env.PROD) {
+    pwa.init();
+}
webapp/templates/index.html (1)

3-9: Invalid HTML: Script placed outside <head> element.

The script preventing dark mode flash is placed between <html> and <head>, which is invalid HTML structure. While browsers are forgiving, this should be moved inside <head> as the first element.

Suggested fix
 <!DOCTYPE html>
-<html lang="en" class="min-h-full">
-<script>
-    // Apply dark mode immediately to prevent flash
-    if (localStorage.getItem('darkMode') === 'true' ||
-        (!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
-        document.documentElement.classList.add('dark');
-    }
-</script>
-
+<html lang="en" class="min-h-full">
 <head>
+    <script>
+        // Apply dark mode immediately to prevent flash
+        if (localStorage.getItem('darkMode') === 'true' ||
+            (!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+            document.documentElement.classList.add('dark');
+        }
+    </script>
     <meta charset="UTF-8">
frontend/src/pwa.ts (1)

60-68: Add error handling for userChoice promise.

The userChoice promise could reject in edge cases (e.g., user closes browser during prompt). Consider adding a .catch() handler to prevent unhandled promise rejections.

Suggested fix
     // Native prompt on Android/Chrome
     if (deferredPrompt) {
         deferredPrompt.prompt();
         deferredPrompt.userChoice.then((choice) => {
             console.log('PWA install:', choice.outcome);
             deferredPrompt = null;
             hideBanner();
+        }).catch((err) => {
+            console.log('PWA install prompt error:', err);
+            deferredPrompt = null;
         });
         return;
     }

Comment on lines +6 to +71
/**
* Color calculation algorithm - extracted from game.ts updateColors()
* Returns an array of color states: 'correct', 'semicorrect', or 'incorrect'
*/
export function calculateColors(
guess: string,
target: string
): ('correct' | 'semicorrect' | 'incorrect')[] {
const result: ('correct' | 'semicorrect' | 'incorrect')[] = [];
const charCounts: Record<string, number> = {};

// Count characters in target word
for (const char of target) {
charCounts[char] = (charCounts[char] || 0) + 1;
}

// First pass: mark correct positions (green)
const guessChars = guess.split('');
const isCorrect: boolean[] = [];

for (let i = 0; i < guessChars.length; i++) {
const char = guessChars[i]!;
if (char === target[i]) {
isCorrect[i] = true;
charCounts[char]!--;
} else {
isCorrect[i] = false;
}
}

// Second pass: mark semicorrect (yellow) and incorrect (gray)
for (let i = 0; i < guessChars.length; i++) {
const char = guessChars[i]!;
if (isCorrect[i]) {
result[i] = 'correct';
} else if (target.includes(char) && charCounts[char]! > 0) {
result[i] = 'semicorrect';
charCounts[char]!--;
} else {
result[i] = 'incorrect';
}
}

return result;
}

/**
* Stats calculation - extracted from game.ts calculateStats()
*/
export interface GameResult {
won: boolean;
attempts: string | number;
date?: Date;
}

export interface GameStats {
n_wins: number;
n_losses: number;
n_games: number;
n_attempts: number;
avg_attempts: number;
win_percentage: number;
longest_streak: number;
current_streak: number;
guessDistribution: { 1: number; 2: number; 3: number; 4: number; 5: number; 6: number };
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Extract shared utilities from test file.

Biome correctly flags that test files should not export. The calculateColors and calculateStats functions duplicate logic from game.ts, and the interfaces duplicate those in types/index.ts.

Consider extracting these to a shared utility module that both game.ts and tests can import:

♻️ Suggested approach
  1. Create frontend/src/utils/game-logic.ts:
import type { GameResult, GameStats } from '../types';

export function calculateColors(
    guess: string,
    target: string
): ('correct' | 'semicorrect' | 'incorrect')[] {
    // ... implementation
}

export function calculateStats(results: GameResult[]): GameStats {
    // ... implementation
}
  1. Import in game.ts and use within the Vue component methods.

  2. Update test file to import from the utility:

import { calculateColors, calculateStats } from '../utils/game-logic';
import type { GameResult, GameStats } from '../types';
// Remove local definitions, keep only tests
🧰 Tools
🪛 Biome (2.1.2)

[error] 9-50: Do not export from a test file.

(lint/suspicious/noExportsInTest)


[error] 54-59: Do not export from a test file.

(lint/suspicious/noExportsInTest)


[error] 60-71: Do not export from a test file.

(lint/suspicious/noExportsInTest)

🤖 Prompt for AI Agents
In `@frontend/src/__tests__/game-logic.test.ts` around lines 6 - 71, The test file
exports duplicated logic and types; extract the calculateColors function, the
calculateStats function, and the GameResult/GameStats interfaces into a shared
utility module and have both game.ts and the test import them instead of
defining them locally; update tests to import calculateColors/calculateStats and
the GameResult/GameStats types from the new module and remove the local exports,
and update game.ts to call the shared calculateColors/calculateStats
implementations so there is a single source of truth.

Comment on lines +55 to +59
export interface GameResult {
won: boolean;
attempts: string | number;
date?: Date;
}
Copy link

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
# Find all GameResult interface/type definitions
echo "=== Searching for GameResult definitions ==="
rg -n "interface GameResult|type GameResult|class GameResult" --type=ts --type=tsx

# Also search for GameResult declarations without interface/type keywords
echo -e "\n=== Searching for GameResult in files ==="
rg -l "GameResult" --type=ts --type=tsx | head -20

Repository: Hugo0/wordle

Length of output: 216


🏁 Script executed:

#!/bin/bash
# Search for GameResult without file type restrictions
echo "=== Searching for GameResult ==="
rg "GameResult" -n

echo -e "\n=== Listing repository structure ==="
find . -type f -name "*.ts" -o -name "*.js" | head -30

echo -e "\n=== Checking if specific files exist ==="
test -f "frontend/src/__tests__/game-logic.test.ts" && echo "Found: frontend/src/__tests__/game-logic.test.ts" || echo "NOT FOUND: frontend/src/__tests__/game-logic.test.ts"
test -f "types/index.ts" && echo "Found: types/index.ts" || echo "NOT FOUND: types/index.ts"
test -f "index-app.ts" && echo "Found: index-app.ts" || echo "NOT FOUND: index-app.ts"

echo -e "\n=== Git repo structure ==="
git ls-files | head -40

Repository: Hugo0/wordle

Length of output: 3119


🏁 Script executed:

#!/bin/bash
echo "=== frontend/src/__tests__/game-logic.test.ts (lines 55-65) ==="
sed -n '55,65p' frontend/src/__tests__/game-logic.test.ts

echo -e "\n=== frontend/src/types/index.ts (lines 92-100) ==="
sed -n '92,100p' frontend/src/types/index.ts

echo -e "\n=== frontend/src/index-app.ts (lines 20-30) ==="
sed -n '20,30p' frontend/src/index-app.ts

Repository: Hugo0/wordle

Length of output: 843


Consolidate GameResult interface definitions across codebase.

This local GameResult interface differs from frontend/src/types/index.ts (line 92: date: Date | string, required) and frontend/src/index-app.ts (line 20: attempts: number, date: string, both required). The test file defines date?: Date as optional and attempts: string | number. These inconsistencies can cause type mismatches and runtime bugs if code mixes implementations from different files. Ensure a single canonical GameResult definition is used throughout.

🧰 Tools
🪛 Biome (2.1.2)

[error] 54-59: Do not export from a test file.

(lint/suspicious/noExportsInTest)

🤖 Prompt for AI Agents
In `@frontend/src/__tests__/game-logic.test.ts` around lines 55 - 59, The local
GameResult interface in game-logic.test.ts conflicts with the canonical
type—remove the local export and import the canonical GameResult from the shared
types module (frontend/src/types/index.ts); update any test fixtures/usages in
game-logic.test.ts to match the canonical shape (make attempts a number, make
date the required string type per the shared definition) so all places use the
same GameResult definition.

Hugo0 added 2 commits January 25, 2026 20:13
- Install @khmyznikov/pwa-install (28KB brotli) for better PWA install UX
- iOS: Native look & feel with "Add to Home Screen" instructions + screenshots
- Android: Intercepts beforeinstallprompt for native install dialog
- macOS/Chrome: Proper dock/install instructions
- 20+ built-in language translations

Changes:
- Add <pwa-install> component to game.html template
- Import component in pwa.ts (auto-registers custom element)
- Remove old custom iOS modal (pwa-install handles it better)
- Expand debug tools: debug.pwa.forceDialog(), component(), hideDialog()
- Update PWAStatus type with component state info

Bundle size: ~75KB → ~105KB gzipped (28KB for pwa-install component)

Testing:
  Open browser console and run:
  - debug.help()           - Show all commands
  - debug.pwa.status()     - Check PWA state
  - debug.pwa.forceDialog() - Test install dialog UI
CLAUDE.md:
- Convert bare URL to Markdown link (MD034)
- Add language hint to fenced code block (MD040)
- Clarify pytest xfails note

game-logic.test.ts:
- Remove exports from test file (test isolation)

Accessibility (WCAG):
- Add aria-labels to icon-only buttons in offline-game.html
- Remove maximum-scale=1, user-scalable=no from viewport meta
  (allows users to zoom to 200% per WCAG SC 1.4.4)

Error handling:
- Guard localStorage access in dark mode bootstrap (Safari Private Browsing)
- Add try-catch for JSON.parse in offline.html language list

Service Worker:
- Wrap cache clear in event.waitUntil to prevent premature termination
Copy link

@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

🤖 Fix all issues with AI agents
In `@frontend/src/game.ts`:
- Around line 400-423: The Enter-key branch only matches 'Enter' and '⇨', so
on-screen keys emitting '⟹' or 'ENTER' won't trigger submission; update the
conditional inside the handler (the if that currently checks key === 'Enter' ||
key === '⇨') to accept the additional variants '⟹' and 'ENTER' (or normalize key
to lower/upper-case and compare against a set like {'enter','⇨','⟹'}), ensuring
the existing logic that reads this.tiles[this.active_row], calls
checkWord(word), updateColors(), gameWon(), gameLost(), and showNotification()
runs for those keys as well.

In `@frontend/src/pwa.ts`:
- Around line 8-86: The module reads and writes localStorage directly which can
throw in restricted contexts; wrap the top-level initialization of dismissed
(currently using localStorage.getItem) in a try/catch and fall back to false if
access fails, and likewise wrap localStorage.setItem and localStorage.removeItem
inside try/catch in the dismiss and resetDismissed functions so exceptions are
swallowed and the UI still works; update references to dismissed, dismiss(), and
resetDismissed() accordingly to use the safe access.

In `@frontend/src/types/index.ts`:
- Around line 92-96: The test-level GameResult definition mismatches the
exported GameResult (exported has date required); in the test
(game-logic.test.ts) remove the local GameResult interface and import the
exported GameResult type from the shared types module (types/index.ts) and
update usages accordingly, or alternatively change the test’s local interface to
match the exported shape (make date: Date | string required) so both definitions
are consistent; reference the exported symbol GameResult and the test file’s
GameResult usage when making the change.

In `@tests/test_language_config.py`:
- Around line 41-63: These class-level mutable lists (REQUIRED_FIELDS,
REQUIRED_META_FIELDS, REQUIRED_TEXT_FIELDS, REQUIRED_HELP_FIELDS) should be
annotated as ClassVar[list[str]] to satisfy RUF012; import ClassVar from typing
and change each declaration to use an explicit ClassVar[list[str]] annotation
(e.g., REQUIRED_FIELDS: ClassVar[list[str]] = [...]) so the mutable defaults are
recognized as intentional class variables.

In `@webapp/templates/index.html`:
- Around line 3-9: Wrap the dark-mode bootstrap logic so calls to
localStorage.getItem are protected by a try/catch: attempt to read
localStorage.getItem('darkMode') inside a try block, fall back to using
window.matchMedia('(prefers-color-scheme: dark)').matches if reading
localStorage throws or returns null/undefined, and then call
document.documentElement.classList.add('dark') only when the resolved boolean is
true; update the inline script containing localStorage.getItem,
window.matchMedia and document.documentElement.classList.add accordingly.
♻️ Duplicate comments (14)
CLAUDE.md (3)

9-9: Convert bare URL to Markdown link.

This was previously flagged. Convert to: [https://wordle.global](https://wordle.global)


13-39: Add language hint to fenced code block.

This was previously flagged. Add ```text to the opening fence.


95-95: Clarify the pytest failures note.

This was previously flagged. The current wording "145 pytest failures are expected" implies CI should fail. Consider rephrasing to indicate these are known xfails or data quality issues that don't block CI.

README.md (1)

178-178: Capitalize “GitHub.”

Minor text quality fix.

✅ Proposed fix
-- All users, github issue raisers, and PR creators! Thanks so much!
+- All users, GitHub issue raisers, and PR creators! Thanks so much!
frontend/src/__tests__/game-logic.test.ts (1)

10-129: Move exported logic/types out of the test file and import shared definitions.

This triggers Biome’s noExportsInTest and duplicates logic/types that should stay canonical (e.g., frontend/src/types/index.ts). Recommend extracting to a shared module and importing in both game.ts and tests; also align fixtures with the canonical GameResult shape (required date).

♻️ Suggested direction (test file)
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect } from 'vitest';
+import { calculateColors, calculateStats } from '../utils/game-logic';
+import type { GameResult, GameStats } from '../types';

-// local exported implementations and interfaces ...
#!/bin/bash
# Locate duplicated implementations/types to consolidate
rg -n "calculateColors|calculateStats" frontend/src -g "*.ts"
rg -n "interface GameResult|interface GameStats" frontend/src -g "*.ts"
tests/test_daily_word.py (2)

14-39: Type hints should allow None inputs/outputs.

date=None is accepted and get_daily_word can return None on empty lists; update annotations accordingly.

✅ Proposed fix
-def get_todays_idx(date: datetime.date = None) -> int:
+def get_todays_idx(date: datetime.date | None = None) -> int:
@@
-def get_daily_word(word_list: list[str], date: datetime.date = None) -> str:
+def get_daily_word(word_list: list[str], date: datetime.date | None = None) -> str | None:

139-159: Deduplicate word lists before enforcing “no repeats” to fix CI failure.

The failing test indicates duplicates (e.g., pau). Either fix the data or dedupe in the test before sampling; otherwise the no‑repeat assertion is invalid.

🔧 Minimal test-side fix
-        words = load_word_list(lang)
+        words = list(dict.fromkeys(load_word_list(lang)))
         if not words:
             pytest.skip(f"{lang}: No word list")
@@
-        num_days = min(len(words), 365)  # Check up to a year or list length
+        num_days = min(len(words), 365)  # Check up to a year or list length
#!/bin/bash
# Find duplicates in word lists to decide whether to fix data or test
python - <<'PY'
from pathlib import Path
root = Path("webapp/data/languages")
for f in root.rglob("*_5words.txt"):
    words = [w.strip() for w in f.read_text(encoding="utf-8").splitlines() if w.strip()]
    if len(words) != len(set(words)):
        print(f"{f}: {len(words) - len(set(words))} duplicates")
PY
webapp/app.py (1)

39-55: Fix manifest entry key to match Vite output.

manifest["src/main.ts"] will KeyError if the manifest entry is main (per Vite config). This breaks every request that renders templates.

🐛 Required fix
-    entry = manifest["src/main.ts"]
+    entry = manifest["main"]
@@
-    collect_css("src/main.ts")
+    collect_css("main")
#!/bin/bash
# Inspect manifest keys to confirm the correct entry name
python - <<'PY'
import json
from pathlib import Path
path = Path("webapp/static/dist/.vite/manifest.json")
data = json.loads(path.read_text(encoding="utf-8"))
print("manifest keys:", list(data.keys()))
PY
frontend/src/index-app.ts (2)

20-24: GameResult type inconsistency with shared types.

This local GameResult has attempts: number and date: string, while types/index.ts defines attempts: string | number and date: Date | string. Import the shared type for consistency.


99-110: Wrap localStorage access in try-catch.

In private browsing or when storage quota is exceeded, localStorage.getItem/setItem can throw. Unlike game.ts, this file lacks exception handling.

webapp/static/sw.js (1)

94-111: Wrap async cache operations in event.waitUntil.

The CLEAR_CACHE handler performs async cache deletions and client notifications without event.waitUntil(). The service worker can terminate before these operations complete.

webapp/templates/game.html (2)

3-9: Guard dark-mode bootstrap against localStorage errors.

In Safari Private Browsing and other privacy modes, localStorage.getItem() can throw. Wrap in try-catch with a fallback to prefers-color-scheme.


13-13: Remove maximum-scale=1 and user-scalable=no from viewport meta.

These attributes violate WCAG SC 1.4.4 (Resize Text) by preventing users from zooming to 200%.

frontend/src/game.ts (1)

730-755: Avoid innerHTML with partial escaping in the copy fallback modal.

text.replace(/</g, '&lt;') is insufficient and still allows XSS vectors via other characters. Build the modal with DOM APIs and set textarea.value instead.

🛠️ Suggested fix
             const modal = document.createElement('div');
             modal.style.cssText =
                 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:9999;';
-            modal.innerHTML = `
-                <div style="background:white;border-radius:12px;padding:20px;margin:20px;max-width:320px;text-align:center;">
-                    <p style="font-weight:600;margin-bottom:12px;">Copy your results:</p>
-                    <textarea readonly style="width:100%;height:120px;padding:8px;border:1px solid `#ccc`;border-radius:6px;font-family:monospace;font-size:12px;resize:none;">${text.replace(/</g, '&lt;')}</textarea>
-                    <p style="font-size:12px;color:`#666`;margin:12px 0;">Select all and copy (Ctrl+C / Cmd+C)</p>
-                    <button style="background:`#6aaa63`;color:white;border:none;padding:10px 24px;border-radius:6px;font-weight:600;cursor:pointer;">Done</button>
-                </div>
-            `;
+            const content = document.createElement('div');
+            content.style.cssText =
+                'background:white;border-radius:12px;padding:20px;margin:20px;max-width:320px;text-align:center;';
+
+            const title = document.createElement('p');
+            title.style.cssText = 'font-weight:600;margin-bottom:12px;';
+            title.textContent = 'Copy your results:';
+
+            const textarea = document.createElement('textarea');
+            textarea.readOnly = true;
+            textarea.style.cssText =
+                'width:100%;height:120px;padding:8px;border:1px solid `#ccc`;border-radius:6px;font-family:monospace;font-size:12px;resize:none;';
+            textarea.value = text;
+
+            const hint = document.createElement('p');
+            hint.style.cssText = 'font-size:12px;color:`#666`;margin:12px 0;';
+            hint.textContent = 'Select all and copy (Ctrl+C / Cmd+C)';
+
+            const button = document.createElement('button');
+            button.style.cssText =
+                'background:`#6aaa63`;color:white;border:none;padding:10px 24px;border-radius:6px;font-weight:600;cursor:pointer;';
+            button.textContent = 'Done';
+
+            content.append(title, textarea, hint, button);
+            modal.appendChild(content);
             document.body.appendChild(modal);
 
-            const textarea = modal.querySelector('textarea');
-            if (textarea) {
-                textarea.focus();
-                textarea.select();
-            }
+            textarea.focus();
+            textarea.select();
OWASP guidance on avoiding XSS when using innerHTML vs textContent/DOM APIs
🧹 Nitpick comments (5)
tests/conftest.py (1)

62-77: Improve JSON decode error diagnostics for language configs/keyboards.

Current json.load failures don't identify which file is malformed. These functions are used in the app startup (webapp/app.py) and tests, and a malformed JSON file exists (webapp/data/languages/bg/bg_keyboard.json with trailing comma). Consider wrapping decode errors with the filename so failing data can be fixed quickly.

🔧 Example helper
+def _load_json(path: Path) -> dict | list:
+    try:
+        with open(path, "r", encoding="utf-8") as f:
+            return json.load(f)
+    except json.JSONDecodeError as e:
+        raise AssertionError(f"Invalid JSON in {path}: {e}") from e
@@
-    with open(config_file, "r", encoding="utf-8") as f:
-        return json.load(f)
+    return _load_json(config_file)
@@
-    with open(keyboard_file, "r", encoding="utf-8") as f:
-        return json.load(f)
+    return _load_json(keyboard_file)
frontend/src/index-app.ts (2)

112-114: Event listener added in mounted() is never removed.

The keydown listener should be cleaned up in beforeUnmount to prevent memory leaks if the component is destroyed and recreated.

♻️ Suggested fix
+        beforeUnmount() {
+            window.removeEventListener('keydown', this.keyDown);
+        },
+
         mounted() {
-            window.addEventListener('keydown', (e) => this.keyDown(e));
+            window.addEventListener('keydown', this.keyDown);
         },

136-146: localStorage.setItem in toggleDarkMode also lacks try-catch.

For consistency with the prior feedback on created(), wrap these localStorage calls as well.

♻️ Suggested fix
             toggleDarkMode(): void {
                 this.$nextTick(() => {
-                    if (this.darkMode) {
-                        document.documentElement.classList.add('dark');
-                        localStorage.setItem('darkMode', 'true');
-                    } else {
-                        document.documentElement.classList.remove('dark');
-                        localStorage.setItem('darkMode', 'false');
+                    try {
+                        if (this.darkMode) {
+                            document.documentElement.classList.add('dark');
+                            localStorage.setItem('darkMode', 'true');
+                        } else {
+                            document.documentElement.classList.remove('dark');
+                            localStorage.setItem('darkMode', 'false');
+                        }
+                    } catch {
+                        // Storage unavailable, apply class change only
+                        if (this.darkMode) {
+                            document.documentElement.classList.add('dark');
+                        } else {
+                            document.documentElement.classList.remove('dark');
+                        }
                     }
                 });
             },
webapp/static/sw.js (1)

74-91: Static asset caching silently ignores put failures.

The empty .catch(() => {}) on line 83 silently swallows cache write errors. Consider logging these failures for debugging, especially during development.

♻️ Suggested improvement
                     const responseClone = response.clone();
                     caches.open(CACHE_NAME)
                         .then((cache) => cache.put(event.request, responseClone))
-                        .catch(() => {}); // Ignore cache write failures
+                        .catch((err) => {
+                            // Log cache write failures for debugging (quota exceeded, etc.)
+                            console.debug('SW: Cache write failed', event.request.url, err);
+                        });
frontend/src/types/index.ts (1)

41-52: right_to_left uses string literals instead of boolean.

Using 'true' | 'false' string literals works but is unconventional. If this matches the JSON structure from language_config.json, consider adding a comment explaining this design choice for clarity.

Comment on lines +92 to +96
export interface GameResult {
won: boolean;
attempts: string | number;
date: Date | string;
}
Copy link

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
# Show the GameResult definition in the test file
rg -n "interface GameResult" -C3 frontend/src/__tests__/game-logic.test.ts
# Show usages of .date in that file
rg -n "\.date" -C2 frontend/src/__tests__/game-logic.test.ts

Repository: Hugo0/wordle

Length of output: 243


Unify GameResult definitions across codebase
In frontend/src/types/index.ts GameResult.date is required (Date | string), but in frontend/src/__tests__/game-logic.test.ts it’s optional (Date). Import and use the exported GameResult type in tests or update the test interface to match.

🤖 Prompt for AI Agents
In `@frontend/src/types/index.ts` around lines 92 - 96, The test-level GameResult
definition mismatches the exported GameResult (exported has date required); in
the test (game-logic.test.ts) remove the local GameResult interface and import
the exported GameResult type from the shared types module (types/index.ts) and
update usages accordingly, or alternatively change the test’s local interface to
match the exported shape (make date: Date | string required) so both definitions
are consistent; reference the exported symbol GameResult and the test file’s
GameResult usage when making the change.

Comment on lines +41 to +63
REQUIRED_FIELDS = [
"language_code",
"name",
"name_native",
"right_to_left",
]

REQUIRED_META_FIELDS = [
"locale",
"title",
"description",
]

REQUIRED_TEXT_FIELDS = [
"subheader",
"next_word",
"share",
]

REQUIRED_HELP_FIELDS = [
"title",
"close",
]
Copy link

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:

cat -n tests/test_language_config.py | head -80

Repository: Hugo0/wordle

Length of output: 2893


🏁 Script executed:

# Check for ruff configuration
find . -type f \( -name "pyproject.toml" -o -name "ruff.toml" -o -name ".flake8" -o -name "setup.cfg" \) | head -10

Repository: Hugo0/wordle

Length of output: 84


🏁 Script executed:

# Check if ruff is in project dependencies
find . -type f \( -name "requirements*.txt" -o -name "pyproject.toml" -o -name "setup.py" \) | head -10

Repository: Hugo0/wordle

Length of output: 91


🏁 Script executed:

cat ruff.toml

Repository: Hugo0/wordle

Length of output: 962


🏁 Script executed:

cat pyproject.toml

Repository: Hugo0/wordle

Length of output: 242


🌐 Web query:

RUF012 Ruff rule mutable class attributes ClassVar

💡 Result:

RUF012 (Ruff): mutable-class-default flags mutable default values in class attributes (e.g., [], {}, set()) because they’re shared across all instances unless that sharing is intentional. When the sharing is intentional, Ruff expects you to annotate the attribute with typing.ClassVar. [1], [2]

What triggers RUF012

class A:
    items: list[int] = []      # RUF012
    cache: dict[str, int] = {} # RUF012

Ruff warns because A.items / A.cache are single objects reused by every instance. [2]

Common fixes

1) Make them instance attributes (usually best):

class A:
    def __init__(self) -> None:
        self.items: list[int] = []
        self.cache: dict[str, int] = {}

[2]

2) If you really want shared state, mark it ClassVar:

from typing import ClassVar

class A:
    items: ClassVar[list[int]] = []
    cache: ClassVar[dict[str, int]] = {}

[2]

3) Use immutable defaults (if possible):

from collections.abc import Sequence, Mapping
from types import MappingProxyType

class A:
    items: Sequence[int] = ()
    cache: Mapping[str, int] = MappingProxyType({})

[2]

Note (dataclasses): unannotated class attributes may still be enforced by RUF012 (even where other dataclass-default rules might not apply). [3]

Sources: Ruff rules index (shows RUF012 purpose) [1]; dedicated RUF012 documentation with rationale and fixes [2]; Ruff commit note about dataclasses/unannotated class vars and RUF012 [3].


Add ClassVar annotations to class-level lists to comply with RUF012.

These mutable class attributes violate Ruff's RUF012 rule (mutable-class-default). Since these lists are intentionally shared across all test methods, annotate them with ClassVar[list[str]].

Proposed fix
+from typing import ClassVar
@@
-    REQUIRED_FIELDS = [
+    REQUIRED_FIELDS: ClassVar[list[str]] = [
@@
-    REQUIRED_META_FIELDS = [
+    REQUIRED_META_FIELDS: ClassVar[list[str]] = [
@@
-    REQUIRED_TEXT_FIELDS = [
+    REQUIRED_TEXT_FIELDS: ClassVar[list[str]] = [
@@
-    REQUIRED_HELP_FIELDS = [
+    REQUIRED_HELP_FIELDS: ClassVar[list[str]] = [
🧰 Tools
🪛 Ruff (0.14.13)

41-46: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


48-52: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


54-58: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


60-63: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

🤖 Prompt for AI Agents
In `@tests/test_language_config.py` around lines 41 - 63, These class-level
mutable lists (REQUIRED_FIELDS, REQUIRED_META_FIELDS, REQUIRED_TEXT_FIELDS,
REQUIRED_HELP_FIELDS) should be annotated as ClassVar[list[str]] to satisfy
RUF012; import ClassVar from typing and change each declaration to use an
explicit ClassVar[list[str]] annotation (e.g., REQUIRED_FIELDS:
ClassVar[list[str]] = [...]) so the mutable defaults are recognized as
intentional class variables.

Hooked Model - Investment features:
- Add per-language stats tracking with streak, win rate, games played
- Add global stats modal on homepage with expandable language breakdown
- Show language cards with green highlight for played languages
- Display streak badges (🔥) and stats directly on language cards
- Sort languages by recently played, then by GA popularity data

UX Improvements:
- Add settings modal to homepage (dark mode, haptics, sound toggles)
- Add haptic feedback module for keyboard interactions
- Add sound effects module (keystroke, flip, win/lose sounds)
- Consolidate settings modal styling between homepage and game page
- Make stats modal compact and scrollable for mobile (max-h-[85vh])
- Reduce guess distribution graph size for better mobile fit
- Add tab-style stats toggle with neutral colors (not green)
- Fix OG image meta tags for better social sharing

Word list improvements:
- Remove 728 proper nouns from Bulgarian word list
- Clean up Arabic, Turkish, Hungarian word lists
- Shuffle Finnish word list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@Hugo0
Copy link
Owner Author

Hugo0 commented Jan 25, 2026

@coderabbitai full review

Please conduct a thorough review of this PR, paying special attention to:

  1. TypeScript code quality - Check game.ts, index-app.ts, haptics.ts, sounds.ts for type safety and best practices
  2. Vue.js patterns - Review the Vue components for reactivity patterns and proper lifecycle handling
  3. Mobile UX - Verify the stats modal compact layout and scrollable containers work well
  4. Accessibility - Check the new settings toggles, stats tabs, and language cards for a11y compliance
  5. Performance - Review haptics/sounds modules for any performance concerns
  6. Data handling - Check localStorage operations for error handling in private browsing mode

Key changes in this commit:

  • Investment features for user retention (stats tracking, streak badges, language cards)
  • Haptic feedback and sound effects modules
  • Settings modal on homepage
  • Mobile-optimized stats modal
  • Word list improvements for Bulgarian, Hungarian, Turkish, Arabic

The share button success state was using || which is JavaScript syntax,
but this is a Jinja template that needs 'or' for the fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Jan 25, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all TypeScript files in the frontend source directory
echo "=== TypeScript files in frontend/src ==="
fd -e ts -e tsx . frontend/src --type f

echo -e "\n=== Checking for haptics.ts and sounds.ts specifically ==="
fd 'haptics|sounds' frontend/src --type f

echo -e "\n=== All files in frontend/src ==="
tree -L 2 frontend/src

Length of output: 623


🏁 Script executed:

#!/bin/bash
# Read the haptics and sounds modules
echo "=== haptics.ts ==="
cat frontend/src/haptics.ts

echo -e "\n\n=== sounds.ts ==="
cat frontend/src/sounds.ts

echo -e "\n\n=== Searching for localStorage usage ==="
rg -n "localStorage" frontend/src --type ts -C2

echo -e "\n\n=== Checking templates for stats modal and settings ==="
rg -n "stats.*modal|settings.*modal" webapp/templates --type html -C2

Length of output: 20422


🏁 Script executed:

#!/bin/bash
# Check for accessibility attributes in templates
echo "=== ARIA attributes in templates ==="
rg -n "aria-|role=|tabindex" webapp/templates --type html -C1

echo -e "\n\n=== Stats modal structure in game.html ==="
sed -n '410,550p' webapp/templates/game.html

echo -e "\n\n=== Settings toggles in templates ==="
rg -n "hapticsEnabled|soundEnabled" webapp/templates --type html -C3

echo -e "\n\n=== Keyboard event handling in TypeScript ==="
rg -n "addEventListener.*key|@keydown|@keyup|keyDown" frontend/src --type ts -C2

Length of output: 20690


@Hugo0, I've completed a thorough review of PR #109. Here's my analysis across your 6 focus areas:

1. TypeScript Code Quality ✅

haptics.ts - Well implemented with a few opportunities:

  • ✅ Excellent cross-platform approach using checkbox trick for iOS
  • ✅ Clean interface definition with function overloads
  • ⚠️ Consider making supportsHaptics a lazy-evaluated getter to avoid premature window access during SSR scenarios
  • ⚠️ The hapticsEnabled state could use export const with getters/setters to prevent direct mutation

sounds.ts - Solid implementation:

  • ✅ Proper lazy initialization of AudioContext
  • ✅ Good autoplay policy handling with context resumption
  • ⚠️ Minor issue: No cleanup method for AudioContext. Consider adding a dispose() function:
export function dispose(): void {
    if (audioContext) {
        audioContext.close();
        audioContext = null;
    }
}

game.ts & index-app.ts:

  • ✅ Strong type safety throughout
  • ✅ Proper null coalescing and optional chaining
  • ⚠️ Some methods exceed 50 lines (e.g., checkWord, updateColors). Consider extracting helper functions for better testability

2. Vue.js Patterns ✅

Reactivity:

  • ✅ Proper use of Vue 3's reactivity system
  • ✅ Good separation between data, computed, and methods
  • ✅ Watch handlers properly manage side effects (dark mode, haptics, sounds)

Lifecycle:

  • ✅ Appropriate use of beforeCreate for HTTPS redirect
  • created for data loading
  • mounted for DOM interactions and event listeners
  • ✅ Proper cleanup considerations

3. Mobile UX ✅

Stats Modal - Well optimized:

  • max-h-[85vh] with overflow-y-auto ensures scrollability on small screens
  • ✅ Compact text sizes (text-xs, text-[10px]) save space
  • grid-cols-4 layout fits stats efficiently
  • ✅ Per-language breakdown has max-h-24 scrollable container
  • ⚠️ Touch target concern: The close button (×) should be larger. Recommend:
<button class="absolute top-0 right-0 p-3 ml-auto z-50 min-w-[44px] min-h-[44px]" aria-label="Close">
    <span class="text-2xl text-neutral-400">×</span>
</button>

4. Accessibility ⚠️

Good implementations:

  • aria-label on header buttons
  • role="switch" and aria-checked on toggles in index.html
  • ✅ Settings toggles in game.html use sr-only checkboxes (proper semantic HTML)

Issues to address:

  1. Stats tabs missing ARIA attributes (Lines 486-500 in game.html):
<!-- Tab container needs tablist role -->
<div class="flex" role="tablist" aria-label="Statistics view">
    <button v-on:click="statsTab = 'language'"
        class="flex-1 py-2 text-xs font-medium transition-colors border-b-2"
        role="tab"
        :aria-selected="statsTab === 'language'"
        :tabindex="statsTab === 'language' ? 0 : -1"
        :class="statsTab === 'language' ? '...' : '...'">
        {{ language.config.name }}
    </button>
    <!-- Same for global tab -->
</div>

<!-- Tab panels need appropriate roles -->
<div v-show="statsTab === 'language'" 
     role="tabpanel" 
     :aria-hidden="statsTab !== 'language'"
     class="px-4 py-3">
    <!-- content -->
</div>
  1. Inconsistent toggle implementation: game.html uses native checkboxes (good), but index.html uses custom buttons. Recommend standardizing on the game.html pattern with sr-only checkboxes.

  2. Modal close buttons need aria-label:

<button class="absolute top-0 right-0 p-3 ml-auto z-50" aria-label="Close statistics">
  1. Guess distribution bars: Consider adding aria-label to provide context:
<div class="flex items-center gap-1.5" :aria-label="`${n} guesses: ${stats.guessDistribution[n]} times`">

5. Performance ✅

haptics.ts:

  • ✅ Minimal DOM operations with immediate cleanup
  • ✅ Lightweight vibration patterns
  • ⚠️ Minor: supportsHaptics is evaluated once at module load—consider memoizing if it changes

sounds.ts:

  • ✅ Lazy AudioContext initialization respects autoplay policies
  • ✅ Short-lived oscillators prevent memory leaks
  • ⚠️ Potential issue: Rapid successive calls (e.g., spam clicking) could create many overlapping oscillators. Consider throttling:
let lastPlayTime = 0;
const MIN_INTERVAL = 100; // ms

function playTone(frequency: number, duration: number, type: OscillatorType = 'sine'): void {
    const now = Date.now();
    if (now - lastPlayTime < MIN_INTERVAL) return;
    lastPlayTime = now;
    // ... rest of implementation
}

6. Data Handling ✅ Excellent!

localStorage error handling - Outstanding implementation:

  • ✅ All localStorage operations wrapped in try/catch
  • ✅ Proper comments explaining private browsing mode issues
  • ✅ Graceful fallbacks (empty objects, default values)
  • ✅ Handles quota exceeded scenarios
  • ✅ Consistent pattern across all files

Example from game.ts (lines 632-643):

try {
    const stored = localStorage.getItem(pageName);
    if (!stored) return;
    const data = JSON.parse(stored) as SavedGameState;
    // ... use data
} catch {
    // localStorage unavailable or corrupted data
}

This is textbook error handling for localStorage. No changes needed here! 🎉


Summary

Strengths:

  • Excellent localStorage error handling throughout
  • Well-structured TypeScript with good type safety
  • Proper Vue lifecycle and reactivity patterns
  • Mobile-optimized layouts with appropriate scrolling
  • Cross-platform haptics and sounds implementations

Priority improvements:

  1. High: Add ARIA attributes to stats tabs (accessibility)
  2. Medium: Increase close button touch target size (UX)
  3. Medium: Add throttling to sound effects (performance)
  4. Low: Add AudioContext cleanup method (memory management)
  5. Low: Standardize toggle implementations (consistency)

The investment features (stats tracking, streak badges, haptics, sounds) are well-implemented and will definitely improve user retention. The code quality is production-ready with the minor improvements noted above.

✅ Actions performed

Full review triggered.

@Hugo0 Hugo0 merged commit 1c55411 into main Jan 25, 2026
2 of 4 checks passed
Copy link

@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: 13

Caution

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

⚠️ Outside diff range comments (2)
webapp/templates/game.html (1)

132-144: Add aria‑labels to icon‑only buttons (header + modal closes).
Without labels, screen readers can’t identify these controls.

♿ Suggested fix (apply to all icon‑only buttons)
-                        <button class="m-0 sm:my-1" v-on:click="show_stats_modal = !show_stats_modal">
+                        <button class="m-0 sm:my-1" aria-label="Statistics"
+                            v-on:click="show_stats_modal = !show_stats_modal">
@@
-                        <button class="absolute top-0 right-0 p-1 ml-auto z-50">
-                            <span v-on:click="showHelpModal = !showHelpModal"
+                        <button class="absolute top-0 right-0 p-2 ml-auto z-50" aria-label="Close dialog"
+                            v-on:click="showHelpModal = !showHelpModal">
+                            <span
                                 class="leading-[0.25] h-5 w-5 text-3xl text-neutral-400 block outline-none focus:outline-none">×</span>
                         </button>

Apply the same pattern to the Settings modal close button and Stats modal close button.

Also applies to: 200-203, 288-291, 421-423

webapp/app.py (1)

1-519: Fix formatting to pass CI pipeline.

The pipeline failure indicates black --check failed for this file. Run black webapp/app.py to auto-format.

#!/bin/bash
# Check what black wants to change
black --diff webapp/app.py 2>/dev/null | head -50
🤖 Fix all issues with AI agents
In `@frontend/src/game.ts`:
- Around line 631-644: The loadFromLocalStorage method currently calls
Object.assign(this, data) which permits prototype-pollution from untrusted
localStorage; instead, replace that call in loadFromLocalStorage with a safe,
whitelisted property assignment: parse JSON into data, verify it's an object
(not null), reject if it has __proto__ or constructor keys, then copy only known
game state fields (e.g., todays_word, guesses, gameOver, score — whatever
explicit fields your SavedGameState defines) onto the Vue instance (this) rather
than assigning all keys; alternatively implement a small helper that iterates
allowedKeys and assigns this[key] = data[key] when present to avoid copying
unknown properties.
- Around line 298-319: The component currently adds a window 'keydown' listener
in created() and starts a repeating timer in mounted() but never cleans them up,
causing leaks; to fix, store the reference to the bound handler (e.g., save the
arrow function or create a named wrapper that calls keyDown) and the interval id
(from setInterval) on the component instance, then implement beforeUnmount() to
call window.removeEventListener('keydown', thatHandler) and
clearInterval(thatIntervalId); update created(), mounted(), and add
beforeUnmount() (referencing keyDown and
getTimeUntilNextDay/time_until_next_day) so the listener and timer are reliably
removed when the component is destroyed.

In `@frontend/src/index-app.ts`:
- Around line 150-152: The openLink method currently calls window.open(url)
which is vulnerable to reverse-tabnabbing; update the openLink function (in
frontend/src/index-app.ts) to open external links with noopener/noreferrer and a
new browsing context — e.g., use window.open(url, '_blank',
'noopener,noreferrer') and defensively set newWindow.opener = null if a Window
is returned — so that the opened page cannot access or manipulate the opener.

In `@frontend/src/pwa.ts`:
- Around line 38-116: The getComponent() return type is too broad; update its
signature to return the specific custom element type so callers (install,
status, etc.) see properties like showDialog and isInstallAvailable. Change
getComponent to return HTMLElementTagNameMap['pwa-install'] | null (and cast the
querySelector result accordingly) so existing definitions in your types are used
and lines that call component.showDialog or read component?.isInstallAvailable
compile cleanly.

In `@tests/test_word_lists.py`:
- Around line 39-72: Tests fail because some language data violate new
validations in test_supplement_words_are_5_letters, test_no_duplicate_words, and
test_words_are_lowercase (which call load_supplement_words and load_word_list).
Fix by updating the offending language data files (the word lists used by
load_word_list/load_supplement_words): ensure all supplement entries for ckb are
exactly 5 characters, remove duplicate entries in pau, convert sv entries to
lowercase, strip/replace invalid characters in mi/az/pt, address
keyboard-coverage issues for mk/he per the keyboard-coverage tests, and trim
trailing/leading whitespace in pt; alternatively, if the deviations are
intentional, add explicit allowlist/xfail entries in the test harness for those
specific languages (referencing the test names
test_supplement_words_are_5_letters, test_no_duplicate_words,
test_words_are_lowercase) so CI passes until data is corrected.

In `@webapp/app.py`:
- Around line 39-59: The manifest key used in get_vite_assets is wrong: change
all references from "src/main.ts" to "main" so you fetch the correct entry and
traverse imports; update the lookup manifest["src/main.ts"] -> manifest["main"]
and call collect_css("main") instead of collect_css("src/main.ts") in the
get_vite_assets function (affecting the entry variable and the initial
collect_css invocation) so the JS/CSS URLs are built from the correct manifest
entry.

In `@webapp/templates/game.html`:
- Around line 294-327: The checkbox inputs for Dark Mode, Haptic Feedback and
Sound Effects are not accessible to screen readers; update each input (the ones
bound to darkMode/toggleDarkMode, hapticsEnabled/toggleHaptics and
soundEnabled/toggleSound) to include proper accessible labeling—either add an id
on the input and a corresponding <label for="..."> wrapper around the visible
text or add an aria-label/aria-labelledby that references the visible <p>
text—so screen readers can associate each toggle with its descriptive label;
apply this change consistently to all three toggles.
- Around line 435-447: The guess distribution bars are purely visual so add
accessible text and ARIA attributes: for the inner bar element produced in the
v-for (the div using isCurrentGuess and getDistributionBarWidth), expose the
numeric value from stats.guessDistribution[n] via an aria-label like "Guess n: X
guesses" and/or add a visually-hidden span (screen-reader-only) containing the
same text; also ensure the bar has role="img" or role="progressbar" with
appropriate aria-valuenow/aria-valuemax if you prefer a progress semantics.
Update the template where getDistributionBarWidth(n), isCurrentGuess(n), and
stats.guessDistribution are used to include these accessibility attributes/text.
- Around line 384-401: The two external anchor elements that open in new tabs
(the <a> tags with target="_blank" linking to
"https://github.com/Hugo0/wordle/issues" and "https://github.com/Hugo0/wordle")
should include rel="noopener noreferrer" to prevent reverse-tabnabbing; update
those <a> tags (the "Report an Issue" and "View Source Code" anchors) to add
rel="noopener noreferrer" alongside their existing attributes.
- Around line 471-549: Wrap the tab buttons container with role="tablist" and
update the two tab buttons (the ones that set statsTab = 'language' and statsTab
= 'global') to include role="tab", unique ids (e.g., "stats-tab-language" and
"stats-tab-global"), aria-selected reflecting statsTab, aria-controls pointing
to matching panel ids, and tabindex so only the selected tab is tabbable; then
update the two panels (the divs with v-show="statsTab === 'language'" and
v-show="statsTab === 'global'") to include role="tabpanel", matching ids (e.g.,
"stats-panel-language"/"stats-panel-global"), aria-labelledby pointing back to
the tab ids, and a hidden attribute when not selected to match accessibility
expectations for tab/tabpanel semantics (ensure statsTab is used to derive all
aria-selected, aria-controls, aria-labelledby and hidden state).

In `@webapp/templates/index.html`:
- Around line 123-126: The search input bound to v-model="search_text" lacks an
accessible label for screen readers; add an explicit label associated with the
input (e.g., give the input an id like "language-search" and add a <label
for="language-search">Search language</label>—use a visually-hidden class such
as "sr-only" if you don't want it visible) or alternatively add a descriptive
aria-label/aria-labelledby attribute (aria-label="Search language" or
aria-labelledby referencing a hidden label element) so assistive tech can
announce the field.
- Around line 338-399: The switch buttons (the buttons toggling darkMode,
hapticsEnabled, soundEnabled) lack accessible names; add an accessible name to
each switch by either 1) adding an id to the corresponding label span (e.g., the
<span class="font-medium"> for "Dark Mode", "Haptic Feedback", "Sound Effects")
and setting the button's aria-labelledby to that id, or 2) adding a descriptive
aria-label on each button (e.g., aria-label="Toggle Dark Mode") and ensure you
keep :aria-checked binding and existing role="switch"; update the buttons
associated with darkMode/toggleDarkMode(), hapticsEnabled/toggleHaptics(), and
soundEnabled/toggleSound() accordingly.
- Around line 405-428: The three anchor elements rendered with target="_blank"
(the links whose text is "Report an Issue", "View Source Code", and the "/stats"
link) lack rel attributes and are vulnerable to reverse‑tabnabbing; add
rel="noopener noreferrer" to every <a> that uses target="_blank" in this
template so external/new‑tab links include both noopener and noreferrer for
security.
♻️ Duplicate comments (7)
webapp/static/offline.html (1)

144-166: Past review concern addressed: JSON.parse is now properly guarded.

The code now correctly wraps the second localStorage parse in a try/catch block (lines 152-157), addressing the previous review feedback.

Minor optimization opportunity: the parsed data could be returned from getCachedLanguages() to avoid parsing twice, but the current approach is clear and maintainable.

frontend/src/__tests__/game-logic.test.ts (2)

55-71: Type definitions diverge from canonical types.

The local GameResult interface has date?: Date (optional Date), while frontend/src/types/index.ts defines it as date: Date | string (required). This mismatch could cause type confusion if tests are later updated to import canonical types.


6-50: Local test implementations duplicate production logic.

These functions replicate logic from game.ts. While the exports issue from the past review appears resolved (no export keywords), consider extracting shared pure functions to a utility module that both production code and tests can import. This would ensure tests validate the actual implementation rather than a copy.

frontend/src/types/index.ts (1)

92-96: GameResult.date is required here but optional in tests.

This is the canonical definition. The test file's local GameResult with date?: Date differs from this required date: Date | string. Ensure test fixtures include the date field to match this interface.

frontend/src/index-app.ts (2)

22-26: Use the shared GameResult type to keep attempts/date unions consistent.
The local interface narrows attempts and date, which can conflict with the canonical frontend/src/types/index.ts definition.

♻️ Proposed fix
+import type { GameResult } from './types';
+
 // Types for homepage data
 interface Language {
     // ...
 }
-
-interface GameResult {
-    won: boolean;
-    attempts: number;
-    date: string;
-}

107-115: Guard localStorage + JSON.parse to avoid crashes in private/blocked storage contexts.
Uncaught storage or parse errors can abort app creation or dark-mode toggling.

🛠️ Proposed fix
         created() {
             // Load game results from localStorage
-            const stored = localStorage.getItem('game_results');
-            if (stored) {
-                this.game_results = JSON.parse(stored);
-            } else {
-                this.game_results = {};
-                localStorage.setItem('game_results', JSON.stringify(this.game_results));
-            }
+            try {
+                const stored = localStorage.getItem('game_results');
+                if (stored) {
+                    this.game_results = JSON.parse(stored);
+                } else {
+                    this.game_results = {};
+                    localStorage.setItem('game_results', JSON.stringify(this.game_results));
+                }
+            } catch {
+                this.game_results = {};
+            }

             // Cache languages for game page to access
             this.cacheLanguages();
         },
@@
             toggleDarkMode(): void {
                 this.$nextTick(() => {
                     if (this.darkMode) {
                         document.documentElement.classList.add('dark');
-                        localStorage.setItem('darkMode', 'true');
+                        try { localStorage.setItem('darkMode', 'true'); } catch {}
                     } else {
                         document.documentElement.classList.remove('dark');
-                        localStorage.setItem('darkMode', 'false');
+                        try { localStorage.setItem('darkMode', 'false'); } catch {}
                     }
                 });
             },

Also applies to: 171-179

frontend/src/game.ts (1)

878-904: Incomplete XSS sanitization persists in showCopyFallbackModal.

This was flagged in a previous review. The text.replace(/</g, '&lt;') only escapes < but not >, ", ', or &. While text comes from getShareText() which uses controlled data, using DOM APIs is safer.

🧹 Nitpick comments (16)
frontend/src/sounds.ts (3)

19-32: Consider adding a dispose function for AudioContext cleanup.

The lazy initialization is well-implemented, but there's no way to release the AudioContext resources. This could matter in scenarios like SPA hot-reloading or when a user disables audio permanently.

♻️ Suggested addition
 function getAudioContext(): AudioContext | null {
     if (!audioContext && typeof AudioContext !== 'undefined') {
         try {
             audioContext = new AudioContext();
         } catch {
             // AudioContext not supported
             return null;
         }
     }
     return audioContext;
 }
+
+/**
+ * Close and release the AudioContext resources
+ */
+export function dispose(): void {
+    if (audioContext) {
+        audioContext.close();
+        audioContext = null;
+    }
+}

41-44: ctx.resume() returns a Promise that isn't awaited.

While this typically works in practice because resume is fast, the first tone could theoretically be missed if the context takes time to resume. Consider making playTone async or handling this at a higher level.

♻️ Optional async approach
-function playTone(frequency: number, duration: number, type: OscillatorType = 'sine'): void {
+async function playTone(frequency: number, duration: number, type: OscillatorType = 'sine'): Promise<void> {
     const ctx = getAudioContext();
     if (!ctx) return;
 
     // Resume context if suspended (autoplay policy)
     if (ctx.state === 'suspended') {
-        ctx.resume();
+        await ctx.resume();
     }

64-71: Add throttling and cancellation to prevent overlapping sound sequences.

If sound.win() or sound.lose() is triggered rapidly (e.g., double-tap, edge-case bugs), multiple sequences will overlap creating cacophony. Additionally, there's no way to cancel pending tones if the user disables sound mid-sequence.

♻️ Suggested throttling approach
+/** Track pending timeouts for cancellation */
+let pendingTimeouts: number[] = [];
+
+/** Timestamp of last sequence start for throttling */
+let lastSequenceTime = 0;
+const THROTTLE_MS = 500;
+
+function cancelPendingSequence(): void {
+    pendingTimeouts.forEach(id => clearTimeout(id));
+    pendingTimeouts = [];
+}
+
 function playSequence(notes: Array<{ freq: number; duration: number; delay: number }>): void {
+    const now = Date.now();
+    if (now - lastSequenceTime < THROTTLE_MS) return;
+    lastSequenceTime = now;
+    
+    cancelPendingSequence();
+    
     notes.forEach(({ freq, duration, delay }) => {
-        setTimeout(() => playTone(freq, duration), delay * 1000);
+        const id = window.setTimeout(() => playTone(freq, duration), delay * 1000);
+        pendingTimeouts.push(id);
     });
 }
webapp/data/languages/he/he_keyboard.json (1)

1-21: LGTM!

The Hebrew keyboard configuration is well-structured with both alphabetical and QWERTY layouts. Final letter forms (ך, ם, ן, ף, ץ) are correctly included.

Minor style observation: this file uses 2-space indentation while ar_keyboard.json uses 4-space indentation. Consider standardizing JSON formatting across keyboard files for consistency.

webapp/static/offline.html (1)

68-82: Consider adding focus styles for keyboard navigation.

The button styles don't include :focus or :focus-visible states. Users navigating with keyboard won't see clear focus indicators beyond browser defaults.

Suggested addition for focus visibility
 button:active {
     transform: scale(0.98);
 }
+button:focus-visible {
+    outline: 2px solid `#4a7c59`;
+    outline-offset: 2px;
+}
+.dark button:focus-visible {
+    outline-color: `#6aaa63`;
+}
webapp/data/languages/bg/bg_keyboard.json (1)

1-40: Fix is correct, but consider migrating to consistent keyboard format.

The trailing comma removal fixes the JSON validity. However, the Bulgarian keyboard uses the legacy array format [[[...], [...], [...]]] while other keyboards in this codebase (Arabic, Hebrew, Turkish) have been migrated to a newer object structure with default and layouts keys. For consistency, consider migrating bg_keyboard.json to the same structure as the other keyboards.

webapp/static/sw.js (1)

21-34: Consider wrapping clients.claim() inside event.waitUntil.

While the current code works, clients.claim() returns a Promise. Calling it outside waitUntil means the browser may terminate the SW before it completes. For maximum reliability, chain it with the cache cleanup:

♻️ Suggested improvement
 self.addEventListener('activate', (event) => {
     event.waitUntil(
         caches.keys().then((cacheNames) => {
             return Promise.all(
                 cacheNames
                     .filter((name) => name !== CACHE_NAME)
                     .map((name) => caches.delete(name))
             );
-        })
+        }).then(() => self.clients.claim())
     );
-    // Take control of all pages immediately
-    self.clients.claim();
 });
frontend/src/types/index.ts (1)

41-52: Consider using boolean for right_to_left.

The right_to_left: 'true' | 'false' string literal type is unusual. If this matches the JSON schema from language config files, it's fine, but converting to boolean at the type level (with runtime parsing) would be more idiomatic TypeScript.

frontend/src/debug.ts (2)

7-7: getComponent duplicates logic from pwa.ts.

The pwa.ts module already has a getComponent function (used in status()). Consider importing and reusing it to maintain a single source of truth:

♻️ Suggested approach

If pwa.ts exports getComponent, import it here instead of redefining:

import pwa, { getComponent } from './pwa';

Otherwise, consider exporting it from pwa.ts for reuse.


123-124: Debug console log always runs in production.

This unconditionally logs "Debug tools available" on every page load, which may not be desirable in production builds. Consider gating this behind an environment check:

♻️ Suggested fix
-// Log availability on load (only in dev/debug scenarios)
-console.log('Debug tools available. Type debug.help() for commands.');
+// Log availability on load (only in dev/debug scenarios)
+if (import.meta.env?.DEV) {
+    console.log('Debug tools available. Type debug.help() for commands.');
+}
frontend/src/index-app.ts (1)

133-135: Move filtering out of beforeUpdate into a watcher/computed.
Mutating state in beforeUpdate is non‑idiomatic Vue and causes filtering on every update.

♻️ Suggested refactor (watcher)
-        beforeUpdate() {
-            this.filterWordles(this.search_text.toLowerCase());
-        },
+        watch: {
+            search_text(next: string) {
+                this.filterWordles(next.toLowerCase());
+            },
+        },
tests/test_word_lists.py (1)

135-140: Ruff B007: unused loop variable layout_name.
Rename it to _layout_name to satisfy lint.

♻️ Suggested fix
-            for layout_name, layout_meta in keyboard_data.get("layouts", {}).items():
+            for _layout_name, layout_meta in keyboard_data.get("layouts", {}).items():
@@
-            for layout_name, layout_meta in keyboard_data.get("layouts", {}).items():
+            for _layout_name, layout_meta in keyboard_data.get("layouts", {}).items():

Also applies to: 178-183

webapp/app.py (2)

152-161: Bare except clause should specify exception types.

The fallback to default config is reasonable, but catching all exceptions silently can hide unexpected errors (e.g., JSON decode errors, permission issues).

♻️ Suggested fix
-    except:
+    except (FileNotFoundError, json.JSONDecodeError):
         # english is fallback (not ideal but better than empty...)
         with open(f"{data_dir}default_language_config.json", "r") as f:
             language_config = json.load(f)
         return language_config

175-178: Catching bare Exception hides unexpected errors.

Per static analysis hint (BLE001), consider catching specific exceptions. If you intend to handle any failure gracefully, at least log the exception for debugging.

♻️ Suggested fix
     except FileNotFoundError:
         return {"default": None, "layouts": {}}
-    except Exception:
+    except (json.JSONDecodeError, TypeError, KeyError) as e:
+        # Log for debugging but fail gracefully
+        print(f"Warning: Failed to parse keyboard config for {lang}: {e}")
         return {"default": None, "layouts": {}}
frontend/src/game.ts (2)

746-796: calculateTotalStats is well-implemented but could be optimized.

The implementation correctly aggregates results across languages and calculates streaks chronologically. One minor optimization: you're iterating Object.keys(this.game_results) twice (once for collecting results, once for per-language stats). Consider combining these loops for large datasets.


100-282: Data initialization is thorough but verbose.

The 6x5 grid initialization for tiles, tile_classes, tiles_visual, and tile_classes_visual is repetitive. Consider extracting a helper function to generate these arrays.

♻️ Example helper to reduce verbosity
// Helper to create initial grid
const createGrid = <T>(rows: number, cols: number, value: T): T[][] =>
    Array.from({ length: rows }, () => Array(cols).fill(value));

const createTileClassGrid = () =>
    createGrid(6, 5, 'border-2 border-neutral-300');

// In data():
tiles: createGrid(6, 5, ''),
tile_classes: createTileClassGrid(),
tiles_visual: createGrid(6, 5, ''),
tile_classes_visual: createTileClassGrid(),

Comment on lines +298 to +319
created() {
window.addEventListener('keydown', (e) => this.keyDown(e));
this.loadGameResults();
this.loadLanguages();
this.loadHapticsPreference();
this.loadSoundPreference();
this.stats = this.calculateStats(this.config?.language_code);
this.total_stats = this.calculateTotalStats();
this.time_until_next_day = this.getTimeUntilNextDay();
},

mounted() {
setInterval(() => {
this.time_until_next_day = this.getTimeUntilNextDay();
}, 1000);
this.loadFromLocalStorage();
this.showTiles();

if (this.game_over) {
this.show_stats_modal = true;
}
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Memory leak: event listener and interval are never cleaned up.

The keydown listener added in created() and setInterval in mounted() are never removed. If the component is destroyed and recreated (e.g., hot module replacement during development), these will accumulate.

🔧 Recommended fix - add cleanup in beforeUnmount
+        // Store references for cleanup
+        private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
+        private timerInterval: number | null = null;

         created() {
-            window.addEventListener('keydown', (e) => this.keyDown(e));
+            this.keyDownHandler = (e: KeyboardEvent) => this.keyDown(e);
+            window.addEventListener('keydown', this.keyDownHandler);
             // ... rest of created
         },

         mounted() {
-            setInterval(() => {
+            this.timerInterval = window.setInterval(() => {
                 this.time_until_next_day = this.getTimeUntilNextDay();
             }, 1000);
             // ... rest of mounted
         },

+        beforeUnmount() {
+            if (this.keyDownHandler) {
+                window.removeEventListener('keydown', this.keyDownHandler);
+            }
+            if (this.timerInterval !== null) {
+                clearInterval(this.timerInterval);
+            }
+        },
🤖 Prompt for AI Agents
In `@frontend/src/game.ts` around lines 298 - 319, The component currently adds a
window 'keydown' listener in created() and starts a repeating timer in mounted()
but never cleans them up, causing leaks; to fix, store the reference to the
bound handler (e.g., save the arrow function or create a named wrapper that
calls keyDown) and the interval id (from setInterval) on the component instance,
then implement beforeUnmount() to call window.removeEventListener('keydown',
thatHandler) and clearInterval(thatIntervalId); update created(), mounted(), and
add beforeUnmount() (referencing keyDown and
getTimeUntilNextDay/time_until_next_day) so the listener and timer are reliably
removed when the component is destroyed.

Comment on lines +631 to +644
loadFromLocalStorage(): void {
try {
const pageName = window.location.pathname.split('/').pop() || 'home';
const stored = localStorage.getItem(pageName);
if (!stored) return;

const data = JSON.parse(stored) as SavedGameState | null;
if (data?.todays_word === this.todays_word) {
Object.assign(this, data);
}
} catch {
// localStorage unavailable or corrupted data
}
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential prototype pollution via Object.assign from untrusted localStorage.

Object.assign(this, data) copies all properties from parsed localStorage data onto the Vue instance. If data contains unexpected keys (e.g., __proto__, constructor, or method names), it could cause issues.

♻️ Safer approach - explicitly assign known properties
                     const data = JSON.parse(stored) as SavedGameState | null;
                     if (data?.todays_word === this.todays_word) {
-                        Object.assign(this, data);
+                        // Explicitly restore only known state properties
+                        this.tiles = data.tiles;
+                        this.tile_classes = data.tile_classes;
+                        this.key_classes = data.key_classes;
+                        this.active_row = data.active_row;
+                        this.active_cell = data.active_cell;
+                        this.game_over = data.game_over;
+                        this.game_won = data.game_won;
+                        this.emoji_board = data.emoji_board;
+                        this.attempts = data.attempts;
+                        this.full_word_inputted = data.full_word_inputted;
                     }
🤖 Prompt for AI Agents
In `@frontend/src/game.ts` around lines 631 - 644, The loadFromLocalStorage method
currently calls Object.assign(this, data) which permits prototype-pollution from
untrusted localStorage; instead, replace that call in loadFromLocalStorage with
a safe, whitelisted property assignment: parse JSON into data, verify it's an
object (not null), reject if it has __proto__ or constructor keys, then copy
only known game state fields (e.g., todays_word, guesses, gameOver, score —
whatever explicit fields your SavedGameState defines) onto the Vue instance
(this) rather than assigning all keys; alternatively implement a small helper
that iterates allowedKeys and assigns this[key] = data[key] when present to
avoid copying unknown properties.

Comment on lines +150 to +152
openLink(url: string): void {
window.open(url);
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent reverse‑tabnabbing when opening external links.
Use noopener (and ideally noreferrer) for window.open.

🛡️ Suggested hardening
             openLink(url: string): void {
-                window.open(url);
+                const w = window.open(url, '_blank', 'noopener,noreferrer');
+                if (w) w.opener = null;
             },
📝 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
openLink(url: string): void {
window.open(url);
},
openLink(url: string): void {
const w = window.open(url, '_blank', 'noopener,noreferrer');
if (w) w.opener = null;
},
🤖 Prompt for AI Agents
In `@frontend/src/index-app.ts` around lines 150 - 152, The openLink method
currently calls window.open(url) which is vulnerable to reverse-tabnabbing;
update the openLink function (in frontend/src/index-app.ts) to open external
links with noopener/noreferrer and a new browsing context — e.g., use
window.open(url, '_blank', 'noopener,noreferrer') and defensively set
newWindow.opener = null if a Window is returned — so that the opened page cannot
access or manipulate the opener.

Comment on lines +38 to +116
// Get the pwa-install component
const getComponent = () => document.querySelector('pwa-install');

const getBanner = (): HTMLElement | null => document.getElementById('pwa-install-banner');

export const hideBanner = (): void => {
const banner = getBanner();
if (banner) banner.style.display = 'none';
};

export const showBanner = (): void => {
if (dismissed || isStandalone()) return;
const banner = getBanner();
if (banner && (deferredPrompt || isIOS())) {
banner.style.display = 'flex';
}
};

/**
* Trigger PWA install dialog
* Uses @khmyznikov/pwa-install component which handles:
* - Native Chrome/Edge install prompt
* - iOS "Add to Home Screen" instructions with screenshots
* - Android fallback instructions
* - macOS dock instructions
*/
export const install = (): void => {
const component = getComponent();

// Use pwa-install component (handles all platforms with proper UI)
if (component) {
component.showDialog(true);
hideBanner();
return;
}

// Fallback: Native prompt on Android/Chrome (shouldn't reach here normally)
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt.userChoice.then((choice) => {
console.log('PWA install:', choice.outcome);
deferredPrompt = null;
hideBanner();
});
}
};

export const dismiss = (): void => {
dismissed = true;
try {
localStorage.setItem('pwa_install_dismissed_at', Date.now().toString());
} catch {
// localStorage may throw in private browsing mode
}
hideBanner();
};

export const resetDismissed = (): void => {
dismissed = false;
try {
localStorage.removeItem('pwa_install_dismissed_at');
} catch {
// localStorage may throw in private browsing mode
}
};

export const status = (): PWAStatus => {
const component = getComponent();
return {
hasPrompt: !!deferredPrompt,
dismissed,
isStandalone: isStandalone(),
isIOS: isIOS(),
hasComponent: !!component,
// Additional info from pwa-install component
componentReady: !!component,
isInstallAvailable: component?.isInstallAvailable ?? false,
isAppleMobile: component?.isAppleMobilePlatform ?? false,
isAppleDesktop: component?.isAppleDesktopPlatform ?? false,
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Run the suggested verification search
echo "=== Searching for existing pwa-install type declarations ==="
rg -n "pwa-install|PwaInstall|showDialog|isInstallAvailable|HTMLElementTagNameMap" -g '*.ts' -g '*.d.ts' frontend/src 2>/dev/null || echo "No matches found in frontend/src"

# Also check root and other common locations
echo -e "\n=== Checking for type declarations in root ==="
rg -n "pwa-install|PwaInstall" -g '*.d.ts' -g '*.ts' --max-count=20 2>/dev/null || echo "No matches"

Repository: Hugo0/wordle

Length of output: 4725


🏁 Script executed:

#!/bin/bash

# Check TypeScript config for strict mode
echo "=== TypeScript Configuration ==="
if [ -f tsconfig.json ]; then
  cat tsconfig.json | grep -A 5 '"strict"'
fi

# Check for `@khmyznikov/pwa-install` in package.json
echo -e "\n=== Package dependencies ==="
if [ -f package.json ]; then
  cat package.json | grep -A 2 -B 2 "pwa-install" || echo "No pwa-install found in package.json"
fi

Repository: Hugo0/wordle

Length of output: 433


🏁 Script executed:

#!/bin/bash

# Check if `@types/pwa-install` or similar exists
echo "=== Checking node_modules for pwa-install types ==="
if [ -d node_modules ]; then
  find node_modules -name "*pwa-install*" -type d | head -10
  find node_modules/@khmyznikov -name "*.d.ts" 2>/dev/null | head -20
fi

Repository: Hugo0/wordle

Length of output: 107


🏁 Script executed:

#!/bin/bash

# Examine the full pwa.ts file to understand current type usage
echo "=== Full content of frontend/src/pwa.ts ==="
if [ -f frontend/src/pwa.ts ]; then
  wc -l frontend/src/pwa.ts
  head -150 frontend/src/pwa.ts
fi

Repository: Hugo0/wordle

Length of output: 4698


Add explicit return type to getComponent() using the existing HTMLElementTagNameMap type.

The getComponent() function on line 39 lacks a return type annotation. With TypeScript strict mode enabled, it defaults to Element | null, which doesn't include the custom element's properties (showDialog, isInstallAvailable, etc.), causing type errors on lines 69 and 114.

Type definitions for pwa-install already exist in frontend/src/types/index.ts (lines 156–161). Use those instead of creating a duplicate interface:

Fix
-const getComponent = () => document.querySelector('pwa-install');
+const getComponent = (): HTMLElementTagNameMap['pwa-install'] | null =>
+    document.querySelector('pwa-install');
🤖 Prompt for AI Agents
In `@frontend/src/pwa.ts` around lines 38 - 116, The getComponent() return type is
too broad; update its signature to return the specific custom element type so
callers (install, status, etc.) see properties like showDialog and
isInstallAvailable. Change getComponent to return
HTMLElementTagNameMap['pwa-install'] | null (and cast the querySelector result
accordingly) so existing definitions in your types are used and lines that call
component.showDialog or read component?.isInstallAvailable compile cleanly.

Comment on lines +471 to 549
<!-- Stats Tabs -->
<div class="border-t border-solid border-gray-200 dark:border-gray-600">
<!-- Tab buttons -->
<div class="flex">
<button v-on:click="statsTab = 'language'"
class="flex-1 py-2 text-xs font-medium transition-colors border-b-2"
:class="statsTab === 'language'
? 'text-neutral-900 dark:text-white border-neutral-800 dark:border-neutral-200'
: 'text-neutral-500 dark:text-neutral-400 border-transparent hover:text-neutral-700 dark:hover:text-neutral-300'">
{{ language.config.name }}
</button>
<button v-on:click="statsTab = 'global'"
class="flex-1 py-2 text-xs font-medium transition-colors border-b-2"
:class="statsTab === 'global'
? 'text-neutral-900 dark:text-white border-neutral-800 dark:border-neutral-200'
: 'text-neutral-500 dark:text-neutral-400 border-transparent hover:text-neutral-700 dark:hover:text-neutral-300'">
All Languages
</button>
</div>
<div class="flex flex-col text-center flex-grow">
<p class="text-3xl">[[stats.longest_streak]]</p>
<p class="text-sm">Best Streak</p>

<!-- Language Stats (current language) -->
<div v-show="statsTab === 'language'" class="px-4 py-3">
<div class="grid grid-cols-4 gap-1">
<div class="text-center">
<p class="text-xl font-bold">[[stats.n_games]]</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Games</p>
</div>
<div class="text-center">
<p class="text-xl font-bold">[[Math.round(stats.win_percentage)]]%</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Win %</p>
</div>
<div class="text-center">
<p class="text-xl font-bold"><span v-if="stats.current_streak > 0">🔥</span>[[stats.current_streak]]</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Streak</p>
</div>
<div class="text-center">
<p class="text-xl font-bold">[[stats.longest_streak]]</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Best</p>
</div>
</div>
</div>

<!-- Global Stats (all languages) -->
<div v-show="statsTab === 'global'" class="px-4 py-3">
<!-- Summary grid -->
<div class="grid grid-cols-4 gap-1 mb-2">
<div class="text-center">
<p class="text-xl font-bold">[[total_stats.total_games]]</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Games</p>
</div>
<div class="text-center">
<p class="text-xl font-bold">[[Math.round(total_stats.total_win_percentage)]]%</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Win %</p>
</div>
<div class="text-center">
<p class="text-xl font-bold"><span v-if="total_stats.current_overall_streak > 0">🔥</span>[[total_stats.current_overall_streak]]</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Streak</p>
</div>
<div class="text-center">
<p class="text-xl font-bold">[[total_stats.languages_won?.length || 0]]</p>
<p class="text-[10px] text-neutral-500 dark:text-neutral-400">Languages</p>
</div>
</div>

<!-- Per-language breakdown -->
<div v-if="Object.keys(total_stats.game_stats || {}).length > 0" class="max-h-24 overflow-y-auto border-t border-neutral-200 dark:border-neutral-600 pt-1">
<div v-for="(langStats, code) in total_stats.game_stats" :key="code"
class="flex items-center justify-between py-1 text-xs">
<span class="font-medium">[[getLanguageName(code)]]</span>
<span class="text-neutral-500 dark:text-neutral-400">
[[langStats.n_games]] · [[Math.round(langStats.win_percentage)]]%
<span v-if="langStats.current_streak > 0" class="text-orange-500 ml-1">🔥[[langStats.current_streak]]</span>
</span>
</div>
</div>
<p v-else class="text-xs text-neutral-500 dark:text-neutral-400 text-center pt-1 border-t border-neutral-200 dark:border-neutral-600">
Play more languages to see your global stats!
</p>
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add ARIA tablist/tab/tabpanel semantics to the stats tabs.
Buttons alone don’t provide the proper relationships for assistive tech.

♿ Suggested fix
-                        <div class="flex">
+                        <div class="flex" role="tablist" aria-label="Stats tabs">
                             <button v-on:click="statsTab = 'language'"
-                                class="flex-1 py-2 text-xs font-medium transition-colors border-b-2"
+                                id="stats-tab-language"
+                                role="tab"
+                                :aria-selected="statsTab === 'language'"
+                                :tabindex="statsTab === 'language' ? 0 : -1"
+                                aria-controls="stats-panel-language"
+                                class="flex-1 py-2 text-xs font-medium transition-colors border-b-2"
                                 :class="statsTab === 'language'
                                     ? 'text-neutral-900 dark:text-white border-neutral-800 dark:border-neutral-200'
                                     : 'text-neutral-500 dark:text-neutral-400 border-transparent hover:text-neutral-700 dark:hover:text-neutral-300'">
                                 {{ language.config.name }}
                             </button>
                             <button v-on:click="statsTab = 'global'"
-                                class="flex-1 py-2 text-xs font-medium transition-colors border-b-2"
+                                id="stats-tab-global"
+                                role="tab"
+                                :aria-selected="statsTab === 'global'"
+                                :tabindex="statsTab === 'global' ? 0 : -1"
+                                aria-controls="stats-panel-global"
+                                class="flex-1 py-2 text-xs font-medium transition-colors border-b-2"
                                 :class="statsTab === 'global'
                                     ? 'text-neutral-900 dark:text-white border-neutral-800 dark:border-neutral-200'
                                     : 'text-neutral-500 dark:text-neutral-400 border-transparent hover:text-neutral-700 dark:hover:text-neutral-300'">
                                 All Languages
                             </button>
                         </div>
@@
-                        <div v-show="statsTab === 'language'" class="px-4 py-3">
+                        <div id="stats-panel-language" role="tabpanel" aria-labelledby="stats-tab-language"
+                             v-show="statsTab === 'language'" class="px-4 py-3">
@@
-                        <div v-show="statsTab === 'global'" class="px-4 py-3">
+                        <div id="stats-panel-global" role="tabpanel" aria-labelledby="stats-tab-global"
+                             v-show="statsTab === 'global'" class="px-4 py-3">
🤖 Prompt for AI Agents
In `@webapp/templates/game.html` around lines 471 - 549, Wrap the tab buttons
container with role="tablist" and update the two tab buttons (the ones that set
statsTab = 'language' and statsTab = 'global') to include role="tab", unique ids
(e.g., "stats-tab-language" and "stats-tab-global"), aria-selected reflecting
statsTab, aria-controls pointing to matching panel ids, and tabindex so only the
selected tab is tabbable; then update the two panels (the divs with
v-show="statsTab === 'language'" and v-show="statsTab === 'global'") to include
role="tabpanel", matching ids (e.g.,
"stats-panel-language"/"stats-panel-global"), aria-labelledby pointing back to
the tab ids, and a hidden attribute when not selected to match accessibility
expectations for tab/tabpanel semantics (ensure statsTab is used to derive all
aria-selected, aria-controls, aria-labelledby and hidden state).

Comment on lines +123 to 126
<div class="flex flex-col break-words bg-white dark:bg-neutral-800 border-2 border-neutral-300 dark:border-neutral-600 rounded shadow-md mx-4 mb-2 sm:mb-6 w-full max-w-xs md:max-w-sm lg:max-w-md">
<input v-model="search_text"
class="shadow appearance-none w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline hover:shadow-xl"
class="shadow appearance-none w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-neutral-800 leading-tight focus:outline-none focus:shadow-outline hover:shadow-xl"
type="text" placeholder="Search language...">
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add an accessible label for the language search input.
Placeholders aren’t labels for screen readers.

♿ Suggested fix
-                <input v-model="search_text"
+                <input id="language-search" v-model="search_text" aria-label="Search languages"
                     class="shadow appearance-none w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-neutral-800 leading-tight focus:outline-none focus:shadow-outline hover:shadow-xl"
                     type="text" placeholder="Search language...">
📝 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
<div class="flex flex-col break-words bg-white dark:bg-neutral-800 border-2 border-neutral-300 dark:border-neutral-600 rounded shadow-md mx-4 mb-2 sm:mb-6 w-full max-w-xs md:max-w-sm lg:max-w-md">
<input v-model="search_text"
class="shadow appearance-none w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline hover:shadow-xl"
class="shadow appearance-none w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-neutral-800 leading-tight focus:outline-none focus:shadow-outline hover:shadow-xl"
type="text" placeholder="Search language...">
<div class="flex flex-col break-words bg-white dark:bg-neutral-800 border-2 border-neutral-300 dark:border-neutral-600 rounded shadow-md mx-4 mb-2 sm:mb-6 w-full max-w-xs md:max-w-sm lg:max-w-md">
<input id="language-search" v-model="search_text" aria-label="Search languages"
class="shadow appearance-none w-full py-2 px-3 text-gray-700 dark:text-gray-200 dark:bg-neutral-800 leading-tight focus:outline-none focus:shadow-outline hover:shadow-xl"
type="text" placeholder="Search language...">
🤖 Prompt for AI Agents
In `@webapp/templates/index.html` around lines 123 - 126, The search input bound
to v-model="search_text" lacks an accessible label for screen readers; add an
explicit label associated with the input (e.g., give the input an id like
"language-search" and add a <label for="language-search">Search
language</label>—use a visually-hidden class such as "sr-only" if you don't want
it visible) or alternatively add a descriptive aria-label/aria-labelledby
attribute (aria-label="Search language" or aria-labelledby referencing a hidden
label element) so assistive tech can announce the field.

Comment on lines +338 to 399
<!-- Dark Mode Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span class="font-medium">Dark Mode</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Toggle dark theme</span>
</div>
<button
v-on:click="darkMode = !darkMode; toggleDarkMode()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="darkMode ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
:aria-checked="darkMode">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="darkMode ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
<div class="flex flex-row">
<div class="flex flex-col flex-grow">
<div class="">Best current Streak:</div>
<div v-if="total_stats.current_longest_language_streak_language !=''"
class="text-transparent text-sm bg-clip-text bg-gradient-to-r from-cyan-500 to-blue-500 font-bold">
[[languages[total_stats.current_longest_language_streak_language].language_name_native]]</div>

<!-- Separator -->
<hr class="border-neutral-300 dark:border-neutral-600">

<!-- Haptic Feedback Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span class="font-medium">Haptic Feedback</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Vibration on touch</span>
</div>
<div class="font-bold"><span v-if="total_stats.current_longest_language_streak > 0">🔥
</span>[[total_stats.current_longest_language_streak]]</div>
<button
v-on:click="hapticsEnabled = !hapticsEnabled; toggleHaptics()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="hapticsEnabled ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
:aria-checked="hapticsEnabled">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="hapticsEnabled ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
<div class="flex flex-row mt-4">
<div class="flex-grow uppercase tracking-wider font-bold">Languages Conquered:</div>

<!-- Separator -->
<hr class="border-neutral-300 dark:border-neutral-600">

<!-- Sound Effects Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span class="font-medium">Sound Effects</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Win/lose sounds</span>
</div>
<button
v-on:click="soundEnabled = !soundEnabled; toggleSound()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="soundEnabled ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
:aria-checked="soundEnabled">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="soundEnabled ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Give the switch buttons an accessible name.
role="switch" elements currently have no label.

♿ Suggested fix (apply to all switches)
-                            <div class="flex flex-col">
-                                <span class="font-medium">Dark Mode</span>
+                            <div class="flex flex-col">
+                                <span id="dark-mode-label" class="font-medium">Dark Mode</span>
                                 <span class="text-sm text-neutral-500 dark:text-neutral-400">Toggle dark theme</span>
                             </div>
                             <button
                                 v-on:click="darkMode = !darkMode; toggleDarkMode()"
                                 class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
                                 :class="darkMode ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
-                                role="switch"
+                                role="switch"
+                                aria-labelledby="dark-mode-label"
                                 :aria-checked="darkMode">

Repeat the same pattern for Haptic Feedback and Sound Effects.

📝 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
<!-- Dark Mode Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span class="font-medium">Dark Mode</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Toggle dark theme</span>
</div>
<button
v-on:click="darkMode = !darkMode; toggleDarkMode()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="darkMode ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
:aria-checked="darkMode">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="darkMode ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
<div class="flex flex-row">
<div class="flex flex-col flex-grow">
<div class="">Best current Streak:</div>
<div v-if="total_stats.current_longest_language_streak_language !=''"
class="text-transparent text-sm bg-clip-text bg-gradient-to-r from-cyan-500 to-blue-500 font-bold">
[[languages[total_stats.current_longest_language_streak_language].language_name_native]]</div>
<!-- Separator -->
<hr class="border-neutral-300 dark:border-neutral-600">
<!-- Haptic Feedback Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span class="font-medium">Haptic Feedback</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Vibration on touch</span>
</div>
<div class="font-bold"><span v-if="total_stats.current_longest_language_streak > 0">🔥
</span>[[total_stats.current_longest_language_streak]]</div>
<button
v-on:click="hapticsEnabled = !hapticsEnabled; toggleHaptics()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="hapticsEnabled ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
:aria-checked="hapticsEnabled">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="hapticsEnabled ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
<div class="flex flex-row mt-4">
<div class="flex-grow uppercase tracking-wider font-bold">Languages Conquered:</div>
<!-- Separator -->
<hr class="border-neutral-300 dark:border-neutral-600">
<!-- Sound Effects Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span class="font-medium">Sound Effects</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Win/lose sounds</span>
</div>
<button
v-on:click="soundEnabled = !soundEnabled; toggleSound()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="soundEnabled ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
:aria-checked="soundEnabled">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="soundEnabled ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
<!-- Dark Mode Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span id="dark-mode-label" class="font-medium">Dark Mode</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Toggle dark theme</span>
</div>
<button
v-on:click="darkMode = !darkMode; toggleDarkMode()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="darkMode ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
aria-labelledby="dark-mode-label"
:aria-checked="darkMode">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="darkMode ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
<!-- Separator -->
<hr class="border-neutral-300 dark:border-neutral-600">
<!-- Haptic Feedback Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span id="haptics-label" class="font-medium">Haptic Feedback</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Vibration on touch</span>
</div>
<button
v-on:click="hapticsEnabled = !hapticsEnabled; toggleHaptics()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="hapticsEnabled ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
aria-labelledby="haptics-label"
:aria-checked="hapticsEnabled">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="hapticsEnabled ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
<!-- Separator -->
<hr class="border-neutral-300 dark:border-neutral-600">
<!-- Sound Effects Toggle -->
<div class="flex flex-row items-center justify-between">
<div class="flex flex-col">
<span id="sound-label" class="font-medium">Sound Effects</span>
<span class="text-sm text-neutral-500 dark:text-neutral-400">Win/lose sounds</span>
</div>
<button
v-on:click="soundEnabled = !soundEnabled; toggleSound()"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="soundEnabled ? 'bg-green-500' : 'bg-neutral-300 dark:bg-neutral-600'"
role="switch"
aria-labelledby="sound-label"
:aria-checked="soundEnabled">
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="soundEnabled ? 'translate-x-6' : 'translate-x-1'">
</span>
</button>
</div>
🤖 Prompt for AI Agents
In `@webapp/templates/index.html` around lines 338 - 399, The switch buttons (the
buttons toggling darkMode, hapticsEnabled, soundEnabled) lack accessible names;
add an accessible name to each switch by either 1) adding an id to the
corresponding label span (e.g., the <span class="font-medium"> for "Dark Mode",
"Haptic Feedback", "Sound Effects") and setting the button's aria-labelledby to
that id, or 2) adding a descriptive aria-label on each button (e.g.,
aria-label="Toggle Dark Mode") and ensure you keep :aria-checked binding and
existing role="switch"; update the buttons associated with
darkMode/toggleDarkMode(), hapticsEnabled/toggleHaptics(), and
soundEnabled/toggleSound() accordingly.

Comment on lines +405 to +428
<div class="flex flex-col gap-2">
<a href="https://github.com/Hugo0/wordle/issues"
target="_blank"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"></path>
</svg>
Report an Issue
</a>
<a href="https://github.com/Hugo0/wordle"
target="_blank"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M10 14a3.5 3.5 0 0 0 5 0l4 -4a3.5 3.5 0 0 0 -5 -5l-.5 .5"></path>
<path d="M14 10a3.5 3.5 0 0 0 -5 0l-4 4a3.5 3.5 0 0 0 5 5l.5 -.5"></path>
</svg>
View Source Code
</a>
<a href="/stats"
target="_blank"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add rel="noopener noreferrer" to external links opened in new tabs.
Prevents reverse‑tabnabbing via window.opener.

🛡️ Suggested hardening
-                            <a href="https://github.com/Hugo0/wordle/issues"
-                               target="_blank"
+                            <a href="https://github.com/Hugo0/wordle/issues"
+                               target="_blank" rel="noopener noreferrer"
                                class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
@@
-                            <a href="https://github.com/Hugo0/wordle"
-                               target="_blank"
+                            <a href="https://github.com/Hugo0/wordle"
+                               target="_blank" rel="noopener noreferrer"
                                class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
@@
-                            <a href="/stats"
-                               target="_blank"
+                            <a href="/stats"
+                               target="_blank" rel="noopener noreferrer"
                                class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
📝 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
<div class="flex flex-col gap-2">
<a href="https://github.com/Hugo0/wordle/issues"
target="_blank"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"></path>
</svg>
Report an Issue
</a>
<a href="https://github.com/Hugo0/wordle"
target="_blank"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M10 14a3.5 3.5 0 0 0 5 0l4 -4a3.5 3.5 0 0 0 -5 -5l-.5 .5"></path>
<path d="M14 10a3.5 3.5 0 0 0 -5 0l-4 4a3.5 3.5 0 0 0 5 5l.5 -.5"></path>
</svg>
View Source Code
</a>
<a href="/stats"
target="_blank"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<div class="flex flex-col gap-2">
<a href="https://github.com/Hugo0/wordle/issues"
target="_blank" rel="noopener noreferrer"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"></path>
</svg>
Report an Issue
</a>
<a href="https://github.com/Hugo0/wordle"
target="_blank" rel="noopener noreferrer"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M10 14a3.5 3.5 0 0 0 5 0l4 -4a3.5 3.5 0 0 0 -5 -5l-.5 .5"></path>
<path d="M14 10a3.5 3.5 0 0 0 -5 0l-4 4a3.5 3.5 0 0 0 5 5l.5 -.5"></path>
</svg>
View Source Code
</a>
<a href="/stats"
target="_blank" rel="noopener noreferrer"
class="flex items-center gap-2 text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
🤖 Prompt for AI Agents
In `@webapp/templates/index.html` around lines 405 - 428, The three anchor
elements rendered with target="_blank" (the links whose text is "Report an
Issue", "View Source Code", and the "/stats" link) lack rel attributes and are
vulnerable to reverse‑tabnabbing; add rel="noopener noreferrer" to every <a>
that uses target="_blank" in this template so external/new‑tab links include
both noopener and noreferrer for security.

@Hugo0 Hugo0 mentioned this pull request Jan 25, 2026
@Hugo0 Hugo0 deleted the feature/build-system-tests-ci branch February 21, 2026 12:59
Hugo0 added a commit that referenced this pull request Feb 22, 2026
feat: Vite build system, TypeScript, testing infrastructure, and CI/CD
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.

2 participants