0. CONTENT GENERATION --------------------- Create a dataset with the 50 most commonly used Latin loanwords and phrases that are used in English. 1. GLOBAL CONFIGURATION ----------------------- - CARDS_PER_ROUND: 6 - BASE_LANGUAGE: English - TARGET_LANGUAGE: Latin - LANGUAGE_CODE: Two letter standard code for TARGET_LANGUAGE (All Caps). - GA_MEASUREMENT_ID: "G-WFWF326Q9H" Dataset Fields: id | text (Latin) | translation (English Translation. Include the literal translation of the component words and the idiomatic meaning. Label the literal translation "Literally". Do not label the idiomatic translation.) | 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 const `STORAGE_KEY = 'openlang_Latin_Longer-Phrases_progress'`. - Persistence Schema: `{ score: number, streak: number, learnedIds: [] }`. - Initialization Logic (Order of Operations): 1. On mount, read `localStorage`. 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. If items remain, call `startRound()` to load the first batch. - Round State: Use `useState` (`roundData`) for the current CARDS_PER_ROUND pairs. - Mastery Logic: When a match is made (no hint), remove the word from `deckRef.current` immediately. - 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 (50% width). - 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): Left (English) Card: Display the English text at .8rem using the calculated fixed height. Right (TARGET_LANGUAGE) Card: Display the TARGET_LANGUAGE translation 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. 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. 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) -------------------------------------------- You must implement 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. 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. - 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, and RETURN immediately. Clone Creation: 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. 6. GAME LOGIC ------------- - Matching: Drag BASE_LANGUAGE to TARGET_LANGUAGE. - HINTS: - Input: Triggered via the manual Double-Click/Tap logic defined in Section 5. CRITICAL DEBOUNCE: Inside the function that executes the hint, you MUST include a strict debounce at the very top: `if (window._lastHintTime && Date.now() - window._lastHintTime < 500) return; window._lastHintTime = Date.now();`. This prevents the audio from duplicating if native and custom double-tap events overlap. - No points awarded for this word if hint is used. - Set a global state `HintUsed` = true; - Strict: the match is not considered successful, the card that was double-clicked remains in play. - Reset streak. - Effect: Set a global state `hintTargetId`. The *TARGET_LANGUAGE* card with that ID flashes Orange for 2 seconds. The English card does NOT change color. The TARGET_LANGUAGE card is spoken. - SUCCESS: IDs match -> Flash Green 1 second -> TARGET_LANGUAGE text is spoken (if HintUsed = false) -> Cards Collapse/Slide Up 0. Streak bonus: equal to the length of the streak. 1. If !HintUsed, update Score (+10 + Streak Bonus). 2. If !HintUsed, add the matched ID to the list of `learnedIds` (merge with existing). 3. Save the new `score` and `streak`. 4. Persistence: On every successful match (handleMatch), immediately update `localStorage`. 5 Set a global state `HintUsed` = false; - FAIL: IDs mismatch -> Flash Red 2 sec -> Reset Streak -> Deduct Score (-5). 7. AUDIO SYSTEM (MOBILE ROBUSTNESS) ----------------------------------- CRITICAL: iOS Safari TTS is highly unstable. To prevent inconsistent audio logic across different builds, you MUST implement the audio system using these exact constraints. Do not guess or map exact regional locales (like 'es-ES' vs 'es-MX'); use broad 2-letter matching. Initialization & Unlock: - Context: `const isEnglishBrowser = navigator.language.toLowerCase().includes('en');` - Unlock (Mobile Fix): On first user interaction (touchstart/mousedown), call `window.speechSynthesis.resume()` and speak an utterance containing a single space (' ') at normal volume. DO NOT use an empty string ('') or volume = 0. DO NOT call `cancel()` during this step, as it can permanently silence the engine on iOS. Implementation inside `speakCard(item)`: - CRITICAL STALE CLOSURE FIX: At the very top of `speakCard`, you MUST include: `if (localStorage.getItem('openlang_global_mute_state') === 'true') return;`. Do not rely solely on the `isMuted` React state here, as it gets trapped in stale closures within the global touch event listeners. - Target Locale: If `isEnglishBrowser` is true, use the 2-letter `CONFIG.LANGUAGE_CODE` converted to lowercase (e.g., 'es', 'fr', 'de'). If false, 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. Call `window.speechSynthesis.resume()`. (Do NOT call `cancel()`). 2. Create: `const utterance = new SpeechSynthesisUtterance(TextSource);` 3. CRITICAL iOS GARBAGE COLLECTION FIX: Assign it globally BEFORE speaking: `window._activeUtterance = utterance;` 4. Set fallback language: `utterance.lang = TargetLocale;` 5. Fetch voices dynamically: `const voices = window.speechSynthesis.getVoices();` (Do NOT rely entirely on React state for voices, as iOS `onvoiceschanged` often fails). 6. Match broadly: `const voiceMatch = voices.find(v => v.lang.toLowerCase().startsWith(TargetLocale));` 7. If `voiceMatch` exists, set `utterance.voice = voiceMatch;` 8. Call `window.speechSynthesis.speak(utterance);` SYNCHRONOUSLY. Do not wrap in `setTimeout` or promises. 8. HEADER --------- - Header Elements LOGO: SVG logo of "文" U+6587 (1.9em, deep red, linked to "/") TARGET_LANGUAGE (.8rem, blue, underlined, linked to "/Latin", preserve upper/lower case) GAME: Loanwords (.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 localStorage key 'openlang_global_mute_state' to initialize isMuted on mount, and you MUST save 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 (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