0. CONTENT GENERATION --------------------- Create a dataset with these 10 Navajo words that have no direct translation into English: Hózhó K’é T’áá hwó’ ají t’éego Nizhóní Diné Sa’ah naagháí bik’eh hózhóón Iiná Ádee haadzíí’ Shí-k’is Yá’át’ééh 1. GLOBAL CONFIGURATION ----------------------- - CARDS_PER_ROUND: 5 - BASE_LANGUAGE: Navajo - TARGET_LANGUAGE: English - LANGUAGE_CODE: Two letter standard code for TARGET_LANGUAGE (All Caps). - GA_MEASUREMENT_ID: "G-WFWF326Q9H" Dataset Fields: id | text (the Navajo word, exactly as listed above) | translation (English explanation of the Navajo word: at most 200 characters, describing its meaning and cultural sense; closest words in English) | audio (set explicitly to null) Elminate any duplicates in the dataset. Act as an expert Frontend Engineer. Build a single, standalone HTML file (index.html) for a language learning game. IMPORTANT: Do not add any notes at the top or bottom of the HTML file. 2. TECHNICAL STACK & SETUP -------------------------- - React, ReactDOM, Babel, TailwindCSS (via CDN). - Embed all CSS/JS within the single file. - You MUST use the following exact script tags in the for dependencies: - Analytics: Include the standard Google Analytics 4 (gtag.js) script in the . Initialize `window.dataLayer` and config using the `GA_MEASUREMENT_ID`. 3. ARCHITECTURE & STATE MANAGEMENT (CRITICAL) --------------------------------------------- - Storage Key: Use exactly const `STORAGE_KEY = 'openlang_English_Navajo-Culture_progress'`. - SAFE STORAGE (CRITICAL): Browsers can block site data (blocked cookies, privacy extensions, in-app browsers/WebViews). Then merely reading `window.localStorage` throws a SecurityError (or it is null), which crashes the page. The page MUST stay fully usable when storage is unavailable; data is simply not persisted. - In the , IMMEDIATELY BEFORE the first - You MUST NOT reference `localStorage` or `window.localStorage` anywhere else in the file. Every read, write, and removal of saved data (progress, totals, mute state, flags) MUST use `safeStorage.getItem`, `safeStorage.setItem`, or `safeStorage.removeItem`. To list saved keys, use `safeStorage.keys()`. - Wrap every `JSON.parse` of saved data in try/catch; on failure, treat it as no saved data. - Persistence Schema: `{ score: number, streak: number, learnedIds: [] }`. - Initialization Logic (Order of Operations): 1. On mount, read saved progress with `safeStorage.getItem(STORAGE_KEY)`. 2. If data exists, hydrate `score` and `streak` state from it. 3. Filter `RAW_DATA` to exclude any IDs found in `learnedIds`. 4. Initialize `deckRef` using a Fisher-Yates shuffle of the *remaining* (unlearned) items. 5. If no items remain (all learned), trigger the Win Condition immediately. 6. Do NOT call startRound() here. Let the Round Transition useEffect handle loading the first batch automatically when it detects roundData.left.length === 0 on mount. - Round State: Use `useState` (`roundData`) for the current CARDS_PER_ROUND pairs. - Tap Selection State (Mirror Ref pattern): Keep `selectedRef` (`useRef`, the source of truth: `{ id, side }` or null) and `selectedCard` (`useState`, purely for rendering .card-selected). Update both together only via `selectCard(id, side)` and `clearSelection()`. Inside handlers ALWAYS read `selectedRef.current`, never the state. Also keep `successIdsRef` and `showInfoRef` mirrored from the `successIds` and `showInfo` state (update each ref in a `useEffect` on that state). - Mastery Logic: When a match is made (no hint), remove the word from deckRef.current immediately (You MUST use .filter() to do this, never .splice()). When defining startRound(), ensure it reads the cards using .slice(0, CARDS_PER_ROUND) so the active cards remain safely inside deckRef until matched. - Round Transition (useEffect Pattern): - You MUST use a `useEffect` hook to handle transitions. Do NOT trigger the next round inside `handleMatch`. - Watch dependency: `[roundData.left.length, isWon]`. - Logic: IF `roundData.left.length === 0` AND `!isWon` AND `deckRef.current` is initialized: a. Check if `deckRef.current.length > 0`. b. If YES: Call `startRound()`. c. If NO: Set `isWon(true)`. - TARGET_LANGUAGE Column Logic: Must be a VALID DERANGEMENT (shuffled so no TARGET_LANGUAGE card lines up horizontally with its English counterpart). If batch size < 2, return as-is. 4. LAYOUT & VISUALS ------------------- - Master Container: Wrap the entire application in a single div. - Desktop: width: 400px; margin: 0 auto; position: relative; - Mobile: width: 100%; height: 100dvh; - Behavior: All children (Header, Game Area, Footer) must inherit this exact width. - Structure: A flex-column container (h-100dvh) within the master boundary. Header: Fixed height, flex-shrink: 0. Game Area: Flex-1, overflow: hidden, overscroll-behavior: none. (CRITICAL: Do NOT use overflow-y: auto. You MUST enforce the touch-none Tailwind class directly on the JSX elements to prevent entire columns from native dragging/scrolling). This container MUST have the class flex so its children (the two columns) automatically stretch to fill the full available height. Columns: Two columns (40%/60%). - CSS Logic: Apply flex flex-col justify-start to each column. Calculate a fixed card height using calc((100dvh - 6.9rem) / (CARDS_PER_ROUND + 1)). This ensures cards never change size or move positions when others are removed. Card Content (Strict): Each card must be a single
element with key, data-id, data-side ("left" or "right"), className (including game-card), style, draggable, and onClick all on the same element. Do not wrap cards in an outer container div. Left (Navajo) Card: Display `item.text`, the Navajo word (centered, .95rem) using the calculated fixed height. Right (English) Card: Display `item.translation`, the English explanation (left justified, plain text, .8rem) using the calculated fixed height. Card Styling: Use p-2 (0.5rem) padding and leading-tight. Centrally align text vertically and horizontally. The white background must tightly 'hug' the text within the calculated height. MUST INCLUDE CSS: `user-select: none; -webkit-user-select: none;` to prevent native text-drag interference. FIT TEXT TO CARD (CRITICAL): Some datasets have long cards (quotes, slogans, explanations of 150-250 characters). Text MUST NEVER spill outside its card: spilled text covers neighbouring cards and the header, steals taps, and is unreadable. Cards keep their fixed height; long text is made smaller instead. - Every card's inline style MUST include `overflow: 'hidden'` and `overflowWrap: 'anywhere'`, and every card element MUST carry the attribute `data-base-font` set to its starting font size (the left card's '.95rem', the right card's '.8rem'). - Define these pure helpers OUTSIDE the component, exactly: const MIN_CARD_FONT_PX = 9; function fitCardText(el) { el.style.fontSize = el.dataset.baseFont; let px = parseFloat(window.getComputedStyle(el).fontSize); while ((el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1) && px > MIN_CARD_FONT_PX) { px = Math.max(MIN_CARD_FONT_PX, px - 0.5); el.style.fontSize = px + 'px'; } } function fitAllCards() { document.querySelectorAll('[data-side]').forEach(fitCardText); } - Call `fitAllCards()` in a `useLayoutEffect` that runs whenever `roundData` changes (so new cards are fitted before they are painted). - Refit whenever the page's styling or size changes after that, because the Tailwind CDN script and web fonts restyle the page AFTER React's first render (a fit done before that leaves cards overflowing). In a mount-only `useEffect`: let fitFrame = 0; const scheduleFit = () => { cancelAnimationFrame(fitFrame); fitFrame = requestAnimationFrame(fitAllCards); }; const styleObserver = new MutationObserver(scheduleFit); styleObserver.observe(document.head, { childList: true, subtree: true, characterData: true }); window.addEventListener('resize', scheduleFit); window.addEventListener('orientationchange', scheduleFit); window.addEventListener('load', scheduleFit); if (document.fonts) document.fonts.ready.then(scheduleFit); return () => { styleObserver.disconnect(); cancelAnimationFrame(fitFrame); window.removeEventListener('resize', scheduleFit); window.removeEventListener('orientationchange', scheduleFit); window.removeEventListener('load', scheduleFit); }; - Do NOT put the fitted size into React state: `fitCardText` writes `el.style.fontSize` directly, and the JSX keeps passing the starting size, so React never overwrites the fitted value (the prop does not change between renders). Visual Hover State (PRO): Define a CSS class .target-hover. Style: border: 2px solid #3b82f6 !important; background-color: #eff6ff !important; transform: scale(1.05); transition: transform 0.1s ease;. This class will be applied to a TARGET_LANGUAGE card when an BASE_LANGUAGE card is "pointing" at it. Visual Selected State (Tap-to-Match): Define a CSS class .card-selected. Style: border: 3px solid #1d4ed8 !important; background-color: #dbeafe !important; box-shadow: 0 0 0 2px #93c5fd; transform: scale(1.03); transition: transform 0.1s ease;. This class is applied (via React className, driven by `selectedCard` state) to the ONE card the user has tapped/clicked and is waiting to match. It MUST look clearly different from .target-hover so the user immediately sees their tap registered. Right (TARGET_LANGUAGE) cards MUST use `cursor-pointer`. Left cards keep their existing grab cursor. Mobile View: UI must fit on a single screen without horizontal scrolling. Desktop: Set CSS width to 400px (including header and footer). Footer: Fixed height. Contains Copyright (defined below). 5. INTERACTION & EVENTS (STRICT CONSTRAINTS) -------------------------------------------- The game supports TWO ways to match, and both MUST work on every device: drag-and-drop (Systems A and B) and tap-to-match (System C). A first-time player must succeed with whichever one they try, with no instructions. You must implement the drag systems A and B as two completely separate event handling systems. Do not unify them. Use a `ref` (e.g., `clickTracker`) to store `{ id: null, time: 0 }` for double-action detection. TYPE CASTING (CRITICAL): IDs pulled from the DOM (`data-id`) or drag events are ALWAYS Strings. Because the IDs in `RAW_DATA` are Integers, you MUST wrap extracted IDs in `parseInt(id)` before doing ANY game logic, strict equality (`===`) checks, or array filtering. System A: Desktop (Mouse) - Hint Event: Use the standard `onDoubleClick` React event to trigger the hint. CRITICAL FIX: You MUST conditionally disable this on mobile to prevent double-speak (e.g., `onDoubleClick={isTouchDevice ? undefined : () => handleHint(item)}`). If you leave it active on mobile, the OS will fire both the custom touch double-tap AND the native double-click, playing the audio twice! - Draggable Attribute: Conditionally set draggable={!isTouchDevice}. CRITICAL FIX: You MUST manage isTouchDevice using React useState (e.g., const [isTouchDevice, setIsTouchDevice] = useState(false)). Do NOT use useRef. Update it to true inside useEffect on mount. This guarantees React re-renders and strictly removes the draggable attribute on mobile, preventing native OS drag from hijacking the UI and dragging the whole column! - Events: Use onDragStart, onDrop, and onDoubleClick. `onDragStart` MUST clear any tap selection (`clearSelection()`). - Visuals: Use onDragOver (apply .target-hover) and onDragLeave (remove .target-hover). System B: Mobile (Touch) - PRECISION GHOST CARD & HOVER IMPLEMENTATION CSS Requirement: You MUST add the Tailwind class touch-none directly to the JSX className of the .game-area container, the two column containers, AND every .game-card element. Do not rely on custom CSS blocks for this. Add onContextMenu={(e) => e.preventDefault()} to the card elements to prevent long-press ghost dragging. Logic Pattern: Use the "Global Window Listener" pattern. CRITICAL LISTENER LIFECYCLE: You MUST attach `touchstart`, `touchmove`, and `touchend` window listeners exactly once inside a `useEffect` on component mount. DO NOT attach the `touchmove` listener dynamically inside the `touchstart` handler, otherwise mobile browsers will ignore `e.preventDefault()`. Always use the `{ passive: false }` option. Double Tap Guard: At start of onTouchStart, check if id === ref.current.id and Date.now() - ref.current.time < 300. If YES: trigger hint, remove existing clones, mark this touch as consumed (so its `touchend` does nothing: no tap, no drop), and RETURN immediately. TAP vs DRAG (CRITICAL): On `touchstart` over a card, record the start coordinates and the card's id/side in a ref, but DO NOT create the clone yet. In `touchmove`, create the clone ONLY once the finger has moved 10px or more (Euclidean distance) from the start point; from then on it is a drag (call `clearSelection()` at that moment) and behaves exactly as described below. Only LEFT cards can be dragged. In `touchend`: if no clone was ever created (movement stayed under 10px), it is a TAP: set `window._lastTouchTapTime = Date.now()` and call `handleCardTap(id, side)` for the touched card (left OR right). Otherwise it is a drop (activeTouchEnd below). Always reset the touch-tracking ref and remove the clone on `touchend` and `touchcancel`. Clone Creation (only once the 10px drag threshold is passed): Create visual CLONE appended to document.body. Set clone.style.width to source element's offsetWidth, pointer-events: none, and z-index: 9999. Visibility Offset: Set style.top to (touch.clientY - 90) + 'px' and style.left to (touch.clientX - (clone.offsetWidth / 2) - 30) + 'px'. This large offset ensures the card is fully visible above and to the left of the finger. The activeTouchMove function: CRITICAL ANTI-SCROLL FIX: At the VERY TOP of the function, you MUST add: if (e.target.closest('.game-area')) { e.preventDefault(); } BEFORE any guard clauses like if (!isDragging) return;. If you place preventDefault after the guard clause, touching the gaps between cards will drag/scroll the entire column! After your guard clause, update Clone top and left using the same offsets. Hover State Management (The "Pro" Standard): Calculate the clone's center point: const centerX = clone.offsetLeft + (clone.offsetWidth / 2); const centerY = clone.offsetTop + (clone.offsetHeight / 2); Use document.elementFromPoint(centerX, centerY) to find the element currently under the Clone. If the element is a TARGET_LANGUAGE card (or inside one), apply .target-hover to it. CRITICAL: Remove .target-hover from all other cards immediately so only one is highlighted at a time. CRITICAL CONSTRAINT: You MUST use direct DOM manipulation (element.classList.add/remove) to apply .target-hover. DO NOT use React state (useState) to track the hovered card during activeTouchMove. The activeTouchEnd function: Remove window listeners and the Clone. Precision Drop Logic: Identify the drop target using document.elementFromPoint() at the Clone's last center coordinates (not the finger coordinates). Remove .target-hover from all cards. Call handleMatch(id) or handleFail(id) based on the data-id of the target found. Critical: Inside the onTouchStart global listener, you MUST verify the target is a card using const sourceCard = e.target.closest('.game-card'). If no card is found, return immediately before creating any clones or preventing defaults. Cleanup: Ensure listeners and hover classes are removed even if the component unmounts. System C: Tap-to-Match (all devices) - A single function `handleCardTap(id, side)` handles every tap/click on a card. - ID TYPES (CRITICAL): `handleCardTap` MUST begin with `id = parseInt(id);`, and `selectCard` MUST store `parseInt(id)`. When rendering, compute `isSelected` as `selectedCard && selectedCard.id === parseInt(item.id) && selectedCard.side === ''`. Never compare against `String(...)`: clicks pass numeric ids while touch handlers read string `data-id` values, and a type mismatch silently hides the highlight. - NO REACT STATE INSIDE `handleCardTap` (CRITICAL): it is called from the global touch listeners attached once on mount, so any `useState` value it reads is frozen at its initial value. Read only `selectedRef`, `successIdsRef` and `showInfoRef` (Section 3). - Rules, in order: 1. Ignore the tap if the Info modal is open (`showInfoRef.current`), or if the tapped card's id is in `successIdsRef.current` (it is already matched and about to be removed; without this a player could re-match the green cards and score twice). 2. Nothing selected -> select the tapped card (left OR right; either column may be tapped first). 3. Tapped the currently selected card again -> deselect it (`clearSelection()`). 4. Tapped a different card in the SAME column -> move the selection to the tapped card. 5. Tapped a card in the OTHER column -> attempt a match: baseId = the LEFT card's id, targetId = the RIGHT card's id (regardless of which was tapped first). Call `clearSelection()`, then run exactly the same SUCCESS / FAIL logic used by drag-and-drop (Section 6). Do NOT duplicate the scoring logic; both systems call the same match function. - Wiring: every card has `onClick={() => onCardClick(item.id, 'left'|'right')}`. `onCardClick` MUST first check `if (window._lastTouchTapTime && Date.now() - window._lastTouchTapTime < 700) return;` because mobile browsers fire a synthetic click after a touch tap, and handling both would select and immediately deselect the card. Otherwise it calls `handleCardTap`. - Clear the selection whenever a new round starts (`startRound`) and after every match attempt (success or fail). - Hint compatibility: a double-click fires click, click, dblclick (and a double tap fires two taps), so the card is selected and then deselected; this is expected. The hint handler MUST end by calling `selectCard(id, 'left')` for the hinted card, so after the orange flash the user can simply tap/click the highlighted TARGET_LANGUAGE card to complete the (no-points) match. Do not add any other click-delay or click-cancelling logic. 6. GAME LOGIC ------------- - Matching: Drag BASE_LANGUAGE to TARGET_LANGUAGE, or tap one card and then its partner in the other column (System C). Both paths call the same SUCCESS / FAIL logic below. - HINTS: - Input: Triggered via the manual Double-Click/Tap logic defined in Section 5. - CRITICAL DEBOUNCE: Inside the function that executes the hint, include: `if (window._lastHintTime && Date.now() - window._lastHintTime < 500) return; window._lastHintTime = Date.now();`. - Effect: Set a GLOBAL variable `window._hintUsed = true;` (DO NOT use React useState for this, it will fail in touch closures). - Reset streak to 0. - Set a React state `hintTargetId` to the item.id. The TARGET_LANGUAGE card flashes Orange for 2 seconds. Speak the card. - SUCCESS (IDs match): - Execution Order (CRITICAL iOS TIMING): 1. Read `window._hintUsed`. 2. If false, IMMEDIATELY AND SYNCHRONOUSLY call `speakCard(matchedItem)`. 3. If false, update Score (+10 + Streak) and update `learnedIds`. Save with safeStorage.setItem. 4. Increment Streak (if !hintUsed). 5. Set `window._hintUsed = false`. 6. Add baseId to a `successIds` array state to trigger a 1-second Green Flash via CSS. 7. CRITICAL: Wrap the actual removal of the cards from `roundData` in a `setTimeout` of 1000ms. - FAIL (IDs mismatch): - Add IDs to a `failIds` array state to trigger a 2-second Red Flash via CSS. - Set `window._hintUsed = false`. - Reset Streak to 0. Deduct 5 from Score. Save with safeStorage.setItem. - Remove from `failIds` after 2000ms. 7. AUDIO SYSTEM (MOBILE ROBUSTNESS - iOS FIXES) ----------------------------------------------- CRITICAL: iOS Safari TTS is highly unstable. You MUST implement the audio system exactly as described below. Initialization & Unlock (The Native Pattern): - You MUST use a `useEffect` on component mount to attach native listeners: `window.addEventListener('touchstart', unlockAudio, { once: true });` and `mousedown`. - Inside `unlockAudio`: Call `window.speechSynthesis.resume()`. Create `const unlockUtterance = new SpeechSynthesisUtterance(' ');`. Set volume to 1. Call `window.speechSynthesis.speak(unlockUtterance);`. DO NOT place this unlock logic inside the game's drag-and-drop touch events. Implementation inside `speakCard(item)`: - STALE CLOSURE FIX: At the very top, add: `if (safeStorage.getItem('openlang_global_mute_state') === 'true') return;`. - Target Locale: If `navigator.language.toLowerCase().includes('en')`, use `CONFIG.LANGUAGE_CODE.toLowerCase()`. Otherwise, use 'en'. - Text Source: If English browser, use TARGET_LANGUAGE. Otherwise, use English. Execution Constraints: - Priority 1: If `item.audio` exists, `new Audio(item.audio).play(); return;` - Priority 2 (Synthesis Fallback): 1. CRITICAL iOS QUEUE FIX: `window.speechSynthesis.cancel();` 2. `window.speechSynthesis.resume();` 3. Create: `const utterance = new SpeechSynthesisUtterance(TextSource);` 4. CRITICAL iOS GARBAGE COLLECTION FIX: `window._activeUtterance = utterance;` 5. `utterance.lang = TargetLocale;` 6. `const voices = window.speechSynthesis.getVoices();` 7. `const voiceMatch = voices.find(v => v.lang.toLowerCase().startsWith(TargetLocale));` 8. CRITICAL iOS LANG FIX: If `voiceMatch` exists, set `utterance.voice = voiceMatch;` AND `utterance.lang = voiceMatch.lang;` (This safely adopts the exact device tag, preventing silent drops). 9. Call `window.speechSynthesis.speak(utterance);` SYNCHRONOUSLY. 8. HEADER --------- - Header Elements LOGO: SVG logo of "文" U+6587 (1.9em, deep red, linked to "/") LANGUAGE: the exact text "Navajo" (.8rem, blue, underlined, linked to "/Navajo") GAME: Navajo Culture (.8rem, dark grey) MUTE: Mute Button: The exact text "MUTE" (1rem). Toggles isMuted state, mutes all sound, text toggles to "UNMUTE". CRITICAL: You MUST use the exact storage key 'openlang_global_mute_state' (via `safeStorage.getItem`) to initialize isMuted on mount, and you MUST save (via `safeStorage.setItem`) the boolean value to this exact key every time the button is toggled so the setting persists universally across all games. INFO: Info icon: question mark ("?" 1.8rem): Opens Info modal. - Info Modal: - Explain rules in detail (how to match: drag a card onto its partner, OR tap a card and then tap its partner in the other column; point scoring for correct and penalty for wrong answer, streak bonus = length of the streak, hint rules, no points when hint is used, hint resets streak, MUTE button to kill sound, game is won when all cards are guessed). - Closing: Add a global `window.addEventListener('keydown')` to close modal when "Escape" is pressed or on any tap or mouse click. SCORE: "Score X" (.8rem) LEARNED: Learned Y% (.8rem) SOURCE: Button with the text "SOURCE" (1rem). Link . - Implementation: Use 'display: grid; grid-template-columns: 6% 24% 20% 50%; width: 100%; box-sizing: border-box; height: 2.5rem;', tight veritcal spacing. - Column 1: - LOGO - Column 2: - First Row: LANGUAGE - Second Row: GAME - Column 3: - First Row: SCORE - Second Row: LEARNED - Column 4: - Layout: This grid cell MUST be a flex container: 'display: flex; width: 100%; height: 100%; align-items: stretch; justify-content: flex-end; gap: 2px;'. - Button/Link Constraints: MUTE, INFO, SOURCE: - Every element in this column MUST have 'flex: 1 1 0%;' (force equal growth/shrink) and 'display: flex;'. - Use 'align-items: center; justify-content: center;' on the buttons so text remains centered as they grow. - Set 'width: 100%' and 'height: 100%' for each button/link to fill the header's vertical and horizontal space. - Appearance: Add a border (e.g., 'border: 1px solid #e5e7eb') and background to make them look like large, distinct touch targets. - General Styling: - Metadata Font: 10px to 12px. - Button Font: 9px to 11px, 'font-weight: bold', 'white-space: nowrap'. - Use 'text-center' on all text elements. 9. DEBUGGING & SAFETY (CRITICAL) --------------------------------- To prevent "Blank Screen" errors, you MUST implement a global error handler at the VERY TOP of the