0. CONTENT GENERATION
---------------------
Start with this data:
-------------------------------
Quote | Author
I have a dream. | Martin Luther King Jr.
We have no right to be tired. | Che Guevara
Big Brother is watching you. | George Orwell
Every state is founded on force. | Leon Trotsky
I trust no one, not even myself. | Josef Stalin
Fascism is capitalism in decay. | Vladimir Lenin
Charity is a failure of government. | Zohran Mamdani
Revolutions are the locomotives of history. | Karl Marx
To achieve much, you must lose everything. | Che Guevara
A winner is a dreamer who never gives up. | Nelson Mandela
Freedom is the recognition of necessity. | Friedrich Engels
A lie told often enough becomes the truth. | Vladimir Lenin
Who controls the present controls the past. | George Orwell
Silence is argument carried on by other means. | Che Guevara
It always seems impossible until it’s done. | Nelson Mandela
Death solves all problems; no man, no problem. | Josef Stalin
Political power grows out of the barrel of a gun. | Mao Zedong
An ounce of action is worth a ton of theory. | Friedrich Engels
A riot is the language of the unheard. | Martin Luther King Jr.
Better to die standing than to live on your knees. | Che Guevara
It was patriotism, not communism, that inspired me. | Ho Chi Minh
Those who do not move, do not notice their chains. | Rosa Luxemburg
Nothing is more precious than independence and liberty. | Ho Chi Minh
Condemn me. It does not matter. History will absolve me. | Fidel Castro
One man with a gun can control one hundred without one. | Vladimir Lenin
We think too small, like the frog at the bottom of the well. | Mao Zedong
We are not broke, we are just unwilling to tax the rich. | Zohran Mamdani
The true revolutionary is guided by a great feeling of love. | Che Guevara
Liberty is precious; so precious that it must be rationed. | Vladimir Lenin
Healthcare must be recognized as a right, not a privilege. | Bernie Sanders
Housing is a human right, not a commodity for speculation. | Zohran Mamdani
Hate cannot drive out hate; only love can do that. | Martin Luther King Jr.
A single death is a tragedy; a million deaths is a statistic. | Josef Stalin
Cruel leaders are replaced only to have new leaders turn cruel. | Che Guevara
Men do not shape destiny, Destiny produces the man for the hour. | Fidel Castro
Lead from the back — and let others believe they are in front. | Nelson Mandela
Capitalists will sell us the rope with which we will hang them. | Vladimir Lenin
Workers of the world unite; you have nothing to lose but your chains. | Karl Marx
Freedom is always the freedom of the one who thinks differently. | Rosa Luxemburg
In the Soviet army it takes more courage to retreat than to advance. | Josef Stalin
Everything under heaven is in utter chaos; the situation is excellent. | Mao Zedong
Let a hundred flowers bloom, let a hundred schools of thought contend. | Mao Zedong
We don't just need a seat at the table; we need to flip the table. | Zohran Mamdani
Education is a weapon whose effects depend on who holds it in his hands. | Josef Stalin
A revolution is a struggle to the death between the future and the past. | Fidel Castro
Free at last, Free at last, Thank God almighty we are free at last. | Martin Luther King Jr.
Resentment is like drinking poison and then hoping it will kill your enemies. | Nelson Mandela
The end may justify the means as long as there is something that justifies the end. | Leon Trotsky
You can kill ten of my men for every one I kill... even at those odds, you will lose. | Ho Chi Minh
Ideas are more powerful than guns. We would not let our enemies have guns, why ideas? | Josef Stalin
Do not judge me by my successes, judge me by how many times I fell down and got back up. | Nelson Mandela
The people who cast the votes decide nothing. The people who count the votes decide everything. | Josef Stalin
-------------------------------
Use the entre quote, do not cut it in mid-sentence.
Dataset Fields: id | text (English) | author | translation (_LANGUAGE Translation)
Translation is of the English text only, not the author.
Concatenate the author to the text (English) field: " –author". Do not start a new line.
Do not add the author to the translation (_LANGUAGE) field.
Delete the author column, and forget that the author field ever existed.
Add an audio field:
1. GLOBAL CONFIGURATION
-----------------------
- CARDS_PER_ROUND: 6
- BASE_LANGUAGE: English
- TARGET_LANGUAGE: Telugu
- GAME: Leftist
- 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 "/Telugu", preserve upper/lower case)
GAME: Leftist (.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