0. CONTENT GENERATION --------------------- Create a dataset of multiplication facts from the 2 to 9 times tables. Each card pairs a multiplication with its product. Use exactly these 31 facts, in this order, with these ids (one fact per product, so no two cards share the same product): 1 | 2 × 2 | 4 2 | 2 × 3 | 6 3 | 2 × 4 | 8 4 | 3 × 3 | 9 5 | 2 × 5 | 10 6 | 3 × 4 | 12 7 | 2 × 7 | 14 8 | 3 × 5 | 15 9 | 4 × 4 | 16 10 | 3 × 6 | 18 11 | 4 × 5 | 20 12 | 3 × 7 | 21 13 | 4 × 6 | 24 14 | 5 × 5 | 25 15 | 3 × 9 | 27 16 | 4 × 7 | 28 17 | 5 × 6 | 30 18 | 4 × 8 | 32 19 | 5 × 7 | 35 20 | 6 × 6 | 36 21 | 5 × 8 | 40 22 | 6 × 7 | 42 23 | 5 × 9 | 45 24 | 6 × 8 | 48 25 | 7 × 7 | 49 26 | 6 × 9 | 54 27 | 7 × 8 | 56 28 | 7 × 9 | 63 29 | 8 × 8 | 64 30 | 8 × 9 | 72 31 | 9 × 9 | 81 Dataset Fields: id | text (the multiplication exactly as written above, with the × sign, e.g. "7 × 8") | translation (the product as digits only, e.g. "56") | audio (set explicitly to null) 1. GLOBAL CONFIGURATION ----------------------- - CARDS_PER_ROUND: 8 - BASE_LANGUAGE: English - TARGET_LANGUAGE: Math - GAME: Multiplication - LANGUAGE_CODE: EN - MUTED: true - COMPACT_CARDS: false - GA_MEASUREMENT_ID: "G-WFWF326Q9H" Dataset Fields: id | text (the multiplication, e.g. "7 × 8") | translation (the product as digits, e.g. "56") | 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 matching 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 include at the very top of the . - 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_' + CONFIG.TARGET_LANGUAGE + '_' + CONFIG.GAME + '_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 game. The game MUST stay fully playable when storage is unavailable; progress 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) MUST use `safeStorage.getItem`, `safeStorage.setItem`, or `safeStorage.removeItem`. To list saved keys, use `safeStorage.keys()`. - Wrap `JSON.parse` of saved progress in try/catch; on failure, treat it as no saved data. - The "Mirror Ref" Pattern for Stale Closures: Because global touch listeners trap state, you MUST use `useRef` hooks (`scoreRef`, `streakRef`, `learnedIdsRef`, `deckRef`, `selectedRef`) as the absolute source of truth for math and logic. You MUST also maintain separate `useState` hooks (`score`, `streak`, `learnedIds`, `selectedCard`) purely for rendering the UI. - The Golden Rule: Inside your event handlers, ALWAYS read from the Ref, do the math, update the Ref, and then pass that new Ref value into the state setter (e.g., `scoreRef.current += 10; setScore(scoreRef.current);`). NEVER read the current value from the `useState` variable inside a handler. - Initialization: On mount, read saved progress with `safeStorage.getItem(STORAGE_KEY)`. If data exists, hydrate BOTH the Refs and the State variables. Write total words to storage: `safeStorage.setItem('openlang_' + CONFIG.TARGET_LANGUAGE + '_' + CONFIG.GAME + '_total', RAW_DATA.length);` - Deck Logic: Initialize `deckRef` using a Fisher-Yates shuffle of RAW_DATA items that are NOT in `learnedIdsRef.current`. - Round Transition: Watch `[roundData.left.length, isWon]`. If `roundData.left.length === 0` and `deckRef.current.length > 0`, call `startRound()`. 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)), defined once as the constant `CARD_MAX_HEIGHT` (see FIT TEXT TO CARD) and passed as every card's inline `height`. This ensures cards never change size or move positions when others are removed. - COMPACT CARDS (optional config): CONFIG may contain `COMPACT_CARDS: true` (a boolean; treat a missing field as false). When true, `fitAllCards` gives all cards of a round ONE shared height: just tall enough for the round's longest text (no empty space above and below the text beyond the p-2 padding), at least 44px and never more than CARD_MAX_HEIGHT. That height is set when the round starts and does not shrink while cards are removed, so cards still never change size when others are removed. When false, every card keeps the full CARD_MAX_HEIGHT. Card Content (Strict): Each card must be a single
element with key, data-id, data-side, className, style, draggable, and onClick all on the same element. Do not wrap cards in an outer container div. Left (English) Card: Display the English text using the calculated fixed height .95rem. Right (TARGET_LANGUAGE) Card: Display the translation. CRITICAL SIZING & LOCALIZATION: Use the exact same font size (1rem) by default. CRITICAL FONT SIZING FIX: You MUST use a dynamic inline style for the TARGET_LANGUAGE card's font size. Use exactly this logic: fontSize: ['Arabic', 'Hebrew', 'Farsi', 'Urdu', 'Hindi', 'Thai', 'Chinese', 'Japanese', 'Sindhi'].includes(CONFIG.TARGET_LANGUAGE) ? '1.25rem' : '.95rem'. (Do not use Tailwind text classes for the right card's size). You MUST also include dir={['Arabic', 'Hebrew', 'Farsi', 'Urdu'].includes(CONFIG.TARGET_LANGUAGE) ? 'rtl' : 'ltr'} on the target card element. 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. FONT SUPPORT: Apply a robust modern system font fallback stack tailored to the TARGET_LANGUAGE to ensure non-Latin scripts render beautifully and readably without external font files. 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 (or, with CONFIG.COMPACT_CARDS, the round's shared compact height); text that still does not fit 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 dynamic TARGET_LANGUAGE size). - 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'; } } const MIN_CARD_HEIGHT_PX = 44; const CARD_MAX_HEIGHT = `calc((100dvh - 6.9rem) / ${CONFIG.CARDS_PER_ROUND + 1})`; let fittedIds = ''; let fittedHeight = 0; function fitAllCards() { const cards = Array.from(document.querySelectorAll('[data-side]')); if (cards.length === 0) return; if (!CONFIG.COMPACT_CARDS) { cards.forEach(fitCardText); return; } const ids = cards.map(el => el.dataset.side + el.dataset.id); const newRound = ids.some(id => !fittedIds.includes('|' + id + '|')); fittedIds = '|' + ids.join('|') + '|'; cards[0].style.height = CARD_MAX_HEIGHT; const maxPx = cards[0].offsetHeight; let needPx = MIN_CARD_HEIGHT_PX; cards.forEach(el => { el.style.fontSize = el.dataset.baseFont; el.style.height = 'auto'; needPx = Math.max(needPx, el.offsetHeight); }); if (!newRound) needPx = Math.max(needPx, fittedHeight); fittedHeight = Math.min(maxPx, Math.ceil(needPx)); cards.forEach(el => { el.style.height = fittedHeight + 'px'; fitCardText(el); }); } - 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); }; - Every card's inline style passes `height: CARD_MAX_HEIGHT` (the same constant on every render). Do NOT compute card heights or fitted sizes in React state or in JSX: `fitAllCards` writes `el.style.height` and `el.style.fontSize` directly, and the JSX keeps passing the same starting values, so React never overwrites the fitted values (the props do 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) -------------------------------------------- - 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. - 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. - System A (Desktop): Use standard React `onDragStart`, `onDrop`, `onDragOver`, `onDragLeave`. Conditionally set `draggable={!isTouchDevice}`. Use `onDoubleClick` for hints (conditionally disabled on mobile). `onDragStart` MUST clear any tap selection (`clearSelection()`). - System B (Mobile): Use global `touchstart`, `touchmove`, `touchend` window listeners attached exactly ONCE in a `useEffect`. Use a visual absolute clone for dragging. Identify the drop target using `document.elementFromPoint()` at the Clone's center coordinates. Apply `.target-hover` directly via DOM classList manipulation. - 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 before. 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: run the existing drop logic, then remove the clone and any `.target-hover`. - Always reset the touch-tracking ref and remove the clone on `touchend` and `touchcancel`. - System C (Tap-to-Match, all devices): A single function `handleCardTap(id, side)` handles every tap/click on a card. - Selection state (Mirror Ref pattern): keep `selectedRef` (source of truth, `{ id, side }` or null) and `selectedCard` state (for rendering .card-selected). Update both together via `selectCard(id, side)` and `clearSelection()`. Inside handlers ALWAYS read `selectedRef.current`, never the state. - 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. Keep `successIdsRef` and `showInfoRef` mirrored from `successIds` and `showInfo` (update each ref in a `useEffect` on that state), and read only those refs in the step-1 guard below. - 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 MATCH VALIDATION / 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). - Double-click hint compatibility (desktop): a double-click fires click, click, dblclick, so the first two clicks select and then deselect the left card; this is expected. The hint handler MUST end by calling `selectCard(id, 'left')` for the double-clicked card, so after the orange flash the user can simply 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 ------------- - MATCH VALIDATION: Check if `parseInt(baseId) === parseInt(targetId)`. - SUCCESS (IDs match): 1. Check the global `window._hintUsed` boolean. 2. If hint was NOT used: - Call `speakCard()`. - Increment `scoreRef.current` by (10 + `streakRef.current`). - Increment `streakRef.current` by 1. - Push the parsed ID to `learnedIdsRef.current` (if not already there). - Filter the ID out of `deckRef.current`. 3. If hint WAS used: Apply no points, do not increase streak, do not add to learned words, and reset `window._hintUsed = false`. 4. Sync UI & Storage: Call `setScore`, `setStreak`, and `setLearnedIds` using the newly updated Ref values. Save the Refs with `safeStorage.setItem(STORAGE_KEY, ...)`. 5. Add parsed baseId to `successIds` state to trigger Green Flash. 6. Remove both cards from `roundData` state inside a 1000ms `setTimeout`. - FAIL (IDs mismatch): 1. Add parsed targetId to `failIds` state for Red Flash. 2. Reset `window._hintUsed = false`. 3. Reset `streakRef.current` to 0. Decrease `scoreRef.current` by 5 (minimum 0). 4. Sync UI & Storage: Call `setScore` and `setStreak` with the updated Refs. Save with `safeStorage.setItem(STORAGE_KEY, ...)`. 5. 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 (isMutedNow()) return;` (see MUTE in the HEADER section; it reads the saved mute state from storage). - 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: An anchor tag wrapping the character "文" (U+6587). Style: font-size 1.9em, color deep red, no text-decoration. The entire element must be a clickable link to "/". TARGET_LANGUAGE (.8rem, blue, underlined, linked to "/Math", preserve upper/lower case) GAME: Multiplication (.8rem, dark grey) MUTE: Mute Button: The exact text "MUTE" (1rem). Toggles isMuted state, mutes all sound, text toggles to "UNMUTE". - MUTED BY DEFAULT (optional config): CONFIG may contain `MUTED: true` (a boolean; treat a missing field as false). Define, with the global constants: `const MUTE_KEY = CONFIG.MUTED ? 'openlang_' + CONFIG.TARGET_LANGUAGE + '_' + CONFIG.GAME + '_mute_state' : 'openlang_global_mute_state';` and `function isMutedNow() { const saved = safeStorage.getItem(MUTE_KEY); return saved === null ? !!CONFIG.MUTED : saved === 'true'; }`. - A game with MUTED: true starts muted (the button shows "UNMUTE") and keeps its own mute setting under its own key, so it never changes the mute state of other games. All other games share the exact key 'openlang_global_mute_state' so the setting persists universally across them. - CRITICAL: initialize isMuted on mount with `setIsMuted(isMutedNow())`; every time the button is toggled save the boolean to MUTE_KEY (via `safeStorage.setItem`); and the mute check at the top of speakCard MUST be `if (isMutedNow()) return;` (read storage, not React state, to avoid stale closures). 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 (double-click a left card on desktop), 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). CRITICAL: You MUST dynamically build the URL using the config variables via JSX template literals. 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. STRICT CODE ORGANIZATION (NON-NEGOTIABLE) ---------------------------------------------- To ensure consistency and prevent Scope/Reference errors, you MUST structure the