0. CONTENT GENERATION
---------------------
Create a dataset with these words:
--------
suppress
supreme
surge
surgical
surplus
surrender
surveillance
suspension
suspicion
suspicious
sustain
swing
sword
symbolic
syndrome
synthesis
systematic
tackle
tactic
tactical
taxpayer
tempt
tenant
tender
tenure
terminal
terminate
terrain
terrific
testify
testimony
texture
thankfully
theatrical
theology
theoretical
thereafter
thereby
thoughtful
thoughtprovoking
thread
threshold
thrilled
thrive
tide
tighten
timber
timely
tobacco
tolerance
tolerate
toll
top
torture
toss
total
toxic
trace
trademark
trail
trailer
transaction
transcript
transformation
transit
transmission
transparency
transparent
trauma
treaty
tremendous
tribal
tribunal
tribute
trigger
trio
triumph
trophy
troubled
trustee
tuition
turnout
turnover
-------
Note for the translation below: If the English word has no direct translation, describe its use (5 words maximum)
1. GLOBAL CONFIGURATION
-----------------------
- CARDS_PER_ROUND: 9
- BASE_LANGUAGE: English
- TARGET_LANGUAGE: Hindi
- GAME: Advanced-15
- LANGUAGE_CODE: Two letter standard code for TARGET_LANGUAGE (All Caps).
- GA_MEASUREMENT_ID: "G-WFWF326Q9H"
Dataset Fields: id | text (English) | translation (TARGET_LANGUAGE 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 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)). 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, 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; 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 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';
}
}
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)
--------------------------------------------
- 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 (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: 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 "/Hindi", preserve upper/lower case)
GAME: Advanced-15 (.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 (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