diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 4b97ae11..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"37c19a57-aa11-49bc-8a3a-6af7238b3c9f","pid":853176,"acquiredAt":1774052854550} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b29bcb4..784f751e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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}/` @@ -133,7 +145,6 @@ 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 @@ -141,12 +152,28 @@ UI text is in `data/languages/{lang}/language_config.json`. Please ensure transl - 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`, 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 diff --git a/assets/css/design-system.css b/assets/css/design-system.css index 5ba2e442..e3f50d9b 100644 --- a/assets/css/design-system.css +++ b/assets/css/design-system.css @@ -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; @@ -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; diff --git a/assets/css/main.css b/assets/css/main.css index 5386243b..7f6f2545 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -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%); } @@ -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; @@ -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 { @@ -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 { diff --git a/components/app/AppSidebar.vue b/components/app/AppSidebar.vue index 4a7979d8..817ac851 100644 --- a/components/app/AppSidebar.vue +++ b/components/app/AppSidebar.vue @@ -60,6 +60,7 @@ :src="flagSrc" :alt="languageName" class="flag-icon flag-icon-sm" + @error="flagFailed = true" /> @@ -95,6 +96,16 @@ + +
+
Learn
+ +
+
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( @@ -145,7 +156,8 @@ const emit = defineEmits<{ const sidebarEl = ref(null); -const flagSrc = computed(() => useFlag(props.langCode)); +const flagFailed = ref(false); +const flagSrc = computed(() => (flagFailed.value ? null : useFlag(props.langCode))); function close() { emit('close'); @@ -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(() => { diff --git a/components/app/GameModePicker.vue b/components/app/GameModePicker.vue index 830cd6dc..44f21f7e 100644 --- a/components/app/GameModePicker.vue +++ b/components/app/GameModePicker.vue @@ -32,6 +32,7 @@ :src="flagSrc" :alt="languageName" class="flag-icon flag-icon-sm" + @error="flagFailed = true" /> {{ languageName }} @@ -89,7 +90,12 @@ + + diff --git a/components/game/CopyFallbackModal.vue b/components/game/CopyFallbackModal.vue index 16dc7b00..e56ca2c1 100644 --- a/components/game/CopyFallbackModal.vue +++ b/components/game/CopyFallbackModal.vue @@ -1,35 +1,26 @@