Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
05b4f68
feat: streak badge, multi-board modes, and game polish
Hugo0 Mar 29, 2026
91967cd
chore: remove strategic docs from git + additional agent fixes
Hugo0 Mar 29, 2026
081d923
refactor: deduplicate multi-board pages + formatting cleanup
Hugo0 Mar 29, 2026
be4fe01
fix: address CodeRabbit review feedback
Hugo0 Mar 29, 2026
1501908
refactor: consolidate SEO, noscript, and page boilerplate
Hugo0 Mar 29, 2026
1095c6b
fix: streak system UX improvements from audit
Hugo0 Mar 29, 2026
5885370
refactor: single dynamic [mode].vue for all multi-board pages
Hugo0 Mar 29, 2026
1231c94
fix: streak modal — add flags, cap language list at 5
Hugo0 Mar 29, 2026
089e72f
fix: multi-board layout — vertical centering, no row truncation, grid…
Hugo0 Mar 29, 2026
4f88128
fix: badge labels, multi-board layout fixes, and misc polish
Hugo0 Mar 29, 2026
ff2c6f6
feat: best starting words page and analysis API
Hugo0 Mar 29, 2026
d175b3f
fix: multi-board layout sizing + minimap colors + disable focus mode
Hugo0 Mar 29, 2026
ff1c202
refactor: extract shared streak date utilities, add effectiveStreak
Hugo0 Mar 29, 2026
03517aa
fix: SeoNoscript formatting
Hugo0 Mar 29, 2026
56aaa2e
fix: CodeRabbit + simplify audit fixes
Hugo0 Mar 29, 2026
7331add
perf: fix 3 reactive performance bottlenecks for 16/32-board modes
Hugo0 Mar 29, 2026
c88f15a
perf: debounce localStorage save + skip tileColors for 5+ boards
Hugo0 Mar 29, 2026
848d2be
perf: only sync visible boards during typing (major speedup for 16/32)
Hugo0 Mar 29, 2026
c616970
fix: replace DOM manipulation with reactive patterns, DRY homepage mo…
Hugo0 Mar 29, 2026
8d2debe
fix: restore lost changes — scrollRef measurement, minimap arrows, an…
Hugo0 Mar 29, 2026
b263c0f
fix: rescue reverted code quality fixes
Hugo0 Mar 29, 2026
74595fc
fix: type useDefinitions API response, fix @vue/reactivity test resol…
Hugo0 Mar 29, 2026
c06c50b
fix: expand/collapse button, centered toolbar, skeleton placeholders
Hugo0 Mar 29, 2026
cd2274e
fix: show expand/collapse for 8+ boards with >9 guesses (collapsed by…
Hugo0 Mar 29, 2026
e0117b2
fix: mg not defined in useMultiBoardLayout
Hugo0 Mar 29, 2026
15a430c
fix: grid height accounts for allExpanded state
Hugo0 Mar 29, 2026
37ebc3a
fix: make language search E2E test resilient to language count changes
Hugo0 Mar 29, 2026
fd53ff4
.
Hugo0 Mar 29, 2026
3851855
.
Hugo0 Mar 29, 2026
0e0ffaf
formatting
Hugo0 Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 0 additions & 1 deletion .claude/scheduled_tasks.lock

This file was deleted.

29 changes: 28 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ Keyboard layouts are in `data/languages/{lang}/keyboard.json`.

UI text is in `data/languages/{lang}/language_config.json`. Please ensure translations are natural and accurate (not machine-translated).

### SEO Content (`seo` block)

All SEO content (FAQ, HowTo, tips, value props, section headings) lives in the `seo` section of each language's `language_config.json`. English defaults are in `data/default_language_config.json`.

**Contract for translators:**
- Provide the **full `seo` block** when translating — the data loader does a shallow merge, so a partial `seo` override replaces the entire object and loses unspecified keys.
- Placeholders are interpolated at runtime: `{langName}`, `{lang}`, `{modeName}`, `{boardCount}`, `{maxGuesses}`. Keep them as-is in translations.
- Mode-specific content uses `mode_*` keys. Multi-board modes (dordle through duotrigordle) share the `multiboard` key.
- `faq` is the default (classic mode). `mode_faq.unlimited`, `mode_faq.speed`, `mode_faq.multiboard` override per mode.
- Same pattern for `howto` / `mode_howto` and `tips` / `tips_speed` / `tips_multiboard`.
- Currently translated: `en` (defaults), `fi`, `ar`, `de`. All other languages fall back to English.

### Adding a New Language

1. Create folder: `data/languages/{lang_code}/`
Expand All @@ -133,20 +145,35 @@ UI text is in `data/languages/{lang}/language_config.json`. Please ensure transl

- Link the issue in your PR description (`Fixes #123`)
- All tests must pass
- One logical change per PR

## Do

- Add try-catch around localStorage (fails in private browsing)
- Test both light and dark modes
- Consider RTL languages (Hebrew, Arabic, Persian)
- Keep bundle size small
- **Test direct navigation**, not just SPA clicks. Pages like `/en/word/123` and `/en/words` are reached via Google, bookmarks, and shared links — not just by clicking through the app. If a page depends on store state, verify it works when that store hasn't been initialized.
- **Check what already exists** before building something new. Search `components/shared/` for reusable components (e.g., `BaseModal`), `composables/` for shared logic, and `utils/` for helpers. Don't hand-roll what's already available.
- **Type API responses explicitly.** Don't use `any`, `Record<string, unknown>`, or untyped `let x = null`. Define interfaces for response shapes so downstream consumers get type safety.
- **Clean up after yourself.** Remove dead code, remove unused imports, add `onUnmounted` cleanup for event listeners added in `onMounted`.

## Don't

- Change the daily word algorithm — breaks word selection globally
- Add console.logs to production code
- Modify `.nuxt/` or `.output/` manually (auto-generated)
- **Don't manipulate the DOM imperatively in Vue components.** No `$event.target.style.display`, no `classList.add/remove`, no `parentElement!.classList`. Use reactive state (`ref`, `reactive`, `computed`) and let Vue's template directives (`v-if`, `v-show`, `:class`) handle visibility. The only exception is third-party integrations (e.g., Giscus) that require direct DOM access.
- **Don't copy-paste template blocks.** If you have similar markup in 2+ places, extract it into a component or use a computed to unify the data source. Three definition blocks that differ only in which variable they read is not DRY.
- **Don't use `langStore` outside game pages.** The language store requires `init()` which only happens via `useGamePage()` on game routes. Standalone pages (`/[lang]/word/[id]`, `/[lang]/words`, etc.) must get UI labels from their own API response, not from the store. If a page needs translated strings, add `ui: config.ui` to the API endpoint's return value.

## Multi-Agent Collaboration

When multiple AI agents work on this repo concurrently, follow these rules:

- Every agent **MUST** work in its own git worktree. Use `git worktree add` to create an isolated copy of the repo before making changes.
- If an agent is not working in a worktree, it **MUST** be careful with other agents' code. Never stash, checkout, reset, or in any way modify or discard another agent's in-progress work without explicit permission.
- Coordinate before touching shared files. If two agents need to edit the same file, one should finish and commit first.
- Never force-push or rewrite history on a branch another agent is using.

## License Agreement

Expand Down
4 changes: 4 additions & 0 deletions assets/css/design-system.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
--color-accent-soft: #e8d5d0;
--color-correct: #2d8544;
--color-correct-soft: #d4edda;
--color-flame: #e8590c;
--color-flame-soft: #fff1e6;
--color-semicorrect: #b8860b;
--color-semicorrect-soft: #fdf3d0;
--color-muted: #8c8c8c;
Expand All @@ -52,6 +54,8 @@
--color-accent-soft: #3d2624;
--color-correct: #4ead6a;
--color-correct-soft: #1e3328;
--color-flame: #fd7e14;
--color-flame-soft: #3d2015;
--color-semicorrect: #d4a62a;
--color-semicorrect-soft: #3a3019;
--color-muted: #9a9a9a;
Expand Down
144 changes: 134 additions & 10 deletions assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -178,14 +178,6 @@
cursor: pointer;
}

