feat: Vite build system, TypeScript, testing infrastructure, and CI/CD#109
feat: Vite build system, TypeScript, testing infrastructure, and CI/CD#109
Conversation
* Add keyboard layout selection and update keyboard configurations for multiple languages * Refactor keyboard layout selector form for improved styling
…sibility in game.html
|
Caution Review failedThe pull request is closed. 📝 WalkthroughWalkthroughAdds 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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
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. Comment |
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
d3edf54 to
886b124
Compare
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>
There was a problem hiding this comment.
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.
- Hardcoded repo: Lines 29, 34, 38 use
Hugo0/wordledirectly, but line 24 uses implicit repo detection. This inconsistency could cause issues if the script is forked.- Missing pagination: Line 38 doesn't use
--paginatefor 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 | jqpattern 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-lockfilein 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-lockfileAlso applies to: 84-86, 108-110
frontend/src/types/index.ts (2)
58-65: Aligntodays_idxtype 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 transitionalnumber | string) and normalize once at load.🔧 Possible adjustment
- todays_idx: string; + todays_idx: number | string;
92-96: Consider makingGameResult.dateoptional.Tests define
dateas 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: Normalizeload_keyboardto always return a list.Other loaders return
[]when missing; returningNoneadds 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 atfrontend/src/types/index.ts, so the exclude pattern fails to filter them from coverage. Update tofrontend/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_charactersandtest_all_word_chars_in_character_setare 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: Considerpytest.mark.xfailor warnings for quality checks.Using
pytest.skipfor quality warnings means these tests always show as "skipped" rather than providing visibility into which languages have fewer words. Consider usingpytest.warnswith a custom warning category orpytest.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 withClassVar.Per Ruff RUF012, mutable class attributes should be annotated with
typing.ClassVarto 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.keyDownin the constructor or use an arrow function property for properthiscontext.
263-273: Clear interval on component unmount.The
setIntervalfor updatingtime_until_next_dayis 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/clientand 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: Gatewindow.debugto 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 foruserChoicepromise.The
userChoicepromise 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; }
| /** | ||
| * 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 }; | ||
| } |
There was a problem hiding this comment.
🛠️ 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
- 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
}-
Import in
game.tsand use within the Vue component methods. -
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.
| export interface GameResult { | ||
| won: boolean; | ||
| attempts: string | number; | ||
| date?: Date; | ||
| } |
There was a problem hiding this comment.
🧩 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 -20Repository: 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 -40Repository: 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.tsRepository: 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.
- 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
There was a problem hiding this comment.
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
```textto 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
noExportsInTestand duplicates logic/types that should stay canonical (e.g.,frontend/src/types/index.ts). Recommend extracting to a shared module and importing in bothgame.tsand tests; also align fixtures with the canonicalGameResultshape (requireddate).♻️ 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 allowNoneinputs/outputs.
date=Noneis accepted andget_daily_wordcan returnNoneon 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") PYwebapp/app.py (1)
39-55: Fix manifest entry key to match Vite output.
manifest["src/main.ts"]will KeyError if the manifest entry ismain(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())) PYfrontend/src/index-app.ts (2)
20-24:GameResulttype inconsistency with shared types.This local
GameResulthasattempts: numberanddate: string, whiletypes/index.tsdefinesattempts: string | numberanddate: 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/setItemcan throw. Unlikegame.ts, this file lacks exception handling.webapp/static/sw.js (1)
94-111: Wrap async cache operations inevent.waitUntil.The
CLEAR_CACHEhandler performs async cache deletions and client notifications withoutevent.waitUntil(). The service worker can terminate before these operations complete.webapp/templates/game.html (2)
3-9: Guard dark-mode bootstrap againstlocalStorageerrors.In Safari Private Browsing and other privacy modes,
localStorage.getItem()can throw. Wrap in try-catch with a fallback toprefers-color-scheme.
13-13: Removemaximum-scale=1anduser-scalable=nofrom 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: AvoidinnerHTMLwith partial escaping in the copy fallback modal.
text.replace(/</g, '<')is insufficient and still allows XSS vectors via other characters. Build the modal with DOM APIs and settextarea.valueinstead.🛠️ 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, '<')}</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.loadfailures 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.jsonwith 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 inmounted()is never removed.The
keydownlistener should be cleaned up inbeforeUnmountto 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.setItemintoggleDarkModealso 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_leftuses string literals instead of boolean.Using
'true' | 'false'string literals works but is unconventional. If this matches the JSON structure fromlanguage_config.json, consider adding a comment explaining this design choice for clarity.
| export interface GameResult { | ||
| won: boolean; | ||
| attempts: string | number; | ||
| date: Date | string; | ||
| } |
There was a problem hiding this comment.
🧩 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.tsRepository: 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.
| 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", | ||
| ] |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n tests/test_language_config.py | head -80Repository: 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 -10Repository: 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 -10Repository: Hugo0/wordle
Length of output: 91
🏁 Script executed:
cat ruff.tomlRepository: Hugo0/wordle
Length of output: 962
🏁 Script executed:
cat pyproject.tomlRepository: 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] = {} # RUF012Ruff 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>
|
@coderabbitai full review Please conduct a thorough review of this PR, paying special attention to:
Key changes in this commit:
|
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>
🧩 Analysis chain🏁 Script executed: Length of output: 623 🏁 Script executed: Length of output: 20422 🏁 Script executed: Length of output: 20690
1. TypeScript Code Quality ✅haptics.ts - Well implemented with a few opportunities:
sounds.ts - Solid implementation:
export function dispose(): void {
if (audioContext) {
audioContext.close();
audioContext = null;
}
}game.ts & index-app.ts:
2. Vue.js Patterns ✅Reactivity:
Lifecycle:
3. Mobile UX ✅Stats Modal - Well optimized:
<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
|
There was a problem hiding this comment.
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 --checkfailed for this file. Runblack webapp/app.pyto 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
localStorageparse 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
GameResultinterface hasdate?: Date(optionalDate), whilefrontend/src/types/index.tsdefines it asdate: 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 (noexportkeywords), 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.dateis required here but optional in tests.This is the canonical definition. The test file's local
GameResultwithdate?: Datediffers from this requireddate: Date | string. Ensure test fixtures include thedatefield to match this interface.frontend/src/index-app.ts (2)
22-26: Use the sharedGameResulttype to keep attempts/date unions consistent.
The local interface narrowsattemptsanddate, which can conflict with the canonicalfrontend/src/types/index.tsdefinition.♻️ Proposed fix
+import type { GameResult } from './types'; + // Types for homepage data interface Language { // ... } - -interface GameResult { - won: boolean; - attempts: number; - date: string; -}
107-115: GuardlocalStorage+JSON.parseto 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 inshowCopyFallbackModal.This was flagged in a previous review. The
text.replace(/</g, '<')only escapes<but not>,",', or&. Whiletextcomes fromgetShareText()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
playToneasync 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()orsound.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.jsonuses 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
:focusor:focus-visiblestates. 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 withdefaultandlayoutskeys. For consistency, consider migratingbg_keyboard.jsonto the same structure as the other keyboards.webapp/static/sw.js (1)
21-34: Consider wrappingclients.claim()insideevent.waitUntil.While the current code works,
clients.claim()returns a Promise. Calling it outsidewaitUntilmeans 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 forright_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 tobooleanat the type level (with runtime parsing) would be more idiomatic TypeScript.frontend/src/debug.ts (2)
7-7:getComponentduplicates logic frompwa.ts.The
pwa.tsmodule already has agetComponentfunction (used instatus()). Consider importing and reusing it to maintain a single source of truth:♻️ Suggested approach
If
pwa.tsexportsgetComponent, import it here instead of redefining:import pwa, { getComponent } from './pwa';Otherwise, consider exporting it from
pwa.tsfor 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 ofbeforeUpdateinto a watcher/computed.
Mutating state inbeforeUpdateis 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 variablelayout_name.
Rename it to_layout_nameto 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: Bareexceptclause 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 bareExceptionhides 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:calculateTotalStatsis 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, andtile_classes_visualis 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(),
| 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; | ||
| } | ||
| }, |
There was a problem hiding this comment.
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.
| 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 | ||
| } | ||
| }, |
There was a problem hiding this comment.
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.
| openLink(url: string): void { | ||
| window.open(url); | ||
| }, |
There was a problem hiding this comment.
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.
| 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.
| // 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, |
There was a problem hiding this comment.
🧩 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"
fiRepository: 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
fiRepository: 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
fiRepository: 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.
| <!-- 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> |
There was a problem hiding this comment.
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).
| <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..."> |
There was a problem hiding this comment.
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.
| <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.
| <!-- 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> |
There was a problem hiding this comment.
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.
| <!-- 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.
| <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"> |
There was a problem hiding this comment.
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.
| <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.
feat: Vite build system, TypeScript, testing infrastructure, and CI/CD
Summary
Test plan
@coderabbitai review
🤖 Generated with Claude Code
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.