.pwa-install-banner {
background: linear-gradient(135deg, var(--color-accent) 0%, #764ba2 100%);
}

.pwa-install-banner .action-btn {
color: var(--color-accent);
}

.embed-banner {
background: linear-gradient(135deg, var(--color-correct) 0%, #1b4332 100%);
}
Expand Down Expand Up @@ -239,6 +231,118 @@
overflow-y: auto;
}

/* Streak flame flicker — organic wobble for hot streaks (7+).
Layered keyframes at prime-ish durations so the motion never visibly loops.
transform-origin at bottom center (flame base). */
@keyframes flame-wobble {
0%, 100% { transform: rotate(0deg) scaleY(1) skewX(0deg); }
15% { transform: rotate(-2.5deg) scaleY(1.03) skewX(-1deg); }
35% { transform: rotate(1.8deg) scaleY(0.97) skewX(0.8deg); }
55% { transform: rotate(-1.2deg) scaleY(1.02) skewX(-0.5deg); }
75% { transform: rotate(2deg) scaleY(0.98) skewX(1.2deg); }
90% { transform: rotate(-0.8deg) scaleY(1.01) skewX(-0.3deg); }
}

.flame-flicker {
animation: flame-wobble 2.7s ease-in-out infinite;
transform-origin: 50% 85%;
filter: drop-shadow(0 0 3px rgba(232, 89, 12, 0.25));
}

/* Streak flame ignite — dramatic burst on win with glow escalation */
@keyframes flame-ignite {
0% { transform: scale(1) rotate(0deg); filter: drop-shadow(0 0 0 transparent); }
20% { transform: scale(1.6) rotate(-4deg); filter: drop-shadow(0 0 10px rgba(232, 89, 12, 0.6)); }
40% { transform: scale(1.9) rotate(3deg); filter: drop-shadow(0 0 16px rgba(232, 89, 12, 0.7)) drop-shadow(0 0 30px rgba(253, 126, 20, 0.3)); }
60% { transform: scale(1.3) rotate(-2deg); filter: drop-shadow(0 0 8px rgba(232, 89, 12, 0.4)); }
80% { transform: scale(1.05) rotate(1deg); filter: drop-shadow(0 0 4px rgba(232, 89, 12, 0.25)); }
100% { transform: scale(1) rotate(0deg); filter: drop-shadow(0 0 3px rgba(232, 89, 12, 0.25)); }
}

.flame-ignite {
animation: flame-ignite 800ms cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: 50% 85%;
}

/* Spark particles — CSS-only dots that float up from the flame tip.
Uses ::before and ::after on a wrapper element (SVGs can't have pseudo-elements).
Each pseudo has multiple box-shadows for 2-3 sparks at different positions. */
.flame-wrap {
position: relative;
display: inline-flex;
}

/* Idle sparks — subtle, occasional dots drifting up (hot streaks 7+) */
@keyframes spark-float-1 {
0% { opacity: 0; transform: translate(0, 0) scale(0.8); }
15% { opacity: 1; }
100% { opacity: 0; transform: translate(2px, -12px) scale(0.3); }
}
@keyframes spark-float-2 {
0% { opacity: 0; transform: translate(0, 0) scale(0.6); }
20% { opacity: 0.8; }
100% { opacity: 0; transform: translate(-3px, -14px) scale(0.2); }
}

.flame-sparks::before,
.flame-sparks::after {
content: '';
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
background: #e8590c;
top: 0;
left: 50%;
pointer-events: none;
z-index: 1;
}
.flame-sparks::before {
animation: spark-float-1 2.1s ease-out infinite;
margin-left: -2px;
}
.flame-sparks::after {
animation: spark-float-2 2.7s ease-out 0.8s infinite;
margin-left: 2px;
background: #fd7e14;
}

/* Ignite sparks — more intense, faster, more particles via box-shadow */
@keyframes spark-burst-1 {
0% { opacity: 0; transform: translate(0, 0) scale(1); }
10% { opacity: 1; }
100% { opacity: 0; transform: translate(4px, -18px) scale(0.2); }
}
@keyframes spark-burst-2 {
0% { opacity: 0; transform: translate(0, 0) scale(1); }
10% { opacity: 1; }
100% { opacity: 0; transform: translate(-5px, -16px) scale(0.2); }
}

.flame-sparks-intense::before,
.flame-sparks-intense::after {
content: '';
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
background: #fd7e14;
top: -2px;
left: 50%;
pointer-events: none;
z-index: 1;
}
.flame-sparks-intense::before {
animation: spark-burst-1 0.6s ease-out forwards;
margin-left: -3px;
box-shadow: 5px 2px 0 #e8590c, -2px -1px 0 #fd7e14;
}
.flame-sparks-intense::after {
animation: spark-burst-2 0.7s ease-out 0.1s forwards;
margin-left: 3px;
box-shadow: -4px 1px 0 #e8590c, 3px -2px 0 #fd7e14;
}

/* Prevent text selection on game elements */
.no-select {
user-select: none;
Expand Down Expand Up @@ -440,8 +544,18 @@
.key-pulse,
.modal-animate,
.key-correct,
.key-semicorrect {
.key-semicorrect,
.flame-flicker,
.flame-ignite {
animation: none !important;
}

.flame-sparks::before,
.flame-sparks::after,
.flame-sparks-intense::before,
.flame-sparks-intense::after {
animation: none !important;
opacity: 0 !important;
}

.diacritic-popup {
Expand All @@ -463,8 +577,18 @@
.reduce-animations .key-shake,
.reduce-animations .key-pulse,
.reduce-animations .key-correct,
.reduce-animations .key-semicorrect {
.reduce-animations .key-semicorrect,
.reduce-animations .flame-flicker,
.reduce-animations .flame-ignite {
animation: none !important;
}

.reduce-animations .flame-sparks::before,
.reduce-animations .flame-sparks::after,
.reduce-animations .flame-sparks-intense::before,
.reduce-animations .flame-sparks-intense::after {
animation: none !important;
opacity: 0 !important;
}

.reduce-animations .diacritic-popup {
Expand Down
28 changes: 21 additions & 7 deletions components/app/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
:src="flagSrc"
:alt="languageName"
class="flag-icon flag-icon-sm"
@error="flagFailed = true"
/>
<Globe v-else :size="18" class="text-muted" />
</span>
Expand Down Expand Up @@ -95,6 +96,16 @@
</a>
</div>

<!-- Learn section -->
<div class="py-4 editorial-rule">
<div class="mono-label px-6 pb-2">Learn</div>
<SidebarItem
icon="Lightbulb"
label="Best Starting Words"
:href="`/${langCode}/best-starting-words`"
/>
</div>

<!-- Profile (placeholder for Phase 2) — pinned to bottom -->
<div
class="mt-auto px-6 py-4 editorial-rule flex items-center gap-3 cursor-default"
Expand All @@ -117,7 +128,7 @@
<script setup lang="ts">
import { Globe, ChevronRight, Bug } from 'lucide-vue-next';
import { useFlag } from '~/composables/useFlag';
import { GAME_MODES_UI, getModeRoute } from '~/composables/useGameModes';
import { GAME_MODES_UI, getModeRoute, getModeLabel } from '~/composables/useGameModes';
import SidebarItem from './SidebarItem.vue';

const props = withDefaults(
Expand Down Expand Up @@ -145,7 +156,8 @@ const emit = defineEmits<{

const sidebarEl = ref<HTMLElement | null>(null);

const flagSrc = computed(() => useFlag(props.langCode));
const flagFailed = ref(false);
const flagSrc = computed(() => (flagFailed.value ? null : useFlag(props.langCode)));

function close() {
emit('close');
Expand All @@ -156,16 +168,18 @@ function selectMode(mode: string) {
close();
}

const gameModes = computed(() =>
GAME_MODES_UI.map((mode) => ({
const langStore = useLanguageStore();
const gameModes = computed(() => {
const ui = langStore.config?.ui;
return GAME_MODES_UI.map((mode) => ({
id: mode.id,
icon: mode.icon,
label: mode.label,
label: getModeLabel(mode, ui),
badge: mode.badge,
href: getModeRoute(mode, props.langCode) ?? undefined,
disabled: !mode.enabled,
}))
);
}));
});

// Pre-fill bug report with language, mode, and device info
const bugReportUrl = computed(() => {
Expand Down
23 changes: 17 additions & 6 deletions components/app/GameModePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
:src="flagSrc"
:alt="languageName"
class="flag-icon flag-icon-sm"
@error="flagFailed = true"
/>
<span class="text-sm font-semibold text-ink">{{ languageName }}</span>
<ChevronDown :size="14" class="text-muted" />
Expand Down Expand Up @@ -89,7 +90,12 @@
<script setup lang="ts">
import { ChevronDown } from 'lucide-vue-next';
import { useFlag } from '~/composables/useFlag';
import { GAME_MODES_UI, getModeRoute } from '~/composables/useGameModes';
import {
GAME_MODES_UI,
getModeRoute,
getModeLabel,
getModeDescription,
} from '~/composables/useGameModes';

const props = defineProps<{
visible: boolean;
Expand All @@ -103,16 +109,21 @@ const emit = defineEmits<{
'change-language': [];
}>();

const flagSrc = computed(() => useFlag(props.langCode));
const flagFailed = ref(false);
const flagSrc = computed(() => (flagFailed.value ? null : useFlag(props.langCode)));

const modes = computed(() =>
GAME_MODES_UI.map((mode) => ({
const langStore = useLanguageStore();
const modes = computed(() => {
const ui = langStore.config?.ui;
return GAME_MODES_UI.map((mode) => ({
...mode,
iconComponent: mode.icon,
label: getModeLabel(mode, ui),
description: getModeDescription(mode, ui),
disabled: !mode.enabled,
route: getModeRoute(mode, props.langCode),
}))
);
}));
});

const analytics = useAnalytics();

Expand Down
Loading
Loading