milwright commited on
Commit
a6d0aac
·
0 Parent(s):

clean production deployment with comprehensive readme

Browse files
.dockerignore ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node modules
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+
6
+ # Git
7
+ .git/
8
+ .gitignore
9
+
10
+ # Environment files
11
+ .env
12
+ .env.*
13
+
14
+ # IDE files
15
+ .vscode/
16
+ .idea/
17
+ *.swp
18
+ *.swo
19
+
20
+ # OS files
21
+ .DS_Store
22
+ Thumbs.db
23
+
24
+ # Logs
25
+ *.log
26
+ logs/
27
+
28
+ # Temporary files
29
+ tmp/
30
+ temp/
31
+ .aider*
32
+
33
+ # Documentation
34
+ *.md
35
+ !README.md
36
+
37
+ # Planning documents
38
+ *_PLAN.md
39
+ IMPLEMENTATION_*.md
40
+
41
+ # Python cache
42
+ __pycache__/
43
+ *.pyc
44
+ *.pyo
45
+ *.pyd
46
+
47
+ # Coverage
48
+ coverage/
49
+ .nyc_output/
50
+
51
+ # Build artifacts
52
+ dist/
53
+ build/
.env.example ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenRouter API Key (for AI functionality)
2
+ OPENROUTER_API_KEY=your_openrouter_key_here
3
+
4
+ # Hugging Face API Token (for leaderboard persistence)
5
+ # Get your token from: https://huggingface.co/settings/tokens
6
+ # Required permissions: write access to datasets
7
+ HF_TOKEN=your_hf_token_here
8
+ # OR use HF_API_KEY (both work)
9
+ HF_API_KEY=your_hf_token_here
10
+
11
+ # Hugging Face Leaderboard Repository (optional, defaults to zmuhls/cloze-reader-leaderboard)
12
+ # Format: username/repo-name
13
+ HF_LEADERBOARD_REPO=your_username/cloze-reader-leaderboard
.gitignore ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+
7
+ # Build outputs
8
+ dist/
9
+ .parcel-cache/
10
+
11
+ # Environment variables
12
+ .env
13
+ .env.local
14
+ .env.development.local
15
+ .env.test.local
16
+ .env.production.local
17
+
18
+ # IDE and editor files
19
+ .vscode/
20
+ .idea/
21
+ *.swp
22
+ *.swo
23
+ *~
24
+
25
+ # OS generated files
26
+ .DS_Store
27
+ .DS_Store?
28
+ ._*
29
+ .Spotlight-V100
30
+ .Trashes
31
+ ehthumbs.db
32
+ Thumbs.db
33
+
34
+ # Logs
35
+ logs
36
+ *.log
37
+
38
+ # Runtime data
39
+ pids
40
+ *.pid
41
+ *.seed
42
+ *.pid.lock
43
+
44
+ # Leaderboard data (stored in dedicated HF Space)
45
+ leaderboard.json
46
+
47
+ # Optional npm cache directory
48
+ .npm
49
+
50
+ # Optional eslint cache
51
+ .eslintcache
52
+
53
+ # Coverage directory used by tools like istanbul
54
+ coverage/
55
+
56
+ # Temporary folders
57
+ tmp/
58
+ temp/
59
+ .aider*
60
+
61
+ # Python
62
+ __pycache__/
63
+ *.py[cod]
64
+ *$py.class
65
+ *.so
66
+
67
+ # Planning documents
68
+ HF_DATASET_INTEGRATION_PLAN.md
69
+ *_PLAN.md
70
+ IMPLEMENTATION_*.md
71
+
72
+ # Local configuration files
73
+ *.local
74
+ .env
75
+
76
+ # Development and testing files
77
+ favicon.ico
78
+ package-lock.json
79
+ test-*.html
80
+
81
+ # Temporary files and screenshots
82
+ Screenshot*.png
83
+ *-screenshot.png
84
+ error-screenshot.png
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
2
+ FROM python:3.9
3
+
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV PATH="/home/user/.local/bin:$PATH"
7
+
8
+ WORKDIR /app
9
+
10
+ COPY --chown=user ./requirements.txt requirements.txt
11
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
12
+
13
+ COPY --chown=user . /app
14
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
Makefile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: help dev dev-python dev-docker build test clean install docker-build docker-run docker-dev
2
+
3
+ help: ## Show this help message
4
+ @echo "Available commands:"
5
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
6
+
7
+ install: ## Install dependencies
8
+ @echo "Installing Python dependencies..."
9
+ pip install -r requirements.txt
10
+ @echo "No Node dependencies to install (vanilla JS)"
11
+
12
+ dev: ## Start local static server (for frontend only)
13
+ @echo "Starting local static server on http://localhost:8000"
14
+ python -m http.server 8000
15
+
16
+ dev-python: ## Start FastAPI development server
17
+ @echo "Starting FastAPI server on http://localhost:7860"
18
+ python app.py
19
+
20
+ dev-docker: ## Start development environment with Docker Compose
21
+ docker-compose --profile dev up --build
22
+
23
+ build: ## Build the application (no-op for vanilla JS)
24
+ @echo "No build step needed for vanilla JS application"
25
+
26
+ test: ## Run tests (placeholder)
27
+ @echo "No tests configured yet"
28
+
29
+ clean: ## Clean temporary files
30
+ find . -type f -name "*.pyc" -delete
31
+ find . -type d -name "__pycache__" -delete
32
+ find . -type d -name "node_modules" -exec rm -rf {} + 2>/dev/null || true
33
+
34
+ docker-build: ## Build Docker image
35
+ docker build -t cloze-reader .
36
+
37
+ docker-run: ## Run Docker container
38
+ docker run -p 7860:7860 --env-file .env cloze-reader
39
+
40
+ docker-dev: ## Start with docker-compose
41
+ docker-compose up --build
42
+
43
+ logs: ## Show Docker logs
44
+ docker-compose logs -f
45
+
46
+ stop: ## Stop Docker containers
47
+ docker-compose down
README.md ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cloze Reader: Recovering Deep Reading Through Algorithmic Assessment
2
+
3
+ ## Introduction
4
+
5
+ The Cloze Reader transforms classic literature from the public domain into interactive vocabulary exercises powered by large language models. The application uses Google's Gemma-3 architecture to analyze passages from Project Gutenberg, select contextually appropriate words for deletion, and provide adaptive guidance through a conversational interface. Rather than generating novel text, the system surfaces forgotten public domain literature and invites users back to sustained engagement with individual texts—an act of critical resistance against generative systems designed for infinite, undifferentiated content production.
6
+
7
+ This project stages a recursive relationship: language models trained on prediction tasks are now used to generate prediction exercises for human readers. It forces us to ask what happens when assessment methodology and training methodology become instrumentalized through identical computational systems, when algorithmic selection replaces human pedagogical judgment, and when the boundary between human and machine comprehension becomes difficult to locate.
8
+
9
+ ## Historical Context: Two Parallel Histories That Have Collapsed Into Each Other
10
+
11
+ ### The Educational Assessment History: 1953
12
+
13
+ Wilson L. Taylor, a reading researcher, published a foundational paper introducing the cloze procedure as a tool for measuring reading comprehension. The methodology is deceptively simple: systematically delete words from a passage, ask readers to fill in the blanks, and score accuracy. Taylor argued that successful completion requires more than mere vocabulary recall. Instead, cloze tests measure contextual understanding—the capacity to integrate syntactic, semantic, pragmatic, and discourse cues to reconstruct deleted language.
14
+
15
+ The appeal was immediate and substantial. By the 1960s, cloze testing had become standard in American educational assessment, literacy research, and second-language instruction. The procedure offered educators what they desperately wanted: efficiency (many items from a single passage), objectivity (matching against answer keys rather than interpreting essays), and empirical rigor. Cloze tests promised to bypass the messiness of subjective evaluation while producing quantifiable measures of reading ability.
16
+
17
+ Cloze testing became embedded in the architecture of standardized testing across the United States. It shaped how literacy was defined, measured, and valued. The procedure was not merely an assessment instrument—it codified assumptions about what reading comprehension was and how it should be operationalized for institutional purposes.
18
+
19
+ ### The AI Training History: 2018
20
+
21
+ In October 2018, researchers at Google, Facebook, and University of Washington published BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. The paper introduced masked language modeling (MLM) as a pre-training objective. The approach: randomly mask 15% of tokens in a sequence, train the model to predict the missing tokens from context, and iterate across internet-scale text corpora.
22
+
23
+ Remarkably, the researchers did not reference Taylor or invoke the language of educational assessment. They did not frame their work as a reinvention of the cloze procedure. Yet they had independently converged on cloze methodology as the core training objective for modern transformer models. The theoretical justification was different (learning contextualized embeddings rather than measuring comprehension), but the operational logic was identical: masking creates inference demands; understanding means predicting from context; success can be scored deterministically.
24
+
25
+ BERT and its successors (RoBERTa, ELECTRA, and countless variants) demonstrated that masked prediction is a powerful inductive bias for learning language. Models trained on MLM objectives developed robust representations of language structure, semantic relationships, and pragmatic patterns. The technique scaled to billions of parameters and trillions of tokens. It became foundational to modern large language models and consumer-facing AI systems.
26
+
27
+ The methodology migrated from educational psychology to computational linguistics because both domains had discovered something fundamental: understanding language, whether human or algorithmic, is fundamentally about prediction from context.
28
+
29
+ ### The Convergence: Training Becomes Assessment, Assessment Becomes Training
30
+
31
+ Cloze testing and masked language modeling operate on identical premises:
32
+
33
+ - **Masking creates inference demands**: Removing a token forces reliance on surrounding structure rather than surface pattern matching.
34
+ - **Context integration**: Success requires synthesizing information across syntactic, semantic, and discourse boundaries.
35
+ - **Efficiency and scalability**: Many items can be processed rapidly; scoring is deterministic (for humans, match vs. non-match; for models, cross-entropy loss).
36
+ - **Variable difficulty**: Some blanks are trivial to fill (limited candidates, high probability); others require deep contextual reasoning.
37
+
38
+ Recent research explicitly draws these connections. Matsumori et al. (2023) used masked language models to generate open cloze questions for L2 English learners, treating MLM-equipped models as natural question generators. Ondov et al. (2024, NAACL) demonstrated that "the cloze training objective of Masked Language Models makes them a natural choice for generating plausible distractors for human cloze questions." Zhang & Hashimoto (2021) analyzed the inductive biases learned by masked tokens in transformer models, showing that these models acquire statistical and syntactic dependencies—precisely the linguistic phenomena that cloze tests aim to measure in human readers.
39
+
40
+ The Cloze Reader stages this convergence in a different way. While Gemma-3 (the model powering the system) was trained on next-token prediction rather than masked language modeling, the conceptual framework remains: we are using a language model trained on prediction tasks to generate prediction exercises for human learners. The system demonstrates that cloze generation and evaluation methodologies—once separate domains—are now instrumentalized through the same computational substrate. The boundary between training methodology and evaluation methodology has become permeable.
41
+
42
+ ## Critical Framework: What This Convergence Reveals
43
+
44
+ ### Recursive Assessment
45
+
46
+ Traditional assessment assumed a clean separation: educators design instruments; students complete them; data informs pedagogy. The Cloze Reader collapses this boundary. The same prediction task that trains state-of-the-art language models now serves as an assessment tool. This recursion raises uncomfortable questions. Can a model trained on surface pattern prediction—albeit at scale and with sophisticated architectures—generate assessment instruments that genuinely measure comprehension? Or does it reproduce the same surface-level pattern matching that enabled its own training? When human users solve cloze exercises generated by such a model, are they engaging in comprehension or replicating the model's own heuristics?
47
+
48
+ ### Standardization Versus Serendipity
49
+
50
+ Educational cloze testing sought psychometric validity. Practitioners conducted readability studies, piloted passages with student populations, refined difficulty levels, and designed items to discriminate across ability ranges. The entire enterprise was about controlling and calibrating difficulty through human expertise.
51
+
52
+ The Cloze Reader abandons this control. It uses Gemma models trained on internet-scale text, applies them to the full Project Gutenberg corpus (70,000+ books with no curricular planning), and relies on statistical algorithms to select words for deletion. Difficulty emerges from the algorithmic interaction between passage content and model parameters, not from designed pedagogical intent. The same passage might yield wildly different difficulty levels depending on which words the model selects. What does "appropriate difficulty" mean when the system that determines it is a black box trained on patterns that nobody fully understands?
53
+
54
+ ### Surface Cues Versus Deep Comprehension
55
+
56
+ Educational researchers have long critiqued cloze testing on the grounds that it measures local, sentence-level inference rather than global text comprehension. A reader might successfully complete a cloze passage by exploiting surface regularities and syntactic patterns without grasping the broader meaning of the text. Similarly, critics of masked language modeling note that models can achieve high accuracy by exploiting spurious statistical correlations rather than developing genuine semantic understanding. When a model trained on surface patterns generates cloze exercises, and human users solve those exercises using similar heuristics, where is comprehension happening? Are we measuring something real, or are we staging an elaborate performance of mutual statistical prediction?
57
+
58
+ ### Authority and Transparency in Assessment Design
59
+
60
+ For most of the twentieth century, assessment authority was vested in institutional expertise. Teachers selected texts. Curriculum experts designed items. Psychometricians validated instruments. Authority was hierarchical, but it was also documentable: you could point to a specific teacher's choice or a specific item-analysis report. Assessment design was legible, even if it was unequally distributed.
61
+
62
+ The Cloze Reader distributes authority differently. The models it uses (Gemma-3) are open-weight models that anyone can download, inspect, and run locally. The system is designed for interrogation rather than institutional gatekeeping. You can examine the model's outputs, trace its decisions, modify its behavior, and deploy it on your own hardware. But this distribution of authority comes at a cost. Assessment design is now black-boxed in model weights rather than documented in curriculum guides. Patterns that determine difficulty are learned from billions of internet text samples rather than articulated through expert judgment. Authority is distributed, but it is also obscured.
63
+
64
+ ### The Project Gutenberg Paradox
65
+
66
+ Project Gutenberg texts occupy a peculiar position in contemporary information ecology. These works are public domain—legally and technically available to everyone. Yet they are remarkably absent from popular consciousness. Most readers encounter these texts, if at all, through abridged excerpts in educational settings. The original, uncut texts languish in digital repositories.
67
+
68
+ Simultaneously, these same texts have been appropriated relentlessly as training data for large language models. Every major AI system trained on internet-scale corpora has incorporated Project Gutenberg texts. Commercial models have profited from and learned from this public archive without returning anything to the public sphere. The texts that should be most widely read have become most thoroughly invisible—present only as statistical patterns embedded in proprietary model weights.
69
+
70
+ The Cloze Reader inverts this trajectory. It surfaces Project Gutenberg texts. It makes them the primary content, not auxiliary training material. It asks users to read actual passages, carefully selected for quality but not processed or summarized. The design philosophy is explicit: bring people back to deep reading practices, precisely through engagement with a fill-in-the-blank game that generates genuinely novel exercises for each reader, each session. Unlike generative AI systems designed to produce infinite novel text, the Cloze Reader is designed to deepen engagement with finite, singular texts.
71
+
72
+ This is not a return to pre-digital reading practices. The system is thoroughly computational. But it is a return to the idea that reading means sustained engagement with specific texts, that repetition and variation can deepen understanding, and that computational systems can serve that deepening rather than obscuring it.
73
+
74
+ ## Architecture and Data Flow
75
+
76
+ ### System Overview
77
+
78
+ The Cloze Reader is a vanilla JavaScript application with a minimal FastAPI backend. The entire frontend runs in the browser using ES6 modules; there is no build step. This architectural choice prioritizes transparency and modifiability—users can inspect every component without specialized tooling.
79
+
80
+ ### Application Flow
81
+
82
+ When a user loads the application, the initialization sequence follows a precise path:
83
+
84
+ 1. **Welcome Overlay** displays onboarding instructions for first-time users.
85
+ 2. **Game Engine Initialization** loads book data and AI services.
86
+ 3. **First Round Generation** creates the initial cloze exercise.
87
+ 4. **UI Activation** reveals the interactive game interface.
88
+
89
+ ```tree
90
+ Page Load → app.js initialization
91
+ ├─ welcomeOverlay.js → First-time user onboarding
92
+ ├─ clozeGameEngine.js → Game state initialization
93
+ │ ├─ bookDataService.js → Stream from Hugging Face Datasets API
94
+ │ │ ├─ Primary: manu/project_gutenberg (70,000+ books)
95
+ │ │ └─ Fallback: Local embedded canonical works
96
+ │ ├─ aiService.js → Gemma-3-27b model selection and word generation
97
+ │ │ ├─ Level-based vocabulary constraints
98
+ │ │ ├─ Passage analysis and candidate filtering
99
+ │ │ └─ Distribution algorithm for blank placement
100
+ │ └─ Content quality filtering (statistical analysis)
101
+ └─ leaderboardService.js → localStorage persistence
102
+
103
+ User Interaction Loop:
104
+ ├─ Input validation → app.js
105
+ ├─ Chat interface → chatInterface.js
106
+ │ └─ conversationManager.js → AI conversation state
107
+ ├─ Answer submission → clozeGameEngine.js scoring
108
+ └─ Round progression → Level advancement logic
109
+ ```
110
+
111
+ ### Core Module Interactions
112
+
113
+ **bookDataService.js** streams text from the Hugging Face Datasets API, accessing the manu/project_gutenberg dataset containing 70,000+ public domain texts. When streaming fails or APIs become unavailable, the system falls back to locally embedded canonical works (Pride and Prejudice, Tom Sawyer, Great Expectations, and others). Book selection is level-aware: levels 1-2 draw from 1900s publications; levels 3-4 from the 1800s; levels 5+ from any historical period.
114
+
115
+ **aiService.js** handles all AI operations using Gemma-3-27b via OpenRouter. The same model handles word selection, hint generation, literary contextualization, and conversational responses. For local deployment, the system automatically switches to Gemma-3-12b when `?local=true` is appended to the URL, connecting to port 1234 (compatible with LM Studio and similar tools).
116
+
117
+ Word selection follows a sophisticated multi-step process: level-based constraints define vocabulary difficulty ranges; passage analysis identifies candidate words while avoiding capitalized words, proper nouns, sentence beginnings, and artifacts; a distribution algorithm ensures blanks are spread throughout the passage; validation filtering confirms words exist in the passage and meet length requirements.
118
+
119
+ **chatInterface.js** provides word-level assistance through a modal-based interface. Users can ask questions about grammar, meaning, context, or request clues. The system preserves chat history per blank across the entire round, allowing for progressive disclosure of information.
120
+
121
+ **leaderboardService.js** manages high scores, player statistics, and localStorage persistence. The system tracks highest level reached, round numbers, total passages passed and attempted, and longest consecutive success streaks. Leaderboard data resets on each page load (ensuring fresh competition), but performance statistics persist within a session.
122
+
123
+ ### Content Quality Filtering
124
+
125
+ The passage extraction system includes sophisticated quality detection to avoid dictionary entries, academic references, technical documentation, and formatting artifacts. The system analyzes capitalization ratios, punctuation density, and sentence structure. It applies pattern recognition for academic material (citations, abbreviations, etymology brackets, Roman numeral references). It detects dictionary-specific formatting (hash symbols used for entry organization) and technical terminology. A progressive scoring system rejects passages above a quality threshold.
126
+
127
+ ### Difficulty Progression
128
+
129
+ The game implements level-aware difficulty scaling:
130
+
131
+ - **Levels 1-5**: 1 blank per passage, easier vocabulary (4-7 letters), full hints available
132
+ - **Levels 6-10**: 2 blanks per passage, medium vocabulary (4-10 letters), partial hints
133
+ - **Levels 11+**: 3 blanks per passage, challenging vocabulary (5-14 letters), minimal hints
134
+
135
+ Level advancement requires passing a single round. Each round contains two passages. Scoring is strict: for 1 blank, 100% accuracy; for 2 blanks, both correct; for 3+ blanks, all but one correct.
136
+
137
+ ## Data Source and Content Pipeline
138
+
139
+ ### Hugging Face Datasets Integration
140
+
141
+ The primary data source is the manu/project_gutenberg dataset available at [https://huggingface.co/datasets/manu/project_gutenberg](https://huggingface.co/datasets/manu/project_gutenberg). This dataset contains full texts from 70,000+ public domain works, updated continuously as new texts enter the public domain. The system uses lazy loading and on-demand processing, streaming excerpts rather than loading entire books into memory.
142
+
143
+ ### Content Processing
144
+
145
+ Books undergo sophisticated cleaning to remove Project Gutenberg metadata (scanning notes, OCR artifacts, structural markers), chapter headers, page numbers, and formatting noise. The system identifies the actual narrative content and validates it for sufficient length and narrative structure. This processing is deferred until a book is selected for gameplay, optimizing overall performance.
146
+
147
+ ### Quality Validation
148
+
149
+ Passages are subject to multiple quality checks. The system analyzes statistical properties (capitalization density, punctuation distribution, sentence length variance), applies pattern detection for reference material and technical content, and screens for dictionary-specific formatting. A passage that fails quality validation is rejected and the system selects a different excerpt or book.
150
+
151
+ ## Technology Stack and Deployment
152
+
153
+ ### Frontend Architecture
154
+
155
+ The application uses vanilla JavaScript with ES6 modules. There is no build process. This architectural choice is intentional: it keeps the machinery visible and modifiable rather than obscured behind tooling. The application is Progressive Web App (PWA) compatible, with manifest configuration supporting installation on mobile devices.
156
+
157
+ ### Backend and API Integration
158
+
159
+ The FastAPI server (`app.py`) serves static files and injects API credentials securely into the browser via meta tags. This approach allows the system to handle sensitive credentials (like OpenRouter API keys) without exposing them in client-side code.
160
+
161
+ ### AI Models
162
+
163
+ **Production Deployment**: Gemma-3-27b via OpenRouter API
164
+
165
+ **Local Deployment**: Gemma-3-12b on port 1234 (LM Studio, ollama, or OpenAI-compatible servers)
166
+
167
+ The model selection prioritizes:
168
+
169
+ - **Open-weight models**: Enables local deployment and inspection
170
+ - **Consistent architecture**: Same model family across production and local deployment
171
+ - **Performance**: Sufficient scale for sophisticated language tasks while remaining computationally feasible
172
+
173
+ ### State Management
174
+
175
+ All game state persists in `localStorage`. There is no backend database. Storage keys are:
176
+
177
+ - `cloze-reader-leaderboard`: Top 10 high scores
178
+ - `cloze-reader-player`: Player profile (initials, session metadata)
179
+ - `cloze-reader-stats`: Comprehensive performance analytics
180
+
181
+ This client-side approach ensures the assessment system is fully auditable. All data transformations occur transparently in browser-side code, not on remote servers.
182
+
183
+ ## Quick Start
184
+
185
+ ### Docker Deployment (Recommended)
186
+
187
+ ```bash
188
+ # Build the image
189
+ docker build -t cloze-reader .
190
+
191
+ # Run with OpenRouter API key
192
+ docker run -p 7860:7860 -e OPENROUTER_API_KEY=your_key_here cloze-reader
193
+
194
+ # Access at http://localhost:7860
195
+ ```
196
+
197
+ ### Local Python Development
198
+
199
+ ```bash
200
+ # Install dependencies
201
+ pip install -r requirements.txt
202
+
203
+ # Start FastAPI server
204
+ python app.py
205
+
206
+ # Access at http://localhost:7860
207
+ ```
208
+
209
+ ### Local Development with Simple HTTP Server
210
+
211
+ ```bash
212
+ # Minimal setup without FastAPI
213
+ python -m http.server 8000
214
+
215
+ # Access at http://localhost:8000
216
+ ```
217
+
218
+ ### Local LLM Integration
219
+
220
+ To run with a local language model server (no API key required):
221
+
222
+ ```bash
223
+ # Start local LLM server on port 1234 (LM Studio, ollama, or similar)
224
+ # Then access the application with:
225
+ http://localhost:8000?local=true
226
+ ```
227
+
228
+ ### Environment Variables
229
+
230
+ - `OPENROUTER_API_KEY`: Required for production deployment (get from [https://openrouter.ai](https://openrouter.ai))
231
+ - `HF_API_KEY`: Optional, for accessing Hugging Face APIs
232
+ - `HF_TOKEN`: Optional, for Hugging Face Hub leaderboard integration
233
+
234
+ ## Development Commands
235
+
236
+ Use the provided Makefile for convenience:
237
+
238
+ ```bash
239
+ make install # Install Python and Node.js dependencies
240
+ make dev # Start development server (simple HTTP)
241
+ make dev-python # Start FastAPI development server
242
+ make docker-build # Build Docker image
243
+ make docker-run # Run Docker container
244
+ make docker-dev # Full Docker development environment
245
+ make clean # Clean build artifacts and cache
246
+ make logs # View Docker container logs
247
+ make stop # Stop Docker containers
248
+ ```
249
+
250
+ ## Critical Questions This System Poses
251
+
252
+ 1. **Recursive Collapse**: What happens when training methodology becomes assessment tool, and assessment data becomes training data? Where is the meaningful boundary?
253
+
254
+ 2. **Pedagogical Authority**: Can algorithmic selection of passages and words, guided by statistical patterns learned from internet-scale corpora, capture what human educators mean by "appropriate difficulty"?
255
+
256
+ 3. **The Nature of Comprehension**: When a model trained on surface pattern prediction generates cloze exercises, and humans solve those exercises using similar heuristics, what is being measured? Is comprehension happening, or are we staging an elaborate performance of mutual statistical inference?
257
+
258
+ 4. **Institutional Authority**: Traditional assessment vested authority in documented expert judgment. This system distributes authority through open-weight models and client-side code, making it interrogable rather than institutional. What is gained or lost in this distribution?
259
+
260
+ 5. **Reading in the Age of Algorithmic Text Generation**: When generative systems produce infinite novel text, what does it mean to ask users to engage deeply with finite, singular public domain texts? Is this a form of resistance, or capitulation to the same systems that trained the models?
261
+
262
+ 6. **Data Extraction and Repatriation**: Public domain texts have been appropriated relentlessly for AI training. What does it mean to surface these texts, return them to visibility, and ask people to read them directly rather than through algorithmic summaries?
263
+
264
+ ## Technical Details and Error Handling
265
+
266
+ ### AI Service Resilience
267
+
268
+ The system implements multiple fallback layers:
269
+
270
+ 1. **Retry with exponential backoff**: Up to 3 attempts with increasing delays
271
+ 2. **Response extraction hierarchy**: Primary (message.content), secondary (reasoning field), tertiary (reasoning_details array), final (regex pattern matching)
272
+ 3. **Manual word selection**: If AI fails, the system falls back to statistical selection based on content analysis
273
+ 4. **Generic hint generation**: Fallback responses based on question type when AI service is unavailable
274
+
275
+ ### Content Service Resilience
276
+
277
+ 1. **HF API availability check**: Test connection before streaming attempts
278
+ 2. **Preloaded content**: Cached books for immediate access
279
+ 3. **Local book fallback**: 10 embedded classics guarantee offline functionality
280
+ 4. **Quality validation retry**: Multiple attempts with different passage selections if quality filtering rejects initial selections
281
+ 5. **Timeout handling**: 15-second request timeout with automatic fallback to sequential processing
282
+
283
+ ## On This Codebase's Design Philosophy
284
+
285
+ The technical decisions in this codebase reflect the conceptual interests outlined above:
286
+
287
+ - **Vanilla JavaScript, no build step**: Keeps machinery visible rather than obscured behind tooling
288
+ - **Open-weight Gemma models**: Enables inspection, local deployment, and interrogation of the assessment generator
289
+ - **Streaming from Project Gutenberg**: Makes the pipeline reproducible without proprietary content
290
+ - **Local LLM support**: Removes dependency on API providers; you can run the entire system on personal hardware
291
+ - **No backend database**: All state is client-side localStorage; the assessment system is fully auditable
292
+ - **Vanilla HTML and CSS**: Aesthetic choices reflect mid-century design rather than contemporary web trends, creating temporal distance between the user and algorithmic systems
293
+
294
+ The goal is not to resolve the tensions between human assessment and algorithmic generation, between deep reading and computational efficiency, between transparent pedagogy and black-boxed AI. Instead, the goal is to **stage these tensions**—to make them experientially tangible through gameplay. Every passage you encounter is a collision between seventy years of educational assessment theory and seven years of masked language modeling. Every blank you fill is an act of both comprehension and statistical prediction.
295
+
296
+ ## References and Further Reading
297
+
298
+ Recent research connecting cloze assessment and masked language modeling:
299
+
300
+ - Matsumori, A., et al. (2023). CLOZER: Generating open cloze questions with masked language models. Proceedings of the 2023 Conference on Empirical Methods in Natural Language Processing.
301
+ - Ondov, B., et al. (2024). Masked language models as natural generators for cloze questions. Proceedings of the 2024 Conference of the North American Chapter of the Association for Computational Linguistics (NAACL).
302
+ - Zhang, Y., & Hashimoto, K. (2021). What do language models learn about the structure of their language? Proceedings of the 59th Annual Meeting of the Association for Computational Linguistics.
303
+
304
+ ## Attribution
305
+
306
+ This project was created by [Zach Muhlbauer](https://huggingface.co/milwright) at the CUNY Graduate Center. The application draws on extensive research in both educational assessment and natural language processing, bringing two disciplinary histories into dialogue through interactive gameplay.
307
+
308
+ For more information, visit the development space at [https://huggingface.co/spaces/milwright/cloze-reader](https://huggingface.co/spaces/milwright/cloze-reader) or the underlying dataset at [https://huggingface.co/datasets/manu/project_gutenberg](https://huggingface.co/datasets/manu/project_gutenberg).
309
+
310
+ ---
311
+
312
+ *The Cloze Reader invites you to encounter public domain literature on its own terms, to practice contextual reasoning in real time, and to confront the strange convergence between how humans and machines understand language through prediction.*
app.py ADDED
@@ -0,0 +1,638 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Query
2
+ from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse, JSONResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+ from pydantic import BaseModel, Field
6
+ from typing import List, Optional
7
+ import os
8
+ import time
9
+ import json
10
+ import asyncio
11
+ import urllib.request
12
+ import urllib.parse
13
+ from dotenv import load_dotenv
14
+ import logging
15
+
16
+ # Load environment variables from .env file
17
+ load_dotenv()
18
+
19
+ # Import Leaderboard Services (Redis primary, HF fallback)
20
+ from redis_leaderboard import RedisLeaderboardService
21
+ from redis_analytics import RedisAnalyticsService
22
+
23
+ # Configure logging
24
+ logging.basicConfig(level=logging.INFO)
25
+ logger = logging.getLogger(__name__)
26
+
27
+ app = FastAPI()
28
+
29
+ # Add CORS middleware for local development
30
+ app.add_middleware(
31
+ CORSMiddleware,
32
+ allow_origins=["*"],
33
+ allow_credentials=True,
34
+ allow_methods=["*"],
35
+ allow_headers=["*"],
36
+ )
37
+
38
+ # Initialize Leaderboard Service (Redis primary, HF Space fallback)
39
+ # REDIS_URL is auto-injected by Railway when Redis plugin is added
40
+ try:
41
+ leaderboard_service = RedisLeaderboardService(
42
+ redis_url=os.getenv("REDIS_URL"),
43
+ hf_fallback_url="https://milwright-cloze-leaderboard.hf.space",
44
+ hf_token=os.getenv("HF_TOKEN"),
45
+ )
46
+ if leaderboard_service.is_redis_available():
47
+ logger.info("Leaderboard using Redis (primary) with HF Space (fallback)")
48
+ else:
49
+ logger.info("Leaderboard using HF Space (Redis unavailable)")
50
+ except Exception as e:
51
+ logger.warning(f"Could not initialize Leaderboard Service: {e}")
52
+ logger.warning("Leaderboard will use localStorage fallback only")
53
+ leaderboard_service = None
54
+
55
+ # Initialize Analytics Service (Redis)
56
+ try:
57
+ analytics_service = RedisAnalyticsService(redis_url=os.getenv("REDIS_URL"))
58
+ if analytics_service.is_available():
59
+ logger.info("Analytics Service using Redis")
60
+ else:
61
+ logger.info("Analytics Service unavailable (Redis not connected)")
62
+ except Exception as e:
63
+ logger.warning(f"Could not initialize Analytics Service: {e}")
64
+ analytics_service = None
65
+
66
+ # Pydantic models for API
67
+ class LeaderboardEntry(BaseModel):
68
+ initials: str
69
+ level: int
70
+ round: int
71
+ passagesPassed: int
72
+ date: str
73
+
74
+ class LeaderboardResponse(BaseModel):
75
+ success: bool
76
+ leaderboard: List[LeaderboardEntry]
77
+ message: Optional[str] = None
78
+
79
+
80
+ # Pydantic models for Analytics API
81
+ class WordAnalytics(BaseModel):
82
+ word: str
83
+ length: Optional[int] = None
84
+ attemptsToCorrect: int = 1
85
+ # Avoid mutable default list
86
+ hintsUsed: List[str] = Field(default_factory=list)
87
+ finalCorrect: bool = False
88
+
89
+
90
+ class PassageAnalytics(BaseModel):
91
+ passageId: str
92
+ sessionId: str
93
+ bookTitle: str
94
+ bookAuthor: str
95
+ level: int
96
+ round: int
97
+ words: List[WordAnalytics]
98
+ totalBlanks: int
99
+ correctOnFirstTry: int
100
+ totalHintsUsed: int
101
+ passed: bool
102
+ timestamp: Optional[str] = None
103
+
104
+ # Mount static files
105
+ app.mount("/src", StaticFiles(directory="src"), name="src")
106
+
107
+ @app.get("/icon.png")
108
+ async def get_icon():
109
+ """Serve the app icon locally if available, else fallback to GitHub."""
110
+ local_icon = "icon.png"
111
+ if os.path.exists(local_icon):
112
+ return FileResponse(local_icon, media_type="image/png")
113
+ # Fallback to GitHub-hosted icon
114
+ return RedirectResponse(url="https://media.githubusercontent.com/media/milwrite/cloze-reader/main/icon.png")
115
+
116
+ @app.get("/favicon.png")
117
+ async def get_favicon_png():
118
+ """Serve favicon as PNG by pointing to the canonical PNG icon."""
119
+ return await get_icon()
120
+
121
+ @app.get("/favicon.ico")
122
+ async def get_favicon_ico():
123
+ """Serve an ICO route that points to our PNG so browsers can find it."""
124
+ # Many browsers request /favicon.ico explicitly; return PNG is acceptable
125
+ return await get_favicon_png()
126
+
127
+ @app.get("/favicon.svg")
128
+ async def get_favicon_svg():
129
+ """Serve SVG favicon for browsers that support it."""
130
+ # Prefer `icon.svg` if available
131
+ for candidate in ["favicon.svg", "icon.svg"]:
132
+ if os.path.exists(candidate):
133
+ return FileResponse(candidate, media_type="image/svg+xml")
134
+ # If missing, fall back to PNG icon
135
+ return await get_favicon_png()
136
+
137
+ @app.get("/icon.svg")
138
+ async def get_icon_svg():
139
+ """Serve the SVG icon at /icon.svg if present, else fallback to PNG."""
140
+ candidate = "icon.svg"
141
+ if os.path.exists(candidate):
142
+ return FileResponse(candidate, media_type="image/svg+xml")
143
+ return await get_icon()
144
+
145
+ @app.get("/apple-touch-icon.png")
146
+ async def get_apple_touch_icon():
147
+ """Serve Apple touch icon, fallback to main icon."""
148
+ candidate = "apple-touch-icon.png"
149
+ if os.path.exists(candidate):
150
+ return FileResponse(candidate, media_type="image/png")
151
+ return await get_icon()
152
+
153
+ @app.get("/site.webmanifest")
154
+ async def site_manifest():
155
+ """Serve the web app manifest if present, else a minimal generated one."""
156
+ manifest_path = "site.webmanifest"
157
+ if os.path.exists(manifest_path):
158
+ return FileResponse(manifest_path, media_type="application/manifest+json")
159
+ # Minimal default manifest
160
+ content = {
161
+ "name": "Cloze Reader",
162
+ "short_name": "Cloze",
163
+ "icons": [
164
+ {"src": "./icon-192.png", "type": "image/png", "sizes": "192x192"},
165
+ {"src": "./icon-512.png", "type": "image/png", "sizes": "512x512"}
166
+ ],
167
+ "start_url": "./",
168
+ "display": "standalone",
169
+ "background_color": "#ffffff",
170
+ "theme_color": "#2c2826"
171
+ }
172
+ return JSONResponse(content=content, media_type="application/manifest+json")
173
+
174
+ @app.get("/icon-192.png")
175
+ async def get_icon_192():
176
+ path = "icon-192.png"
177
+ if os.path.exists(path):
178
+ return FileResponse(path, media_type="image/png")
179
+ return await get_icon()
180
+
181
+ @app.get("/icon-512.png")
182
+ async def get_icon_512():
183
+ path = "icon-512.png"
184
+ if os.path.exists(path):
185
+ return FileResponse(path, media_type="image/png")
186
+ return await get_icon()
187
+
188
+ @app.get("/admin")
189
+ async def admin_dashboard():
190
+ """Serve the analytics admin dashboard"""
191
+ with open("admin.html", "r") as f:
192
+ return HTMLResponse(content=f.read())
193
+
194
+
195
+ @app.get("/")
196
+ async def read_root():
197
+ # Read the HTML file and inject environment variables
198
+ with open("index.html", "r") as f:
199
+ html_content = f.read()
200
+
201
+ # Inject environment variables as a script
202
+ openrouter_key = os.getenv("OPENROUTER_API_KEY", "")
203
+ hf_key = os.getenv("HF_API_KEY", "")
204
+
205
+ # Create a CSP-compliant way to inject the keys
206
+ env_script = f"""
207
+ <meta name="openrouter-key" content="{openrouter_key}">
208
+ <meta name="hf-key" content="{hf_key}">
209
+ <script src="./src/init-env.js"></script>
210
+ """
211
+
212
+ # Insert the script before closing head tag
213
+ html_content = html_content.replace("</head>", env_script + "</head>")
214
+
215
+ return HTMLResponse(content=html_content)
216
+
217
+
218
+ # ===== LEADERBOARD API ENDPOINTS =====
219
+
220
+ @app.get("/api/leaderboard", response_model=LeaderboardResponse)
221
+ async def get_leaderboard():
222
+ """
223
+ Get current leaderboard data (Redis primary, HF Space fallback)
224
+ """
225
+ if not leaderboard_service:
226
+ return {
227
+ "success": True,
228
+ "leaderboard": [],
229
+ "message": "Leaderboard service not available (using localStorage only)"
230
+ }
231
+
232
+ try:
233
+ leaderboard = leaderboard_service.get_leaderboard()
234
+ return {
235
+ "success": True,
236
+ "leaderboard": leaderboard,
237
+ "message": f"Retrieved {len(leaderboard)} entries"
238
+ }
239
+ except Exception as e:
240
+ logger.error(f"Error fetching leaderboard: {e}")
241
+ raise HTTPException(status_code=500, detail=str(e))
242
+
243
+
244
+ @app.post("/api/leaderboard/add")
245
+ async def add_leaderboard_entry(entry: LeaderboardEntry):
246
+ """
247
+ Add new entry to leaderboard
248
+ """
249
+ if not leaderboard_service:
250
+ raise HTTPException(status_code=503, detail="Leaderboard service not available")
251
+
252
+ try:
253
+ success = leaderboard_service.add_entry(entry.dict())
254
+ if success:
255
+ return {
256
+ "success": True,
257
+ "message": f"Added {entry.initials} to leaderboard"
258
+ }
259
+ else:
260
+ raise HTTPException(status_code=500, detail="Failed to add entry")
261
+ except ValueError as e:
262
+ raise HTTPException(status_code=403, detail=str(e))
263
+ except Exception as e:
264
+ logger.error(f"Error adding entry: {e}")
265
+ raise HTTPException(status_code=500, detail=str(e))
266
+
267
+
268
+ @app.post("/api/leaderboard/update")
269
+ async def update_leaderboard(entries: List[LeaderboardEntry]):
270
+ """
271
+ Update entire leaderboard (replace all data)
272
+ """
273
+ if not leaderboard_service:
274
+ raise HTTPException(status_code=503, detail="Leaderboard service not available")
275
+
276
+ try:
277
+ success = leaderboard_service.update_leaderboard([e.dict() for e in entries])
278
+ if success:
279
+ return {
280
+ "success": True,
281
+ "message": "Leaderboard updated successfully"
282
+ }
283
+ else:
284
+ raise HTTPException(status_code=500, detail="Failed to update leaderboard")
285
+ except ValueError as e:
286
+ raise HTTPException(status_code=403, detail=str(e))
287
+ except Exception as e:
288
+ logger.error(f"Error updating leaderboard: {e}")
289
+ raise HTTPException(status_code=500, detail=str(e))
290
+
291
+
292
+ @app.delete("/api/leaderboard/clear")
293
+ async def clear_leaderboard():
294
+ """
295
+ Clear all leaderboard data (admin only)
296
+ """
297
+ if not leaderboard_service:
298
+ raise HTTPException(status_code=503, detail="Leaderboard service not available")
299
+
300
+ try:
301
+ success = leaderboard_service.clear_leaderboard()
302
+ if success:
303
+ return {
304
+ "success": True,
305
+ "message": "Leaderboard cleared"
306
+ }
307
+ else:
308
+ raise HTTPException(status_code=500, detail="Failed to clear leaderboard")
309
+ except ValueError as e:
310
+ raise HTTPException(status_code=403, detail=str(e))
311
+ except Exception as e:
312
+ logger.error(f"Error clearing leaderboard: {e}")
313
+ raise HTTPException(status_code=500, detail=str(e))
314
+
315
+
316
+ @app.post("/api/leaderboard/seed-from-hf")
317
+ async def seed_leaderboard_from_hf():
318
+ """
319
+ Force re-seed Redis leaderboard from HF Space (admin function).
320
+ Use this to migrate existing HF Space data to Redis.
321
+ """
322
+ if not leaderboard_service:
323
+ raise HTTPException(status_code=503, detail="Leaderboard service not available")
324
+
325
+ try:
326
+ success = leaderboard_service.force_seed_from_hf()
327
+ if success:
328
+ leaderboard = leaderboard_service.get_leaderboard()
329
+ return {
330
+ "success": True,
331
+ "message": f"Seeded Redis with {len(leaderboard)} entries from HF Space",
332
+ "entries": len(leaderboard)
333
+ }
334
+ else:
335
+ return {
336
+ "success": False,
337
+ "message": "No entries found in HF Space to seed"
338
+ }
339
+ except Exception as e:
340
+ logger.error(f"Error seeding leaderboard from HF: {e}")
341
+ raise HTTPException(status_code=500, detail=str(e))
342
+
343
+
344
+ # ===== ANALYTICS API ENDPOINTS =====
345
+
346
+ @app.post("/api/analytics/passage")
347
+ async def record_passage_analytics(data: PassageAnalytics):
348
+ """
349
+ Record a completed passage attempt with analytics data.
350
+ Called by frontend when a passage is completed (pass or fail).
351
+ """
352
+ if not analytics_service or not analytics_service.is_available():
353
+ # Gracefully degrade - don't fail the game if analytics unavailable
354
+ return {
355
+ "success": False,
356
+ "message": "Analytics service unavailable"
357
+ }
358
+
359
+ try:
360
+ # Convert Pydantic models to dicts
361
+ data_dict = data.dict()
362
+ data_dict["words"] = [w.dict() for w in data.words]
363
+
364
+ entry_id = analytics_service.record_passage(data_dict)
365
+ if entry_id:
366
+ return {
367
+ "success": True,
368
+ "entryId": entry_id,
369
+ "message": "Passage analytics recorded"
370
+ }
371
+ else:
372
+ return {
373
+ "success": False,
374
+ "message": "Failed to record analytics"
375
+ }
376
+ except Exception as e:
377
+ logger.error(f"Error recording passage analytics: {e}")
378
+ # Don't raise - analytics failure shouldn't break gameplay
379
+ return {
380
+ "success": False,
381
+ "message": str(e)
382
+ }
383
+
384
+
385
+ @app.get("/api/analytics/summary")
386
+ async def get_analytics_summary():
387
+ """
388
+ Get aggregate analytics statistics for admin dashboard.
389
+ Returns totals, hardest/easiest words, and popular books.
390
+ """
391
+ if not analytics_service:
392
+ return {
393
+ "success": True,
394
+ "data": {
395
+ "totalPassages": 0,
396
+ "totalSessions": 0,
397
+ "hardestWords": [],
398
+ "easiestWords": [],
399
+ "popularBooks": []
400
+ },
401
+ "message": "Analytics service unavailable"
402
+ }
403
+
404
+ try:
405
+ summary = analytics_service.get_summary()
406
+ return {
407
+ "success": True,
408
+ "data": summary,
409
+ "message": f"Retrieved analytics summary"
410
+ }
411
+ except Exception as e:
412
+ logger.error(f"Error getting analytics summary: {e}")
413
+ raise HTTPException(status_code=500, detail=str(e))
414
+
415
+
416
+ @app.get("/api/analytics/recent")
417
+ async def get_recent_analytics(count: int = 50):
418
+ """
419
+ Get recent passage attempts for admin review.
420
+
421
+ Args:
422
+ count: Number of recent entries to retrieve (default: 50, max: 200)
423
+ """
424
+ if not analytics_service:
425
+ return {
426
+ "success": True,
427
+ "passages": [],
428
+ "message": "Analytics service unavailable"
429
+ }
430
+
431
+ # Cap at 200 entries
432
+ count = min(count, 200)
433
+
434
+ try:
435
+ passages = analytics_service.get_recent_passages(count)
436
+ return {
437
+ "success": True,
438
+ "passages": passages,
439
+ "count": len(passages),
440
+ "message": f"Retrieved {len(passages)} recent passages"
441
+ }
442
+ except Exception as e:
443
+ logger.error(f"Error getting recent analytics: {e}")
444
+ raise HTTPException(status_code=500, detail=str(e))
445
+
446
+
447
+ @app.get("/api/analytics/export")
448
+ async def export_all_analytics():
449
+ """
450
+ Export all analytics data as JSON (admin function).
451
+ Use for backup or external analysis.
452
+ """
453
+ if not analytics_service:
454
+ return {
455
+ "success": False,
456
+ "passages": [],
457
+ "message": "Analytics service unavailable"
458
+ }
459
+
460
+ try:
461
+ all_data = analytics_service.export_all()
462
+ return {
463
+ "success": True,
464
+ "passages": all_data,
465
+ "count": len(all_data),
466
+ "message": f"Exported {len(all_data)} passage records"
467
+ }
468
+ except Exception as e:
469
+ logger.error(f"Error exporting analytics: {e}")
470
+ raise HTTPException(status_code=500, detail=str(e))
471
+
472
+
473
+ @app.get("/api/analytics/word/{word}")
474
+ async def get_word_statistics(word: str):
475
+ """
476
+ Get statistics for a specific word.
477
+ Shows how often the word was correct on first try vs needing retries.
478
+ """
479
+ if not analytics_service:
480
+ return {
481
+ "success": True,
482
+ "data": {"word": word, "firstTryCount": 0, "retryCount": 0},
483
+ "message": "Analytics service unavailable"
484
+ }
485
+
486
+ try:
487
+ stats = analytics_service.get_word_stats(word)
488
+ return {
489
+ "success": True,
490
+ "data": stats,
491
+ "message": f"Retrieved stats for '{word}'"
492
+ }
493
+ except Exception as e:
494
+ logger.error(f"Error getting word stats: {e}")
495
+ raise HTTPException(status_code=500, detail=str(e))
496
+
497
+
498
+ @app.delete("/api/analytics/clear")
499
+ async def clear_all_analytics():
500
+ """
501
+ Clear all analytics data (admin function).
502
+ WARNING: This permanently deletes all recorded analytics.
503
+ """
504
+ if not analytics_service:
505
+ raise HTTPException(status_code=503, detail="Analytics service unavailable")
506
+
507
+ try:
508
+ success = analytics_service.clear_analytics()
509
+ if success:
510
+ return {
511
+ "success": True,
512
+ "message": "All analytics data cleared"
513
+ }
514
+ else:
515
+ raise HTTPException(status_code=500, detail="Failed to clear analytics")
516
+ except Exception as e:
517
+ logger.error(f"Error clearing analytics: {e}")
518
+ raise HTTPException(status_code=500, detail=str(e))
519
+
520
+
521
+ if __name__ == "__main__":
522
+ import uvicorn
523
+ uvicorn.run(app, host="0.0.0.0", port=7860)
524
+
525
+
526
+ # ================== HF DATASETS PROXY ENDPOINTS ==================
527
+
528
+ HF_DATASETS_BASE = "https://datasets-server.huggingface.co"
529
+
530
+ # very small in-memory cache suitable for single-process app
531
+ _proxy_cache = {
532
+ "splits": {}, # key -> {value, ts, ttl}
533
+ "rows": {},
534
+ }
535
+
536
+
537
+ def _cache_get(bucket: str, key: str):
538
+ entry = _proxy_cache.get(bucket, {}).get(key)
539
+ if not entry:
540
+ return None
541
+ if time.time() - entry["ts"] > entry["ttl"]:
542
+ try:
543
+ del _proxy_cache[bucket][key]
544
+ except Exception:
545
+ pass
546
+ return None
547
+ return entry["value"]
548
+
549
+
550
+ def _cache_set(bucket: str, key: str, value, ttl: int):
551
+ _proxy_cache.setdefault(bucket, {})[key] = {
552
+ "value": value,
553
+ "ts": time.time(),
554
+ "ttl": ttl,
555
+ }
556
+
557
+
558
+ def _fetch_sync(url: str, timeout: float = 3.0):
559
+ req = urllib.request.Request(
560
+ url,
561
+ headers={
562
+ "Accept": "application/json",
563
+ "User-Agent": "cloze-reader/1.0 (+fastapi-proxy)",
564
+ },
565
+ )
566
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
567
+ status = resp.getcode()
568
+ body = resp.read()
569
+ return status, body
570
+
571
+
572
+ async def _fetch_json(url: str, timeout: float = 3.0):
573
+ try:
574
+ status, body = await asyncio.to_thread(_fetch_sync, url, timeout)
575
+ if status != 200:
576
+ raise HTTPException(status_code=status, detail=f"Upstream returned {status}")
577
+ return json.loads(body.decode("utf-8"))
578
+ except HTTPException:
579
+ raise
580
+ except Exception as e:
581
+ raise HTTPException(status_code=502, detail=f"Upstream fetch failed: {e}")
582
+
583
+
584
+ @app.get("/api/books/splits")
585
+ async def proxy_hf_splits(
586
+ dataset: str = Query(..., description="HF dataset repo id, e.g. manu/project_gutenberg"),
587
+ cache_ttl: int = Query(300, description="Cache TTL seconds (default 300)"),
588
+ ):
589
+ """Proxy the HF datasets splits endpoint with caching and timeout.
590
+
591
+ Example: /api/books/splits?dataset=manu/project_gutenberg
592
+ """
593
+ dataset_q = urllib.parse.quote(dataset, safe="")
594
+ url = f"{HF_DATASETS_BASE}/splits?dataset={dataset_q}"
595
+
596
+ cached = _cache_get("splits", url)
597
+ if cached is not None:
598
+ return cached
599
+
600
+ data = await _fetch_json(url, timeout=3.0)
601
+ _cache_set("splits", url, data, ttl=max(1, cache_ttl))
602
+ return data
603
+
604
+
605
+ @app.get("/api/books/rows")
606
+ async def proxy_hf_rows(
607
+ dataset: str = Query(...),
608
+ config: str = Query("default"),
609
+ split: str = Query("en"),
610
+ offset: int = Query(0, ge=0, le=1000000),
611
+ length: int = Query(1, ge=1, le=50),
612
+ cache_ttl: int = Query(60, description="Cache TTL seconds for identical queries (default 60)"),
613
+ ):
614
+ """Proxy the HF datasets rows endpoint with short timeout and small cache.
615
+
616
+ Example:
617
+ /api/books/rows?dataset=manu/project_gutenberg&config=default&split=en&offset=0&length=2
618
+ """
619
+ params = {
620
+ "dataset": dataset,
621
+ "config": config,
622
+ "split": split,
623
+ "offset": str(offset),
624
+ "length": str(length),
625
+ }
626
+ qs = urllib.parse.urlencode(params)
627
+ url = f"{HF_DATASETS_BASE}/rows?{qs}"
628
+
629
+ cached = _cache_get("rows", url)
630
+ if cached is not None:
631
+ return cached
632
+
633
+ # Allow longer timeout for HF API which can be slow under load
634
+ # 15s should handle most cases without client-side abort racing
635
+ data = await _fetch_json(url, timeout=15.0)
636
+ # Cache briefly to smooth bursts; rows vary by offset so cache is typically small
637
+ _cache_set("rows", url, data, ttl=max(1, cache_ttl))
638
+ return data
apple-touch-icon.png ADDED

Git LFS Details

  • SHA256: 7956dcc7499a3b35fbfb1d0b032f5967601c5c1592479b799c2eed9bcfff2af0
  • Pointer size: 130 Bytes
  • Size of remote file: 64.8 kB
docker-compose.yml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ cloze-reader:
5
+ build: .
6
+ ports:
7
+ - "7860:7860"
8
+ environment:
9
+ - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
10
+ - HF_API_KEY=${HF_API_KEY:-}
11
+ volumes:
12
+ - ./src:/app/src:ro
13
+ - ./index.html:/app/index.html:ro
14
+ restart: unless-stopped
15
+
16
+ dev-server:
17
+ image: python:3.9-slim
18
+ working_dir: /app
19
+ ports:
20
+ - "8000:8000"
21
+ volumes:
22
+ - .:/app
23
+ command: python -m http.server 8000
24
+ environment:
25
+ - PYTHONUNBUFFERED=1
26
+ profiles:
27
+ - dev
favicon.png ADDED

Git LFS Details

  • SHA256: 23ad7d957c89f10536d1e06cf55da1cf3e696995da5cc5a1042e7e2c1b786cf1
  • Pointer size: 129 Bytes
  • Size of remote file: 1.21 kB
favicon.svg ADDED
icon-192.png ADDED

Git LFS Details

  • SHA256: 66ece4f66bd66442f72837e0dfef31bf84c6b9aee26ef801042135f0eeab6679
  • Pointer size: 130 Bytes
  • Size of remote file: 74.1 kB
icon-512.png ADDED

Git LFS Details

  • SHA256: b11688c0fc3a27d372ff4825f967024594457f6a2463da318d10bddcfc8e76f5
  • Pointer size: 131 Bytes
  • Size of remote file: 554 kB
icon.png ADDED

Git LFS Details

  • SHA256: e7362c97b16341c56845867adf5e199f11068ff783df2f4c2509f4506113f742
  • Pointer size: 132 Bytes
  • Size of remote file: 2.53 MB
icon.svg ADDED
index.html ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Cloze Reader</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Special+Elite&display=swap" rel="stylesheet">
9
+ <link href="./src/styles.css" rel="stylesheet">
10
+ <link rel="icon" type="image/svg+xml" href="./favicon.svg">
11
+ <link rel="icon" type="image/png" href="./favicon.png">
12
+ <link rel="shortcut icon" href="./favicon.ico">
13
+ <link rel="apple-touch-icon" href="./apple-touch-icon.png">
14
+ <link rel="manifest" href="./site.webmanifest">
15
+ </head>
16
+ <body class="min-h-screen">
17
+ <div id="app" class="container mx-auto px-4 py-8 max-w-4xl">
18
+ <header class="text-center mb-8">
19
+ <div class="flex items-center justify-center mb-4 gap-3">
20
+ <img src="https://media.githubusercontent.com/media/milwrite/cloze-reader/main/icon.png" alt="Cloze Reader" class="w-12 h-12">
21
+ <h1 class="text-4xl font-bold typewriter-text">Cloze Reader</h1>
22
+ </div>
23
+ <p class="typewriter-subtitle text-center">Fill in the blanks to practice reading comprehension</p>
24
+ </header>
25
+
26
+ <main id="game-container" class="space-y-6">
27
+ <div id="loading" class="text-center py-8">
28
+ <p class="text-lg loading-text">Loading passages...</p>
29
+ </div>
30
+
31
+ <div id="game-area" class="paper-sheet rounded-lg p-6 hidden">
32
+ <div class="paper-content">
33
+ <div class="flex justify-between items-center mb-4">
34
+ <div id="book-info" class="biblio-info"></div>
35
+ <div class="flex gap-2 items-center">
36
+ <div id="streak-info" class="round-badge px-3 py-1 rounded-full text-sm hidden"></div>
37
+ <div id="round-info" class="round-badge px-3 py-1 rounded-full text-sm"></div>
38
+ </div>
39
+ </div>
40
+ <div id="contextualization" class="context-box mb-4 p-3 rounded-lg"></div>
41
+ <div id="passage-content" class="prose max-w-none mb-6"></div>
42
+ <div id="hints-section" class="hints-box mb-4 p-3 rounded-lg">
43
+ <div class="text-sm font-semibold mb-2">👉👈 Hints:</div>
44
+ <div id="hints-list" class="text-sm space-y-1"></div>
45
+ </div>
46
+ <div id="controls" class="hidden">
47
+ <!-- Controls moved to sticky footer -->
48
+ </div>
49
+ <div id="result" class="mt-4 text-center result-text"></div>
50
+ </div>
51
+ </div>
52
+ </main>
53
+ </div>
54
+
55
+ <!-- Sticky Control Panel -->
56
+ <div id="sticky-controls" class="sticky-controls hidden">
57
+ <div class="controls-inner">
58
+ <button type="button" id="submit-btn" class="typewriter-button">
59
+ Submit
60
+ </button>
61
+ <button type="button" id="skip-btn" class="typewriter-button hidden">
62
+ Skip
63
+ </button>
64
+ <button type="button" id="next-btn" class="typewriter-button hidden">
65
+ Next Passage
66
+ </button>
67
+ <div class="controls-divider"></div>
68
+ <button type="button" id="hint-btn" class="typewriter-button">
69
+ Show Hints
70
+ </button>
71
+ <div class="controls-divider"></div>
72
+ <button type="button" id="leaderboard-btn" class="leaderboard-footer-btn" title="View leaderboard">
73
+ 🏅
74
+ </button>
75
+ </div>
76
+ </div>
77
+
78
+ <script src="./src/app.js?v=20260109-2" type="module"></script>
79
+ <script type="module">
80
+ // Load test runner and ranking interface only in test mode
81
+ const urlParams = new URLSearchParams(window.location.search);
82
+ if (urlParams.get('testMode') === 'true') {
83
+ import('./src/testGameRunner.js');
84
+ import('./src/userRankingInterface.js');
85
+ }
86
+ </script>
87
+ </body>
88
+ </html>
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ python-dotenv==1.0.0
4
+ redis>=5.0.0
5
+ httpx>=0.25.0
site.webmanifest ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Cloze Reader",
3
+ "short_name": "Cloze",
4
+ "icons": [
5
+ { "src": "./icon-192.png", "type": "image/png", "sizes": "192x192" },
6
+ { "src": "./icon-512.png", "type": "image/png", "sizes": "512x512" }
7
+ ],
8
+ "start_url": "./",
9
+ "display": "standalone",
10
+ "background_color": "#ffffff",
11
+ "theme_color": "#2c2826"
12
+ }
src/aiService.js ADDED
@@ -0,0 +1,736 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class OpenRouterService {
2
+ constructor() {
3
+ // Check for local LLM mode
4
+ this.isLocalMode = this.checkLocalMode();
5
+ this.apiUrl = this.isLocalMode ? 'http://localhost:1234/v1/chat/completions' : 'https://openrouter.ai/api/v1/chat/completions';
6
+ this.apiKey = this.getApiKey();
7
+
8
+ // Single model configuration: Gemma-3-27b for all operations
9
+ this.hintModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
10
+ this.primaryModel = this.isLocalMode ? 'gemma-3-12b' : 'google/gemma-3-27b-it';
11
+ this.model = this.primaryModel; // Default model for backward compatibility
12
+
13
+ console.log('🤖 AI Service initialized', {
14
+ mode: this.isLocalMode ? 'Local LLM' : 'OpenRouter',
15
+ url: this.apiUrl,
16
+ primaryModel: this.primaryModel,
17
+ hintModel: this.hintModel
18
+ });
19
+ }
20
+
21
+ // Helper: Extract content from API response (handles reasoning mode variants)
22
+ _extractContentFromResponse(data) {
23
+ const msg = data?.choices?.[0]?.message;
24
+ if (!msg) return null;
25
+ return msg.content || msg.reasoning || msg.reasoning_details?.[0]?.text || null;
26
+ }
27
+
28
+ // Helper: Build word map from passage for validation
29
+ _createPassageWordMap(passage) {
30
+ const passageWords = passage.split(/\s+/);
31
+ const map = new Map();
32
+ passageWords.forEach((word, idx) => {
33
+ const clean = word.replace(/[^\w]/g, '');
34
+ const lower = clean.toLowerCase();
35
+ const isCapitalized = clean.length > 0 && clean[0] === clean[0].toUpperCase();
36
+ if (!isCapitalized && idx >= 10) {
37
+ if (!map.has(lower)) map.set(lower, []);
38
+ map.get(lower).push(idx);
39
+ }
40
+ });
41
+ return map;
42
+ }
43
+
44
+ // Helper: Validate words against passage and level constraints
45
+ // Note: Length constraints relaxed to ensure playability - difficulty comes from
46
+ // AI word selection prompts ("easy/common" vs "challenging"), not strict length limits
47
+ _validateWords(words, passageWordMap, level, passageText = null) {
48
+ return words.filter(word => {
49
+ if (!/[a-zA-Z]/.test(word)) return false;
50
+ const clean = word.replace(/[^a-zA-Z]/g, '');
51
+ if (!clean.length) return false;
52
+ if (/^(from|to|and)(the|a)$/i.test(clean)) return false;
53
+ if (!passageWordMap.has(clean.toLowerCase())) return false;
54
+ if (passageText && passageText.includes(word.toUpperCase()) && word === word.toUpperCase()) return false;
55
+ // Relaxed: 4-12 letters for levels 1-4, 4-14 for level 5+
56
+ // Matches manual fallback in clozeGameEngine.js selectWordsManually()
57
+ if (level <= 4) return clean.length >= 4 && clean.length <= 12;
58
+ return clean.length >= 4 && clean.length <= 14;
59
+ });
60
+ }
61
+
62
+ // Helper: Clean up AI response artifacts
63
+ _cleanupAIResponse(content) {
64
+ return content
65
+ .replace(/^\s*["']|["']\s*$/g, '')
66
+ .replace(/^\s*[:;.!?]+\s*/, '')
67
+ .replace(/\*+/g, '')
68
+ .replace(/_+/g, '')
69
+ .replace(/#+\s*/g, '')
70
+ .replace(/\s+/g, ' ')
71
+ .trim();
72
+ }
73
+
74
+ checkLocalMode() {
75
+ if (typeof window !== 'undefined' && window.location) {
76
+ const urlParams = new URLSearchParams(window.location.search);
77
+ return urlParams.get('local') === 'true';
78
+ }
79
+ return false;
80
+ }
81
+
82
+ getApiKey() {
83
+ // Local mode doesn't need API key
84
+ if (this.isLocalMode) {
85
+ return 'local-mode-no-key';
86
+ }
87
+ if (typeof process !== 'undefined' && process.env && process.env.OPENROUTER_API_KEY) {
88
+ return process.env.OPENROUTER_API_KEY;
89
+ }
90
+ if (typeof window !== 'undefined' && window.OPENROUTER_API_KEY) {
91
+ return window.OPENROUTER_API_KEY;
92
+ }
93
+ // console.warn('No API key found in getApiKey()');
94
+ return '';
95
+ }
96
+
97
+ setApiKey(key) {
98
+ this.apiKey = key;
99
+ }
100
+
101
+ async retryRequest(requestFn, maxRetries = 3, delayMs = 500) {
102
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
103
+ try {
104
+ return await requestFn();
105
+ } catch (error) {
106
+ if (attempt === maxRetries) {
107
+ throw error; // Final attempt failed, throw the error
108
+ }
109
+ // Wait before retrying, with exponential backoff
110
+ const delay = delayMs * Math.pow(2, attempt - 1);
111
+ await new Promise(resolve => setTimeout(resolve, delay));
112
+ }
113
+ }
114
+ }
115
+
116
+ async generateContextualHint(prompt) {
117
+ // Check for API key at runtime
118
+ const currentKey = this.getApiKey();
119
+ if (currentKey && !this.apiKey) {
120
+ this.apiKey = currentKey;
121
+ }
122
+
123
+ if (!this.apiKey) {
124
+ return 'API key required for hints';
125
+ }
126
+
127
+ try {
128
+ const headers = {
129
+ 'Content-Type': 'application/json'
130
+ };
131
+
132
+ // Only add auth headers for OpenRouter
133
+ if (!this.isLocalMode) {
134
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
135
+ headers['HTTP-Referer'] = window.location.origin;
136
+ headers['X-Title'] = 'Cloze Reader';
137
+ }
138
+
139
+ const response = await fetch(this.apiUrl, {
140
+ method: 'POST',
141
+ headers,
142
+ body: JSON.stringify({
143
+ model: this.hintModel, // Use Gemma-3-27b for hints
144
+ messages: [{
145
+ role: 'system',
146
+ content: 'You are a helpful assistant that provides hints for word puzzles. Never reveal the answer word directly.'
147
+ }, {
148
+ role: 'user',
149
+ content: prompt
150
+ }],
151
+ max_tokens: 150,
152
+ temperature: 0.7,
153
+ // Try to disable reasoning mode for hints
154
+ response_format: { type: "text" }
155
+ })
156
+ });
157
+
158
+ if (!response.ok) {
159
+ throw new Error(`API request failed: ${response.status}`);
160
+ }
161
+
162
+ const data = await response.json();
163
+
164
+
165
+ // Check if data and choices exist before accessing
166
+ if (!data || !data.choices || data.choices.length === 0) {
167
+ console.error('Invalid API response structure:', data);
168
+ return 'Unable to generate hint at this time';
169
+ }
170
+
171
+ // Check if message exists
172
+ if (!data.choices[0].message) {
173
+ console.error('No message in API response');
174
+ return 'Unable to generate hint at this time';
175
+ }
176
+
177
+ // Extract content from response (handles reasoning mode variants)
178
+ let content = this._extractContentFromResponse(data);
179
+
180
+ if (!content) {
181
+ console.error('No content found in hint response');
182
+ // Provide a generic hint based on the prompt type
183
+ if (prompt.toLowerCase().includes('synonym')) {
184
+ return 'Think of a word that means something similar';
185
+ } else if (prompt.toLowerCase().includes('definition')) {
186
+ return 'Consider what this word means in context';
187
+ } else if (prompt.toLowerCase().includes('category')) {
188
+ return 'Think about what type or category this word belongs to';
189
+ } else {
190
+ return 'Consider the context around the blank';
191
+ }
192
+ }
193
+
194
+ content = content.trim();
195
+
196
+ // For OSS-20B, extract hint from reasoning text if needed
197
+ if (content.includes('The user') || content.includes('We need to')) {
198
+ // This looks like reasoning text, try to extract the actual hint
199
+ // Look for text about synonyms, definitions, or clues
200
+ const hintPatterns = [
201
+ /synonym[s]?.*?(?:is|are|include[s]?|would be)\s+([^.]+)/i,
202
+ /means?\s+([^.]+)/i,
203
+ /refers? to\s+([^.]+)/i,
204
+ /describes?\s+([^.]+)/i,
205
+ ];
206
+
207
+ for (const pattern of hintPatterns) {
208
+ const match = content.match(pattern);
209
+ if (match) {
210
+ content = match[1];
211
+ break;
212
+ }
213
+ }
214
+
215
+ // If still has reasoning markers, just return a fallback
216
+ if (content.includes('The user') || content.includes('We need to')) {
217
+ return 'Think about words that mean something similar';
218
+ }
219
+ }
220
+
221
+ // Clean up AI response artifacts
222
+ return this._cleanupAIResponse(content);
223
+ } catch (error) {
224
+ console.error('Error generating contextual hint:', error);
225
+ return 'Unable to generate hint at this time';
226
+ }
227
+ }
228
+
229
+
230
+ async selectSignificantWords(passage, count, level = 1) {
231
+
232
+ // Check for API key at runtime in case it was loaded after initialization
233
+ const currentKey = this.getApiKey();
234
+ if (currentKey && !this.apiKey) {
235
+ this.apiKey = currentKey;
236
+ }
237
+
238
+
239
+ if (!this.apiKey) {
240
+ console.error('No API key for word selection');
241
+ throw new Error('API key required for word selection');
242
+ }
243
+
244
+ // Define level-based constraints (relaxed length to ensure playability)
245
+ let wordLengthConstraint, difficultyGuidance;
246
+ if (level <= 2) {
247
+ wordLengthConstraint = "4-12 letters";
248
+ difficultyGuidance = "Select EASY vocabulary words - common, everyday words that most readers know.";
249
+ } else if (level <= 4) {
250
+ wordLengthConstraint = "4-12 letters";
251
+ difficultyGuidance = "Select MEDIUM difficulty words - mix of common and moderately challenging vocabulary.";
252
+ } else {
253
+ wordLengthConstraint = "4-14 letters";
254
+ difficultyGuidance = "Select CHALLENGING words - sophisticated vocabulary that requires strong reading skills.";
255
+ }
256
+
257
+ try {
258
+ return await this.retryRequest(async () => {
259
+ const response = await fetch(this.apiUrl, {
260
+ method: 'POST',
261
+ headers: {
262
+ 'Content-Type': 'application/json',
263
+ 'Authorization': `Bearer ${this.apiKey}`,
264
+ 'HTTP-Referer': window.location.origin,
265
+ 'X-Title': 'Cloze Reader'
266
+ },
267
+ body: JSON.stringify({
268
+ model: this.primaryModel, // Use Gemma-3-12b for word selection
269
+ messages: [{
270
+ role: 'system',
271
+ content: 'Select words for a cloze exercise. Return ONLY a JSON array of words, nothing else.'
272
+ }, {
273
+ role: 'user',
274
+ content: `Select ${count} ${level <= 2 ? 'easy' : level <= 4 ? 'medium' : 'challenging'} words (${wordLengthConstraint}) from this passage.
275
+
276
+ CRITICAL RULES:
277
+ - Select EXACT words that appear in the passage (copy them exactly as written)
278
+ - ONLY select lowercase words (no capitalized words, no proper nouns)
279
+ - ONLY select words from the MIDDLE or END of the passage (skip the first ~10 words)
280
+ - Words must be ${wordLengthConstraint}
281
+ - Choose nouns, verbs, or adjectives
282
+ - AVOID compound words like "courthouse" or "steamboat" - choose single, verifiable words with semantic inbetweenness
283
+ - AVOID indexes, tables of contents, and capitalized content
284
+ - Return ONLY a JSON array like ["word1", "word2"]
285
+
286
+ Passage: "${passage}"`
287
+ }],
288
+ max_tokens: 200,
289
+ temperature: 0.5,
290
+ // Try to disable reasoning mode for word selection
291
+ response_format: { type: "text" }
292
+ })
293
+ });
294
+
295
+ if (!response.ok) {
296
+ throw new Error(`API request failed: ${response.status}`);
297
+ }
298
+
299
+ const data = await response.json();
300
+
301
+ // Check for OpenRouter error response
302
+ if (data.error) {
303
+ console.error('OpenRouter API error for word selection:', data.error);
304
+ throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
305
+ }
306
+
307
+ // Log the full response to debug structure
308
+
309
+ // Check if response has expected structure
310
+ if (!data.choices || !data.choices[0] || !data.choices[0].message) {
311
+ console.error('Invalid word selection API response structure:', data);
312
+ console.error('Choices[0]:', data.choices?.[0]);
313
+ throw new Error('API response missing expected structure');
314
+ }
315
+
316
+ // Extract content from response (handles reasoning mode variants)
317
+ let content = this._extractContentFromResponse(data);
318
+
319
+ if (!content) {
320
+ console.error('No content found in API response');
321
+ throw new Error('API response missing content');
322
+ }
323
+
324
+ content = content.trim();
325
+
326
+ // Clean up local LLM artifacts
327
+ if (this.isLocalMode) {
328
+ content = this.cleanLocalLLMResponse(content);
329
+ }
330
+
331
+ // Try to parse as JSON array
332
+ try {
333
+ let words;
334
+
335
+ // Try to parse JSON first
336
+ try {
337
+ // Check if content contains JSON array anywhere in it
338
+ const jsonMatch = content.match(/\[[\s\S]*?\]/);
339
+ if (jsonMatch) {
340
+ words = JSON.parse(jsonMatch[0]);
341
+ } else {
342
+ words = JSON.parse(content);
343
+ }
344
+ } catch {
345
+ // If not JSON, check if this is reasoning text from OSS-20B
346
+ if (content.includes('pick') || content.includes('Let\'s')) {
347
+ // Extract words from reasoning text
348
+ // Look for quoted words or words after "pick"
349
+ const quotedWords = content.match(/"([^"]+)"/g);
350
+ if (quotedWords) {
351
+ words = quotedWords.map(w => w.replace(/"/g, ''));
352
+ } else {
353
+ // Look for pattern like "Let's pick 'word'" or "pick word"
354
+ const pickMatch = content.match(/pick\s+['"]?(\w+)['"]?/i);
355
+ if (pickMatch) {
356
+ words = [pickMatch[1]];
357
+ } else {
358
+ // For local LLM, try comma-separated
359
+ if (this.isLocalMode && content.includes(',')) {
360
+ words = content.split(',').map(w => w.trim());
361
+ } else {
362
+ // Single word
363
+ words = [content.trim()];
364
+ }
365
+ }
366
+ }
367
+ } else if (this.isLocalMode) {
368
+ // For local LLM, try comma-separated
369
+ if (content.includes(',')) {
370
+ words = content.split(',').map(w => w.trim());
371
+ } else {
372
+ // Single word
373
+ words = [content.trim()];
374
+ }
375
+ } else {
376
+ throw new Error('Could not parse words from response');
377
+ }
378
+ }
379
+
380
+ if (Array.isArray(words)) {
381
+ // Create passage word map and validate words
382
+ const passageWordMap = this._createPassageWordMap(passage);
383
+ const validWords = this._validateWords(words, passageWordMap, level);
384
+
385
+ if (validWords.length > 0) {
386
+ return validWords.slice(0, count);
387
+ } else {
388
+ console.warn(`No words met requirements for level ${level}`);
389
+ throw new Error(`No valid words for level ${level}`);
390
+ }
391
+ }
392
+ } catch (e) {
393
+ // If not valid JSON, try to extract words from the response
394
+ const matches = content.match(/"([^"]+)"/g);
395
+ if (matches) {
396
+ const words = matches.map(m => m.replace(/"/g, ''));
397
+
398
+ // Create passage word map and validate words
399
+ const passageWordMap = this._createPassageWordMap(passage);
400
+ const validWords = this._validateWords(words, passageWordMap, level);
401
+
402
+ if (validWords.length > 0) {
403
+ return validWords.slice(0, count);
404
+ } else {
405
+ throw new Error(`No valid words for level ${level}`);
406
+ }
407
+ }
408
+ }
409
+
410
+ throw new Error('Failed to parse AI response');
411
+ });
412
+ } catch (error) {
413
+ console.error('Error selecting words with AI:', error);
414
+ throw error;
415
+ }
416
+ }
417
+
418
+ async processBothPassages(passage1, book1, passage2, book2, blanksPerPassage, level = 1) {
419
+ // Process both passages in a single API call to avoid rate limits
420
+ const currentKey = this.getApiKey();
421
+ if (currentKey && !this.apiKey) {
422
+ this.apiKey = currentKey;
423
+ }
424
+
425
+ if (!this.apiKey) {
426
+ throw new Error('API key required for passage processing');
427
+ }
428
+
429
+ // Define level-based constraints (relaxed length to ensure playability)
430
+ let wordLengthConstraint, difficultyGuidance;
431
+ if (level <= 2) {
432
+ wordLengthConstraint = "4-12 letters";
433
+ difficultyGuidance = "Select EASY vocabulary words - common, everyday words that most readers know.";
434
+ } else if (level <= 4) {
435
+ wordLengthConstraint = "4-12 letters";
436
+ difficultyGuidance = "Select MEDIUM difficulty words - mix of common and moderately challenging vocabulary.";
437
+ } else {
438
+ wordLengthConstraint = "4-14 letters";
439
+ difficultyGuidance = "Select CHALLENGING words - sophisticated vocabulary that requires strong reading skills.";
440
+ }
441
+
442
+ try {
443
+ // Add timeout controller to prevent aborted operations
444
+ const controller = new AbortController();
445
+ const timeoutId = setTimeout(() => controller.abort(), 15000); // 15 second timeout
446
+
447
+ const headers = {
448
+ 'Content-Type': 'application/json'
449
+ };
450
+
451
+ // Only add auth headers for OpenRouter
452
+ if (!this.isLocalMode) {
453
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
454
+ headers['HTTP-Referer'] = window.location.origin;
455
+ headers['X-Title'] = 'Cloze Reader';
456
+ }
457
+
458
+ const response = await fetch(this.apiUrl, {
459
+ method: 'POST',
460
+ headers,
461
+ signal: controller.signal,
462
+ body: JSON.stringify({
463
+ model: this.primaryModel, // Use Gemma-3-12b for batch processing
464
+ messages: [{
465
+ role: 'system',
466
+ content: 'Process passages for cloze exercises. Return ONLY a JSON object.'
467
+ }, {
468
+ role: 'user',
469
+ content: `Select ${blanksPerPassage} ${level <= 2 ? 'easy' : level <= 4 ? 'medium' : 'challenging'} words (${wordLengthConstraint}) from each passage.
470
+
471
+ CRITICAL RULES:
472
+ - ONLY select lowercase words (no capitalized words, no proper nouns)
473
+ - ONLY select words from the MIDDLE or END of each passage (skip the first ~10 words)
474
+ - Words must be ${wordLengthConstraint}
475
+ - AVOID compound words like "courthouse" or "steamboat" - choose single, verifiable words with semantic inbetweenness
476
+ - AVOID indexes, tables of contents, and capitalized content
477
+
478
+ Passage 1 ("${book1.title}" by ${book1.author}):
479
+ ${passage1}
480
+
481
+ Passage 2 ("${book2.title}" by ${book2.author}):
482
+ ${passage2}
483
+
484
+ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one sentence about book"}, "passage2": {"words": [${blanksPerPassage} words], "context": "one sentence about book"}}`
485
+ }],
486
+ max_tokens: 800,
487
+ temperature: 0.5,
488
+ response_format: { type: "text" }
489
+ })
490
+ });
491
+
492
+ // Clear timeout on successful response
493
+ clearTimeout(timeoutId);
494
+
495
+ if (!response.ok) {
496
+ throw new Error(`API request failed: ${response.status}`);
497
+ }
498
+
499
+ const data = await response.json();
500
+
501
+ // Check for error response
502
+ if (data.error) {
503
+ console.error('OpenRouter API error for batch processing:', data.error);
504
+ throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
505
+ }
506
+
507
+
508
+ // Check if response has expected structure
509
+ if (!data.choices || !data.choices[0] || !data.choices[0].message) {
510
+ console.error('Invalid batch API response structure:', data);
511
+ console.error('Choices[0]:', data.choices?.[0]);
512
+ throw new Error('API response missing expected structure');
513
+ }
514
+
515
+ // Extract content from response (handles reasoning mode variants)
516
+ let content = this._extractContentFromResponse(data);
517
+
518
+ if (!content) {
519
+ console.error('No content found in batch API response');
520
+ throw new Error('API response missing content');
521
+ }
522
+
523
+ content = content.trim();
524
+
525
+ try {
526
+ // Try to extract JSON from the response
527
+ // Sometimes the model returns JSON wrapped in markdown code blocks
528
+ const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/) || content.match(/```\s*([\s\S]*?)\s*```/);
529
+ let jsonString = jsonMatch ? jsonMatch[1] : content;
530
+
531
+ // Clean up the JSON string
532
+ jsonString = jsonString
533
+ .replace(/^\s*```json\s*/, '')
534
+ .replace(/\s*```\s*$/, '')
535
+ .trim();
536
+
537
+ // Try to fix common JSON issues
538
+ // Fix trailing commas in arrays
539
+ jsonString = jsonString.replace(/,(\s*])/g, '$1');
540
+
541
+ // Check for truncated strings (unterminated quotes)
542
+ const quoteCount = (jsonString.match(/"/g) || []).length;
543
+ if (quoteCount % 2 !== 0) {
544
+ // Add missing closing quote
545
+ jsonString += '"';
546
+ }
547
+
548
+ // Check if JSON is truncated (missing closing braces)
549
+ const openBraces = (jsonString.match(/{/g) || []).length;
550
+ const closeBraces = (jsonString.match(/}/g) || []).length;
551
+
552
+ if (openBraces > closeBraces) {
553
+ // Add missing closing braces
554
+ jsonString += '}'.repeat(openBraces - closeBraces);
555
+ }
556
+
557
+ // Remove any trailing garbage after the last closing brace
558
+ const lastBrace = jsonString.lastIndexOf('}');
559
+ if (lastBrace !== -1 && lastBrace < jsonString.length - 1) {
560
+ jsonString = jsonString.substring(0, lastBrace + 1);
561
+ }
562
+
563
+ const parsed = JSON.parse(jsonString);
564
+
565
+ // Validate the structure
566
+ if (!parsed.passage1 || !parsed.passage2) {
567
+ console.error('Parsed response missing expected structure:', parsed);
568
+ throw new Error('Response missing passage1 or passage2');
569
+ }
570
+
571
+ // Ensure words arrays exist and are arrays
572
+ if (!Array.isArray(parsed.passage1.words)) {
573
+ parsed.passage1.words = [];
574
+ }
575
+ if (!Array.isArray(parsed.passage2.words)) {
576
+ parsed.passage2.words = [];
577
+ }
578
+
579
+ // Filter out empty strings from words arrays (caused by trailing commas)
580
+ parsed.passage1.words = parsed.passage1.words.filter(word => word && word.trim() !== '');
581
+ parsed.passage2.words = parsed.passage2.words.filter(word => word && word.trim() !== '');
582
+
583
+ // Validate words using helper methods
584
+ const map1 = this._createPassageWordMap(passage1);
585
+ const map2 = this._createPassageWordMap(passage2);
586
+ parsed.passage1.words = this._validateWords(parsed.passage1.words, map1, level, passage1);
587
+ parsed.passage2.words = this._validateWords(parsed.passage2.words, map2, level, passage2);
588
+
589
+ return parsed;
590
+ } catch (e) {
591
+ console.error('Failed to parse batch response:', e);
592
+ console.error('Raw content:', content);
593
+
594
+ // Try to extract any usable data from the partial response
595
+ try {
596
+ // Extract passage contexts using regex
597
+ const context1Match = content.match(/"context":\s*"([^"]+)"/);
598
+ const context2Match = content.match(/"passage2"[\s\S]*?"context":\s*"([^"]+)"/);
599
+
600
+ // Extract words arrays using regex
601
+ const words1Match = content.match(/"words":\s*\[([^\]]+)\]/);
602
+ const words2Match = content.match(/"passage2"[\s\S]*?"words":\s*\[([^\]]+)\]/);
603
+
604
+ const extractWords = (match) => {
605
+ if (!match) return [];
606
+ try {
607
+ return JSON.parse(`[${match[1]}]`);
608
+ } catch {
609
+ return match[1].split(',').map(w => w.trim().replace(/['"]/g, ''));
610
+ }
611
+ };
612
+
613
+ return {
614
+ passage1: {
615
+ words: extractWords(words1Match),
616
+ context: context1Match ? context1Match[1] : `From "${book1.title}" by ${book1.author}`
617
+ },
618
+ passage2: {
619
+ words: extractWords(words2Match),
620
+ context: context2Match ? context2Match[1] : `From "${book2.title}" by ${book2.author}`
621
+ }
622
+ };
623
+ } catch (extractError) {
624
+ console.error('Failed to extract partial data:', extractError);
625
+ throw new Error('Invalid API response format');
626
+ }
627
+ }
628
+ } catch (error) {
629
+ // Clear timeout in error case too
630
+ if (typeof timeoutId !== 'undefined') {
631
+ clearTimeout(timeoutId);
632
+ }
633
+
634
+ // Handle specific abort error
635
+ if (error.name === 'AbortError') {
636
+ console.error('Batch processing timed out after 15 seconds');
637
+ throw new Error('Request timed out - falling back to sequential processing');
638
+ }
639
+
640
+ console.error('Error processing passages:', error);
641
+ throw error;
642
+ }
643
+ }
644
+
645
+ async generateContextualization(title, author, passage) {
646
+
647
+ // Check for API key at runtime
648
+ const currentKey = this.getApiKey();
649
+ if (currentKey && !this.apiKey) {
650
+ this.apiKey = currentKey;
651
+ }
652
+
653
+
654
+ if (!this.apiKey) {
655
+ return `A passage from ${author}'s "${title}"`;
656
+ }
657
+
658
+ try {
659
+ return await this.retryRequest(async () => {
660
+ const response = await fetch(this.apiUrl, {
661
+ method: 'POST',
662
+ headers: {
663
+ 'Content-Type': 'application/json',
664
+ 'Authorization': `Bearer ${this.apiKey}`,
665
+ 'HTTP-Referer': window.location.origin,
666
+ 'X-Title': 'Cloze Reader'
667
+ },
668
+ body: JSON.stringify({
669
+ model: this.primaryModel, // Use Gemma-3-27b for contextualization
670
+ messages: [{
671
+ role: 'system',
672
+ content: 'Provide a single contextual insight about the passage: historical context, literary technique, thematic observation, or relevant fact. Be specific and direct. Maximum 25 words. Do not use dashes or em-dashes. Output ONLY the insight itself with no preamble, acknowledgments, or meta-commentary.'
673
+ }, {
674
+ role: 'user',
675
+ content: `From "${title}" by ${author}:\n\n${passage}`
676
+ }],
677
+ max_tokens: 150,
678
+ temperature: 0.7,
679
+ response_format: { type: "text" }
680
+ })
681
+ });
682
+
683
+ if (!response.ok) {
684
+ const errorText = await response.text();
685
+ console.error('Contextualization API error:', response.status, errorText);
686
+ throw new Error(`API request failed: ${response.status}`);
687
+ }
688
+
689
+ const data = await response.json();
690
+
691
+ // Check for OpenRouter error response
692
+ if (data.error) {
693
+ console.error('OpenRouter API error for contextualization:', data.error);
694
+ throw new Error(`OpenRouter API error: ${data.error.message || JSON.stringify(data.error)}`);
695
+ }
696
+
697
+
698
+ // Check if response has expected structure
699
+ if (!data.choices || !data.choices[0] || !data.choices[0].message) {
700
+ console.error('Invalid contextualization API response structure:', data);
701
+ console.error('Choices[0]:', data.choices?.[0]);
702
+ throw new Error('API response missing expected structure');
703
+ }
704
+
705
+ // Extract content from response (handles reasoning mode variants)
706
+ let content = this._extractContentFromResponse(data);
707
+
708
+ if (!content) {
709
+ console.error('No content found in context API response');
710
+ throw new Error('API response missing content');
711
+ }
712
+
713
+ // Clean up AI response artifacts
714
+ content = this._cleanupAIResponse(content.trim());
715
+
716
+ return content;
717
+ });
718
+ } catch (error) {
719
+ console.error('Error getting contextualization:', error);
720
+ return `A passage from ${author}'s "${title}"`;
721
+ }
722
+ }
723
+
724
+ cleanLocalLLMResponse(content) {
725
+ // Remove common artifacts from local LLM responses
726
+ return content
727
+ .replace(/\["?/g, '') // Remove opening bracket and quote
728
+ .replace(/"?\]/g, '') // Remove closing quote and bracket
729
+ .replace(/^[>"|']+/g, '') // Remove leading > or quotes
730
+ .replace(/[>"|']+$/g, '') // Remove trailing > or quotes
731
+ .replace(/\\n/g, ' ') // Replace escaped newlines
732
+ .trim();
733
+ }
734
+ }
735
+
736
+ export { OpenRouterService as AIService };
src/analyticsService.js ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Analytics Service
3
+ * Frontend client for tracking passage attempts, word difficulty, and hint usage.
4
+ * Sends summary data to backend Redis analytics service.
5
+ */
6
+
7
+ export class AnalyticsService {
8
+ constructor() {
9
+ // Generate unique session ID for this browser session
10
+ this.sessionId = this._generateUUID();
11
+
12
+ // Current passage tracking state
13
+ this.currentPassage = null;
14
+
15
+ // Base URL - uses same origin as the app
16
+ this.baseUrl = window.location.origin;
17
+
18
+ }
19
+
20
+ /**
21
+ * Generate a UUID v4
22
+ */
23
+ _generateUUID() {
24
+ // Use crypto.randomUUID if available, otherwise fallback
25
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
26
+ return crypto.randomUUID();
27
+ }
28
+ // Fallback for older browsers
29
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
30
+ const r = Math.random() * 16 | 0;
31
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
32
+ return v.toString(16);
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Start tracking a new passage attempt.
38
+ * Call this when a new passage is loaded.
39
+ *
40
+ * @param {Object} book - Book info {title, author}
41
+ * @param {Array} blanks - Array of blank objects with originalWord
42
+ * @param {number} level - Current game level
43
+ * @param {number} round - Current round number
44
+ */
45
+ startPassage(book, blanks, level, round) {
46
+ this.currentPassage = {
47
+ passageId: this._generateUUID(),
48
+ sessionId: this.sessionId,
49
+ bookTitle: book?.title || 'Unknown',
50
+ bookAuthor: book?.author || 'Unknown',
51
+ level: level || 1,
52
+ round: round || 1,
53
+ words: blanks.map(blank => ({
54
+ word: blank.originalWord || '',
55
+ length: (blank.originalWord || '').length,
56
+ attemptsToCorrect: 0,
57
+ hintsUsed: [],
58
+ finalCorrect: false
59
+ })),
60
+ startTime: Date.now()
61
+ };
62
+
63
+ console.debug('📊 Analytics: Started passage', {
64
+ passageId: this.currentPassage.passageId,
65
+ book: this.currentPassage.bookTitle,
66
+ blanks: this.currentPassage.words.length,
67
+ level,
68
+ round
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Record an attempt on a specific word.
74
+ * Call this each time the user submits an answer for a blank.
75
+ *
76
+ * @param {number} blankIndex - Index of the blank in the passage
77
+ * @param {boolean} correct - Whether the attempt was correct
78
+ */
79
+ recordAttempt(blankIndex, correct) {
80
+ if (!this.currentPassage) {
81
+ console.warn('📊 Analytics: No active passage to record attempt');
82
+ return;
83
+ }
84
+
85
+ if (blankIndex < 0 || blankIndex >= this.currentPassage.words.length) {
86
+ console.warn('📊 Analytics: Invalid blank index', blankIndex);
87
+ return;
88
+ }
89
+
90
+ const wordData = this.currentPassage.words[blankIndex];
91
+ wordData.attemptsToCorrect++;
92
+
93
+ if (correct) {
94
+ wordData.finalCorrect = true;
95
+ }
96
+
97
+ console.debug('📊 Analytics: Recorded attempt', {
98
+ word: wordData.word,
99
+ attempt: wordData.attemptsToCorrect,
100
+ correct
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Record all attempts at once (batch mode).
106
+ * Use when results come in as an array.
107
+ *
108
+ * @param {Array} results - Array of {blankIndex, isCorrect} objects
109
+ */
110
+ recordAttemptsBatch(results) {
111
+ if (!this.currentPassage) {
112
+ console.warn('📊 Analytics: No active passage to record attempts');
113
+ return;
114
+ }
115
+
116
+ results.forEach(result => {
117
+ if (result.blankIndex !== undefined) {
118
+ this.recordAttempt(result.blankIndex, result.isCorrect);
119
+ }
120
+ });
121
+ }
122
+
123
+ /**
124
+ * Record a hint request for a specific word.
125
+ *
126
+ * @param {number} blankIndex - Index of the blank
127
+ * @param {string} hintType - Type of hint requested (e.g., 'part_of_speech', 'synonym', 'first_letter')
128
+ */
129
+ recordHint(blankIndex, hintType) {
130
+ if (!this.currentPassage) {
131
+ console.warn('📊 Analytics: No active passage to record hint');
132
+ return;
133
+ }
134
+
135
+ if (blankIndex < 0 || blankIndex >= this.currentPassage.words.length) {
136
+ console.warn('📊 Analytics: Invalid blank index for hint', blankIndex);
137
+ return;
138
+ }
139
+
140
+ const wordData = this.currentPassage.words[blankIndex];
141
+ wordData.hintsUsed.push(hintType || 'unknown');
142
+
143
+ console.debug('📊 Analytics: Recorded hint', {
144
+ word: wordData.word,
145
+ hintType,
146
+ totalHints: wordData.hintsUsed.length
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Complete the current passage and send analytics to backend.
152
+ *
153
+ * @param {boolean} passed - Whether the user passed the passage
154
+ * @returns {Promise<Object>} - Response from analytics API
155
+ */
156
+ async completePassage(passed) {
157
+ if (!this.currentPassage) {
158
+ console.warn('📊 Analytics: No active passage to complete');
159
+ return { success: false, message: 'No active passage' };
160
+ }
161
+
162
+ // Calculate summary statistics
163
+ const totalBlanks = this.currentPassage.words.length;
164
+ const correctOnFirstTry = this.currentPassage.words.filter(
165
+ w => w.attemptsToCorrect === 1 && w.finalCorrect
166
+ ).length;
167
+ const totalHintsUsed = this.currentPassage.words.reduce(
168
+ (sum, w) => sum + w.hintsUsed.length, 0
169
+ );
170
+
171
+ const data = {
172
+ passageId: this.currentPassage.passageId,
173
+ sessionId: this.currentPassage.sessionId,
174
+ bookTitle: this.currentPassage.bookTitle,
175
+ bookAuthor: this.currentPassage.bookAuthor,
176
+ level: this.currentPassage.level,
177
+ round: this.currentPassage.round,
178
+ words: this.currentPassage.words,
179
+ totalBlanks,
180
+ correctOnFirstTry,
181
+ totalHintsUsed,
182
+ passed,
183
+ timestamp: new Date().toISOString()
184
+ };
185
+
186
+ console.debug('📊 Analytics: Completing passage', {
187
+ passageId: data.passageId,
188
+ passed,
189
+ correctOnFirstTry,
190
+ totalBlanks,
191
+ totalHintsUsed
192
+ });
193
+
194
+ // Clear current passage state
195
+ this.currentPassage = null;
196
+
197
+ // Send to backend
198
+ try {
199
+ const response = await fetch(`${this.baseUrl}/api/analytics/passage`, {
200
+ method: 'POST',
201
+ headers: {
202
+ 'Content-Type': 'application/json'
203
+ },
204
+ body: JSON.stringify(data)
205
+ });
206
+
207
+ if (!response.ok) {
208
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
209
+ }
210
+
211
+ const result = await response.json();
212
+ return result;
213
+
214
+ } catch (error) {
215
+ // Don't throw - analytics failure shouldn't break the game
216
+ console.warn('📊 Analytics: Failed to send (non-critical)', error.message);
217
+ return { success: false, message: error.message };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Cancel tracking for current passage without sending.
223
+ * Use if the user abandons a passage mid-attempt.
224
+ */
225
+ cancelPassage() {
226
+ if (this.currentPassage) {
227
+ console.debug('📊 Analytics: Cancelled passage', {
228
+ passageId: this.currentPassage.passageId
229
+ });
230
+ this.currentPassage = null;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Check if there's an active passage being tracked.
236
+ * @returns {boolean}
237
+ */
238
+ isTrackingPassage() {
239
+ return this.currentPassage !== null;
240
+ }
241
+
242
+ /**
243
+ * Get current passage statistics (for UI display).
244
+ * @returns {Object|null}
245
+ */
246
+ getCurrentStats() {
247
+ if (!this.currentPassage) return null;
248
+
249
+ const totalBlanks = this.currentPassage.words.length;
250
+ const correctOnFirstTry = this.currentPassage.words.filter(
251
+ w => w.attemptsToCorrect === 1 && w.finalCorrect
252
+ ).length;
253
+ const totalCorrect = this.currentPassage.words.filter(w => w.finalCorrect).length;
254
+ const totalHintsUsed = this.currentPassage.words.reduce(
255
+ (sum, w) => sum + w.hintsUsed.length, 0
256
+ );
257
+
258
+ return {
259
+ passageId: this.currentPassage.passageId,
260
+ bookTitle: this.currentPassage.bookTitle,
261
+ level: this.currentPassage.level,
262
+ round: this.currentPassage.round,
263
+ totalBlanks,
264
+ correctOnFirstTry,
265
+ totalCorrect,
266
+ totalHintsUsed,
267
+ words: this.currentPassage.words.map(w => ({
268
+ word: w.word,
269
+ attempts: w.attemptsToCorrect,
270
+ hintsUsed: w.hintsUsed.length,
271
+ correct: w.finalCorrect
272
+ }))
273
+ };
274
+ }
275
+
276
+ // ===== ADMIN API METHODS =====
277
+
278
+ /**
279
+ * Get analytics summary (admin dashboard data).
280
+ * @returns {Promise<Object>}
281
+ */
282
+ async getSummary() {
283
+ try {
284
+ const response = await fetch(`${this.baseUrl}/api/analytics/summary`);
285
+ if (!response.ok) {
286
+ throw new Error(`HTTP ${response.status}`);
287
+ }
288
+ return await response.json();
289
+ } catch (error) {
290
+ console.error('📊 Analytics: Failed to get summary', error);
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Get recent passage attempts.
297
+ * @param {number} count - Number of entries (max 200)
298
+ * @returns {Promise<Object>}
299
+ */
300
+ async getRecentPassages(count = 50) {
301
+ try {
302
+ const response = await fetch(`${this.baseUrl}/api/analytics/recent?count=${count}`);
303
+ if (!response.ok) {
304
+ throw new Error(`HTTP ${response.status}`);
305
+ }
306
+ return await response.json();
307
+ } catch (error) {
308
+ console.error('📊 Analytics: Failed to get recent passages', error);
309
+ throw error;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Export all analytics data.
315
+ * @returns {Promise<Object>}
316
+ */
317
+ async exportAll() {
318
+ try {
319
+ const response = await fetch(`${this.baseUrl}/api/analytics/export`);
320
+ if (!response.ok) {
321
+ throw new Error(`HTTP ${response.status}`);
322
+ }
323
+ return await response.json();
324
+ } catch (error) {
325
+ console.error('📊 Analytics: Failed to export', error);
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Get statistics for a specific word.
332
+ * @param {string} word
333
+ * @returns {Promise<Object>}
334
+ */
335
+ async getWordStats(word) {
336
+ try {
337
+ const response = await fetch(`${this.baseUrl}/api/analytics/word/${encodeURIComponent(word)}`);
338
+ if (!response.ok) {
339
+ throw new Error(`HTTP ${response.status}`);
340
+ }
341
+ return await response.json();
342
+ } catch (error) {
343
+ console.error('📊 Analytics: Failed to get word stats', error);
344
+ throw error;
345
+ }
346
+ }
347
+ }
348
+
349
+ // Export singleton instance
350
+ export const analyticsService = new AnalyticsService();
src/app.js ADDED
@@ -0,0 +1,592 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Main application entry point
2
+ import ClozeGame from './clozeGameEngine.js';
3
+ import ChatUI from './chatInterface.js';
4
+ import WelcomeOverlay from './welcomeOverlay.js';
5
+ import { LeaderboardUI } from './leaderboardUI.js';
6
+ import { analyticsService } from './analyticsService.js';
7
+
8
+ class App {
9
+ constructor() {
10
+ this.game = new ClozeGame();
11
+ this.chatUI = new ChatUI(this.game);
12
+ this.welcomeOverlay = new WelcomeOverlay();
13
+ this.leaderboardUI = new LeaderboardUI(this.game.leaderboardService);
14
+ this.analytics = analyticsService;
15
+ // Prevent double-trigger on rapid Skip clicks
16
+ this._skipInProgress = false;
17
+ this.lastRevealWasSkip = false; // Controls reveal styling after skip
18
+ this.elements = {
19
+ loading: document.getElementById('loading'),
20
+ gameArea: document.getElementById('game-area'),
21
+ stickyControls: document.getElementById('sticky-controls'),
22
+ bookInfo: document.getElementById('book-info'),
23
+ roundInfo: document.getElementById('round-info'),
24
+ streakInfo: document.getElementById('streak-info'),
25
+ contextualization: document.getElementById('contextualization'),
26
+ passageContent: document.getElementById('passage-content'),
27
+ hintsSection: document.getElementById('hints-section'),
28
+ hintsList: document.getElementById('hints-list'),
29
+ skipBtn: document.getElementById('skip-btn'),
30
+ submitBtn: document.getElementById('submit-btn'),
31
+ nextBtn: document.getElementById('next-btn'),
32
+ hintBtn: document.getElementById('hint-btn'),
33
+ result: document.getElementById('result'),
34
+ leaderboardBtn: document.getElementById('leaderboard-btn')
35
+ };
36
+
37
+ this.currentResults = null;
38
+ this.isRetrying = false; // Track if we're in retry mode
39
+ this.setupEventListeners();
40
+ }
41
+
42
+ async initialize() {
43
+ try {
44
+ this.showLoading(true);
45
+ await this.game.initialize();
46
+ await this.startNewGame();
47
+ this.showLoading(false);
48
+ } catch (error) {
49
+ console.error('Failed to initialize app:', error);
50
+ this.showError('Failed to load the game. Please refresh and try again.');
51
+ }
52
+ }
53
+
54
+ setupEventListeners() {
55
+ if (this.elements.skipBtn) {
56
+ this.elements.skipBtn.addEventListener('click', () => {
57
+ // Ensure any unexpected async errors are surfaced but don’t crash UI
58
+ Promise.resolve(this.handleSkip()).catch(err => console.error('handleSkip (event) ERROR:', err));
59
+ });
60
+ } else {
61
+ }
62
+ this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
63
+ this.elements.nextBtn.addEventListener('click', () => this.handleNext());
64
+ this.elements.hintBtn.addEventListener('click', () => this.toggleHints());
65
+
66
+ // Leaderboard button
67
+ if (this.elements.leaderboardBtn) {
68
+ this.elements.leaderboardBtn.addEventListener('click', (e) => {
69
+ e.stopPropagation();
70
+ this.leaderboardUI.show();
71
+ });
72
+ }
73
+
74
+ // Note: Enter key handling is done per-input in setupInputListeners()
75
+ }
76
+
77
+ async startNewGame() {
78
+ try {
79
+ const roundData = await this.game.startNewRound();
80
+ this.displayRound(roundData);
81
+ this.resetUI();
82
+ } catch (error) {
83
+ console.error('Error starting new game:', error);
84
+ this.showError('Could not load a new passage. Please try again.');
85
+ }
86
+ }
87
+
88
+ displayRound(roundData) {
89
+ // Start analytics tracking for this passage
90
+ this.analytics.startPassage(
91
+ { title: roundData.title, author: roundData.author },
92
+ roundData.blanks,
93
+ this.game.currentLevel,
94
+ this.game.currentRound
95
+ );
96
+
97
+ // Reset retry state
98
+ this.isRetrying = false;
99
+
100
+ // Show book information
101
+ this.elements.bookInfo.innerHTML = `
102
+ <strong>${roundData.title}</strong> by ${roundData.author}
103
+ `;
104
+
105
+ // Show level information
106
+ const blanksCount = roundData.blanks.length;
107
+ const levelInfo = `Level ${this.game.currentLevel} • ${blanksCount} blank${blanksCount > 1 ? 's' : ''}`;
108
+
109
+ this.elements.roundInfo.innerHTML = levelInfo;
110
+
111
+ // Update streak display
112
+ this.updateStreakDisplay();
113
+
114
+ // Show contextualization from AI agent
115
+ this.elements.contextualization.innerHTML = `
116
+ <div class="flex items-start gap-2">
117
+ <span class="text-blue-600">📜</span>
118
+ <span>${roundData.contextualization || 'Loading context...'}</span>
119
+ </div>
120
+ `;
121
+
122
+ // Render the cloze text with input fields and chat buttons
123
+ const clozeHtml = this.game.renderClozeTextWithChat();
124
+ this.elements.passageContent.innerHTML = `<p>${clozeHtml}</p>`;
125
+
126
+ // Store hints for later display
127
+ this.currentHints = roundData.hints || [];
128
+ this.populateHints();
129
+
130
+ // Hide hints initially
131
+ this.elements.hintsSection.style.display = 'none';
132
+
133
+ // Set up input field listeners
134
+ this.setupInputListeners();
135
+
136
+ // Set up chat buttons
137
+ this.chatUI.setupChatButtons();
138
+ }
139
+
140
+ setupInputListeners() {
141
+ const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
142
+
143
+ inputs.forEach((input, index) => {
144
+ input.addEventListener('input', () => {
145
+ // Remove any previous styling
146
+ input.classList.remove('correct', 'incorrect');
147
+ this.updateSubmitButton();
148
+ });
149
+
150
+ input.addEventListener('keydown', (e) => {
151
+ if (e.key === 'Enter') {
152
+ e.preventDefault();
153
+
154
+ // Move to next input or submit if last
155
+ const nextInput = inputs[index + 1];
156
+ if (nextInput) {
157
+ nextInput.focus();
158
+ } else {
159
+ this.handleSubmit();
160
+ }
161
+ }
162
+ });
163
+ });
164
+
165
+ // Focus first input
166
+ if (inputs.length > 0) {
167
+ inputs[0].focus();
168
+ }
169
+ }
170
+
171
+ updateSubmitButton() {
172
+ const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
173
+ // Only check non-disabled (non-locked) inputs
174
+ const nonLockedInputs = Array.from(inputs).filter(input => !input.disabled);
175
+ const allFilled = nonLockedInputs.every(input => input.value.trim() !== '');
176
+ this.elements.submitBtn.disabled = !allFilled || nonLockedInputs.length === 0;
177
+ }
178
+
179
+ handleSubmit() {
180
+ const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
181
+ const answers = Array.from(inputs).map(input => input.value.trim());
182
+
183
+ // Check if all non-locked fields are filled
184
+ const nonLockedInputs = Array.from(inputs).filter((input, index) => !this.game.isBlankLocked(index));
185
+ const nonLockedAnswers = nonLockedInputs.map(input => input.value.trim());
186
+
187
+ if (nonLockedAnswers.some(answer => answer === '')) {
188
+ alert('Please fill in all blanks before submitting.');
189
+ return;
190
+ }
191
+
192
+ // Submit answers and get results
193
+ this.currentResults = this.game.submitAnswers(answers);
194
+
195
+ // Record attempts in analytics only for blanks attempted in this submission
196
+ this.currentResults.results.forEach(result => {
197
+ if (result.attemptedThisRound) {
198
+ this.analytics.recordAttempt(result.blankIndex, result.isCorrect);
199
+ }
200
+ });
201
+
202
+ // Handle retry vs final display
203
+ if (this.currentResults.canRetry) {
204
+ this.displayRetryUI(this.currentResults);
205
+ } else {
206
+ // Final submission - send analytics
207
+ this.sendAnalytics(this.currentResults.passed);
208
+ this.displayResults(this.currentResults);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Display UI for retry mode - lock correct answers, highlight wrong ones
214
+ */
215
+ displayRetryUI(results) {
216
+ this.isRetrying = true;
217
+ // Ensure skip can be used during this retry cycle
218
+ this._skipInProgress = false;
219
+ const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
220
+
221
+ results.results.forEach((result, index) => {
222
+ const input = inputs[index];
223
+ if (!input) return;
224
+
225
+ if (result.isCorrect || result.isLocked) {
226
+ // Lock correct answers - show green background and disable
227
+ input.classList.add('correct');
228
+ input.classList.remove('incorrect');
229
+ input.style.backgroundColor = '#dcfce7'; // Light green
230
+ input.style.borderColor = '#16a34a';
231
+ input.disabled = true;
232
+ input.value = result.correctAnswer;
233
+ } else {
234
+ // Highlight wrong answers - show red border, keep editable
235
+ input.classList.add('incorrect');
236
+ input.classList.remove('correct');
237
+ input.style.backgroundColor = '#fef2f2'; // Light red
238
+ input.style.borderColor = '#dc2626';
239
+ input.disabled = false;
240
+ // Clear wrong answer for retry
241
+ input.value = '';
242
+ }
243
+ });
244
+
245
+ // Update result message
246
+ this.elements.result.textContent = results.feedbackText;
247
+ this.elements.result.className = 'mt-4 text-center font-semibold text-amber-600';
248
+
249
+ // Update submit button text and show skip button
250
+ this.elements.submitBtn.textContent = 'Try Again';
251
+ this.elements.submitBtn.disabled = true; // Will be enabled when user types
252
+ if (this.elements.skipBtn) {
253
+ this.elements.skipBtn.classList.remove('hidden');
254
+ this.elements.skipBtn.disabled = false; // Ensure skip is clickable on retries
255
+ this.elements.skipBtn.removeAttribute('disabled');
256
+ this.elements.skipBtn.setAttribute('aria-disabled', 'false');
257
+ } else {
258
+ }
259
+
260
+ // Focus first wrong input
261
+ const firstWrongInput = Array.from(inputs).find(
262
+ (input, index) => results.retryableIndices.includes(index)
263
+ );
264
+ if (firstWrongInput) {
265
+ firstWrongInput.focus();
266
+ }
267
+
268
+ // Don't update streak display during retry - wait for final result
269
+ }
270
+
271
+ /**
272
+ * Handle skip button - skip passage and move to next
273
+ */
274
+ async handleSkip() {
275
+ console.log('handleSkip: START - v2');
276
+
277
+ try {
278
+ // Debounce multiple rapid clicks
279
+ if (this._skipInProgress) {
280
+ console.warn('handleSkip: already in progress, ignoring');
281
+ return;
282
+ }
283
+ this._skipInProgress = true;
284
+ if (this.elements?.skipBtn) {
285
+ this.elements.skipBtn.disabled = true;
286
+ }
287
+ // Early exit if no game or blanks
288
+ if (!this.game || !Array.isArray(this.game.blanks) || this.game.blanks.length === 0) {
289
+ console.warn('handleSkip: No game/blanks, going to next passage');
290
+ this.handleNext();
291
+ return;
292
+ }
293
+
294
+ // Send analytics as failed (non-blocking)
295
+ console.log('📊 Sending analytics (skip - passage failed)...');
296
+ this.sendAnalytics(false).catch(err => console.warn('📊 Analytics (skip) failed non-critically:', err));
297
+
298
+ // Use engine to force-complete this passage to ensure consistent results/state
299
+ const finalResults = this.game.forceCompletePassage();
300
+ console.log('handleSkip: forceCompletePassage results:', finalResults);
301
+
302
+ // Show the results (which reveals correct answers)
303
+ console.log('handleSkip: calling displayResults');
304
+ this.lastRevealWasSkip = true;
305
+ this.displayResults(finalResults);
306
+ console.log('handleSkip: displayResults completed');
307
+
308
+ // Hide skip button
309
+ this.elements.skipBtn.classList.add('hidden');
310
+
311
+ // Hide submit button
312
+ this.elements.submitBtn.style.display = 'none';
313
+
314
+ // Show the next button immediately after skip
315
+ this.elements.nextBtn.classList.remove('hidden');
316
+ } catch (error) {
317
+ console.error('handleSkip ERROR:', error);
318
+ // Fallback: attempt to force-complete and reveal, or at least enable Next
319
+ try {
320
+ if (this.game?.forceCompletePassage) {
321
+ const fallbackResults = this.game.forceCompletePassage();
322
+ this.lastRevealWasSkip = true;
323
+ this.displayResults(fallbackResults);
324
+ } else {
325
+ this.elements.nextBtn.classList.remove('hidden');
326
+ }
327
+ } catch (fallbackError) {
328
+ console.warn('handleSkip fallback failed; showing Next button:', fallbackError);
329
+ this.elements.nextBtn.classList.remove('hidden');
330
+ }
331
+ } finally {
332
+ // Reset guard so future rounds can skip normally
333
+ this._skipInProgress = false;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Send analytics data to backend
339
+ */
340
+ async sendAnalytics(passed) {
341
+ try {
342
+ await this.analytics.completePassage(passed);
343
+ } catch (error) {
344
+ // Non-critical - don't break gameplay
345
+ console.warn('📊 Analytics send failed (non-critical):', error);
346
+ }
347
+ }
348
+
349
+ displayResults(results) {
350
+ let message = `Score: ${results.correct}/${results.total}`;
351
+
352
+ if (results.passed) {
353
+ // Check if level was just advanced
354
+ if (results.justAdvancedLevel) {
355
+ message += ` - Level ${results.currentLevel} unlocked!`;
356
+
357
+ // Check for milestone notification (every 5 levels)
358
+ if (results.currentLevel % 5 === 0) {
359
+ this.leaderboardUI.showMilestoneNotification(results.currentLevel);
360
+ }
361
+
362
+ // Check for high score
363
+ this.checkForHighScore();
364
+ } else {
365
+ message += ` - Passed!`;
366
+ }
367
+ this.elements.result.className = 'mt-4 text-center font-semibold text-green-600';
368
+ } else {
369
+ message += ` - Failed (need ${results.requiredCorrect} correct)`;
370
+ this.elements.result.className = 'mt-4 text-center font-semibold text-red-600';
371
+ }
372
+
373
+ this.elements.result.textContent = message;
374
+
375
+ // Always reveal answers at the end of each round
376
+ this.revealAnswersInPlace(results.results);
377
+
378
+ // Show next button and hide submit/skip buttons
379
+ this.elements.submitBtn.style.display = 'none';
380
+ if (this.elements.skipBtn) {
381
+ this.elements.skipBtn.classList.add('hidden');
382
+ }
383
+ this.elements.nextBtn.classList.remove('hidden');
384
+
385
+ // Update streak display after processing results
386
+ this.updateStreakDisplay();
387
+ }
388
+
389
+ updateStreakDisplay() {
390
+ const stats = this.game.leaderboardService.getPlayerStats();
391
+ const currentStreak = stats.currentStreak;
392
+
393
+ if (currentStreak > 0) {
394
+ this.elements.streakInfo.innerHTML = `🔥 ${currentStreak} streak`;
395
+ this.elements.streakInfo.classList.remove('hidden');
396
+ } else {
397
+ this.elements.streakInfo.classList.add('hidden');
398
+ }
399
+ }
400
+
401
+ highlightAnswers(results) {
402
+ const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
403
+
404
+ results.forEach((result, index) => {
405
+ const input = inputs[index];
406
+ if (input) {
407
+ if (result.isCorrect) {
408
+ input.classList.add('correct');
409
+ } else {
410
+ input.classList.add('incorrect');
411
+ // Show correct answer as placeholder or title
412
+ input.title = `Correct answer: ${result.correctAnswer}`;
413
+ }
414
+ input.disabled = true;
415
+ }
416
+ });
417
+ }
418
+
419
+ async handleNext() {
420
+ try {
421
+ // Show loading immediately with specific message
422
+ this.showLoading(true, 'Loading passages...');
423
+
424
+ // Clear chat history when starting new round
425
+ this.chatUI.clearChatHistory();
426
+
427
+ // Always show loading for at least 1 second for smooth UX
428
+ const startTime = Date.now();
429
+
430
+ // Load next round
431
+ const roundData = await this.game.nextRound();
432
+
433
+ // Ensure loading is shown for at least half a second
434
+ const elapsedTime = Date.now() - startTime;
435
+ if (elapsedTime < 500) {
436
+ await new Promise(resolve => setTimeout(resolve, 500 - elapsedTime));
437
+ }
438
+
439
+ this.displayRound(roundData);
440
+ this.resetUI();
441
+ this.showLoading(false);
442
+ } catch (error) {
443
+ console.error('Error loading next round:', error);
444
+ this.showError('Could not load next round. Please try again.');
445
+ }
446
+ }
447
+
448
+ // Reveal correct answers immediately after submission
449
+ revealAnswersInPlace(results) {
450
+ const inputs = this.elements.passageContent.querySelectorAll('.cloze-input');
451
+ // Remove any previously rendered external labels to avoid duplicates
452
+ const existingLabels = this.elements.passageContent.querySelectorAll('.correct-answer-reveal');
453
+ existingLabels.forEach(node => node.remove());
454
+
455
+ results.forEach((result, index) => {
456
+ const input = inputs[index];
457
+ if (input) {
458
+ // Always set the correct answer in the input
459
+ input.value = result.correctAnswer;
460
+
461
+ if (this.lastRevealWasSkip) {
462
+ // Neutral reveal on skip: no red/green styling
463
+ input.classList.remove('correct', 'incorrect');
464
+ input.style.backgroundColor = '';
465
+ input.style.borderColor = '';
466
+ } else {
467
+ // Normal reveal with visual feedback
468
+ if (result.isCorrect) {
469
+ input.classList.add('correct');
470
+ input.classList.remove('incorrect');
471
+ input.style.backgroundColor = '#dcfce7'; // Light green
472
+ input.style.borderColor = '#16a34a'; // Green border
473
+ } else {
474
+ input.classList.add('incorrect');
475
+ input.classList.remove('correct');
476
+ input.style.backgroundColor = '#fef2f2'; // Light red
477
+ input.style.borderColor = '#dc2626'; // Red border
478
+ }
479
+ }
480
+ input.disabled = true;
481
+ }
482
+ });
483
+ }
484
+
485
+ populateHints() {
486
+ if (!this.currentHints || this.currentHints.length === 0) {
487
+ this.elements.hintsList.innerHTML = '<div class="text-yellow-600">No hints available for this passage.</div>';
488
+ return;
489
+ }
490
+
491
+ const hintsHtml = this.currentHints.map((hintData, index) =>
492
+ `<div class="flex items-start gap-2">
493
+ <span class="font-semibold text-yellow-800">${index + 1}.</span>
494
+ <span>${hintData.hint}</span>
495
+ </div>`
496
+ ).join('');
497
+
498
+ this.elements.hintsList.innerHTML = hintsHtml;
499
+ }
500
+
501
+ toggleHints() {
502
+ const isHidden = this.elements.hintsSection.style.display === 'none';
503
+ this.elements.hintsSection.style.display = isHidden ? 'block' : 'none';
504
+ this.elements.hintBtn.textContent = isHidden ? 'Hide Hints' : 'Show Hints';
505
+ }
506
+
507
+ resetUI() {
508
+ this.elements.result.textContent = '';
509
+ this.elements.submitBtn.style.display = 'inline-block';
510
+ this.elements.submitBtn.disabled = true;
511
+ this.elements.submitBtn.textContent = 'Submit'; // Reset button text
512
+ if (this.elements.skipBtn) {
513
+ this.elements.skipBtn.classList.add('hidden'); // Hide skip button
514
+ this.elements.skipBtn.disabled = false; // Re-enable for new round
515
+ this.elements.skipBtn.removeAttribute('disabled');
516
+ this.elements.skipBtn.setAttribute('aria-disabled', 'false');
517
+ }
518
+ this.elements.nextBtn.classList.add('hidden');
519
+ this.elements.hintsSection.style.display = 'none';
520
+ this.elements.hintBtn.textContent = 'Show Hints';
521
+ this.currentResults = null;
522
+ this.currentHints = [];
523
+ this.isRetrying = false; // Reset retry state
524
+ this._skipInProgress = false; // Allow skip again in new round
525
+ this.lastRevealWasSkip = false;
526
+ }
527
+
528
+ showLoading(show, message = 'Loading passages...') {
529
+ if (show) {
530
+ this.elements.loading.innerHTML = `
531
+ <div class="text-center py-8">
532
+ <p class="text-lg loading-text">${message}</p>
533
+ </div>
534
+ `;
535
+ this.elements.loading.classList.remove('hidden');
536
+ this.elements.gameArea.classList.add('hidden');
537
+ this.elements.stickyControls.classList.add('hidden');
538
+ } else {
539
+ this.elements.loading.classList.add('hidden');
540
+ this.elements.gameArea.classList.remove('hidden');
541
+ this.elements.stickyControls.classList.remove('hidden');
542
+ }
543
+ }
544
+
545
+ showError(message) {
546
+ this.elements.loading.innerHTML = `
547
+ <div class="text-center py-8">
548
+ <p class="text-lg text-red-600 mb-4">${message}</p>
549
+ <button onclick="location.reload()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
550
+ Reload
551
+ </button>
552
+ </div>
553
+ `;
554
+ this.elements.loading.classList.remove('hidden');
555
+ this.elements.gameArea.classList.add('hidden');
556
+ }
557
+
558
+ checkForHighScore() {
559
+ // Check if current score qualifies for leaderboard
560
+ if (this.game.checkForHighScore()) {
561
+ const rank = this.game.getHighScoreRank();
562
+ const stats = this.game.leaderboardService.getStats();
563
+
564
+ // Always show initials entry when achieving a high score
565
+ // Pre-fills with previous initials if available, allowing changes
566
+ this.leaderboardUI.showInitialsEntry(
567
+ stats.highestLevel,
568
+ stats.roundAtHighestLevel,
569
+ rank,
570
+ (initials) => {
571
+ // Save to leaderboard
572
+ const finalRank = this.game.addToLeaderboard(initials);
573
+ }
574
+ );
575
+ }
576
+ }
577
+ }
578
+
579
+ // Initialize the app when DOM is loaded
580
+ document.addEventListener('DOMContentLoaded', () => {
581
+ const app = new App();
582
+
583
+ // Show welcome overlay immediately before any loading
584
+ app.welcomeOverlay.show();
585
+
586
+ app.initialize();
587
+
588
+ // Expose API key setter for browser console
589
+ window.setOpenRouterKey = (key) => {
590
+ app.game.chatService.aiService.setApiKey(key);
591
+ };
592
+ });
src/bookDataService.js ADDED
@@ -0,0 +1,631 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Hugging Face Project Gutenberg Dataset Service
2
+ class HuggingFaceDatasetService {
3
+ constructor() {
4
+ // Use Hugging Face Datasets API for streaming
5
+ this.datasetName = 'manu/project_gutenberg';
6
+ this.apiBase = 'https://datasets-server.huggingface.co';
7
+ this.proxyBase = '/api/books'; // server-side proxy for CORS-safe HF access
8
+ this.hfConfig = 'default';
9
+ this.hfSplit = 'en';
10
+ this.books = [];
11
+ this.isLoaded = false;
12
+ this.streamingEnabled = false;
13
+ this.cache = new Map();
14
+ this.preloadedBooks = [];
15
+ this.usedBooks = new Set(); // Track books used this session
16
+ }
17
+
18
+ // Local fallback books for when HF streaming is unavailable
19
+ getSampleBooks() {
20
+ return [
21
+ {
22
+ id: 1,
23
+ title: "Pride and Prejudice",
24
+ author: "Jane Austen",
25
+ year: 1813,
26
+ text: "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, this truth is so well fixed in the minds of the surrounding families, that he is considered the rightful property of some one or other of their daughters. \"My dear Mr. Bennet,\" said his lady to him one day, \"have you heard that Netherfield Park is let at last?\" Mr. Bennet replied that he had not. \"But it is,\" returned she; \"for Mrs. Long has just been here, and she told me all about it.\" Mr. Bennet made no answer. \"Do you not want to know who has taken it?\" cried his wife impatiently. \"You want to tell me, and I have no objection to hearing it.\" This was invitation enough."
27
+ },
28
+ {
29
+ id: 2,
30
+ title: "The Adventures of Tom Sawyer",
31
+ author: "Mark Twain",
32
+ year: 1876,
33
+ text: "\"Tom!\" No answer. \"Tom!\" No answer. \"What's gone with that boy, I wonder? You TOM!\" No answer. The old lady pulled her spectacles down and looked over them about the room; then she put them up and looked out under them. She seldom or never looked through them for so small a thing as a boy; they were her state pair, the pride of her heart, and were built for \"style,\" not service--she could have seen through a pair of stove-lids just as well. She looked perplexed for a moment, and then said, not fiercely, but still loud enough for the furniture to hear: \"Well, I lay if I get hold of you I'll--\""
34
+ },
35
+ {
36
+ id: 3,
37
+ title: "Great Expectations",
38
+ author: "Charles Dickens",
39
+ year: 1861,
40
+ text: "My father's family name being Pirrip, and my Christian name Philip, my infant tongue could make of both names nothing longer or more explicit than Pip. So, I called myself Pip, and came to be called Pip. I give Pirrip as my father's family name, on the authority of his tombstone and my sister,--Mrs. Joe Gargery, who married the blacksmith. As I never saw my father or my mother, and never saw any likeness of them (for their days were long before the days of photographs), my first fancies regarding what they were like were unreasonably derived from their tombstones."
41
+ },
42
+ {
43
+ id: 4,
44
+ title: "Alice's Adventures in Wonderland",
45
+ author: "Lewis Carroll",
46
+ year: 1865,
47
+ text: "Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, 'and what is the use of a book,' thought Alice 'without pictures or conversation?' So she was considering in her own mind (as well as she could, for the hot day made her feel very sleepy and stupid), whether the pleasure of making a daisy-chain would be worth the trouble of getting up and picking the daisies, when suddenly a White Rabbit with pink eyes ran close by her."
48
+ },
49
+ {
50
+ id: 5,
51
+ title: "The Picture of Dorian Gray",
52
+ author: "Oscar Wilde",
53
+ year: 1890,
54
+ text: "The studio was filled with the rich odour of roses, and when the strong summer wind stirred, amidst the trees of the garden, there came through the open door the heavy scent of the lilac, or the more delicate perfume of the pink-flowering thorn. From the corner of the divan of Persian saddle-bags on which he was lying, smoking, as was his custom, innumerable cigarettes, Lord Henry Wotton could just catch the gleam of the honey-sweet and honey-coloured blossoms of a laburnum, whose tremulous branches seemed hardly able to bear the burden of a beauty so flamelike as theirs."
55
+ },
56
+ {
57
+ id: 6,
58
+ title: "Moby Dick",
59
+ author: "Herman Melville",
60
+ year: 1851,
61
+ text: "Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking people's hats off—then, I account it high time to get to sea as soon as possible."
62
+ },
63
+ {
64
+ id: 7,
65
+ title: "Jane Eyre",
66
+ author: "Charlotte Bronte",
67
+ year: 1847,
68
+ text: "There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery an hour in the morning; but since dinner (Mrs. Reed, when there was no company, dined early) the cold winter wind had brought with it clouds so sombre, and a rain so penetrating, that further out-door exercise was now out of the question. I was glad of it: I never liked long walks, especially on chilly afternoons: dreadful to me was the coming home in the raw twilight, with nipped fingers and toes, and a heart saddened by the chidings of Bessie, the nurse, and humbled by the consciousness of my physical inferiority to Eliza, John, and Georgiana Reed."
69
+ },
70
+ {
71
+ id: 8,
72
+ title: "The Count of Monte Cristo",
73
+ author: "Alexandre Dumas",
74
+ year: 1844,
75
+ text: "On the first Monday of February, 1815, the watchtower at Marseilles signaled the arrival of the three-master Pharaon from Smyrna, Trieste, and Naples. As was customary, the pilot immediately left the port and steered toward the château d'If to conduct the ship through the narrow passage that leads to the harbor. However, a young sailor of about nineteen or twenty years, standing on the ship's bow, had signaled the pilot even before he had time to ask the traditional questions that are exchanged between the pilot and the captain. The young man had already assumed command, being the ship's owner and captain."
76
+ },
77
+ {
78
+ id: 9,
79
+ title: "Wuthering Heights",
80
+ author: "Emily Bronte",
81
+ year: 1847,
82
+ text: "I have just returned from a visit to my landlord—the solitary neighbour that I shall be troubled with. This is certainly a beautiful country! In all England, I do not believe that I could have fixed on a situation so completely removed from the stir of society. A perfect misanthropist's Heaven: and Mr. Heathcliff and I are such a suitable pair to divide the desolation between us. A capital fellow! He little imagined how my heart warmed towards him when I beheld his black eyes withdraw so suspiciously under their brows, as I rode up, and when his fingers sheltered themselves, with a jealous resolution, still further in his waistcoat, as I announced my name."
83
+ },
84
+ {
85
+ id: 10,
86
+ title: "Frankenstein",
87
+ author: "Mary Shelley",
88
+ year: 1818,
89
+ text: "It was on a dreary night of November that I beheld the accomplishment of my toils. With an anxiety that almost amounted to agony, I collected the instruments of life around me, that I might infuse a spark of being into the lifeless thing that lay at my feet. It was already one in the morning; the rain pattered dismally against the panes, and my candle was nearly burnt out, when, by the glimmer of the half-extinguished light, I saw the dull yellow eye of the creature open; it breathed hard, and a convulsive motion agitated its limbs. How can I describe my emotions at this catastrophe, or how delineate the wretch whom with such infinite pains and care I had endeavoured to form?"
90
+ }
91
+ ];
92
+ }
93
+
94
+ async loadDataset() {
95
+ try {
96
+ // Try to connect to HF Datasets API
97
+ await this.initializeStreaming();
98
+
99
+ if (this.streamingEnabled) {
100
+ // Preload some books for immediate access
101
+ await this.preloadBooks(2);
102
+ if (this.preloadedBooks.length > 0) {
103
+ } else {
104
+ // Fast fallback if HF is slow/unavailable
105
+ console.warn('HF streaming returned 0 books; falling back to local samples for this session');
106
+ this.streamingEnabled = false;
107
+ this.books = this.getSampleBooks();
108
+ }
109
+ } else {
110
+ // Fall back to local samples
111
+ this.books = this.getSampleBooks();
112
+ }
113
+
114
+ this.isLoaded = true;
115
+ return this.books;
116
+ } catch (error) {
117
+ console.error('Error loading dataset:', error);
118
+ // Ensure we always have local fallback
119
+ this.books = this.getSampleBooks();
120
+ this.isLoaded = true;
121
+ return this.books;
122
+ }
123
+ }
124
+
125
+ async initializeStreaming() {
126
+ try {
127
+ // Test HF Datasets API availability
128
+ const testUrl = `${this.proxyBase}/splits?dataset=${encodeURIComponent(this.datasetName)}`;
129
+ const response = await this.fetchWithTimeout(testUrl, { timeoutMs: 3000 });
130
+
131
+ if (response.ok) {
132
+ const data = await response.json();
133
+ // Check if English split is available
134
+ let chosenSplit = null;
135
+ let chosenConfig = null;
136
+ const splits = Array.isArray(data.splits) ? data.splits : [];
137
+ // Prefer explicit English split on default config
138
+ const enDefault = splits.find(s => s.split === 'en' && s.config === 'default');
139
+ if (enDefault) {
140
+ chosenSplit = 'en';
141
+ chosenConfig = 'default';
142
+ } else {
143
+ // Otherwise prefer 'train' on default config
144
+ const trainDefault = splits.find(s => s.split === 'train' && s.config === 'default');
145
+ if (trainDefault) {
146
+ chosenSplit = 'train';
147
+ chosenConfig = 'default';
148
+ } else if (splits.length > 0) {
149
+ // Last resort: take the first available split/config
150
+ chosenSplit = splits[0].split;
151
+ chosenConfig = splits[0].config;
152
+ }
153
+ }
154
+
155
+ this.hfSplit = chosenSplit || this.hfSplit;
156
+ this.hfConfig = chosenConfig || this.hfConfig;
157
+ this.streamingEnabled = Boolean(chosenSplit);
158
+ if (this.streamingEnabled) {
159
+ }
160
+ }
161
+ } catch (error) {
162
+ console.warn('HF Datasets API test failed:', error);
163
+ this.streamingEnabled = false;
164
+ }
165
+ }
166
+
167
+ async preloadBooks(count = 2) {
168
+ if (!this.streamingEnabled) return;
169
+
170
+ try {
171
+ // Use random offset to avoid always getting the same books
172
+ const randomOffset = Math.floor(Math.random() * 1000);
173
+ const url = `${this.proxyBase}/rows?dataset=${encodeURIComponent(this.datasetName)}&config=${encodeURIComponent(this.hfConfig)}&split=${encodeURIComponent(this.hfSplit)}&offset=${randomOffset}&length=${count}`;
174
+
175
+ // Use retry logic with 20s timeout to handle slow HF API
176
+ const response = await this.retryFetch(
177
+ () => this.fetchWithTimeout(url, { timeoutMs: 20000 }),
178
+ 3, // max retries
179
+ 1000 // base delay 1s
180
+ );
181
+
182
+ if (response.ok) {
183
+ const data = await response.json();
184
+
185
+ // Check if data has expected structure
186
+ if (!data.rows || !Array.isArray(data.rows)) {
187
+ console.error('Unexpected HF API response structure:', data);
188
+ return;
189
+ }
190
+
191
+
192
+ this.preloadedBooks = data.rows
193
+ .map(row => {
194
+ try {
195
+ return this.processHFBookLazy(row.row);
196
+ } catch (e) {
197
+ console.warn('Error processing book:', e);
198
+ return null;
199
+ }
200
+ })
201
+ .filter(book => book !== null);
202
+
203
+ } else {
204
+ console.error(`HF API request failed: ${response.status} ${response.statusText}`);
205
+ }
206
+ } catch (error) {
207
+ console.warn('Failed to preload books after retries:', error);
208
+ }
209
+ }
210
+
211
+ processHFBookLazy(rowData) {
212
+ // Minimal processing - defer text cleaning and validation until book is selected
213
+ const rawText = rowData.text || '';
214
+
215
+ // Do basic metadata extraction to get proper title/author
216
+ const extractedMetadata = this.extractMetadata(rawText);
217
+ const title = extractedMetadata.title || rowData.title || 'Classic Literature';
218
+ const author = extractedMetadata.author || rowData.author || 'Unknown Author';
219
+
220
+ return {
221
+ id: rowData.id || Math.random().toString(36),
222
+ title: title,
223
+ author: author,
224
+ rawText: rawText,
225
+ text: null, // Will clean when needed
226
+ language: rowData.language || 'en',
227
+ source: 'project_gutenberg',
228
+ processed: false
229
+ };
230
+ }
231
+
232
+ async processBookOnDemand(book) {
233
+ if (book.processed) return book;
234
+
235
+ const startTime = Date.now();
236
+
237
+ // Clean text when actually needed
238
+ const cleanedText = this.cleanProjectGutenbergText(book.rawText);
239
+
240
+ book.text = cleanedText;
241
+ book.processed = true;
242
+
243
+ // Validate after processing
244
+ if (!this.isValidForCloze(book)) {
245
+ return null;
246
+ }
247
+
248
+ return book;
249
+ }
250
+
251
+
252
+ cleanProjectGutenbergText(text) {
253
+ if (!text) return '';
254
+
255
+ let cleaned = text;
256
+
257
+ // Remove Project Gutenberg start markers and everything before
258
+ const startPatterns = [
259
+ /\*\*\* START OF .*? \*\*\*/i,
260
+ /\*\*\*START OF .*?\*\*\*/i,
261
+ /START OF THE PROJECT GUTENBERG/i,
262
+ /GUTENBERG.*?EBOOK/i
263
+ ];
264
+
265
+ for (const pattern of startPatterns) {
266
+ const match = cleaned.match(pattern);
267
+ if (match) {
268
+ const startIndex = match.index + match[0].length;
269
+ // Skip to next line
270
+ const nextLine = cleaned.indexOf('\n', startIndex);
271
+ if (nextLine !== -1) {
272
+ cleaned = cleaned.substring(nextLine + 1);
273
+ }
274
+ break;
275
+ }
276
+ }
277
+
278
+ // Remove Project Gutenberg end markers and everything after
279
+ const endPatterns = [
280
+ /\*\*\* END OF .*? \*\*\*/i,
281
+ /\*\*\*END OF .*?\*\*\*/i,
282
+ /END OF THE PROJECT GUTENBERG/i,
283
+ /End of the Project Gutenberg/i
284
+ ];
285
+
286
+ for (const pattern of endPatterns) {
287
+ const match = cleaned.match(pattern);
288
+ if (match) {
289
+ cleaned = cleaned.substring(0, match.index);
290
+ break;
291
+ }
292
+ }
293
+
294
+ // Remove common Project Gutenberg artifacts and scanning notes
295
+ cleaned = cleaned
296
+ .replace(/\r\n/g, '\n') // Normalize line endings
297
+ .replace(/produced from images generously.*?\n/gi, '') // Remove scanning notes
298
+ .replace(/^.*page\s+scan\s+source:.*$/gmi, '') // Remove IA page scan source lines
299
+ .replace(/^\s*https?:\/\/\S+.*$/gmi, '') // Remove standalone URL lines
300
+ .replace(/\n\s*\n\s*\n+/g, '\n\n') // Remove excessive line breaks
301
+ .replace(/^\s*CHAPTER.*$/gm, '') // Remove chapter headers
302
+ .replace(/^\s*Chapter.*$/gm, '') // Remove chapter headers
303
+ .replace(/^\s*\d+\s*$/gm, '') // Remove page numbers
304
+ .replace(/^\s*\[.*?\]\s*$/gm, '') // Remove bracketed notes
305
+ .replace(/^\s*_.*_\s*$/gm, '') // Remove italic notes
306
+ .replace(/[_*]/g, '') // Remove underscores and asterisks
307
+ .trim();
308
+
309
+ // Find the actual start of narrative content
310
+ const lines = cleaned.split('\n');
311
+ let contentStart = 0;
312
+
313
+ for (let i = 0; i < Math.min(80, lines.length); i++) {
314
+ const line = lines[i].trim();
315
+
316
+ // Skip empty lines, title pages, and metadata
317
+ const looksAllCaps = /^[^a-z]*[A-Z][A-Z\s'.,:&;-]*$/.test(line) && line.length <= 60;
318
+ const looksPublisher = /(PUBLISHER|PRESS|NEW YORK|LONDON|BOSTON|PARIS|MURRAY STREET|COMPANY|LIMITED)/i.test(line);
319
+ const looksFrontMatter = /(^BY\s+[A-Z .'-]{2,}$|A NOVEL|REVISED AND CORRECTED|COPYRIGHT|Entered according to Act of Congress)/i.test(line);
320
+ const looksScanOrUrl = /(archive\.org|Internet Archive|Google|HathiTrust|scann?ed|page\s+scan|https?:\/\/|www\.)/i.test(line);
321
+
322
+ if (!line ||
323
+ line.includes('Title:') ||
324
+ line.includes('Author:') ||
325
+ line.includes('Release Date:') ||
326
+ line.includes('Language:') ||
327
+ line.includes('Character set') ||
328
+ line.includes('www.gutenberg') ||
329
+ line.includes('Project Gutenberg') ||
330
+ looksAllCaps ||
331
+ looksPublisher ||
332
+ looksFrontMatter ||
333
+ looksScanOrUrl ||
334
+ line.length < 20) {
335
+ contentStart = i + 1;
336
+ continue;
337
+ }
338
+
339
+ // Found actual content
340
+ break;
341
+ }
342
+
343
+ if (contentStart > 0 && contentStart < lines.length) {
344
+ cleaned = lines.slice(contentStart).join('\n').trim();
345
+ }
346
+
347
+ return cleaned;
348
+ }
349
+
350
+ extractMetadata(text) {
351
+ const metadata = { title: 'Classic Literature', author: 'Unknown Author' };
352
+
353
+ if (!text) return metadata;
354
+
355
+ // Look for the standard Project Gutenberg header format
356
+ const firstLine = text.split('\n')[0].trim();
357
+
358
+ // Parse the standard format: "The Project Gutenberg EBook of [TITLE], by [AUTHOR]"
359
+ const pgMatch = firstLine.match(/^.*?The Project Gutenberg EBook of (.+?),\s*by\s+(.+?)$/i);
360
+ if (pgMatch) {
361
+ const title = pgMatch[1].trim();
362
+ const author = pgMatch[2].trim();
363
+
364
+ if (title && this.isValidTitle(title)) {
365
+ metadata.title = this.cleanMetadataField(title);
366
+ }
367
+ if (author && this.isValidAuthor(author)) {
368
+ metadata.author = this.cleanMetadataField(author);
369
+ }
370
+
371
+ return metadata;
372
+ }
373
+
374
+ // Fallback: Look for explicit Title: and Author: fields in first 50 lines
375
+ const lines = text.split('\n').slice(0, 50);
376
+
377
+ for (let i = 0; i < lines.length; i++) {
378
+ const line = lines[i].trim();
379
+
380
+ if (line.startsWith('Title:')) {
381
+ const title = line.replace('Title:', '').trim();
382
+ if (title && title.length > 1) {
383
+ metadata.title = this.cleanMetadataField(title);
384
+ }
385
+ } else if (line.startsWith('Author:')) {
386
+ const author = line.replace('Author:', '').trim();
387
+ if (author && author.length > 1) {
388
+ metadata.author = this.cleanMetadataField(author);
389
+ }
390
+ }
391
+ }
392
+
393
+ return metadata;
394
+ }
395
+
396
+ cleanMetadataField(field) {
397
+ return field
398
+ .replace(/\[.*?\]/g, '') // Remove bracketed info
399
+ .replace(/\s+/g, ' ') // Normalize whitespace
400
+ .trim();
401
+ }
402
+
403
+
404
+ isValidTitle(title) {
405
+ if (!title || title.length < 3 || title.length > 100) return false;
406
+ // Avoid fragments that are clearly not titles
407
+ if (title.includes('Project Gutenberg') ||
408
+ title.includes('www.') ||
409
+ title.includes('produced from') ||
410
+ title.includes('images generously')) return false;
411
+ return true;
412
+ }
413
+
414
+ isValidAuthor(author) {
415
+ if (!author || author.length < 3 || author.length > 50) return false;
416
+ // Basic validation - should look like a name
417
+ if (author.includes('Project Gutenberg') ||
418
+ author.includes('www.') ||
419
+ author.includes('produced from')) return false;
420
+ return true;
421
+ }
422
+
423
+ isValidForCloze(book) {
424
+ if (!book.text) return false;
425
+
426
+ const textLength = book.text.length;
427
+
428
+ // Basic length criteria
429
+ if (textLength < 2000) return false; // Minimum readable length
430
+ if (textLength > 500000) return false; // Too long for performance
431
+
432
+ // Check for excessive formatting (likely reference material)
433
+ const lineBreakRatio = (book.text.match(/\n\n/g) || []).length / textLength;
434
+ if (lineBreakRatio > 0.05) return false; // Fragmentation threshold
435
+
436
+ // Ensure it has actual narrative content
437
+ const sentenceCount = (book.text.match(/[.!?]+/g) || []).length;
438
+ if (sentenceCount < 10) return false; // Sentence requirement
439
+
440
+ // Sample text for quality check (first 5000 chars should be representative)
441
+ const sampleText = book.text.substring(0, 5000);
442
+
443
+ // Check for index/TOC patterns
444
+ const indexPatterns = [
445
+ 'CONTENTS', 'INDEX', 'CHAPTER', 'Volume', 'Vol.',
446
+ 'Part I', 'Part II', 'BOOK I', 'APPENDIX'
447
+ ];
448
+ const indexCount = indexPatterns.reduce((count, pattern) =>
449
+ count + (sampleText.match(new RegExp(pattern, 'gi')) || []).length, 0
450
+ );
451
+ const indexRatio = indexCount / (sampleText.split(/\s+/).length || 1);
452
+
453
+ if (indexRatio > 0.05) {
454
+ return false;
455
+ }
456
+
457
+ // Check for catalog/bibliography patterns
458
+ if (book.title && (
459
+ book.title.toLowerCase().includes('index') ||
460
+ book.title.toLowerCase().includes('catalog') ||
461
+ book.title.toLowerCase().includes('bibliography') ||
462
+ book.title.toLowerCase().includes('contents')
463
+ )) {
464
+ return false;
465
+ }
466
+
467
+ return true;
468
+ }
469
+
470
+ async getRandomBook() {
471
+ if (!this.isLoaded) {
472
+ throw new Error('Dataset not loaded');
473
+ }
474
+
475
+ // First, try to find a successfully processed HF book
476
+ if (this.streamingEnabled && this.preloadedBooks.length > 0) {
477
+ const availableHFBooks = this.preloadedBooks.filter(book =>
478
+ !this.usedBooks.has(this.getBookId(book))
479
+ );
480
+
481
+ for (const book of availableHFBooks) {
482
+ const processedBook = await this.processBookOnDemand(book);
483
+ if (processedBook) {
484
+ this.usedBooks.add(this.getBookId(processedBook));
485
+ return processedBook;
486
+ }
487
+ }
488
+
489
+ // If no HF books worked, try streaming
490
+ const streamedBook = await this.getStreamingBook();
491
+ if (streamedBook) {
492
+ this.usedBooks.add(this.getBookId(streamedBook));
493
+ return streamedBook;
494
+ }
495
+ }
496
+
497
+ // Fallback to local samples
498
+ const fallbackBooks = this.books.length > 0 ? this.books : this.getSampleBooks();
499
+ const availableBooks = fallbackBooks.filter(book =>
500
+ !this.usedBooks.has(this.getBookId(book))
501
+ );
502
+
503
+ if (availableBooks.length > 0) {
504
+ const randomIndex = Math.floor(Math.random() * availableBooks.length);
505
+ const book = availableBooks[randomIndex];
506
+ this.usedBooks.add(this.getBookId(book));
507
+ return book;
508
+ }
509
+
510
+ // If all books used, clear cache and start over
511
+ this.usedBooks.clear();
512
+ return this.getRandomBook();
513
+ }
514
+
515
+ getBookId(book) {
516
+ // Create unique ID from title and author to track duplicates
517
+ return `${book.title}_${book.author}`.replace(/\s+/g, '_').toLowerCase();
518
+ }
519
+
520
+ async getStreamingBook() {
521
+ // Use preloaded books for immediate access
522
+ if (this.preloadedBooks.length > 0) {
523
+ const randomIndex = Math.floor(Math.random() * this.preloadedBooks.length);
524
+ let book = this.preloadedBooks[randomIndex];
525
+
526
+ // Process on demand if needed
527
+ if (!book.processed) {
528
+ book = await this.processBookOnDemand(book);
529
+ }
530
+
531
+ return book;
532
+ }
533
+
534
+ // If no preloaded books, try to fetch directly with retry
535
+ try {
536
+ if (!this.streamingEnabled) return null;
537
+ const offset = Math.floor(Math.random() * 1000);
538
+ const url = `${this.proxyBase}/rows?dataset=${encodeURIComponent(this.datasetName)}&config=${encodeURIComponent(this.hfConfig)}&split=${encodeURIComponent(this.hfSplit)}&offset=${offset}&length=1`;
539
+
540
+ const response = await this.retryFetch(
541
+ () => this.fetchWithTimeout(url, { timeoutMs: 10000 }),
542
+ 2, // fewer retries for on-demand fetch
543
+ 500
544
+ );
545
+
546
+ if (response.ok) {
547
+ const data = await response.json();
548
+ if (data.rows && data.rows.length > 0) {
549
+ const book = this.processHFBookLazy(data.rows[0].row);
550
+ return await this.processBookOnDemand(book);
551
+ }
552
+ }
553
+ } catch (error) {
554
+ console.warn('Direct streaming failed after retries:', error);
555
+ }
556
+
557
+ return null;
558
+ }
559
+
560
+ async getBookByLevelCriteria(level) {
561
+ return await this.getRandomBook();
562
+ }
563
+
564
+
565
+
566
+ getBookById(id) {
567
+ // Search in both preloaded and local books
568
+ const allBooks = [...this.preloadedBooks, ...this.books];
569
+ return allBooks.find(book => book.id === id);
570
+ }
571
+
572
+ searchBooks(query) {
573
+ if (!query) return [...this.preloadedBooks, ...this.books];
574
+
575
+ const lowerQuery = query.toLowerCase();
576
+ const allBooks = [...this.preloadedBooks, ...this.books];
577
+ return allBooks.filter(book =>
578
+ book.title.toLowerCase().includes(lowerQuery) ||
579
+ book.author.toLowerCase().includes(lowerQuery)
580
+ );
581
+ }
582
+
583
+ // Health check for streaming status
584
+ getStatus() {
585
+ return {
586
+ streamingEnabled: this.streamingEnabled,
587
+ preloadedBooks: this.preloadedBooks.length,
588
+ localBooks: this.books.length,
589
+ totalAvailable: this.preloadedBooks.length + this.books.length,
590
+ source: this.streamingEnabled ? 'HuggingFace Datasets' : 'Local Samples'
591
+ };
592
+ }
593
+
594
+ // Refresh preloaded books cache
595
+ async refreshCache() {
596
+ if (this.streamingEnabled) {
597
+ await this.preloadBooks(20);
598
+ }
599
+ }
600
+
601
+ // Small helper for fast-fail network calls
602
+ async fetchWithTimeout(resource, options = {}) {
603
+ const { timeoutMs = 5000, ...rest } = options;
604
+ const controller = new AbortController();
605
+ const id = setTimeout(() => controller.abort(), timeoutMs);
606
+ try {
607
+ return await fetch(resource, { ...rest, signal: controller.signal });
608
+ } finally {
609
+ clearTimeout(id);
610
+ }
611
+ }
612
+
613
+ // Retry wrapper with exponential backoff
614
+ async retryFetch(fetchFn, maxRetries = 3, baseDelayMs = 500) {
615
+ let lastError;
616
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
617
+ try {
618
+ return await fetchFn();
619
+ } catch (error) {
620
+ lastError = error;
621
+ if (attempt < maxRetries) {
622
+ const delay = baseDelayMs * Math.pow(2, attempt - 1);
623
+ await new Promise(resolve => setTimeout(resolve, delay));
624
+ }
625
+ }
626
+ }
627
+ throw lastError;
628
+ }
629
+ }
630
+
631
+ export default new HuggingFaceDatasetService();
src/chatInterface.js ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Chat UI components for contextual hints
2
+ import { analyticsService } from './analyticsService.js';
3
+
4
+ class ChatUI {
5
+ constructor(gameLogic) {
6
+ this.game = gameLogic;
7
+ this.activeChatBlank = null;
8
+ this.chatModal = null;
9
+ this.isOpen = false;
10
+ this.messageHistory = new Map(); // blankId -> array of messages for persistent history
11
+ this.analytics = analyticsService;
12
+ this.setupChatModal();
13
+ }
14
+
15
+ // Create and setup chat modal
16
+ setupChatModal() {
17
+ // Create modal HTML
18
+ const modalHTML = `
19
+ <div id="chat-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
20
+ <div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
21
+ <!-- Header -->
22
+ <div class="flex items-center justify-between p-4 border-b">
23
+ <h3 id="chat-title" class="text-lg font-semibold text-gray-900">
24
+ Chat about Word #1
25
+ </h3>
26
+ <button id="chat-close" class="text-gray-400 hover:text-gray-600">
27
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
28
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
29
+ </svg>
30
+ </button>
31
+ </div>
32
+
33
+ <!-- Chat messages area -->
34
+ <div id="chat-messages" class="flex-1 overflow-y-auto p-4 min-h-[200px] max-h-[400px]">
35
+ <div class="text-center text-gray-500 text-sm">
36
+ Ask me anything about this word! I can help with meaning, context, grammar, or give you hints.
37
+ </div>
38
+ </div>
39
+
40
+ <!-- Suggested questions -->
41
+ <div id="suggested-questions" class="px-4 py-2 border-t border-gray-100">
42
+ <div id="suggestion-buttons" class="flex flex-wrap gap-1">
43
+ <!-- Suggestion buttons will be inserted here -->
44
+ </div>
45
+ </div>
46
+
47
+ <!-- Question dropdown area -->
48
+ <div class="p-4 border-t">
49
+ <!-- Dropdown for all devices -->
50
+ <select id="question-dropdown" class="w-full p-2 border rounded mb-4">
51
+ <option value="">Select a question...</option>
52
+ </select>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ `;
57
+
58
+ // Insert modal into page
59
+ document.body.insertAdjacentHTML('beforeend', modalHTML);
60
+
61
+ this.chatModal = document.getElementById('chat-modal');
62
+ this.setupEventListeners();
63
+ }
64
+
65
+ // Setup event listeners for chat modal
66
+ setupEventListeners() {
67
+ const closeBtn = document.getElementById('chat-close');
68
+
69
+ // Close modal
70
+ closeBtn.addEventListener('click', () => this.closeChat());
71
+ this.chatModal.addEventListener('click', (e) => {
72
+ if (e.target === this.chatModal) this.closeChat();
73
+ });
74
+
75
+ // ESC key to close
76
+ document.addEventListener('keydown', (e) => {
77
+ if (e.key === 'Escape' && this.isOpen) this.closeChat();
78
+ });
79
+ }
80
+
81
+ // Open chat for specific blank
82
+ async openChat(blankIndex) {
83
+ this.activeChatBlank = blankIndex;
84
+ this.isOpen = true;
85
+
86
+ // Update title
87
+ const title = document.getElementById('chat-title');
88
+ title.textContent = `Help with Word #${blankIndex + 1}`;
89
+
90
+ // Restore previous messages or show intro
91
+ this.restoreMessages(blankIndex);
92
+
93
+ // Load question buttons
94
+ this.loadQuestionButtons();
95
+
96
+ // Show modal
97
+ this.chatModal.classList.remove('hidden');
98
+ }
99
+
100
+ // Close chat modal
101
+ closeChat() {
102
+ this.isOpen = false;
103
+ this.chatModal.classList.add('hidden');
104
+ this.activeChatBlank = null;
105
+ }
106
+
107
+ // Clear messages and show intro
108
+ clearMessages() {
109
+ const messagesContainer = document.getElementById('chat-messages');
110
+ messagesContainer.innerHTML = `
111
+ <div class="text-center text-gray-500 text-sm mb-4">
112
+ Choose a question below to get help with this word.
113
+ </div>
114
+ `;
115
+ }
116
+
117
+ // Restore messages for a specific blank or show intro
118
+ restoreMessages(blankIndex) {
119
+ const messagesContainer = document.getElementById('chat-messages');
120
+ const blankId = `blank_${blankIndex}`;
121
+ const history = this.messageHistory.get(blankId);
122
+
123
+ if (history && history.length > 0) {
124
+ // Restore previous messages
125
+ messagesContainer.innerHTML = '';
126
+ history.forEach(msg => {
127
+ this.displayMessage(msg.sender, msg.content, msg.isUser);
128
+ });
129
+ } else {
130
+ // Show intro for new conversation
131
+ this.clearMessages();
132
+ }
133
+ }
134
+
135
+ // Display a message without storing it (used for restoration)
136
+ displayMessage(sender, content, isUser) {
137
+ const messagesContainer = document.getElementById('chat-messages');
138
+ const alignment = isUser ? 'flex justify-end' : 'flex justify-start';
139
+ const messageClass = isUser
140
+ ? 'bg-blue-500 text-white'
141
+ : 'bg-gray-100 text-gray-900';
142
+ const displaySender = isUser ? 'You' : sender;
143
+
144
+ const messageHTML = `
145
+ <div class="mb-3 ${alignment}">
146
+ <div class="${messageClass} rounded-lg px-3 py-2 max-w-[80%]">
147
+ <div class="text-xs font-medium mb-1">${displaySender}</div>
148
+ <div class="text-sm">${this.escapeHtml(content)}</div>
149
+ </div>
150
+ </div>
151
+ `;
152
+
153
+ messagesContainer.insertAdjacentHTML('beforeend', messageHTML);
154
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
155
+ }
156
+
157
+ // Clear all chat history (called when round ends)
158
+ clearChatHistory() {
159
+ this.messageHistory.clear();
160
+ }
161
+
162
+ // Load question dropdown with disabled state for used questions
163
+ loadQuestionButtons() {
164
+ const dropdown = document.getElementById('question-dropdown');
165
+ const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
166
+
167
+ // Clear existing content
168
+ dropdown.innerHTML = '<option value="">Select a question...</option>';
169
+
170
+ // Build dropdown options
171
+ questions.forEach(question => {
172
+ const isDisabled = question.used;
173
+ const optionText = isDisabled ? `${question.text} ✓` : question.text;
174
+
175
+ // Add all options but mark used ones as disabled
176
+ const option = document.createElement('option');
177
+ option.value = isDisabled ? '' : question.type;
178
+ option.textContent = optionText;
179
+ option.disabled = isDisabled;
180
+ option.style.color = isDisabled ? '#9CA3AF' : '#111827';
181
+ dropdown.appendChild(option);
182
+ });
183
+
184
+ // Add change listener to dropdown
185
+ dropdown.addEventListener('change', (e) => {
186
+ if (e.target.value) {
187
+ this.askQuestion(e.target.value);
188
+ e.target.value = ''; // Reset dropdown
189
+ }
190
+ });
191
+ }
192
+
193
+ // Ask a specific question
194
+ async askQuestion(questionType) {
195
+ if (this.activeChatBlank === null) return;
196
+
197
+ // Get current user input for the blank
198
+ const currentInput = this.getCurrentBlankInput();
199
+
200
+ // Get the actual question text from the button that was clicked
201
+ const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
202
+ const selectedQuestion = questions.find(q => q.type === questionType);
203
+ const questionText = selectedQuestion ? selectedQuestion.text : this.getQuestionText(questionType);
204
+
205
+ // Show question and loading
206
+ this.addMessageToChat('You', questionText, true);
207
+ this.showTypingIndicator();
208
+
209
+ try {
210
+ // Send to chat service with question type
211
+ const response = await this.game.askQuestionAboutBlank(
212
+ this.activeChatBlank,
213
+ questionType,
214
+ currentInput
215
+ );
216
+
217
+ this.hideTypingIndicator();
218
+
219
+ if (response.success) {
220
+ // Make sure we're displaying the response string, not the object
221
+ const responseText = typeof response.response === 'string'
222
+ ? response.response
223
+ : response.response.response || 'Sorry, I had trouble with that question.';
224
+ this.addMessageToChat('Cluemaster', responseText, false);
225
+
226
+ // Track hint usage in analytics
227
+ this.analytics.recordHint(this.activeChatBlank, questionType);
228
+
229
+ // Refresh question buttons to show the used question as disabled
230
+ this.loadQuestionButtons();
231
+ } else {
232
+ this.addMessageToChat('Cluemaster', response.message || 'Sorry, I had trouble with that question.', false);
233
+ }
234
+
235
+ } catch (error) {
236
+ this.hideTypingIndicator();
237
+ console.error('Chat error:', error);
238
+ this.addMessageToChat('Cluemaster', 'Sorry, I encountered an error. Please try again.', false);
239
+ }
240
+ }
241
+
242
+ // Get question text for display
243
+ getQuestionText(questionType) {
244
+ const questions = {
245
+ 'grammar': 'What type of word is this?',
246
+ 'meaning': 'What does this word mean?',
247
+ 'context': 'Why does this word fit here?',
248
+ 'clue': 'Give me a clue'
249
+ };
250
+ return questions[questionType] || questions['clue'];
251
+ }
252
+
253
+ // Get current input for the active blank
254
+ getCurrentBlankInput() {
255
+ const input = document.querySelector(`input[data-blank-index="${this.activeChatBlank}"]`);
256
+ return input ? input.value.trim() : '';
257
+ }
258
+
259
+ // Add message to chat display and store in history
260
+ addMessageToChat(sender, content, isUser) {
261
+ // Store message in history for current blank
262
+ if (this.activeChatBlank !== null) {
263
+ const blankId = `blank_${this.activeChatBlank}`;
264
+ if (!this.messageHistory.has(blankId)) {
265
+ this.messageHistory.set(blankId, []);
266
+ }
267
+
268
+ // Change "Tutor" to "Cluemaster" for display and storage
269
+ const displaySender = sender === 'Tutor' ? 'Cluemaster' : sender;
270
+
271
+ this.messageHistory.get(blankId).push({
272
+ sender: displaySender,
273
+ content: content,
274
+ isUser: isUser,
275
+ timestamp: Date.now()
276
+ });
277
+ }
278
+
279
+ // Display the message
280
+ this.displayMessage(sender === 'Tutor' ? 'Cluemaster' : sender, content, isUser);
281
+ }
282
+
283
+ // Show typing indicator
284
+ showTypingIndicator() {
285
+ const messagesContainer = document.getElementById('chat-messages');
286
+ const typingHTML = `
287
+ <div id="typing-indicator" class="mb-3 mr-auto max-w-[80%]">
288
+ <div class="bg-gray-100 text-gray-900 rounded-lg px-3 py-2">
289
+ <div class="text-xs font-medium mb-1">Cluemaster</div>
290
+ <div class="text-sm">
291
+ <span class="typing-dots">
292
+ <span>.</span><span>.</span><span>.</span>
293
+ </span>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ `;
298
+
299
+ messagesContainer.insertAdjacentHTML('beforeend', typingHTML);
300
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
301
+ }
302
+
303
+ // Hide typing indicator
304
+ hideTypingIndicator() {
305
+ const indicator = document.getElementById('typing-indicator');
306
+ if (indicator) indicator.remove();
307
+ }
308
+
309
+ // Escape HTML to prevent XSS
310
+ escapeHtml(text) {
311
+ const div = document.createElement('div');
312
+ div.textContent = text;
313
+ return div.innerHTML;
314
+ }
315
+
316
+ // Setup chat buttons for blanks
317
+ setupChatButtons() {
318
+ // Remove existing listeners
319
+ document.querySelectorAll('.chat-button').forEach(btn => {
320
+ btn.replaceWith(btn.cloneNode(true));
321
+ });
322
+
323
+ // Add new listeners
324
+ document.querySelectorAll('.chat-button').forEach(btn => {
325
+ btn.addEventListener('click', (e) => {
326
+ e.preventDefault();
327
+ e.stopPropagation();
328
+ const blankIndex = parseInt(btn.dataset.blankIndex);
329
+ this.openChat(blankIndex);
330
+ });
331
+ });
332
+ }
333
+ }
334
+
335
+ export default ChatUI;
src/clozeGameEngine.js ADDED
@@ -0,0 +1,970 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Core game logic for minimal cloze reader
2
+ import bookDataService from './bookDataService.js';
3
+ import { AIService } from './aiService.js';
4
+ import ChatService from './conversationManager.js';
5
+ import { LeaderboardService } from './leaderboardService.js';
6
+
7
+ const aiService = new AIService();
8
+
9
+ class ClozeGame {
10
+ constructor() {
11
+ this.currentBook = null;
12
+ this.originalText = '';
13
+ this.clozeText = '';
14
+ this.blanks = [];
15
+ this.userAnswers = [];
16
+ this.score = 0;
17
+ this.currentRound = 1;
18
+ this.currentLevel = 1; // Track difficulty level separately from round
19
+ this.contextualization = '';
20
+ this.hints = [];
21
+ this.chatService = new ChatService(aiService);
22
+ this.lastResults = null; // Store results for answer revelation
23
+ this.leaderboardService = new LeaderboardService();
24
+ this.passagesPassedAtCurrentLevel = 0; // Track progress toward level advancement
25
+
26
+ // Multiple retry support
27
+ this.attemptCounts = {}; // blankIndex -> number of attempts
28
+ this.lockedBlanks = new Set(); // blanks that are correct and locked
29
+ this.maxRetries = 5; // Maximum retry attempts before forcing completion
30
+
31
+ }
32
+
33
+ // --- User-visible framing helpers ---
34
+ getBlanksPerPassage(level = this.currentLevel) {
35
+ if (level <= 5) return 1;
36
+ if (level <= 10) return 2;
37
+ return 3;
38
+ }
39
+
40
+ getProgressSnapshot() {
41
+ return {
42
+ round: this.currentRound,
43
+ level: this.currentLevel,
44
+ blanksPerPassage: this.getBlanksPerPassage()
45
+ };
46
+ }
47
+
48
+ formatProgressText(snapshot = this.getProgressSnapshot()) {
49
+ const blanksLabel = `${snapshot.blanksPerPassage} blank${snapshot.blanksPerPassage > 1 ? 's' : ''}`;
50
+ return `Level ${snapshot.level} • ${blanksLabel}`;
51
+ }
52
+
53
+ formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }) {
54
+ if (passed) {
55
+ if (justAdvancedLevel) {
56
+ return `✓ Passed • Level up! Welcome to Level ${this.currentLevel}`;
57
+ }
58
+ return `✓ Passed • Advancing to next level!`;
59
+ }
60
+ return `Try again • Need ${requiredCorrect}/${this.blanks.length} correct (you got ${correctCount})`;
61
+ }
62
+
63
+ async initialize() {
64
+ try {
65
+ await bookDataService.loadDataset();
66
+ } catch (error) {
67
+ console.error('Failed to initialize game:', error);
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async startNewRound() {
73
+ try {
74
+
75
+ // Reset retry tracking for new round
76
+ this.attemptCounts = {};
77
+ this.lockedBlanks = new Set();
78
+
79
+ // Get one book for this round based on current level criteria
80
+ const book = await bookDataService.getBookByLevelCriteria(this.currentLevel);
81
+
82
+ // Extract passage from book
83
+ const passage = this.extractCoherentPassage(book.text);
84
+
85
+ // Store book and passage (normalize whitespace to prevent compound words)
86
+ this.currentBook = book;
87
+ this.originalText = passage.trim().replace(/\s+/g, ' ');
88
+
89
+ // Create cloze text using AI
90
+ try {
91
+ await this.createClozeText();
92
+ await this.generateContextualization();
93
+ } catch (error) {
94
+ console.warn('AI processing failed:', error);
95
+ throw error;
96
+ }
97
+
98
+ const snapshot = this.getProgressSnapshot();
99
+ return {
100
+ title: this.currentBook.title,
101
+ author: this.currentBook.author,
102
+ text: this.clozeText,
103
+ blanks: this.blanks,
104
+ contextualization: this.contextualization,
105
+ hints: this.hints,
106
+ progressText: this.formatProgressText(snapshot)
107
+ };
108
+ } catch (error) {
109
+ console.error('Error starting new round:', error);
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ extractCoherentPassage(text) {
115
+ // Simple elegant solution: start from middle third of book where actual content is
116
+ const textLength = text.length;
117
+ const startFromMiddle = Math.floor(textLength * 0.3); // Skip first 30%
118
+ const endAtThreeQuarters = Math.floor(textLength * 0.8); // Stop before last 20%
119
+
120
+ let attempts = 0;
121
+ let passage = '';
122
+
123
+ while (attempts < 8) {
124
+ // Random position in the middle section
125
+ const availableLength = endAtThreeQuarters - startFromMiddle;
126
+ const randomOffset = Math.floor(Math.random() * Math.max(0, availableLength - 1000));
127
+ const startIndex = startFromMiddle + randomOffset;
128
+
129
+ // Extract longer initial passage for better sentence completion
130
+ passage = text.substring(startIndex, startIndex + 1000);
131
+
132
+ // Clean up start - find first complete sentence that starts with capital letter
133
+ const firstSentenceMatch = passage.match(/[.!?]\s+([A-Z][^.!?]*)/);
134
+ if (firstSentenceMatch && firstSentenceMatch.index < 200) {
135
+ // Start from the capital letter after punctuation
136
+ passage = passage.substring(firstSentenceMatch.index + firstSentenceMatch[0].length - firstSentenceMatch[1].length);
137
+ } else {
138
+ // If no good sentence break found, find first capital letter
139
+ const firstCapitalMatch = passage.match(/[A-Z][^.!?]*/);
140
+ if (firstCapitalMatch) {
141
+ passage = passage.substring(firstCapitalMatch.index);
142
+ }
143
+ }
144
+
145
+ // Clean up end - ensure we end at a complete sentence
146
+ const sentences = passage.split(/(?<=[.!?])\s+/);
147
+ if (sentences.length > 1) {
148
+ // Remove the last sentence if it might be incomplete
149
+ sentences.pop();
150
+ passage = sentences.join(' ');
151
+ }
152
+
153
+ // Immediate rejects for known front-matter/meta patterns
154
+ const frontMatterRegexes = [
155
+ /(archive\.org|Internet Archive|HathiTrust|Google)/i,
156
+ /page\s+scan\s+source/i,
157
+ /Entered according to Act of Congress/i,
158
+ /COPYRIGHT/i,
159
+ /PUBLISHER|PRESS|MURRAY STREET|NEW YORK|LONDON|BOSTON/i,
160
+ /\bBY\s+[A-Z .'-]{2,}\b/,
161
+ /\bA NOVEL\b/i,
162
+ /https?:\/\//i,
163
+ ];
164
+ if (frontMatterRegexes.some(r => r.test(passage))) {
165
+ attempts++;
166
+ continue;
167
+ }
168
+
169
+ // Enhanced quality check based on narrative flow characteristics
170
+ const words = passage.split(/\s+/);
171
+ const totalWords = words.length;
172
+
173
+ // Count various quality indicators
174
+ const capsWords = words.filter(w => w.length > 1 && w === w.toUpperCase() && !/^\d+$/.test(w));
175
+ const capsCount = capsWords.length;
176
+ const numbersCount = words.filter(w => /\d/.test(w)).length;
177
+ const shortWords = words.filter(w => w.length <= 3).length;
178
+ const punctuationMarks = (passage.match(/[;:()[\]{}—–]/g) || []).length;
179
+ const sentenceList = passage.split(/[.!?]+/).filter(s => s.trim().length > 10);
180
+ const lines = passage.split('\n').filter(l => l.trim());
181
+
182
+ // Debug logging for caps detection
183
+ if (capsCount > 5) {
184
+ }
185
+
186
+ // Count excessive dashes (n-dashes, m-dashes, hyphens in sequence)
187
+ const dashSequences = (passage.match(/[-—–]{3,}/g) || []).length;
188
+ const totalDashes = (passage.match(/[-—–]/g) || []).length;
189
+
190
+ // Count additional formatting patterns
191
+ const asteriskSequences = (passage.match(/\*{3,}/g) || []).length;
192
+ const asteriskLines = (passage.match(/^\s*\*+\s*$/gm) || []).length;
193
+ const underscoreSequences = (passage.match(/_{3,}/g) || []).length;
194
+ const equalSequences = (passage.match(/={3,}/g) || []).length;
195
+ const pipeCount = (passage.match(/\|/g) || []).length;
196
+ const numberedLines = (passage.match(/^\s*\d+[\.\)]\s/gm) || []).length;
197
+ const parenthesesCount = (passage.match(/[()]/g) || []).length;
198
+ const squareBrackets = (passage.match(/[\[\]]/g) || []).length;
199
+
200
+ // Dictionary/glossary patterns
201
+ const hashSymbols = (passage.match(/#/g) || []).length;
202
+ const abbreviationPattern = /\b(n\.|adj\.|adv\.|v\.|pl\.|sg\.|cf\.|e\.g\.|i\.e\.|etc\.|vs\.|viz\.|OE\.|OFr\.|L\.|ME\.|NE\.|AN\.|ON\.|MDu\.|MLG\.|MHG\.|Ger\.|Du\.|Dan\.|Sw\.|Icel\.)\b/gi;
203
+ const abbreviations = (passage.match(abbreviationPattern) || []).length;
204
+ const etymologyBrackets = (passage.match(/\[[^\]]+\]/g) || []).length;
205
+ const referenceNumbers = (passage.match(/\b[IVX]+\s+[abc]?\s*\d+/g) || []).length;
206
+ const definitionPattern = /^[^.]+,\s*(n\.|adj\.|adv\.|v\.)/gm;
207
+ const definitionLines = (passage.match(definitionPattern) || []).length;
208
+
209
+ // Academic/reference patterns
210
+ const citationPattern = /\(\d{4}\)|p\.\s*\d+|pp\.\s*\d+-\d+|vol\.\s*\d+|ch\.\s*\d+/gi;
211
+ const citations = (passage.match(citationPattern) || []).length;
212
+ const technicalTerms = ['etymology', 'phoneme', 'morpheme', 'lexicon', 'syntax', 'semantics', 'glossary', 'vocabulary', 'dialect', 'pronunciation'];
213
+ const technicalTermCount = technicalTerms.reduce((count, term) =>
214
+ count + (passage.match(new RegExp(term, 'gi')) || []).length, 0
215
+ );
216
+
217
+ // Check for repetitive patterns (common in indexes/TOCs)
218
+ const repeatedPhrases = ['CONTENTS', 'CHAPTER', 'Volume', 'Vol.', 'Part', 'Book'];
219
+ const repetitionCount = repeatedPhrases.reduce((count, phrase) =>
220
+ count + (passage.match(new RegExp(phrase, 'gi')) || []).length, 0
221
+ );
222
+
223
+ // Check for title patterns (common in TOCs)
224
+ const titlePattern = /^[A-Z][A-Z\s]+$/m;
225
+ const titleLines = lines.filter(line => titlePattern.test(line.trim())).length;
226
+
227
+ // Check for consecutive all-caps lines (title pages, copyright notices)
228
+ let consecutiveCapsLines = 0;
229
+ let maxConsecutiveCaps = 0;
230
+ lines.forEach(line => {
231
+ const trimmed = line.trim();
232
+ if (trimmed.length > 3 && trimmed === trimmed.toUpperCase() && !/^\d+$/.test(trimmed)) {
233
+ consecutiveCapsLines++;
234
+ maxConsecutiveCaps = Math.max(maxConsecutiveCaps, consecutiveCapsLines);
235
+ } else {
236
+ consecutiveCapsLines = 0;
237
+ }
238
+ });
239
+
240
+ // Calculate quality ratios
241
+ const capsRatio = capsCount / totalWords;
242
+ const numbersRatio = numbersCount / totalWords;
243
+ const shortWordRatio = shortWords / totalWords;
244
+ const punctuationRatio = punctuationMarks / totalWords;
245
+ const avgWordsPerSentence = totalWords / Math.max(1, sentenceList.length);
246
+ const repetitionRatio = repetitionCount / totalWords;
247
+ const titleLineRatio = titleLines / Math.max(1, lines.length);
248
+ const dashRatio = totalDashes / totalWords;
249
+ const parenthesesRatio = parenthesesCount / totalWords;
250
+ const squareBracketRatio = squareBrackets / totalWords;
251
+ const hashRatio = hashSymbols / totalWords;
252
+ const abbreviationRatio = abbreviations / totalWords;
253
+ const etymologyRatio = etymologyBrackets / totalWords;
254
+ const definitionRatio = definitionLines / Math.max(1, lines.length);
255
+ const technicalRatio = technicalTermCount / totalWords;
256
+
257
+ // Stricter thresholds for higher levels
258
+ const capsThreshold = this.currentLevel >= 3 ? 0.03 : 0.05;
259
+ const numbersThreshold = this.currentLevel >= 3 ? 0.02 : 0.03;
260
+
261
+ // Reject if passage shows signs of being technical/reference material
262
+ let qualityScore = 0;
263
+ let issues = [];
264
+
265
+ // Immediate rejection for excessive caps (title pages, headers, etc)
266
+ if (capsRatio > 0.12) {
267
+ attempts++;
268
+ continue;
269
+ }
270
+
271
+ // Immediate rejection for consecutive all-caps lines (title pages, copyright)
272
+ if (maxConsecutiveCaps >= 2) {
273
+ attempts++;
274
+ continue;
275
+ }
276
+
277
+ if (capsRatio > capsThreshold) { qualityScore += capsRatio * 100; issues.push(`caps: ${Math.round(capsRatio * 100)}%`); }
278
+ if (numbersRatio > numbersThreshold) { qualityScore += numbersRatio * 40; issues.push(`numbers: ${Math.round(numbersRatio * 100)}%`); }
279
+ if (punctuationRatio > 0.08) { qualityScore += punctuationRatio * 15; issues.push(`punct: ${Math.round(punctuationRatio * 100)}%`); }
280
+ if (avgWordsPerSentence < 8 || avgWordsPerSentence > 40) { qualityScore += 2; issues.push(`sent-len: ${Math.round(avgWordsPerSentence)}`); }
281
+ if (shortWordRatio < 0.3) { qualityScore += 2; issues.push(`short-words: ${Math.round(shortWordRatio * 100)}%`); }
282
+ if (repetitionRatio > 0.02) { qualityScore += repetitionRatio * 50; issues.push(`repetitive: ${Math.round(repetitionRatio * 100)}%`); }
283
+ if (titleLineRatio > 0.2) { qualityScore += 5; issues.push(`title-lines: ${Math.round(titleLineRatio * 100)}%`); }
284
+ if (dashSequences > 0) { qualityScore += dashSequences * 3; issues.push(`dash-sequences: ${dashSequences}`); }
285
+ if (dashRatio > 0.02) { qualityScore += dashRatio * 25; issues.push(`dashes: ${Math.round(dashRatio * 100)}%`); }
286
+ if (asteriskSequences > 0 || asteriskLines > 0) { qualityScore += (asteriskSequences + asteriskLines) * 2; issues.push(`asterisk-separators: ${asteriskSequences + asteriskLines}`); }
287
+ if (underscoreSequences > 0) { qualityScore += underscoreSequences * 2; issues.push(`underscore-lines: ${underscoreSequences}`); }
288
+ if (equalSequences > 0) { qualityScore += equalSequences * 2; issues.push(`equal-lines: ${equalSequences}`); }
289
+ if (pipeCount > 5) { qualityScore += 3; issues.push(`table-formatting: ${pipeCount} pipes`); }
290
+ if (numberedLines > 3) { qualityScore += 2; issues.push(`numbered-list: ${numberedLines} items`); }
291
+ if (parenthesesRatio > 0.05) { qualityScore += 2; issues.push(`excessive-parentheses: ${Math.round(parenthesesRatio * 100)}%`); }
292
+ if (squareBracketRatio > 0.02) { qualityScore += 2; issues.push(`excessive-brackets: ${Math.round(squareBracketRatio * 100)}%`); }
293
+
294
+ // Dictionary/glossary/academic content detection
295
+ if (hashRatio > 0.01) { qualityScore += hashRatio * 100; issues.push(`hash-symbols: ${hashSymbols}`); }
296
+ if (abbreviationRatio > 0.03) { qualityScore += abbreviationRatio * 50; issues.push(`abbreviations: ${abbreviations}`); }
297
+ if (etymologyRatio > 0.005) { qualityScore += etymologyRatio * 100; issues.push(`etymology-brackets: ${etymologyBrackets}`); }
298
+ if (definitionRatio > 0.1) { qualityScore += definitionRatio * 20; issues.push(`definition-lines: ${Math.round(definitionRatio * 100)}%`); }
299
+ if (referenceNumbers > 0) { qualityScore += referenceNumbers * 2; issues.push(`reference-numbers: ${referenceNumbers}`); }
300
+ if (citations > 0) { qualityScore += citations * 2; issues.push(`citations: ${citations}`); }
301
+ if (technicalRatio > 0.01) { qualityScore += technicalRatio * 30; issues.push(`technical-terms: ${technicalTermCount}`); }
302
+
303
+ // Reject if quality score indicates technical/non-narrative content
304
+ if (qualityScore > 2.5) {
305
+ attempts++;
306
+ continue;
307
+ }
308
+
309
+ // Good passage found
310
+ break;
311
+ }
312
+
313
+ // Ensure minimum length - if too short, return what we have rather than infinite recursion
314
+ if (passage.length < 400) {
315
+ console.warn('Short passage extracted, using fallback approach');
316
+ // Try one more time with a simpler approach
317
+ const simpleStart = text.indexOf('. ') + 2;
318
+ if (simpleStart > 1 && simpleStart < text.length - 500) {
319
+ passage = text.substring(simpleStart, simpleStart + 600);
320
+ const lastPeriod = passage.lastIndexOf('.');
321
+ if (lastPeriod > 200) {
322
+ passage = passage.substring(0, lastPeriod + 1);
323
+ }
324
+ }
325
+ }
326
+
327
+ return passage.trim();
328
+ }
329
+
330
+
331
+ async createClozeText() {
332
+ const words = this.originalText.split(' ');
333
+ // Progressive difficulty: levels 1-5 = 1 blank, levels 6-10 = 2 blanks, level 11+ = 3 blanks
334
+ let numberOfBlanks;
335
+ if (this.currentLevel <= 5) {
336
+ numberOfBlanks = 1;
337
+ } else if (this.currentLevel <= 10) {
338
+ numberOfBlanks = 2;
339
+ } else {
340
+ numberOfBlanks = 3;
341
+ }
342
+
343
+ // Update chat service with current level
344
+ this.chatService.setLevel(this.currentLevel);
345
+
346
+ // Always use AI for word selection with fallback
347
+ let significantWords;
348
+ try {
349
+ significantWords = await aiService.selectSignificantWords(
350
+ this.originalText,
351
+ numberOfBlanks,
352
+ this.currentLevel
353
+ );
354
+ } catch (error) {
355
+ console.warn('AI word selection failed, using manual fallback:', error);
356
+ significantWords = this.selectWordsManually(words, numberOfBlanks);
357
+ }
358
+
359
+ // Ensure we have valid words
360
+ if (!significantWords || significantWords.length === 0) {
361
+ console.warn('No words selected, using emergency fallback');
362
+ significantWords = this.selectWordsManually(words, numberOfBlanks);
363
+ }
364
+
365
+ // Find word indices for selected significant words, distributed throughout passage
366
+ const selectedIndices = [];
367
+ const wordsLower = words.map(w => w.toLowerCase().replace(/[^\w]/g, ''));
368
+
369
+ // Create sections of the passage to ensure distribution
370
+ const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks);
371
+
372
+ significantWords.forEach((significantWord, index) => {
373
+ // Clean the significant word for matching
374
+ const cleanSignificant = significantWord.toLowerCase().replace(/[^\w]/g, '');
375
+
376
+ // Look for the word within the appropriate section for better distribution
377
+ const sectionStart = passageSections[index] ? passageSections[index].start : 0;
378
+ const sectionEnd = passageSections[index] ? passageSections[index].end : words.length;
379
+
380
+ let wordIndex = -1;
381
+
382
+ // First try to find the word in the designated section (avoiding first 10 words and capitalized words)
383
+ for (let i = Math.max(10, sectionStart); i < sectionEnd; i++) {
384
+ const originalWord = words[i].replace(/[^\w]/g, '');
385
+ const isCapitalized = originalWord.length > 0 && originalWord[0] === originalWord[0].toUpperCase();
386
+ if (wordsLower[i] === cleanSignificant && !selectedIndices.includes(i) && !isCapitalized) {
387
+ wordIndex = i;
388
+ break;
389
+ }
390
+ }
391
+
392
+ // If not found in section, try progressively more relaxed searches
393
+ if (wordIndex === -1) {
394
+ // Try 1: Global search avoiding caps and first 10 words
395
+ wordIndex = wordsLower.findIndex((word, idx) => {
396
+ const originalWord = words[idx].replace(/[^\w]/g, '');
397
+ const isCapitalized = originalWord.length > 0 && originalWord[0] === originalWord[0].toUpperCase();
398
+ return word === cleanSignificant && !selectedIndices.includes(idx) && idx >= 10 && !isCapitalized;
399
+ });
400
+ }
401
+
402
+ // Try 2: Allow capitalized words but still avoid first 10 words
403
+ if (wordIndex === -1) {
404
+ wordIndex = wordsLower.findIndex((word, idx) => {
405
+ return word === cleanSignificant && !selectedIndices.includes(idx) && idx >= 10;
406
+ });
407
+ }
408
+
409
+ // Try 3: Allow words from position 5 onwards (more relaxed)
410
+ if (wordIndex === -1) {
411
+ wordIndex = wordsLower.findIndex((word, idx) => {
412
+ return word === cleanSignificant && !selectedIndices.includes(idx) && idx >= 5;
413
+ });
414
+ }
415
+
416
+ // Try 4: Check if word contains the target (partial match)
417
+ if (wordIndex === -1) {
418
+ wordIndex = wordsLower.findIndex((word, idx) => {
419
+ return word.includes(cleanSignificant) && !selectedIndices.includes(idx) && idx >= 10;
420
+ });
421
+ }
422
+
423
+ if (wordIndex !== -1) {
424
+ selectedIndices.push(wordIndex);
425
+ } else {
426
+ console.warn(`Could not find word "${significantWord}" in passage`);
427
+ }
428
+ });
429
+
430
+
431
+ // If no words were matched, fall back to manual selection
432
+ if (selectedIndices.length === 0) {
433
+ console.warn('No AI words matched in passage, using manual selection');
434
+ const manualWords = this.selectWordsManually(words, numberOfBlanks);
435
+
436
+ // Try to match manual words with relaxed criteria
437
+ manualWords.forEach((manualWord) => {
438
+ const cleanManual = manualWord.toLowerCase().replace(/[^\w]/g, '');
439
+ let wordIndex = wordsLower.findIndex((word, idx) => {
440
+ const originalWord = words[idx].replace(/[^\w]/g, '');
441
+ const isCapitalized = originalWord.length > 0 && originalWord[0] === originalWord[0].toUpperCase();
442
+ return word === cleanManual && !selectedIndices.includes(idx) && idx >= 10 && !isCapitalized;
443
+ });
444
+
445
+ // If not found, try allowing capitalized words
446
+ if (wordIndex === -1) {
447
+ wordIndex = wordsLower.findIndex((word, idx) => {
448
+ return word === cleanManual && !selectedIndices.includes(idx) && idx >= 5;
449
+ });
450
+ }
451
+
452
+ if (wordIndex !== -1) {
453
+ selectedIndices.push(wordIndex);
454
+ }
455
+ });
456
+
457
+ }
458
+
459
+ // Sort indices for easier processing
460
+ selectedIndices.sort((a, b) => a - b);
461
+
462
+ // Final safety check - if still no words found, pick random content words (avoiding first 10)
463
+ if (selectedIndices.length === 0) {
464
+ console.error('Critical: No words could be selected, using emergency fallback');
465
+ const contentWords = words.map((word, idx) => ({ word: word.toLowerCase().replace(/[^\w]/g, ''), idx }))
466
+ .filter(item => item.word.length > 3 && !['the', 'and', 'but', 'for', 'are', 'was'].includes(item.word) && item.idx >= 10)
467
+ .slice(0, numberOfBlanks);
468
+
469
+ selectedIndices.push(...contentWords.map(item => item.idx));
470
+ }
471
+
472
+ // Create blanks array and cloze text
473
+ this.blanks = [];
474
+ this.hints = [];
475
+ const clozeWords = [...words];
476
+
477
+ for (let i = 0; i < selectedIndices.length; i++) {
478
+ const index = selectedIndices[i];
479
+ const originalWord = words[index];
480
+ const cleanWord = originalWord.replace(/[^\w]/g, '');
481
+
482
+ const blankData = {
483
+ index: i,
484
+ originalWord: cleanWord,
485
+ wordIndex: index
486
+ };
487
+
488
+ this.blanks.push(blankData);
489
+
490
+ // Initialize chat context for this word
491
+ const wordContext = {
492
+ originalWord: cleanWord,
493
+ sentence: this.originalText,
494
+ passage: this.originalText,
495
+ bookTitle: this.currentBook.title,
496
+ author: this.currentBook.author,
497
+ year: this.currentBook.year,
498
+ wordPosition: index,
499
+ difficulty: this.calculateWordDifficulty(cleanWord, index, words)
500
+ };
501
+
502
+ this.chatService.initializeWordContext(`blank_${i}`, wordContext);
503
+
504
+ // Generate structural hint based on level
505
+ let structuralHint;
506
+ if (this.currentLevel <= 2) {
507
+ // Levels 1-2: show length, first letter, and last letter
508
+ structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`;
509
+ } else {
510
+ // Level 3+: show length and first letter only
511
+ structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}"`;
512
+ }
513
+ this.hints.push({ index: i, hint: structuralHint });
514
+
515
+ // Replace word with input field placeholder
516
+ clozeWords[index] = `___BLANK_${i}___`;
517
+ }
518
+
519
+ this.clozeText = clozeWords.join(' ');
520
+ this.userAnswers = new Array(this.blanks.length).fill('');
521
+
522
+ // Debug: Log the created cloze text
523
+
524
+ return true; // Return success indicator
525
+ }
526
+
527
+ dividePassageIntoSections(totalWords, numberOfBlanks) {
528
+ const sections = [];
529
+ const sectionSize = Math.floor(totalWords / numberOfBlanks);
530
+
531
+ for (let i = 0; i < numberOfBlanks; i++) {
532
+ const start = i * sectionSize;
533
+ const end = i === numberOfBlanks - 1 ? totalWords : (i + 1) * sectionSize;
534
+ sections.push({ start, end });
535
+ }
536
+
537
+ return sections;
538
+ }
539
+
540
+ selectWordsManually(words, numberOfBlanks) {
541
+ // Fallback manual word selection - avoid function words completely
542
+ const functionWords = new Set([
543
+ // Articles
544
+ 'the', 'a', 'an',
545
+ // Prepositions
546
+ 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'over', 'after',
547
+ // Conjunctions
548
+ 'and', 'or', 'but', 'so', 'yet', 'nor', 'because', 'since', 'although', 'if', 'when', 'while',
549
+ // Pronouns
550
+ 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our', 'their',
551
+ 'this', 'that', 'these', 'those', 'who', 'what', 'which', 'whom', 'whose',
552
+ // Auxiliary verbs
553
+ 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',
554
+ 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'shall'
555
+ ]);
556
+
557
+ // Get content words with their indices for better distribution
558
+ const contentWordIndices = [];
559
+ words.forEach((word, index) => {
560
+ const cleanWord = word.toLowerCase().replace(/[^\w]/g, '');
561
+ const originalCleanWord = word.replace(/[^\w]/g, '');
562
+ // Skip capitalized words, function words, and words that are too short/long
563
+ if (cleanWord.length > 3 && cleanWord.length <= 12 &&
564
+ !functionWords.has(cleanWord) &&
565
+ originalCleanWord[0] === originalCleanWord[0].toLowerCase()) {
566
+ contentWordIndices.push({ word: cleanWord, index });
567
+ }
568
+ });
569
+
570
+ // Distribute selection across sections
571
+ const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks);
572
+ const selectedWords = [];
573
+
574
+ for (let i = 0; i < numberOfBlanks && i < passageSections.length; i++) {
575
+ const section = passageSections[i];
576
+ const sectionWords = contentWordIndices.filter(item =>
577
+ item.index >= section.start && item.index < section.end
578
+ );
579
+
580
+ if (sectionWords.length > 0) {
581
+ const randomIndex = Math.floor(Math.random() * sectionWords.length);
582
+ selectedWords.push(sectionWords[randomIndex].word);
583
+ }
584
+ }
585
+
586
+ // Fill remaining slots if needed
587
+ while (selectedWords.length < numberOfBlanks && contentWordIndices.length > 0) {
588
+ const availableWords = contentWordIndices
589
+ .map(item => item.word)
590
+ .filter(word => !selectedWords.includes(word));
591
+
592
+ if (availableWords.length > 0) {
593
+ const randomIndex = Math.floor(Math.random() * availableWords.length);
594
+ selectedWords.push(availableWords[randomIndex]);
595
+ } else {
596
+ break;
597
+ }
598
+ }
599
+
600
+ return selectedWords;
601
+ }
602
+
603
+ async generateContextualization() {
604
+ // Always use AI for contextualization
605
+ try {
606
+ this.contextualization = await aiService.generateContextualization(
607
+ this.currentBook.title,
608
+ this.currentBook.author,
609
+ this.originalText
610
+ );
611
+ return this.contextualization;
612
+ } catch (error) {
613
+ console.warn('AI contextualization failed, using fallback:', error);
614
+ this.contextualization = `"${this.currentBook.title}" by ${this.currentBook.author} - A classic work of literature.`;
615
+ return this.contextualization;
616
+ }
617
+ }
618
+
619
+ renderClozeText() {
620
+ let html = this.clozeText;
621
+
622
+ this.blanks.forEach((blank, index) => {
623
+ const inputHtml = `<input type="text"
624
+ class="cloze-input"
625
+ data-blank-index="${index}"
626
+ placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
627
+ style="width: ${Math.max(50, blank.originalWord.length * 10)}px;">`;
628
+
629
+ html = html.replace(`___BLANK_${index}___`, inputHtml);
630
+ });
631
+
632
+ return html;
633
+ }
634
+
635
+ submitAnswers(answers, forceComplete = false) {
636
+ this.userAnswers = answers;
637
+ let correctCount = 0;
638
+ let newlyCorrectCount = 0;
639
+ const results = [];
640
+ const retryableIndices = [];
641
+
642
+ this.blanks.forEach((blank, index) => {
643
+ // Skip already locked (correct) blanks
644
+ if (this.lockedBlanks.has(index)) {
645
+ correctCount++;
646
+ results.push({
647
+ blankIndex: index,
648
+ userAnswer: answers[index],
649
+ correctAnswer: blank.originalWord,
650
+ isCorrect: true,
651
+ isLocked: true,
652
+ attemptNumber: this.attemptCounts[index] || 1,
653
+ attemptedThisRound: false
654
+ });
655
+ return;
656
+ }
657
+
658
+ // Track attempt for this blank
659
+ if (!this.attemptCounts[index]) {
660
+ this.attemptCounts[index] = 0;
661
+ }
662
+ this.attemptCounts[index]++;
663
+
664
+ const userAnswer = answers[index].trim().toLowerCase();
665
+ const correctAnswer = blank.originalWord.toLowerCase();
666
+ const isCorrect = userAnswer === correctAnswer;
667
+
668
+ if (isCorrect) {
669
+ correctCount++;
670
+ newlyCorrectCount++;
671
+ this.lockedBlanks.add(index); // Lock this blank
672
+ } else {
673
+ retryableIndices.push(index);
674
+ }
675
+
676
+ results.push({
677
+ blankIndex: index,
678
+ userAnswer: answers[index],
679
+ correctAnswer: blank.originalWord,
680
+ isCorrect,
681
+ isLocked: isCorrect, // Newly correct answers get locked
682
+ attemptNumber: this.attemptCounts[index],
683
+ attemptedThisRound: true
684
+ });
685
+ });
686
+
687
+ const scorePercentage = Math.round((correctCount / this.blanks.length) * 100);
688
+ this.score = scorePercentage;
689
+
690
+ // Calculate pass requirements based on number of blanks
691
+ const totalBlanks = this.blanks.length;
692
+ const requiredCorrect = this.calculateRequiredCorrect(totalBlanks);
693
+
694
+ // Check if all blanks are correct
695
+ const allCorrect = correctCount === totalBlanks;
696
+
697
+ // Check if we've exceeded max retries
698
+ const maxAttemptsReached = Object.values(this.attemptCounts).some(
699
+ count => count >= this.maxRetries
700
+ );
701
+
702
+ // Determine if user can retry
703
+ // Can retry if: not all correct, not forced complete, and under max retries
704
+ const canRetry = !allCorrect && !forceComplete && !maxAttemptsReached;
705
+
706
+ // Only finalize (pass/fail) if no retry possible
707
+ const isFinal = !canRetry;
708
+ const passed = allCorrect || (correctCount >= requiredCorrect && isFinal);
709
+
710
+ // Track if we're advancing level (only on final submission)
711
+ let justAdvancedLevel = false;
712
+
713
+ if (isFinal) {
714
+ if (passed) {
715
+ const previousLevel = this.currentLevel;
716
+ this.currentLevel++;
717
+ justAdvancedLevel = true;
718
+ } else {
719
+ }
720
+ } else {
721
+ }
722
+
723
+ const snapshot = this.getProgressSnapshot();
724
+
725
+ // Update passage tracking (only on final)
726
+ const stats = this.leaderboardService.getStats();
727
+ const totalPassagesPassed = (passed && isFinal) ? (stats.totalPassagesPassed + 1) : stats.totalPassagesPassed;
728
+
729
+ const resultsData = {
730
+ correct: correctCount,
731
+ total: this.blanks.length,
732
+ percentage: scorePercentage,
733
+ passed: passed,
734
+ results,
735
+ canAdvanceLevel: passed && isFinal,
736
+ shouldRevealAnswers: isFinal && !passed,
737
+ requiredCorrect: requiredCorrect,
738
+ currentLevel: this.currentLevel,
739
+ justAdvancedLevel: justAdvancedLevel,
740
+ round: snapshot.round,
741
+ passagesPassed: totalPassagesPassed,
742
+ progressText: this.formatProgressText(snapshot),
743
+ feedbackText: canRetry
744
+ ? `${correctCount}/${totalBlanks} correct • Retry the highlighted blank${retryableIndices.length > 1 ? 's' : ''}`
745
+ : this.formatAdvancementText({ passed, correctCount, requiredCorrect, justAdvancedLevel }),
746
+
747
+ // Retry system fields
748
+ canRetry,
749
+ retryableIndices,
750
+ isFinal,
751
+ attemptCounts: { ...this.attemptCounts },
752
+ lockedIndices: Array.from(this.lockedBlanks),
753
+ maxRetriesReached: maxAttemptsReached
754
+ };
755
+
756
+ // Update leaderboard stats only on final submission
757
+ if (isFinal) {
758
+ this.leaderboardService.updateStats(resultsData);
759
+ }
760
+
761
+ // Store results for potential answer revelation
762
+ this.lastResults = resultsData;
763
+
764
+ return resultsData;
765
+ }
766
+
767
+ /**
768
+ * Force complete the current passage (skip remaining retries)
769
+ * Called when user wants to give up and see answers
770
+ */
771
+ forceCompletePassage() {
772
+ return this.submitAnswers(this.userAnswers, true);
773
+ }
774
+
775
+ /**
776
+ * Check if a specific blank is locked (already correct)
777
+ */
778
+ isBlankLocked(blankIndex) {
779
+ return this.lockedBlanks.has(blankIndex);
780
+ }
781
+
782
+ /**
783
+ * Get current attempt count for a blank
784
+ */
785
+ getAttemptCount(blankIndex) {
786
+ return this.attemptCounts[blankIndex] || 0;
787
+ }
788
+
789
+ /**
790
+ * Get analytics data for the current passage attempt
791
+ * Used by AnalyticsService to record data
792
+ */
793
+ getAnalyticsData() {
794
+ if (!this.currentBook || !this.blanks.length) return null;
795
+
796
+ return {
797
+ bookTitle: this.currentBook.title,
798
+ bookAuthor: this.currentBook.author,
799
+ level: this.currentLevel,
800
+ round: this.currentRound,
801
+ blanks: this.blanks.map((blank, index) => ({
802
+ word: blank.originalWord,
803
+ length: blank.originalWord.length,
804
+ attemptsToCorrect: this.attemptCounts[index] || 0,
805
+ finalCorrect: this.lockedBlanks.has(index)
806
+ }))
807
+ };
808
+ }
809
+
810
+ // Calculate required correct answers based on total blanks
811
+ calculateRequiredCorrect(totalBlanks) {
812
+ if (totalBlanks === 1) {
813
+ // 1 blank: Must get it correct
814
+ return 1;
815
+ } else if (totalBlanks === 2) {
816
+ // 2 blanks: Need both correct (keeps current Level 6-10 difficulty)
817
+ return 2;
818
+ } else {
819
+ // 3+ blanks: Need all but one (fixes Level 11+ to be harder than Level 10)
820
+ return totalBlanks - 1;
821
+ }
822
+ }
823
+
824
+ showAnswers() {
825
+ return this.blanks.map(blank => ({
826
+ index: blank.index,
827
+ word: blank.originalWord
828
+ }));
829
+ }
830
+
831
+ nextRound() {
832
+ // Always increment round counter
833
+ this.currentRound++;
834
+
835
+ // Level advancement is now handled in submitAnswers() based on pass/fail
836
+
837
+ // Clear chat conversations for new round
838
+ this.chatService.clearConversations();
839
+
840
+ // Clear results since we're moving to new round
841
+ this.lastResults = null;
842
+
843
+ return this.startNewRound();
844
+ }
845
+
846
+ // Get answers for current round (for revelation when switching passages)
847
+ getCurrentAnswers() {
848
+ if (!this.lastResults) return null;
849
+
850
+ return {
851
+ hasResults: true,
852
+ passed: this.lastResults.passed,
853
+ shouldRevealAnswers: this.lastResults.shouldRevealAnswers,
854
+ currentLevel: this.lastResults.currentLevel,
855
+ requiredCorrect: this.lastResults.requiredCorrect,
856
+ answers: this.blanks.map(blank => ({
857
+ index: blank.index,
858
+ correctAnswer: blank.originalWord,
859
+ userAnswer: this.lastResults.results[blank.index]?.userAnswer || '',
860
+ isCorrect: this.lastResults.results[blank.index]?.isCorrect || false
861
+ }))
862
+ };
863
+ }
864
+
865
+ // Calculate difficulty of a word based on various factors
866
+ calculateWordDifficulty(word, position, allWords) {
867
+ let difficulty = 1;
868
+
869
+ // Length factor
870
+ if (word.length > 8) difficulty += 2;
871
+ else if (word.length > 5) difficulty += 1;
872
+
873
+ // Position factor (middle words might be harder)
874
+ const relativePosition = position / allWords.length;
875
+ if (relativePosition > 0.3 && relativePosition < 0.7) difficulty += 1;
876
+
877
+ // Complexity factors
878
+ if (word.includes('ing') || word.includes('ed')) difficulty += 0.5;
879
+ if (word.includes('tion') || word.includes('sion')) difficulty += 1;
880
+
881
+ // Current level factor
882
+ difficulty += (this.currentLevel - 1) * 0.5;
883
+
884
+ return Math.min(5, Math.max(1, Math.round(difficulty)));
885
+ }
886
+
887
+ // Simple, clean hint with just essential info based on level
888
+ generateContextualFallbackHint(word) {
889
+ if (this.currentLevel <= 2) {
890
+ return `${word.length} letters, starts with "${word[0]}", ends with "${word[word.length - 1]}"`;
891
+ } else {
892
+ return `${word.length} letters, starts with "${word[0]}"`;
893
+ }
894
+ }
895
+
896
+ // Chat functionality methods
897
+ async askQuestionAboutBlank(blankIndex, questionType, currentInput = '') {
898
+ const blankId = `blank_${blankIndex}`;
899
+ return await this.chatService.askQuestion(blankId, questionType, currentInput);
900
+ }
901
+
902
+ getSuggestedQuestionsForBlank(blankIndex) {
903
+ const blankId = `blank_${blankIndex}`;
904
+ return this.chatService.getSuggestedQuestions(blankId);
905
+ }
906
+
907
+
908
+ // Enhanced render method to include chat buttons
909
+ renderClozeTextWithChat() {
910
+ let html = this.clozeText;
911
+
912
+ this.blanks.forEach((blank, index) => {
913
+ const chatButtonId = `chat-btn-${index}`;
914
+ const inputHtml = `
915
+ <span class="inline-flex items-center">
916
+ <input type="text"
917
+ class="cloze-input"
918
+ data-blank-index="${index}"
919
+ placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
920
+ style="width: ${Math.max(50, blank.originalWord.length * 10)}px;">
921
+ <button id="${chatButtonId}"
922
+ class="chat-button text-blue-500 hover:text-blue-700"
923
+ data-blank-index="${index}"
924
+ title="Ask question about this word"
925
+ style="font-size: 1.5rem; line-height: 1;">
926
+ 💬
927
+ </button>
928
+ </span>`;
929
+
930
+ html = html.replace(`___BLANK_${index}___`, inputHtml);
931
+ });
932
+
933
+ return html;
934
+ }
935
+
936
+ // Leaderboard integration methods
937
+ checkForHighScore() {
938
+ const stats = this.leaderboardService.getStats();
939
+ return this.leaderboardService.qualifiesForLeaderboard(
940
+ stats.highestLevel,
941
+ stats.roundAtHighestLevel,
942
+ stats.totalPassagesPassed
943
+ );
944
+ }
945
+
946
+ getHighScoreRank() {
947
+ const stats = this.leaderboardService.getStats();
948
+ return this.leaderboardService.getRankForScore(
949
+ stats.highestLevel,
950
+ stats.roundAtHighestLevel,
951
+ stats.totalPassagesPassed
952
+ );
953
+ }
954
+
955
+ addToLeaderboard(initials) {
956
+ const stats = this.leaderboardService.getStats();
957
+ return this.leaderboardService.addEntry(
958
+ initials,
959
+ stats.highestLevel,
960
+ stats.roundAtHighestLevel,
961
+ stats.totalPassagesPassed
962
+ );
963
+ }
964
+
965
+ getLeaderboardStats() {
966
+ return this.leaderboardService.getPlayerStats();
967
+ }
968
+ }
969
+
970
+ export default ClozeGame;
src/conversationManager.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Chat service for contextual, personalized hints
2
+ class ChatService {
3
+ constructor(aiService) {
4
+ this.aiService = aiService;
5
+ this.conversations = new Map(); // blankId -> conversation history
6
+ this.wordContexts = new Map(); // blankId -> detailed context
7
+ this.blankQuestions = new Map(); // blankId -> Set of used question types (per-blank tracking)
8
+ this.currentLevel = 1; // Track current difficulty level
9
+
10
+ // Distinct, non-overlapping question set
11
+ this.questions = [
12
+ { text: "What is its part of speech?", type: "part_of_speech" },
13
+ { text: "What role does it play in the sentence?", type: "sentence_role" },
14
+ { text: "Is it abstract or a person, place, or thing?", type: "word_category" },
15
+ { text: "What is a synonym for this word?", type: "synonym" }
16
+ ];
17
+ }
18
+
19
+ // Initialize chat context for a specific blank
20
+ initializeWordContext(blankId, wordData) {
21
+ const context = {
22
+ blankId,
23
+ targetWord: wordData.originalWord,
24
+ sentence: wordData.sentence,
25
+ fullPassage: wordData.passage,
26
+ bookTitle: wordData.bookTitle,
27
+ author: wordData.author,
28
+ year: wordData.year || null,
29
+ wordPosition: wordData.wordPosition,
30
+ difficulty: wordData.difficulty,
31
+ previousAttempts: [],
32
+ userQuestions: [],
33
+ hintLevel: 0 // Progressive hint difficulty
34
+ };
35
+
36
+ this.wordContexts.set(blankId, context);
37
+ this.conversations.set(blankId, []);
38
+ return context;
39
+ }
40
+
41
+ // Per-blank question tracking with level awareness
42
+ async askQuestion(blankId, questionType, userInput = '') {
43
+ const context = this.wordContexts.get(blankId);
44
+
45
+ if (!context) {
46
+ return {
47
+ error: true,
48
+ message: "Context not found for this word."
49
+ };
50
+ }
51
+
52
+ // Mark question as used for this specific blank
53
+ if (!this.blankQuestions.has(blankId)) {
54
+ this.blankQuestions.set(blankId, new Set());
55
+ }
56
+ this.blankQuestions.get(blankId).add(questionType);
57
+
58
+ try {
59
+ const response = await this.generateSpecificResponse(context, questionType, userInput);
60
+ return {
61
+ success: true,
62
+ response: response,
63
+ questionType: questionType
64
+ };
65
+ } catch (error) {
66
+ console.error('Chat error:', error);
67
+ return this.getSimpleFallback(context, questionType);
68
+ }
69
+ }
70
+
71
+ // Generate specific response based on question type
72
+ async generateSpecificResponse(context, questionType, userInput) {
73
+ const word = context.targetWord;
74
+ const sentence = context.sentence;
75
+ const bookTitle = context.bookTitle;
76
+ const author = context.author;
77
+
78
+ // Create sentence with blank for context, but tell AI the actual word
79
+ const sentenceWithBlank = sentence.replace(new RegExp(`\\b${word}\\b`, 'gi'), '____');
80
+
81
+ try {
82
+ // Build focused prompt that includes the target word but forbids revealing it
83
+ const prompt = this.buildFocusedPrompt({
84
+ ...context,
85
+ sentence: sentenceWithBlank,
86
+ targetWord: word
87
+ }, questionType, userInput);
88
+
89
+ // Use the AI service as a simple API wrapper
90
+ const aiResponse = await this.aiService.generateContextualHint(prompt);
91
+
92
+ if (aiResponse && typeof aiResponse === 'string' && aiResponse.length > 10) {
93
+ return aiResponse;
94
+ }
95
+ } catch (error) {
96
+ console.warn('AI response failed:', error);
97
+ }
98
+
99
+ // Fallback - return simple fallback response
100
+ return this.getSimpleFallback(context, questionType);
101
+ }
102
+
103
+ // Build focused prompt for specific question types
104
+ buildFocusedPrompt(context, questionType, userInput) {
105
+ const { sentence, bookTitle, author, targetWord, year } = context;
106
+ const yearPrefix = year ? `Published in ${year}, ` : '';
107
+ const baseContext = `${yearPrefix}from "${bookTitle}" by ${author}: "${sentence}"`;
108
+ const safetyRule = `Important: The hidden word is "${targetWord}". Never say this word directly - use "it," "this word," or "the word" instead.`;
109
+
110
+ const prompts = {
111
+ part_of_speech: `${baseContext}\n\n${safetyRule}\n\nIdentify the part of speech and share one interesting grammar tip about this type of word. Keep it conversational and under 25 words.\nExample: "It's a verb! These words show action or states of being, like 'run' or 'exist'."`,
112
+
113
+ sentence_role: `${baseContext}\n\n${safetyRule}\n\nExplain what role this word plays in the sentence - what's its job here? Be specific to THIS sentence. Keep it under 20 words and conversational.\nExample: "Here it connects two ideas, showing how one thing relates to another."`,
114
+
115
+ word_category: `${baseContext}\n\n${safetyRule}\n\nWhat general category does this word belong to? Think broadly - is it about people, things, actions, qualities, feelings, places, or ideas? Explain briefly in under 20 words.\nExample: "This word fits in the 'qualities' category - it describes how something looks or feels."`,
116
+
117
+ synonym: `${baseContext}\n\n${safetyRule}\n\nSuggest a word that could replace it in this sentence. Pick something simple and explain why it works. Under 15 words.\nExample: "You could use 'bright' here - it captures the same feeling of intensity."`
118
+ };
119
+
120
+ return prompts[questionType] || `${baseContext}\n\n${safetyRule}\n\nProvide a helpful hint about "${targetWord}" without revealing it.`;
121
+ }
122
+
123
+ // Simple fallback responses
124
+ getSimpleFallback(context, questionType) {
125
+ const fallbacks = {
126
+ part_of_speech: "Look at the surrounding words. Is it describing something, showing action, or naming something?",
127
+ sentence_role: "Consider how this word connects to the other parts of the sentence.",
128
+ word_category: "Think about whether this represents something concrete or an abstract idea.",
129
+ synonym: "What other word could fit in this same spot with similar meaning?"
130
+ };
131
+
132
+ return fallbacks[questionType] || "Consider the context and what word would make sense here.";
133
+ }
134
+
135
+
136
+ // Clear conversations and reset tracking
137
+ clearConversations() {
138
+ this.conversations.clear();
139
+ this.wordContexts.clear();
140
+ this.blankQuestions.clear();
141
+ }
142
+
143
+ // Set current level for question selection
144
+ setLevel(level) {
145
+ this.currentLevel = level;
146
+ }
147
+
148
+ // Get suggested questions for a specific blank
149
+ getSuggestedQuestions(blankId) {
150
+ const usedQuestions = this.blankQuestions.get(blankId) || new Set();
151
+
152
+ return this.questions.map(q => ({
153
+ ...q,
154
+ used: usedQuestions.has(q.type)
155
+ }));
156
+ }
157
+
158
+ // Reset for new game (clears everything including across-game state)
159
+ resetForNewGame() {
160
+ this.clearConversations();
161
+ this.currentLevel = 1;
162
+ }
163
+ }
164
+
165
+ export default ChatService;
src/hfLeaderboardAPI.js ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Leaderboard API Client
3
+ * Communicates with FastAPI backend (Redis primary, HF Space fallback)
4
+ * Supports near real-time polling for live updates
5
+ */
6
+
7
+ export class HFLeaderboardAPI {
8
+ constructor(baseUrl = '') {
9
+ // HF Space URL (used as fallback and for GitHub Pages hosting)
10
+ const HF_LEADERBOARD_SPACE = 'https://milwright-cloze-leaderboard.hf.space';
11
+
12
+ // For local development, use local server
13
+ // For production (Railway), use same origin (backend serves frontend)
14
+ // For GitHub Pages, fall back to HF Space
15
+ const isLocalDev = window.location.hostname === 'localhost' ||
16
+ window.location.hostname === '127.0.0.1';
17
+ const isGitHubPages = window.location.hostname.includes('github.io');
18
+
19
+ if (baseUrl) {
20
+ this.baseUrl = baseUrl;
21
+ } else if (isLocalDev) {
22
+ this.baseUrl = window.location.origin;
23
+ } else if (isGitHubPages) {
24
+ this.baseUrl = HF_LEADERBOARD_SPACE;
25
+ } else {
26
+ // Railway or other hosting: use same origin (FastAPI serves both)
27
+ this.baseUrl = window.location.origin;
28
+ }
29
+
30
+ // Polling state
31
+ this.pollInterval = null;
32
+ this.pollIntervalMs = 5000; // 5 seconds default
33
+ this.listeners = new Set();
34
+ this.lastLeaderboard = null;
35
+ }
36
+
37
+ /**
38
+ * Start polling for leaderboard updates
39
+ * @param {number} intervalMs - Polling interval in milliseconds (default: 5000)
40
+ */
41
+ startPolling(intervalMs = 5000) {
42
+ if (this.pollInterval) {
43
+ return;
44
+ }
45
+
46
+ this.pollIntervalMs = intervalMs;
47
+
48
+ // Initial fetch
49
+ this._pollOnce();
50
+
51
+ // Set up interval
52
+ this.pollInterval = setInterval(() => {
53
+ this._pollOnce();
54
+ }, intervalMs);
55
+ }
56
+
57
+ /**
58
+ * Stop polling for updates
59
+ */
60
+ stopPolling() {
61
+ if (this.pollInterval) {
62
+ clearInterval(this.pollInterval);
63
+ this.pollInterval = null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Check if polling is currently active
69
+ * @returns {boolean}
70
+ */
71
+ isPolling() {
72
+ return this.pollInterval !== null;
73
+ }
74
+
75
+ /**
76
+ * Subscribe to leaderboard updates
77
+ * @param {Function} callback - Called with leaderboard data when updates occur
78
+ * @returns {Function} Unsubscribe function
79
+ */
80
+ onUpdate(callback) {
81
+ this.listeners.add(callback);
82
+
83
+ // If we have cached data, call immediately
84
+ if (this.lastLeaderboard) {
85
+ callback(this.lastLeaderboard);
86
+ }
87
+
88
+ // Return unsubscribe function
89
+ return () => {
90
+ this.listeners.delete(callback);
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Internal: Fetch leaderboard and notify listeners if changed
96
+ */
97
+ async _pollOnce() {
98
+ try {
99
+ const leaderboard = await this.getLeaderboard();
100
+
101
+ // Check if data changed (simple JSON comparison)
102
+ const newData = JSON.stringify(leaderboard);
103
+ const oldData = JSON.stringify(this.lastLeaderboard);
104
+
105
+ if (newData !== oldData) {
106
+ this.lastLeaderboard = leaderboard;
107
+ this._notifyListeners(leaderboard);
108
+ }
109
+ } catch (error) {
110
+ // Silent fail for polling - don't spam console
111
+ console.debug('⏱️ Leaderboard: Poll failed (will retry)', error.message);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Internal: Notify all listeners of leaderboard update
117
+ */
118
+ _notifyListeners(leaderboard) {
119
+ for (const callback of this.listeners) {
120
+ try {
121
+ callback(leaderboard);
122
+ } catch (error) {
123
+ console.error('⏱️ Leaderboard: Listener error', error);
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Get leaderboard from backend
130
+ * @returns {Promise<Array>} Array of leaderboard entries
131
+ */
132
+ async getLeaderboard() {
133
+ try {
134
+ const response = await fetch(`${this.baseUrl}/api/leaderboard`, {
135
+ method: 'GET',
136
+ headers: {
137
+ 'Content-Type': 'application/json'
138
+ }
139
+ });
140
+
141
+ if (!response.ok) {
142
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
143
+ }
144
+
145
+ const data = await response.json();
146
+
147
+ if (data.success) {
148
+ console.debug('📥 Leaderboard API: Retrieved', {
149
+ entries: data.leaderboard.length,
150
+ message: data.message
151
+ });
152
+ return data.leaderboard;
153
+ } else {
154
+ throw new Error(data.message || 'Failed to retrieve leaderboard');
155
+ }
156
+ } catch (error) {
157
+ console.error('❌ Leaderboard API: Error fetching:', error);
158
+ throw error;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Add new entry to leaderboard
164
+ * @param {Object} entry - Leaderboard entry {initials, level, round, passagesPassed, date}
165
+ * @returns {Promise<Object>} Response object
166
+ */
167
+ async addEntry(entry) {
168
+ try {
169
+ const response = await fetch(`${this.baseUrl}/api/leaderboard/add`, {
170
+ method: 'POST',
171
+ headers: {
172
+ 'Content-Type': 'application/json'
173
+ },
174
+ body: JSON.stringify(entry)
175
+ });
176
+
177
+ if (!response.ok) {
178
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
179
+ throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
180
+ }
181
+
182
+ const data = await response.json();
183
+
184
+ console.log('✅ Leaderboard API: Entry added', {
185
+ initials: entry.initials,
186
+ level: entry.level,
187
+ message: data.message
188
+ });
189
+
190
+ // Trigger immediate poll to refresh data
191
+ if (this.pollInterval) {
192
+ this._pollOnce();
193
+ }
194
+
195
+ return data;
196
+ } catch (error) {
197
+ console.error('❌ Leaderboard API: Error adding entry:', error);
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Update entire leaderboard
204
+ * @param {Array} entries - Array of leaderboard entries
205
+ * @returns {Promise<Object>} Response object
206
+ */
207
+ async updateLeaderboard(entries) {
208
+ try {
209
+ const response = await fetch(`${this.baseUrl}/api/leaderboard/update`, {
210
+ method: 'POST',
211
+ headers: {
212
+ 'Content-Type': 'application/json'
213
+ },
214
+ body: JSON.stringify(entries)
215
+ });
216
+
217
+ if (!response.ok) {
218
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
219
+ throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
220
+ }
221
+
222
+ const data = await response.json();
223
+
224
+ console.log('✅ Leaderboard API: Updated', {
225
+ entries: entries.length,
226
+ message: data.message
227
+ });
228
+
229
+ // Trigger immediate poll to refresh data
230
+ if (this.pollInterval) {
231
+ this._pollOnce();
232
+ }
233
+
234
+ return data;
235
+ } catch (error) {
236
+ console.error('❌ Leaderboard API: Error updating:', error);
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Clear all leaderboard data (admin function)
243
+ * @returns {Promise<Object>} Response object
244
+ */
245
+ async clearLeaderboard() {
246
+ try {
247
+ const response = await fetch(`${this.baseUrl}/api/leaderboard/clear`, {
248
+ method: 'DELETE',
249
+ headers: {
250
+ 'Content-Type': 'application/json'
251
+ }
252
+ });
253
+
254
+ if (!response.ok) {
255
+ const errorData = await response.json().catch(() => ({ detail: response.statusText }));
256
+ throw new Error(`HTTP ${response.status}: ${errorData.detail || response.statusText}`);
257
+ }
258
+
259
+ const data = await response.json();
260
+
261
+ console.log('✅ Leaderboard API: Cleared', {
262
+ message: data.message
263
+ });
264
+
265
+ // Trigger immediate poll to refresh data
266
+ if (this.pollInterval) {
267
+ this._pollOnce();
268
+ }
269
+
270
+ return data;
271
+ } catch (error) {
272
+ console.error('❌ Leaderboard API: Error clearing:', error);
273
+ throw error;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Check if backend is available
279
+ * @returns {Promise<boolean>} True if backend is reachable
280
+ */
281
+ async isAvailable() {
282
+ try {
283
+ const response = await fetch(`${this.baseUrl}/api/leaderboard`, {
284
+ method: 'GET',
285
+ headers: {
286
+ 'Content-Type': 'application/json'
287
+ }
288
+ });
289
+ return response.ok;
290
+ } catch (error) {
291
+ console.warn('⚠️ Leaderboard API: Backend not available, will use localStorage fallback');
292
+ return false;
293
+ }
294
+ }
295
+ }
src/init-env.js ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Initialize environment variables from meta tags
2
+ document.addEventListener('DOMContentLoaded', function() {
3
+ const openrouterMeta = document.querySelector('meta[name="openrouter-key"]');
4
+ const hfMeta = document.querySelector('meta[name="hf-key"]');
5
+
6
+ if (openrouterMeta && openrouterMeta.content) {
7
+ window.OPENROUTER_API_KEY = openrouterMeta.content;
8
+ } else {
9
+ }
10
+
11
+ if (hfMeta && hfMeta.content) {
12
+ window.HF_API_KEY = hfMeta.content;
13
+ }
14
+ });
src/leaderboardService.js ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Leaderboard Service
3
+ * Manages high scores, player stats, with HF Hub persistence and localStorage fallback
4
+ * Following arcade conventions with 3-letter initials and top 10 tracking
5
+ */
6
+
7
+ import { HFLeaderboardAPI } from './hfLeaderboardAPI.js';
8
+
9
+ export class LeaderboardService {
10
+ constructor() {
11
+ this.storageKeys = {
12
+ leaderboard: 'cloze-reader-leaderboard',
13
+ player: 'cloze-reader-player',
14
+ stats: 'cloze-reader-stats'
15
+ };
16
+
17
+ this.maxEntries = 10;
18
+
19
+ // Initialize HF API client
20
+ this.hfAPI = new HFLeaderboardAPI();
21
+ this.useHF = false; // Will be set based on availability check
22
+
23
+ // Check HF availability and initialize
24
+ this.initializeAsync();
25
+ }
26
+
27
+ /**
28
+ * Async initialization to check HF availability
29
+ */
30
+ async initializeAsync() {
31
+ try {
32
+ this.useHF = await this.hfAPI.isAvailable();
33
+ } catch (error) {
34
+ console.warn('⚠️ LEADERBOARD: HF backend unavailable, using localStorage', error);
35
+ this.useHF = false;
36
+ }
37
+
38
+ // Reset all data on initialization (fresh start each session)
39
+ this.resetAll();
40
+ this.initializeStorage();
41
+
42
+ // If HF is available, sync from HF to localStorage
43
+ if (this.useHF) {
44
+ await this.syncFromHF();
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Sync leaderboard from HF Hub to localStorage
50
+ */
51
+ async syncFromHF() {
52
+ try {
53
+ const hfLeaderboard = await this.hfAPI.getLeaderboard();
54
+ if (hfLeaderboard && hfLeaderboard.length > 0) {
55
+ this.saveLeaderboard(hfLeaderboard);
56
+ }
57
+ } catch (error) {
58
+ console.error('❌ LEADERBOARD: Failed to sync from HF', error);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Initialize localStorage with default values if needed
64
+ */
65
+ initializeStorage() {
66
+ if (!this.getLeaderboard()) {
67
+ this.saveLeaderboard([]);
68
+ }
69
+
70
+ if (!this.getPlayerProfile()) {
71
+ this.savePlayerProfile({
72
+ initials: null,
73
+ hasEnteredInitials: false,
74
+ gamesPlayed: 0,
75
+ lastPlayed: null
76
+ });
77
+ }
78
+
79
+ if (!this.getStats()) {
80
+ this.saveStats(this.createEmptyStats());
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Create empty stats object
86
+ */
87
+ createEmptyStats() {
88
+ return {
89
+ highestLevel: 1,
90
+ roundAtHighestLevel: 1,
91
+ totalPassagesPassed: 0,
92
+ totalPassagesAttempted: 0,
93
+ longestStreak: 0,
94
+ currentStreak: 0,
95
+ totalCorrectWords: 0,
96
+ uniqueWordsCorrect: new Set(),
97
+ gamesPlayed: 0,
98
+ lastPlayed: null
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Get leaderboard from localStorage
104
+ */
105
+ getLeaderboard() {
106
+ try {
107
+ const data = localStorage.getItem(this.storageKeys.leaderboard);
108
+ return data ? JSON.parse(data) : null;
109
+ } catch (e) {
110
+ console.error('Error reading leaderboard:', e);
111
+ return [];
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Save leaderboard to localStorage
117
+ */
118
+ saveLeaderboard(entries) {
119
+ try {
120
+ localStorage.setItem(this.storageKeys.leaderboard, JSON.stringify(entries));
121
+ } catch (e) {
122
+ console.error('Error saving leaderboard:', e);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Get player profile from localStorage
128
+ */
129
+ getPlayerProfile() {
130
+ try {
131
+ const data = localStorage.getItem(this.storageKeys.player);
132
+ return data ? JSON.parse(data) : null;
133
+ } catch (e) {
134
+ console.error('Error reading player profile:', e);
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Save player profile to localStorage
141
+ */
142
+ savePlayerProfile(profile) {
143
+ try {
144
+ localStorage.setItem(this.storageKeys.player, JSON.stringify(profile));
145
+ } catch (e) {
146
+ console.error('Error saving player profile:', e);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Get stats from localStorage
152
+ */
153
+ getStats() {
154
+ try {
155
+ const data = localStorage.getItem(this.storageKeys.stats);
156
+ if (!data) return null;
157
+
158
+ const stats = JSON.parse(data);
159
+ // Convert uniqueWordsCorrect back to Set
160
+ if (stats.uniqueWordsCorrect && Array.isArray(stats.uniqueWordsCorrect)) {
161
+ stats.uniqueWordsCorrect = new Set(stats.uniqueWordsCorrect);
162
+ } else {
163
+ stats.uniqueWordsCorrect = new Set();
164
+ }
165
+ return stats;
166
+ } catch (e) {
167
+ console.error('Error reading stats:', e);
168
+ return null;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Save stats to localStorage
174
+ */
175
+ saveStats(stats) {
176
+ try {
177
+ // Convert Set to Array for JSON serialization
178
+ const statsToSave = {
179
+ ...stats,
180
+ uniqueWordsCorrect: Array.from(stats.uniqueWordsCorrect || [])
181
+ };
182
+ localStorage.setItem(this.storageKeys.stats, JSON.stringify(statsToSave));
183
+ } catch (e) {
184
+ console.error('Error saving stats:', e);
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Validate and sanitize initials (3 letters, A-Z only)
190
+ */
191
+ validateInitials(initials) {
192
+ if (!initials || typeof initials !== 'string') {
193
+ return false;
194
+ }
195
+
196
+ const sanitized = initials.toUpperCase().replace(/[^A-Z]/g, '');
197
+ return sanitized.length === 3 ? sanitized : false;
198
+ }
199
+
200
+ /**
201
+ * Sort leaderboard entries
202
+ * Primary: Level (desc), Secondary: Round (desc), Tertiary: Passages passed (desc)
203
+ */
204
+ sortLeaderboard(entries) {
205
+ return entries.sort((a, b) => {
206
+ // Primary: Level (higher is better)
207
+ if (b.level !== a.level) {
208
+ return b.level - a.level;
209
+ }
210
+
211
+ // Secondary: Round at that level (higher is better)
212
+ if (b.round !== a.round) {
213
+ return b.round - a.round;
214
+ }
215
+
216
+ // Tertiary: Total passages passed (higher is better)
217
+ if (b.passagesPassed !== a.passagesPassed) {
218
+ return b.passagesPassed - a.passagesPassed;
219
+ }
220
+
221
+ // Quaternary: Date (newer is better)
222
+ return new Date(b.date) - new Date(a.date);
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Check if a score qualifies for the leaderboard
228
+ */
229
+ qualifiesForLeaderboard(level, round, passagesPassed) {
230
+ const leaderboard = this.getLeaderboard();
231
+
232
+ // If leaderboard isn't full, always qualifies
233
+ if (leaderboard.length < this.maxEntries) {
234
+ return true;
235
+ }
236
+
237
+ // Check if better than lowest entry
238
+ const lowestEntry = leaderboard[leaderboard.length - 1];
239
+
240
+ if (level > lowestEntry.level) return true;
241
+ if (level === lowestEntry.level && round > lowestEntry.round) return true;
242
+ if (level === lowestEntry.level && round === lowestEntry.round && passagesPassed > lowestEntry.passagesPassed) return true;
243
+
244
+ return false;
245
+ }
246
+
247
+ /**
248
+ * Get the rank position for a score (1-10, or null if doesn't qualify)
249
+ */
250
+ getRankForScore(level, round, passagesPassed) {
251
+ if (!this.qualifiesForLeaderboard(level, round, passagesPassed)) {
252
+ return null;
253
+ }
254
+
255
+ const leaderboard = this.getLeaderboard();
256
+ const tempEntry = { level, round, passagesPassed, date: new Date().toISOString() };
257
+ const tempLeaderboard = [...leaderboard, tempEntry];
258
+ const sorted = this.sortLeaderboard(tempLeaderboard);
259
+
260
+ return sorted.findIndex(entry => entry === tempEntry) + 1;
261
+ }
262
+
263
+ /**
264
+ * Add a new entry to the leaderboard
265
+ */
266
+ async addEntry(initials, level, round, passagesPassed) {
267
+ const validInitials = this.validateInitials(initials);
268
+ if (!validInitials) {
269
+ console.error('Invalid initials:', initials);
270
+ return false;
271
+ }
272
+
273
+ const leaderboard = this.getLeaderboard();
274
+ const newEntry = {
275
+ initials: validInitials,
276
+ level,
277
+ round,
278
+ passagesPassed,
279
+ date: new Date().toISOString()
280
+ };
281
+
282
+ leaderboard.push(newEntry);
283
+ const sorted = this.sortLeaderboard(leaderboard);
284
+
285
+ // Keep only top 10
286
+ const trimmed = sorted.slice(0, this.maxEntries);
287
+ this.saveLeaderboard(trimmed);
288
+
289
+ const rank = sorted.findIndex(entry => entry === newEntry) + 1;
290
+
291
+ // If HF is available, also save to HF Hub
292
+ if (this.useHF) {
293
+ try {
294
+ await this.hfAPI.addEntry(newEntry);
295
+ } catch (error) {
296
+ console.error('❌ LEADERBOARD: Failed to save to HF, localStorage only', error);
297
+ }
298
+ }
299
+
300
+ return rank; // Return rank
301
+ }
302
+
303
+ /**
304
+ * Update session stats after a passage attempt
305
+ */
306
+ updateStats(data) {
307
+ const stats = this.getStats() || this.createEmptyStats();
308
+
309
+ console.log('📊 LEADERBOARD: Updating stats', {
310
+ before: { attempted: stats.totalPassagesAttempted, passed: stats.totalPassagesPassed, level: stats.highestLevel },
311
+ passResult: data.passed,
312
+ currentLevel: data.currentLevel
313
+ });
314
+
315
+ stats.totalPassagesAttempted++;
316
+
317
+ if (data.passed) {
318
+ stats.totalPassagesPassed++;
319
+
320
+ // Break streak if user had to retry (any blank took more than 1 attempt)
321
+ const hadToRetry = data.attemptCounts && Object.values(data.attemptCounts).some(count => count > 1);
322
+
323
+ if (hadToRetry) {
324
+ // Passed but had to retry - break streak
325
+ stats.currentStreak = 0;
326
+ } else {
327
+ // Passed on first attempt - continue streak
328
+ stats.currentStreak++;
329
+ stats.longestStreak = Math.max(stats.longestStreak, stats.currentStreak);
330
+ }
331
+ } else {
332
+ stats.currentStreak = 0;
333
+ }
334
+
335
+ // Track highest level reached
336
+ if (data.currentLevel > stats.highestLevel) {
337
+ stats.highestLevel = data.currentLevel;
338
+ stats.roundAtHighestLevel = data.round;
339
+ } else if (data.currentLevel === stats.highestLevel) {
340
+ stats.roundAtHighestLevel = Math.max(stats.roundAtHighestLevel, data.round);
341
+ }
342
+
343
+ // Track correct words
344
+ if (data.results) {
345
+ data.results.forEach(result => {
346
+ if (result.isCorrect) {
347
+ stats.totalCorrectWords++;
348
+ const word = result.correctAnswer.toLowerCase();
349
+ stats.uniqueWordsCorrect.add(word);
350
+ }
351
+ });
352
+ }
353
+
354
+ stats.lastPlayed = new Date().toISOString();
355
+
356
+ console.log('📊 LEADERBOARD: Stats updated', {
357
+ after: { attempted: stats.totalPassagesAttempted, passed: stats.totalPassagesPassed, level: stats.highestLevel }
358
+ });
359
+
360
+ this.saveStats(stats);
361
+ return stats;
362
+ }
363
+
364
+ /**
365
+ * Get formatted leaderboard for display
366
+ */
367
+ getFormattedLeaderboard() {
368
+ const leaderboard = this.getLeaderboard();
369
+ const player = this.getPlayerProfile();
370
+ const stats = this.getStats();
371
+
372
+ return {
373
+ entries: leaderboard.map((entry, index) => ({
374
+ rank: index + 1,
375
+ initials: entry.initials,
376
+ level: entry.level,
377
+ round: entry.round,
378
+ passagesPassed: entry.passagesPassed,
379
+ date: entry.date,
380
+ isPlayer: player && player.initials === entry.initials
381
+ })),
382
+ playerBest: stats ? {
383
+ level: stats.highestLevel,
384
+ round: stats.roundAtHighestLevel,
385
+ passagesPassed: stats.totalPassagesPassed
386
+ } : null,
387
+ playerInitials: player ? player.initials : null
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Reset all leaderboard data (fresh start each session)
393
+ */
394
+ resetAll() {
395
+ this.saveLeaderboard([]);
396
+ this.savePlayerProfile({
397
+ initials: null,
398
+ hasEnteredInitials: false,
399
+ gamesPlayed: 0,
400
+ lastPlayed: null
401
+ });
402
+ this.saveStats(this.createEmptyStats());
403
+ }
404
+
405
+ /**
406
+ * Get player stats summary
407
+ */
408
+ getPlayerStats() {
409
+ const stats = this.getStats() || this.createEmptyStats();
410
+ const profile = this.getPlayerProfile();
411
+
412
+ return {
413
+ initials: profile?.initials || '---',
414
+ highestLevel: stats.highestLevel,
415
+ roundAtHighestLevel: stats.roundAtHighestLevel,
416
+ totalPassagesPassed: stats.totalPassagesPassed,
417
+ totalPassagesAttempted: stats.totalPassagesAttempted,
418
+ successRate: stats.totalPassagesAttempted > 0
419
+ ? Math.round((stats.totalPassagesPassed / stats.totalPassagesAttempted) * 100)
420
+ : 0,
421
+ longestStreak: stats.longestStreak,
422
+ currentStreak: stats.currentStreak,
423
+ totalCorrectWords: stats.totalCorrectWords,
424
+ uniqueWords: stats.uniqueWordsCorrect.size,
425
+ gamesPlayed: stats.gamesPlayed,
426
+ lastPlayed: stats.lastPlayed
427
+ };
428
+ }
429
+ }
src/leaderboardUI.js ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Leaderboard UI
3
+ * Modal display and initials entry interface
4
+ * Following arcade conventions with vintage aesthetic
5
+ * Supports near real-time updates via polling
6
+ */
7
+
8
+ export class LeaderboardUI {
9
+ constructor(leaderboardService) {
10
+ this.service = leaderboardService;
11
+ this.modal = null;
12
+ this.initialsModal = null;
13
+ this.currentSlot = 0;
14
+ this.initials = ['A', 'A', 'A'];
15
+ this.onInitialsSubmit = null;
16
+ this.canSubmitInitials = false; // Prevent accidental immediate submission
17
+ this.pollUnsubscribe = null; // Cleanup function for polling subscription
18
+ }
19
+
20
+ /**
21
+ * Show the leaderboard modal
22
+ */
23
+ show() {
24
+ // Remove existing modal if any
25
+ this.hide();
26
+
27
+ const data = this.service.getFormattedLeaderboard();
28
+ const playerStats = this.service.getPlayerStats();
29
+
30
+ // Create modal HTML
31
+ this.modal = document.createElement('div');
32
+ this.modal.className = 'leaderboard-overlay';
33
+ this.modal.innerHTML = `
34
+ <div class="leaderboard-modal">
35
+ <div class="leaderboard-header">
36
+ <h2 class="leaderboard-title">High Scores</h2>
37
+ <span class="leaderboard-live-indicator" title="Live updates enabled">●</span>
38
+ <button class="leaderboard-close" aria-label="Close leaderboard">×</button>
39
+ </div>
40
+
41
+ <div class="leaderboard-content">
42
+ <div class="leaderboard-list">
43
+ ${this.generateLeaderboardHTML(data.entries, data.playerInitials)}
44
+ </div>
45
+
46
+ ${playerStats.highestLevel > 1 ? `
47
+ <div class="leaderboard-player-stats">
48
+ <div class="player-best">
49
+ Your Best: <span class="highlight">Level ${playerStats.highestLevel}</span>
50
+ </div>
51
+ <div class="player-stats-details">
52
+ <div>Passages: ${playerStats.totalPassagesPassed}/${playerStats.totalPassagesAttempted} (${playerStats.successRate}%)</div>
53
+ <div>Longest Streak: ${playerStats.longestStreak}</div>
54
+ </div>
55
+ </div>
56
+ ` : ''}
57
+ </div>
58
+ </div>
59
+ `;
60
+
61
+ document.body.appendChild(this.modal);
62
+
63
+ // Animate in
64
+ requestAnimationFrame(() => {
65
+ this.modal.classList.add('visible');
66
+ });
67
+
68
+ // Add event listeners
69
+ this.modal.querySelector('.leaderboard-close').addEventListener('click', () => this.hide());
70
+
71
+ // Prevent clicks inside modal content from closing
72
+ this.modal.querySelector('.leaderboard-modal').addEventListener('click', (e) => {
73
+ e.stopPropagation();
74
+ });
75
+
76
+ // Close on backdrop click
77
+ this.modal.addEventListener('click', (e) => {
78
+ if (e.target === this.modal) {
79
+ this.hide();
80
+ }
81
+ });
82
+
83
+ // ESC key to close
84
+ this.escHandler = (e) => {
85
+ if (e.key === 'Escape') {
86
+ this.hide();
87
+ }
88
+ };
89
+ document.addEventListener('keydown', this.escHandler);
90
+
91
+ // Start polling for live updates
92
+ this.startPolling();
93
+ }
94
+
95
+ /**
96
+ * Start polling for live leaderboard updates
97
+ */
98
+ startPolling() {
99
+ // Only poll if HF API is available via service
100
+ if (this.service.hfAPI && this.service.useHF) {
101
+ // Subscribe to updates
102
+ this.pollUnsubscribe = this.service.hfAPI.onUpdate((leaderboard) => {
103
+ this.handleLeaderboardUpdate(leaderboard);
104
+ });
105
+
106
+ // Start the polling (5 second interval)
107
+ this.service.hfAPI.startPolling(5000);
108
+
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Stop polling for updates
114
+ */
115
+ stopPolling() {
116
+ if (this.pollUnsubscribe) {
117
+ this.pollUnsubscribe();
118
+ this.pollUnsubscribe = null;
119
+ }
120
+
121
+ if (this.service.hfAPI) {
122
+ this.service.hfAPI.stopPolling();
123
+ }
124
+
125
+ }
126
+
127
+ /**
128
+ * Handle incoming leaderboard update from polling
129
+ */
130
+ handleLeaderboardUpdate(leaderboard) {
131
+ // Update localStorage with new data
132
+ this.service.saveLeaderboard(leaderboard);
133
+
134
+ // Re-render the leaderboard list if modal is open
135
+ if (this.modal) {
136
+ const data = this.service.getFormattedLeaderboard();
137
+ const listContainer = this.modal.querySelector('.leaderboard-list');
138
+
139
+ if (listContainer) {
140
+ listContainer.innerHTML = this.generateLeaderboardHTML(data.entries, data.playerInitials);
141
+
142
+ // Flash the live indicator to show update received
143
+ const indicator = this.modal.querySelector('.leaderboard-live-indicator');
144
+ if (indicator) {
145
+ indicator.classList.add('pulse');
146
+ setTimeout(() => indicator.classList.remove('pulse'), 500);
147
+ }
148
+
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Generate HTML for leaderboard entries
155
+ */
156
+ generateLeaderboardHTML(entries, playerInitials) {
157
+ if (entries.length === 0) {
158
+ return `
159
+ <div class="leaderboard-empty">
160
+ <p>No high scores yet!</p>
161
+ <p class="text-sm">Be the first to reach Level 2!</p>
162
+ </div>
163
+ `;
164
+ }
165
+
166
+ return entries.map(entry => {
167
+ const rankClass = this.getRankClass(entry.rank);
168
+ const isPlayer = entry.initials === playerInitials;
169
+ const playerClass = isPlayer ? 'player-entry' : '';
170
+
171
+ return `
172
+ <div class="leaderboard-entry ${rankClass} ${playerClass}">
173
+ <span class="entry-rank">#${entry.rank}</span>
174
+ <span class="entry-initials">${entry.initials}</span>
175
+ <span class="entry-score">Level ${entry.level}</span>
176
+ </div>
177
+ `;
178
+ }).join('');
179
+ }
180
+
181
+ /**
182
+ * Get CSS class for rank-based styling
183
+ */
184
+ getRankClass(rank) {
185
+ if (rank === 1) return 'rank-gold';
186
+ if (rank === 2 || rank === 3) return 'rank-silver';
187
+ return 'rank-standard';
188
+ }
189
+
190
+ /**
191
+ * Hide the leaderboard modal
192
+ */
193
+ hide() {
194
+ // Stop polling when modal closes
195
+ this.stopPolling();
196
+
197
+ if (this.modal) {
198
+ this.modal.classList.remove('visible');
199
+ setTimeout(() => {
200
+ if (this.modal && this.modal.parentNode) {
201
+ this.modal.parentNode.removeChild(this.modal);
202
+ }
203
+ this.modal = null;
204
+ }, 300);
205
+ }
206
+
207
+ if (this.escHandler) {
208
+ document.removeEventListener('keydown', this.escHandler);
209
+ this.escHandler = null;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Show initials entry screen for new high score
215
+ */
216
+ showInitialsEntry(level, round, rank, onSubmit) {
217
+ // Store callback
218
+ this.onInitialsSubmit = onSubmit;
219
+
220
+ // Reset initials state
221
+ this.currentSlot = 0;
222
+ this.canSubmitInitials = false; // Disable submission until user has had time to interact
223
+
224
+ // Get existing player initials if available
225
+ const profile = this.service.getPlayerProfile();
226
+ if (profile && profile.initials) {
227
+ this.initials = profile.initials.split('');
228
+ } else {
229
+ this.initials = ['A', 'A', 'A'];
230
+ }
231
+
232
+ // Remove existing modal
233
+ this.hideInitialsEntry();
234
+
235
+ // Create modal HTML
236
+ this.initialsModal = document.createElement('div');
237
+ this.initialsModal.className = 'leaderboard-overlay initials-overlay';
238
+ this.initialsModal.innerHTML = `
239
+ <div class="initials-modal">
240
+ <div class="initials-header">
241
+ <h2 class="initials-title">New High Score</h2>
242
+ <div class="initials-achievement">
243
+ You reached <span class="highlight">Level ${level}</span>
244
+ <br>
245
+ <span class="rank-text">${this.getRankText(rank)}</span>
246
+ </div>
247
+ </div>
248
+
249
+ <div class="initials-content">
250
+ <p class="initials-prompt">Enter or update your initials:</p>
251
+
252
+ <!-- Text Input Method -->
253
+ <div class="text-input-section">
254
+ <input type="text" id="initials-text-input" class="initials-text-input" maxlength="3" value="${this.initials.join('')}" placeholder="ABC">
255
+ <p class="input-help">Type your 3-letter initials directly</p>
256
+ </div>
257
+
258
+ <!-- Divider -->
259
+ <div class="input-divider">
260
+ <span>or use arcade controls</span>
261
+ </div>
262
+
263
+ <!-- Arcade Style Method -->
264
+ <div class="initials-slots">
265
+ ${this.initials.map((letter, index) => `
266
+ <div class="initial-slot ${index === 0 ? 'active' : ''}" data-slot="${index}">
267
+ <div class="slot-letter">${letter}</div>
268
+ <div class="slot-arrows">
269
+ <button class="arrow-up" data-slot="${index}" data-direction="up" aria-label="Increase letter">▲</button>
270
+ <button class="arrow-down" data-slot="${index}" data-direction="down" aria-label="Decrease letter">▼</button>
271
+ </div>
272
+ </div>
273
+ `).join('')}
274
+ </div>
275
+
276
+ <div class="initials-instructions">
277
+ <p>Use arrow keys ↑↓ to change letters</p>
278
+ <p>Press Tab or ←→ to move between slots</p>
279
+ <p>Press Enter to submit</p>
280
+ </div>
281
+
282
+ <button class="initials-submit typewriter-button">
283
+ Submit
284
+ </button>
285
+ </div>
286
+ </div>
287
+ `;
288
+
289
+ document.body.appendChild(this.initialsModal);
290
+
291
+ // Animate in
292
+ requestAnimationFrame(() => {
293
+ this.initialsModal.classList.add('visible');
294
+ });
295
+
296
+ // Add event listeners with a delay to prevent Enter key from passage submission
297
+ // from immediately triggering the modal's submit handler
298
+ setTimeout(() => {
299
+ this.setupInitialsEventListeners();
300
+ // Focus the text input for easier typing
301
+ const textInput = this.initialsModal.querySelector('#initials-text-input');
302
+ if (textInput) {
303
+ textInput.focus();
304
+ textInput.select(); // Select all text for easy overwriting
305
+ }
306
+ // Enable submission after a longer delay to ensure user has time to interact
307
+ setTimeout(() => {
308
+ this.canSubmitInitials = true;
309
+ }, 300);
310
+ }, 100);
311
+ }
312
+
313
+ /**
314
+ * Get rank description text
315
+ */
316
+ getRankText(rank) {
317
+ const ordinal = this.getOrdinal(rank);
318
+ if (rank === 1) return `${ordinal} place - Top Score`;
319
+ if (rank === 2) return `${ordinal} place`;
320
+ if (rank === 3) return `${ordinal} place`;
321
+ return `${ordinal} place on the leaderboard`;
322
+ }
323
+
324
+ /**
325
+ * Get ordinal suffix for rank (1st, 2nd, 3rd, etc.)
326
+ */
327
+ getOrdinal(n) {
328
+ const s = ['th', 'st', 'nd', 'rd'];
329
+ const v = n % 100;
330
+ return n + (s[(v - 20) % 10] || s[v] || s[0]);
331
+ }
332
+
333
+ /**
334
+ * Setup event listeners for initials entry
335
+ */
336
+ setupInitialsEventListeners() {
337
+ // Text input field
338
+ const textInput = this.initialsModal.querySelector('#initials-text-input');
339
+ textInput.addEventListener('input', (e) => {
340
+ const value = e.target.value.toUpperCase().slice(0, 3);
341
+ e.target.value = value;
342
+
343
+ // Update arcade slots to match text input
344
+ this.updateInitialsFromText(value);
345
+ });
346
+
347
+ // Arrow buttons
348
+ this.initialsModal.querySelectorAll('.arrow-up, .arrow-down').forEach(button => {
349
+ button.addEventListener('click', (e) => {
350
+ const slot = parseInt(e.target.dataset.slot);
351
+ const direction = e.target.dataset.direction;
352
+ this.changeInitialLetter(slot, direction === 'up' ? 1 : -1);
353
+ });
354
+ });
355
+
356
+ // Slot clicking to select
357
+ this.initialsModal.querySelectorAll('.initial-slot').forEach(slot => {
358
+ slot.addEventListener('click', (e) => {
359
+ if (!e.target.closest('.arrow-up') && !e.target.closest('.arrow-down')) {
360
+ const slotIndex = parseInt(slot.dataset.slot);
361
+ this.selectSlot(slotIndex);
362
+ }
363
+ });
364
+ });
365
+
366
+ // Submit button
367
+ this.initialsModal.querySelector('.initials-submit').addEventListener('click', () => {
368
+ this.submitInitials();
369
+ });
370
+
371
+ // Keyboard controls
372
+ this.initialsKeyHandler = (e) => {
373
+ // If focus is on text input, handle differently
374
+ if (e.target.id === 'initials-text-input') {
375
+ switch(e.key) {
376
+ case 'Enter':
377
+ e.preventDefault();
378
+ this.submitInitials();
379
+ break;
380
+ case 'Escape':
381
+ e.preventDefault();
382
+ this.hideInitialsEntry();
383
+ break;
384
+ }
385
+ return;
386
+ }
387
+
388
+ // Arcade controls when not focused on text input
389
+ switch(e.key) {
390
+ case 'ArrowUp':
391
+ e.preventDefault();
392
+ this.changeInitialLetter(this.currentSlot, 1);
393
+ break;
394
+ case 'ArrowDown':
395
+ e.preventDefault();
396
+ this.changeInitialLetter(this.currentSlot, -1);
397
+ break;
398
+ case 'ArrowLeft':
399
+ e.preventDefault();
400
+ this.selectSlot(Math.max(0, this.currentSlot - 1));
401
+ break;
402
+ case 'ArrowRight':
403
+ case 'Tab':
404
+ e.preventDefault();
405
+ this.selectSlot(Math.min(2, this.currentSlot + 1));
406
+ break;
407
+ case 'Enter':
408
+ e.preventDefault();
409
+ this.submitInitials();
410
+ break;
411
+ case 'Escape':
412
+ e.preventDefault();
413
+ this.hideInitialsEntry();
414
+ break;
415
+ }
416
+ };
417
+ document.addEventListener('keydown', this.initialsKeyHandler);
418
+
419
+ // Prevent modal close on backdrop click for initials entry
420
+ this.initialsModal.addEventListener('click', (e) => {
421
+ e.stopPropagation();
422
+ });
423
+ }
424
+
425
+ /**
426
+ * Change letter in current slot
427
+ */
428
+ changeInitialLetter(slot, delta) {
429
+ const currentChar = this.initials[slot].charCodeAt(0);
430
+ let newChar = currentChar + delta;
431
+
432
+ // Wrap around A-Z
433
+ if (newChar > 90) newChar = 65; // After Z, go to A
434
+ if (newChar < 65) newChar = 90; // Before A, go to Z
435
+
436
+ this.initials[slot] = String.fromCharCode(newChar);
437
+ this.updateInitialsDisplay();
438
+ this.updateTextFromInitials();
439
+ }
440
+
441
+ /**
442
+ * Select a specific slot
443
+ */
444
+ selectSlot(slot) {
445
+ this.currentSlot = slot;
446
+ this.initialsModal.querySelectorAll('.initial-slot').forEach((el, index) => {
447
+ el.classList.toggle('active', index === slot);
448
+ });
449
+ }
450
+
451
+ /**
452
+ * Update arcade slots from text input
453
+ */
454
+ updateInitialsFromText(text) {
455
+ // Pad with 'A' if less than 3 characters
456
+ const paddedText = text.padEnd(3, 'A');
457
+ this.initials = paddedText.split('');
458
+ this.updateInitialsDisplay();
459
+ }
460
+
461
+ /**
462
+ * Update text input from arcade slots
463
+ */
464
+ updateTextFromInitials() {
465
+ const textInput = this.initialsModal.querySelector('#initials-text-input');
466
+ if (textInput) {
467
+ textInput.value = this.initials.join('');
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Update the visual display of initials
473
+ */
474
+ updateInitialsDisplay() {
475
+ this.initialsModal.querySelectorAll('.initial-slot').forEach((slot, index) => {
476
+ slot.querySelector('.slot-letter').textContent = this.initials[index];
477
+ });
478
+ }
479
+
480
+ /**
481
+ * Submit initials and save to leaderboard
482
+ */
483
+ submitInitials() {
484
+ // Prevent accidental immediate submission
485
+ if (!this.canSubmitInitials) {
486
+ return;
487
+ }
488
+
489
+ const initialsString = this.initials.join('');
490
+
491
+ // Save to player profile
492
+ const profile = this.service.getPlayerProfile();
493
+ profile.initials = initialsString;
494
+ profile.hasEnteredInitials = true;
495
+ this.service.savePlayerProfile(profile);
496
+
497
+ // Call the callback
498
+ if (this.onInitialsSubmit) {
499
+ this.onInitialsSubmit(initialsString);
500
+ }
501
+
502
+ // Hide modal
503
+ this.hideInitialsEntry();
504
+
505
+ // Show success message briefly, then show leaderboard
506
+ this.showSuccessMessage(() => {
507
+ this.show();
508
+ });
509
+ }
510
+
511
+ /**
512
+ * Hide initials entry modal
513
+ */
514
+ hideInitialsEntry() {
515
+ if (this.initialsModal) {
516
+ this.initialsModal.classList.remove('visible');
517
+ setTimeout(() => {
518
+ if (this.initialsModal && this.initialsModal.parentNode) {
519
+ this.initialsModal.parentNode.removeChild(this.initialsModal);
520
+ }
521
+ this.initialsModal = null;
522
+ }, 300);
523
+ }
524
+
525
+ if (this.initialsKeyHandler) {
526
+ document.removeEventListener('keydown', this.initialsKeyHandler);
527
+ this.initialsKeyHandler = null;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Show success message after submitting initials
533
+ */
534
+ showSuccessMessage(onComplete) {
535
+ const successDiv = document.createElement('div');
536
+ successDiv.className = 'leaderboard-overlay visible';
537
+ successDiv.innerHTML = `
538
+ <div class="leaderboard-modal success-message">
539
+ <div class="success-content">
540
+ <h2>Score Saved</h2>
541
+ <p>Your initials have been added to the leaderboard</p>
542
+ </div>
543
+ </div>
544
+ `;
545
+
546
+ document.body.appendChild(successDiv);
547
+
548
+ setTimeout(() => {
549
+ successDiv.classList.remove('visible');
550
+ setTimeout(() => {
551
+ if (successDiv.parentNode) {
552
+ successDiv.parentNode.removeChild(successDiv);
553
+ }
554
+ if (onComplete) {
555
+ onComplete();
556
+ }
557
+ }, 300);
558
+ }, 1500);
559
+ }
560
+
561
+ /**
562
+ * Show notification toast for milestone achievement
563
+ */
564
+ showMilestoneNotification(level) {
565
+ const toast = document.createElement('div');
566
+ toast.className = 'milestone-toast';
567
+ toast.innerHTML = `
568
+ <div class="toast-content">
569
+ Milestone Reached: Level ${level}
570
+ </div>
571
+ `;
572
+
573
+ document.body.appendChild(toast);
574
+
575
+ // Animate in
576
+ requestAnimationFrame(() => {
577
+ toast.classList.add('visible');
578
+ });
579
+
580
+ // Auto-hide after 3 seconds
581
+ setTimeout(() => {
582
+ toast.classList.remove('visible');
583
+ setTimeout(() => {
584
+ if (toast.parentNode) {
585
+ toast.parentNode.removeChild(toast);
586
+ }
587
+ }, 300);
588
+ }, 3000);
589
+ }
590
+ }
src/styles.css ADDED
@@ -0,0 +1,1242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* Chat functionality styles */
6
+ .typing-dots span {
7
+ animation: typing 1.5s infinite;
8
+ }
9
+
10
+ .typing-dots span:nth-child(2) {
11
+ animation-delay: 0.5s;
12
+ }
13
+
14
+ .typing-dots span:nth-child(3) {
15
+ animation-delay: 1s;
16
+ }
17
+
18
+ @keyframes typing {
19
+ 0%, 60%, 100% { opacity: 0.3; }
20
+ 30% { opacity: 1; }
21
+ }
22
+
23
+ .chat-button {
24
+ transition: all 0.2s ease;
25
+ border-radius: 4px;
26
+ padding: 4px 6px;
27
+ font-size: 1.5rem;
28
+ vertical-align: middle;
29
+ display: inline-flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ min-width: 32px;
33
+ min-height: 32px;
34
+ }
35
+
36
+ .chat-button:hover {
37
+ background-color: rgba(59, 130, 246, 0.1);
38
+ transform: scale(1.15);
39
+ }
40
+
41
+ /* Responsive sizing for mobile */
42
+ @media (max-width: 640px) {
43
+ .chat-button {
44
+ font-size: 1.25rem;
45
+ min-width: 28px;
46
+ min-height: 28px;
47
+ }
48
+ }
49
+
50
+ /* Larger screens get bigger icons */
51
+ @media (min-width: 1024px) {
52
+ .chat-button {
53
+ font-size: 1.75rem;
54
+ min-width: 36px;
55
+ min-height: 36px;
56
+ }
57
+ }
58
+
59
+ /* Chat modal styling to match game font */
60
+ #chat-modal {
61
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
62
+ }
63
+
64
+ #chat-modal .bg-white {
65
+ background-color: var(--aged-paper-light);
66
+ border: 2px solid rgba(0, 0, 0, 0.1);
67
+ }
68
+
69
+ #chat-modal h3 {
70
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
71
+ color: var(--typewriter-ink);
72
+ font-weight: 600;
73
+ }
74
+
75
+ #chat-messages {
76
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
77
+ color: var(--typewriter-ink);
78
+ line-height: 1.6;
79
+ }
80
+
81
+ #chat-messages .text-gray-500 {
82
+ color: #666 !important;
83
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
84
+ }
85
+
86
+ .suggestion-btn, .question-btn {
87
+ transition: all 0.2s ease;
88
+ padding: 14px 18px;
89
+ background: #f8f9fa;
90
+ border: 2px solid #e9ecef;
91
+ border-radius: 8px;
92
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
93
+ font-size: 15px;
94
+ font-weight: 500;
95
+ text-align: center;
96
+ cursor: pointer;
97
+ line-height: 1.4;
98
+ min-height: 64px;
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: center;
102
+ width: 100%;
103
+ box-sizing: border-box;
104
+ }
105
+
106
+ .suggestion-btn:hover, .question-btn:hover:not(:disabled) {
107
+ background: #e9ecef;
108
+ border-color: #6c757d;
109
+ transform: translateY(-1px);
110
+ }
111
+
112
+ .suggestion-btn:disabled, .question-btn:disabled {
113
+ opacity: 0.5;
114
+ cursor: not-allowed;
115
+ background: #f8f9fa;
116
+ }
117
+
118
+ /* Answer revelation styles */
119
+ .revealed-answer {
120
+ background-color: #fef3c7 !important;
121
+ font-weight: bold !important;
122
+ color: #92400e !important;
123
+ border: 2px solid #f59e0b !important;
124
+ }
125
+
126
+ /* Correct and incorrect answer styles */
127
+
128
+ /* Welcome overlay styles */
129
+ .welcome-overlay {
130
+ position: fixed;
131
+ top: 0;
132
+ left: 0;
133
+ width: 100%;
134
+ height: 100%;
135
+ background: rgba(0, 0, 0, 0.8);
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: center;
139
+ z-index: 1000;
140
+ opacity: 0;
141
+ transition: opacity 0.3s ease;
142
+ }
143
+
144
+ .welcome-modal {
145
+ background: white;
146
+ border-radius: 12px;
147
+ padding: 24px;
148
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
149
+ max-height: 80vh;
150
+ overflow-y: auto;
151
+ }
152
+
153
+ .welcome-title {
154
+ margin: 0 0 16px 0;
155
+ color: #1f2937;
156
+ font-size: 22px;
157
+ font-weight: 600;
158
+ }
159
+
160
+ .welcome-content {
161
+ text-align: left;
162
+ color: #4b5563;
163
+ line-height: 1.4;
164
+ margin-bottom: 20px;
165
+ font-size: 14px;
166
+ }
167
+
168
+ .welcome-content p {
169
+ margin: 0 0 12px 0;
170
+ }
171
+
172
+ .welcome-overlay .typewriter-button {
173
+ background: #1f2937;
174
+ color: white;
175
+ border: 2px solid #374151;
176
+ padding: 8px 14px;
177
+ font-weight: 600;
178
+ font-size: 14px;
179
+ min-width: 100px;
180
+ min-height: 36px;
181
+ transition: all 0.2s ease;
182
+ }
183
+
184
+ .welcome-overlay .typewriter-button:hover {
185
+ background: #374151;
186
+ border-color: #4b5563;
187
+ transform: translateY(-1px);
188
+ }
189
+ .cloze-input.correct {
190
+ background-color: #dcfce7 !important;
191
+ border-color: #16a34a !important;
192
+ color: #15803d !important;
193
+ font-weight: bold !important;
194
+ }
195
+
196
+ .cloze-input.incorrect {
197
+ background-color: #fef2f2 !important;
198
+ border-color: #dc2626 !important;
199
+ color: #dc2626 !important;
200
+ font-weight: bold !important;
201
+ }
202
+
203
+ .cloze-input:disabled {
204
+ opacity: 0.8;
205
+ cursor: not-allowed;
206
+ }
207
+
208
+ /* Custom typewriter color scheme */
209
+ :root {
210
+ --aged-paper: #faf7f0;
211
+ --aged-paper-dark: #f5f1e8;
212
+ --aged-paper-light: #fefcf7;
213
+ --typewriter-ink: #2c2c2c;
214
+ --typewriter-ribbon: #8b5cf6;
215
+ --paper-shadow: rgba(0, 0, 0, 0.08);
216
+ }
217
+
218
+ @layer base {
219
+ body {
220
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
221
+ background-color: var(--aged-paper);
222
+ color: var(--typewriter-ink);
223
+ line-height: 1.6;
224
+ font-size: 16px;
225
+ background-image:
226
+ radial-gradient(circle at 25% 25%, rgba(139, 92, 246, 0.02) 0%, transparent 50%),
227
+ radial-gradient(circle at 75% 75%, rgba(139, 92, 246, 0.02) 0%, transparent 50%);
228
+ }
229
+ }
230
+
231
+ @layer components {
232
+ .typewriter-text {
233
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
234
+ color: var(--typewriter-ink);
235
+ font-weight: 600;
236
+ line-height: 1.7;
237
+ }
238
+
239
+ .typewriter-subtitle {
240
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
241
+ color: #666;
242
+ font-size: 1rem;
243
+ line-height: 1.5;
244
+ }
245
+
246
+ .paper-sheet {
247
+ background-color: var(--aged-paper-light);
248
+ border: 1px solid rgba(139, 92, 246, 0.1);
249
+ box-shadow:
250
+ 0 2px 8px var(--paper-shadow),
251
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
252
+ position: relative;
253
+ }
254
+
255
+ .paper-sheet::before {
256
+ content: '';
257
+ position: absolute;
258
+ top: 0;
259
+ left: 0;
260
+ right: 0;
261
+ bottom: 0;
262
+ background: repeating-linear-gradient(
263
+ to bottom,
264
+ transparent,
265
+ transparent 23px,
266
+ rgba(139, 92, 246, 0.03) 23px,
267
+ rgba(139, 92, 246, 0.03) 24px
268
+ );
269
+ pointer-events: none;
270
+ z-index: 1;
271
+ }
272
+
273
+ .paper-content {
274
+ position: relative;
275
+ z-index: 2;
276
+ }
277
+
278
+ .cloze-input {
279
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
280
+ background-color: transparent;
281
+ border: none;
282
+ border-bottom: 2px dotted black;
283
+ color: var(--typewriter-ink);
284
+ text-align: center;
285
+ outline: none;
286
+ padding: 3px 4px;
287
+ margin: 0 1px;
288
+ min-width: 4ch;
289
+ width: auto;
290
+ font-size: inherit;
291
+ line-height: inherit;
292
+ transition: all 0.2s ease;
293
+ }
294
+
295
+ .cloze-input:focus {
296
+ border-bottom: 2px dotted black;
297
+ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
298
+ background-color: rgba(0, 0, 0, 0.05);
299
+ }
300
+
301
+ .cloze-input.correct {
302
+ border-bottom: 2px dotted #10b981;
303
+ background-color: rgba(16, 185, 129, 0.1);
304
+ }
305
+
306
+ .cloze-input.incorrect {
307
+ border-bottom: 2px dotted #ef4444;
308
+ background-color: rgba(239, 68, 68, 0.1);
309
+ }
310
+
311
+ .cloze-input::placeholder {
312
+ color: rgba(0, 0, 0, 0.4);
313
+ font-style: normal;
314
+ font-family: monospace;
315
+ letter-spacing: 0.1em;
316
+ }
317
+
318
+ .typewriter-button {
319
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
320
+ min-width: 100px;
321
+ min-height: 36px;
322
+ padding: 8px 14px;
323
+ background-color: var(--aged-paper-dark);
324
+ color: var(--typewriter-ink);
325
+ border: 2px solid black;
326
+ border-radius: 6px;
327
+ font-weight: 600;
328
+ font-size: 14px;
329
+ cursor: pointer;
330
+ transition: all 0.15s ease;
331
+ box-shadow:
332
+ 0 3px 0 rgba(0, 0, 0, 0.3),
333
+ 0 4px 8px rgba(0, 0, 0, 0.1);
334
+ position: relative;
335
+ overflow: hidden;
336
+ }
337
+
338
+ .typewriter-button:hover:not(:disabled) {
339
+ background-color: rgba(0, 0, 0, 0.05);
340
+ transform: translateY(-1px);
341
+ box-shadow:
342
+ 0 4px 0 rgba(0, 0, 0, 0.3),
343
+ 0 6px 12px rgba(0, 0, 0, 0.15);
344
+ }
345
+
346
+ .typewriter-button:active:not(:disabled) {
347
+ transform: translateY(2px);
348
+ box-shadow:
349
+ 0 1px 0 rgba(0, 0, 0, 0.3),
350
+ 0 2px 4px rgba(0, 0, 0, 0.1);
351
+ }
352
+
353
+ .typewriter-button:disabled {
354
+ opacity: 0.6;
355
+ cursor: not-allowed;
356
+ transform: translateY(2px);
357
+ box-shadow:
358
+ 0 2px 0 rgba(0, 0, 0, 0.2),
359
+ 0 3px 6px rgba(0, 0, 0, 0.05);
360
+ }
361
+
362
+ .typewriter-button:focus {
363
+ outline: none;
364
+ box-shadow:
365
+ 0 4px 0 rgba(0, 0, 0, 0.3),
366
+ 0 6px 12px rgba(0, 0, 0, 0.1),
367
+ 0 0 0 3px rgba(0, 0, 0, 0.2);
368
+ }
369
+
370
+ .prose {
371
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
372
+ font-size: 1.125rem;
373
+ line-height: 1.7;
374
+ color: var(--typewriter-ink);
375
+ overflow-wrap: break-word;
376
+ }
377
+
378
+ .prose p {
379
+ margin-bottom: 1.5rem;
380
+ text-align: justify;
381
+ }
382
+
383
+ .biblio-info {
384
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
385
+ font-size: 0.85rem;
386
+ color: #666;
387
+ font-style: italic;
388
+ }
389
+
390
+ .context-box {
391
+ background-color: rgba(245, 158, 11, 0.08);
392
+ border-left: 4px solid #f59e0b;
393
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
394
+ font-size: 0.9rem;
395
+ line-height: 1.6;
396
+ }
397
+
398
+ .hints-box {
399
+ background-color: rgba(245, 158, 11, 0.08);
400
+ border-left: 4px solid #f59e0b;
401
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
402
+ font-size: 0.9rem;
403
+ line-height: 1.6;
404
+ }
405
+
406
+ .round-badge {
407
+ background-color: rgba(245, 158, 11, 0.1);
408
+ color: #666;
409
+ border: 1px solid rgba(245, 158, 11, 0.2);
410
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
411
+ font-weight: 600;
412
+ }
413
+
414
+ /* Ensure specific game info elements use accessible font */
415
+ #book-info, #round-info, #contextualization {
416
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
417
+ }
418
+
419
+ .loading-text {
420
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
421
+ color: #666;
422
+ }
423
+
424
+ .result-text {
425
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
426
+ font-weight: 600;
427
+ }
428
+
429
+ /* Leaderboard button in footer */
430
+ .leaderboard-footer-btn {
431
+ background-color: var(--aged-paper-dark);
432
+ color: var(--typewriter-ink);
433
+ border: 2px solid black;
434
+ border-radius: 0 8px 8px 0;
435
+ padding: 8px 16px;
436
+ cursor: pointer;
437
+ font-size: 1.5rem;
438
+ line-height: 1;
439
+ transition: all 0.15s ease;
440
+ display: flex;
441
+ align-items: center;
442
+ justify-content: center;
443
+ flex: 0 0 auto;
444
+ min-width: 60px;
445
+ min-height: 44px;
446
+ box-shadow:
447
+ 0 3px 0 rgba(0, 0, 0, 0.3),
448
+ 0 4px 8px rgba(0, 0, 0, 0.1);
449
+ touch-action: manipulation;
450
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
451
+ user-select: none;
452
+ }
453
+
454
+ .leaderboard-footer-btn:hover {
455
+ background-color: rgba(0, 0, 0, 0.05);
456
+ transform: translateY(-1px);
457
+ box-shadow:
458
+ 0 4px 0 rgba(0, 0, 0, 0.3),
459
+ 0 6px 12px rgba(0, 0, 0, 0.15);
460
+ }
461
+
462
+ .leaderboard-footer-btn:active {
463
+ transform: translateY(2px);
464
+ box-shadow:
465
+ 0 1px 0 rgba(0, 0, 0, 0.3),
466
+ 0 2px 4px rgba(0, 0, 0, 0.1);
467
+ }
468
+
469
+
470
+ /* Mobile responsive */
471
+ @media (max-width: 768px) {
472
+ .typewriter-subtitle {
473
+ display: none;
474
+ }
475
+ }
476
+
477
+ @media (max-width: 640px) {
478
+ .prose {
479
+ font-size: 1rem;
480
+ }
481
+
482
+ .typewriter-text {
483
+ font-size: 1.75rem;
484
+ }
485
+
486
+ .typewriter-button {
487
+ min-width: 100px;
488
+ min-height: 38px;
489
+ font-size: 0.9rem;
490
+ }
491
+
492
+ .leaderboard-footer-btn {
493
+ font-size: 1.25rem;
494
+ padding: 12px 10px;
495
+ min-width: 50px;
496
+ min-height: 48px;
497
+ }
498
+ }
499
+
500
+ @media (max-width: 480px) {
501
+ .prose {
502
+ font-size: 0.95rem;
503
+ }
504
+
505
+ .typewriter-text {
506
+ font-size: 1.5rem;
507
+ }
508
+
509
+ .cloze-input {
510
+ min-width: 2.5ch;
511
+ }
512
+
513
+ header img {
514
+ width: 2rem;
515
+ height: 2rem;
516
+ }
517
+
518
+ .leaderboard-footer-btn {
519
+ font-size: 1.1rem;
520
+ padding: 12px 8px;
521
+ min-width: 48px;
522
+ }
523
+ }
524
+
525
+ /* Compact question buttons for chat interface */
526
+ #chat-modal .question-btn {
527
+ padding: 6px 8px;
528
+ min-height: auto;
529
+ font-size: 12px;
530
+ line-height: 1.3;
531
+ font-weight: 400;
532
+ transition: all 0.15s ease;
533
+ }
534
+
535
+ /* Mobile dropdown styling */
536
+ #question-dropdown {
537
+ background: var(--aged-paper-dark);
538
+ border: 2px solid rgba(0, 0, 0, 0.1);
539
+ border-radius: 8px;
540
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
541
+ font-size: 16px;
542
+ font-weight: 500;
543
+ color: var(--typewriter-ink);
544
+ transition: all 0.2s ease;
545
+ }
546
+
547
+ #question-dropdown:focus {
548
+ outline: none;
549
+ border-color: rgba(0, 0, 0, 0.3);
550
+ box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
551
+ }
552
+
553
+ /* Sticky Control Panel */
554
+ .sticky-controls {
555
+ position: fixed;
556
+ bottom: 0;
557
+ left: 0;
558
+ right: 0;
559
+ background: var(--aged-paper-light);
560
+ border-top: 2px solid rgba(0, 0, 0, 0.1);
561
+ box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
562
+ padding: 12px 16px;
563
+ z-index: 1000;
564
+ backdrop-filter: blur(8px);
565
+ }
566
+
567
+ .sticky-controls .controls-inner {
568
+ position: relative;
569
+ max-width: 1024px;
570
+ margin: 0 auto;
571
+ display: flex;
572
+ gap: 1px;
573
+ justify-content: center;
574
+ align-items: center;
575
+ }
576
+
577
+ .sticky-controls .typewriter-button {
578
+ flex: 1;
579
+ max-width: 200px;
580
+ min-height: 44px;
581
+ margin: 0;
582
+ border-radius: 0;
583
+ font-size: 16px;
584
+ font-weight: 600;
585
+ position: relative;
586
+ }
587
+
588
+ .sticky-controls .typewriter-button:first-child {
589
+ border-top-left-radius: 8px;
590
+ border-bottom-left-radius: 8px;
591
+ }
592
+
593
+ .sticky-controls .typewriter-button:last-child {
594
+ border-top-right-radius: 8px;
595
+ border-bottom-right-radius: 8px;
596
+ }
597
+
598
+ .sticky-controls .typewriter-button:not(:last-child) {
599
+ border-right: 1px solid rgba(0, 0, 0, 0.2);
600
+ }
601
+
602
+ .controls-divider {
603
+ height: 24px;
604
+ width: 2px;
605
+ background: rgba(0, 0, 0, 0.3);
606
+ margin: 0 -1px;
607
+ z-index: 1;
608
+ }
609
+
610
+ /* Add bottom padding to main content to prevent overlap */
611
+ #game-container {
612
+ padding-bottom: 100px;
613
+ }
614
+
615
+ /* Mobile optimizations for sticky controls */
616
+ @media (max-width: 640px) {
617
+ .sticky-controls {
618
+ padding: 10px 12px;
619
+ }
620
+
621
+ .sticky-controls .typewriter-button {
622
+ min-height: 48px;
623
+ font-size: 16px;
624
+ padding: 12px 8px;
625
+ }
626
+
627
+ #game-container {
628
+ padding-bottom: 90px;
629
+ }
630
+ }
631
+
632
+ /* Ensure controls stay above mobile keyboards */
633
+ @media (max-width: 640px) and (max-height: 500px) {
634
+ .sticky-controls {
635
+ position: fixed;
636
+ bottom: 0;
637
+ }
638
+ }
639
+
640
+ /* Leaderboard Overlay */
641
+ .leaderboard-overlay {
642
+ position: fixed;
643
+ top: 0;
644
+ left: 0;
645
+ width: 100%;
646
+ height: 100%;
647
+ background: rgba(0, 0, 0, 0.75);
648
+ display: flex;
649
+ align-items: center;
650
+ justify-content: center;
651
+ z-index: 2000;
652
+ opacity: 0;
653
+ transition: opacity 0.3s ease;
654
+ backdrop-filter: blur(3px);
655
+ }
656
+
657
+ .leaderboard-overlay.visible {
658
+ opacity: 1;
659
+ }
660
+
661
+ /* Leaderboard Modal */
662
+ .leaderboard-modal {
663
+ background: var(--aged-paper-light);
664
+ border: 3px solid rgba(0, 0, 0, 0.4);
665
+ border-radius: 8px;
666
+ padding: 0;
667
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
668
+ max-width: 600px;
669
+ width: 90%;
670
+ max-height: 85vh;
671
+ overflow: hidden;
672
+ animation: slideIn 0.3s ease-out;
673
+ }
674
+
675
+ @keyframes slideIn {
676
+ from {
677
+ transform: translateY(-30px);
678
+ opacity: 0;
679
+ }
680
+ to {
681
+ transform: translateY(0);
682
+ opacity: 1;
683
+ }
684
+ }
685
+
686
+ /* Leaderboard Header */
687
+ .leaderboard-header {
688
+ background: linear-gradient(180deg, var(--aged-paper-dark) 0%, #d4c9b3 100%);
689
+ padding: 20px 24px;
690
+ border-bottom: 3px solid rgba(0, 0, 0, 0.3);
691
+ display: flex;
692
+ justify-content: space-between;
693
+ align-items: center;
694
+ }
695
+
696
+ .leaderboard-title {
697
+ font-family: 'Special Elite', 'Courier New', monospace;
698
+ font-size: 24px;
699
+ font-weight: 700;
700
+ color: var(--typewriter-ink);
701
+ margin: 0;
702
+ letter-spacing: 1px;
703
+ }
704
+
705
+ .leaderboard-live-indicator {
706
+ color: #4ade80;
707
+ font-size: 12px;
708
+ margin-left: 8px;
709
+ opacity: 0.8;
710
+ transition: opacity 0.3s ease, transform 0.3s ease;
711
+ }
712
+
713
+ .leaderboard-live-indicator.pulse {
714
+ animation: live-pulse 0.5s ease-out;
715
+ }
716
+
717
+ @keyframes live-pulse {
718
+ 0% {
719
+ transform: scale(1);
720
+ opacity: 0.8;
721
+ }
722
+ 50% {
723
+ transform: scale(1.5);
724
+ opacity: 1;
725
+ }
726
+ 100% {
727
+ transform: scale(1);
728
+ opacity: 0.8;
729
+ }
730
+ }
731
+
732
+ .leaderboard-close {
733
+ background: transparent;
734
+ color: var(--typewriter-ink);
735
+ border: 2px solid rgba(0, 0, 0, 0.3);
736
+ border-radius: 4px;
737
+ width: 32px;
738
+ height: 32px;
739
+ font-size: 24px;
740
+ line-height: 1;
741
+ cursor: pointer;
742
+ transition: all 0.2s ease;
743
+ display: flex;
744
+ align-items: center;
745
+ justify-content: center;
746
+ }
747
+
748
+ .leaderboard-close:hover {
749
+ background: rgba(0, 0, 0, 0.1);
750
+ border-color: rgba(0, 0, 0, 0.5);
751
+ }
752
+
753
+ .leaderboard-close:active {
754
+ background: rgba(0, 0, 0, 0.15);
755
+ }
756
+
757
+ /* Leaderboard Content */
758
+ .leaderboard-content {
759
+ padding: 24px;
760
+ max-height: calc(85vh - 100px);
761
+ overflow-y: auto;
762
+ background: var(--aged-paper-light);
763
+ }
764
+
765
+ /* Leaderboard List */
766
+ .leaderboard-list {
767
+ display: flex;
768
+ flex-direction: column;
769
+ gap: 8px;
770
+ margin-bottom: 20px;
771
+ }
772
+
773
+ .leaderboard-entry {
774
+ display: grid;
775
+ grid-template-columns: 50px 80px 1fr;
776
+ align-items: center;
777
+ gap: 12px;
778
+ padding: 14px 16px;
779
+ background: var(--aged-paper-dark);
780
+ border: 3px solid rgba(0, 0, 0, 0.4);
781
+ border-radius: 6px;
782
+ font-family: 'Courier New', monospace;
783
+ transition: all 0.2s ease;
784
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
785
+ }
786
+
787
+ /* Rank Colors */
788
+ .leaderboard-entry.rank-gold {
789
+ background: linear-gradient(135deg, #fff7e6 0%, #ffd700 100%);
790
+ border-color: #b8860b;
791
+ border-width: 4px;
792
+ box-shadow: 0 4px 12px rgba(184, 134, 11, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.5);
793
+ }
794
+
795
+ .leaderboard-entry.rank-silver {
796
+ background: linear-gradient(135deg, #fff9f2 0%, #e6d7b8 100%);
797
+ border-color: #a0826d;
798
+ border-width: 3px;
799
+ box-shadow: 0 3px 8px rgba(160, 130, 109, 0.3);
800
+ }
801
+
802
+ .leaderboard-entry.rank-standard {
803
+ background: #ffffff;
804
+ border-color: rgba(0, 0, 0, 0.3);
805
+ border-width: 2px;
806
+ }
807
+
808
+ .leaderboard-entry.player-entry {
809
+ background: linear-gradient(135deg, #fff9f0 0%, #ffd89b 100%);
810
+ border-color: #cc8800;
811
+ border-width: 4px;
812
+ box-shadow: 0 4px 12px rgba(204, 136, 0, 0.35);
813
+ }
814
+
815
+ .leaderboard-entry:hover {
816
+ transform: translateX(4px);
817
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
818
+ }
819
+
820
+ .entry-rank {
821
+ font-size: 20px;
822
+ font-weight: 700;
823
+ color: var(--typewriter-ink);
824
+ text-align: center;
825
+ }
826
+
827
+ .entry-initials {
828
+ font-size: 18px;
829
+ font-weight: 700;
830
+ color: var(--typewriter-ink);
831
+ letter-spacing: 2px;
832
+ text-align: center;
833
+ font-family: 'Courier New', monospace;
834
+ }
835
+
836
+ .entry-score {
837
+ font-size: 16px;
838
+ font-weight: 600;
839
+ color: #666;
840
+ }
841
+
842
+ .entry-round {
843
+ font-size: 14px;
844
+ color: #999;
845
+ font-weight: 400;
846
+ }
847
+
848
+ /* Empty State */
849
+ .leaderboard-empty {
850
+ text-align: center;
851
+ padding: 40px 20px;
852
+ color: #666;
853
+ }
854
+
855
+ .leaderboard-empty p {
856
+ margin: 8px 0;
857
+ font-size: 16px;
858
+ }
859
+
860
+ /* Player Stats */
861
+ .leaderboard-player-stats {
862
+ margin-top: 24px;
863
+ padding: 18px;
864
+ background: rgba(212, 165, 116, 0.08);
865
+ border: 2px solid rgba(212, 165, 116, 0.25);
866
+ border-radius: 6px;
867
+ }
868
+
869
+ .player-best {
870
+ font-size: 16px;
871
+ font-weight: 600;
872
+ color: var(--typewriter-ink);
873
+ margin-bottom: 8px;
874
+ }
875
+
876
+ .player-best .highlight {
877
+ color: #8b6914;
878
+ font-weight: 700;
879
+ }
880
+
881
+ .player-stats-details {
882
+ display: flex;
883
+ flex-direction: column;
884
+ gap: 4px;
885
+ font-size: 14px;
886
+ color: #666;
887
+ }
888
+
889
+ /* Initials Entry Modal */
890
+ .initials-overlay {
891
+ background: rgba(0, 0, 0, 0.9);
892
+ }
893
+
894
+ .initials-modal {
895
+ max-width: 500px;
896
+ }
897
+
898
+ .initials-header {
899
+ background: linear-gradient(180deg, #f5f1e8 0%, #e8dcc8 100%);
900
+ padding: 24px;
901
+ border-bottom: 3px solid rgba(0, 0, 0, 0.3);
902
+ text-align: center;
903
+ }
904
+
905
+ .initials-title {
906
+ font-family: 'Special Elite', 'Courier New', monospace;
907
+ font-size: 26px;
908
+ font-weight: 700;
909
+ color: #000000;
910
+ margin: 0 0 12px 0;
911
+ letter-spacing: 1px;
912
+ }
913
+
914
+ .initials-achievement {
915
+ font-size: 17px;
916
+ color: #1a1a1a;
917
+ line-height: 1.6;
918
+ font-weight: 500;
919
+ }
920
+
921
+ .initials-achievement .highlight {
922
+ color: #7d3c00;
923
+ font-weight: 700;
924
+ text-decoration: underline;
925
+ }
926
+
927
+ .rank-text {
928
+ font-size: 16px;
929
+ color: #2c2c2c;
930
+ font-weight: 600;
931
+ font-style: normal;
932
+ }
933
+
934
+ .initials-content {
935
+ padding: 32px 24px;
936
+ text-align: center;
937
+ background: var(--aged-paper-light);
938
+ }
939
+
940
+ .initials-prompt {
941
+ font-size: 20px;
942
+ font-weight: 700;
943
+ color: #000000;
944
+ margin-bottom: 24px;
945
+ }
946
+
947
+ /* Initials Slots */
948
+ .initials-slots {
949
+ display: flex;
950
+ justify-content: center;
951
+ gap: 16px;
952
+ margin-bottom: 24px;
953
+ }
954
+
955
+ .initial-slot {
956
+ display: flex;
957
+ flex-direction: column;
958
+ align-items: center;
959
+ gap: 8px;
960
+ }
961
+
962
+ .initial-slot.active .slot-letter {
963
+ border-color: #2563eb;
964
+ background: #eff6ff;
965
+ box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.3), 0 4px 0 rgba(0, 0, 0, 0.5);
966
+ }
967
+
968
+ .slot-letter {
969
+ width: 64px;
970
+ height: 80px;
971
+ display: flex;
972
+ align-items: center;
973
+ justify-content: center;
974
+ font-size: 48px;
975
+ font-weight: 700;
976
+ font-family: 'Courier New', monospace;
977
+ color: #000000;
978
+ background: #ffffff;
979
+ border: 3px solid #000000;
980
+ border-radius: 8px;
981
+ transition: all 0.2s ease;
982
+ box-shadow: 0 4px 0 rgba(0, 0, 0, 0.5);
983
+ }
984
+
985
+ .slot-arrows {
986
+ display: flex;
987
+ flex-direction: column;
988
+ gap: 4px;
989
+ }
990
+
991
+ .arrow-up, .arrow-down {
992
+ width: 40px;
993
+ height: 32px;
994
+ background: var(--aged-paper-dark);
995
+ border: 2px solid black;
996
+ border-radius: 4px;
997
+ font-size: 16px;
998
+ cursor: pointer;
999
+ transition: all 0.15s ease;
1000
+ display: flex;
1001
+ align-items: center;
1002
+ justify-content: center;
1003
+ }
1004
+
1005
+ .arrow-up:hover, .arrow-down:hover {
1006
+ background: rgba(0, 0, 0, 0.05);
1007
+ transform: scale(1.1);
1008
+ }
1009
+
1010
+ .arrow-up:active, .arrow-down:active {
1011
+ transform: scale(0.95);
1012
+ }
1013
+
1014
+ /* Initials Instructions */
1015
+ /* Text Input Section */
1016
+ .text-input-section {
1017
+ display: flex;
1018
+ flex-direction: column;
1019
+ align-items: center;
1020
+ margin-bottom: 20px;
1021
+ }
1022
+
1023
+ .initials-text-input {
1024
+ width: 120px;
1025
+ height: 64px;
1026
+ font-size: 32px;
1027
+ font-weight: 700;
1028
+ font-family: 'Courier New', monospace;
1029
+ text-align: center;
1030
+ text-transform: uppercase;
1031
+ letter-spacing: 8px;
1032
+ background: #ffffff;
1033
+ border: 3px solid #000000;
1034
+ border-radius: 8px;
1035
+ box-shadow: 0 4px 0 rgba(0, 0, 0, 0.5);
1036
+ transition: all 0.2s ease;
1037
+ margin-bottom: 8px;
1038
+ }
1039
+
1040
+ .initials-text-input:focus {
1041
+ outline: none;
1042
+ border-color: #2563eb;
1043
+ background: #eff6ff;
1044
+ box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.3), 0 4px 0 rgba(0, 0, 0, 0.5);
1045
+ }
1046
+
1047
+ .input-help {
1048
+ font-size: 12px;
1049
+ color: #666;
1050
+ margin: 0;
1051
+ font-weight: 400;
1052
+ }
1053
+
1054
+ /* Divider */
1055
+ .input-divider {
1056
+ display: flex;
1057
+ align-items: center;
1058
+ margin: 20px 0;
1059
+ color: #666;
1060
+ font-size: 14px;
1061
+ font-weight: 500;
1062
+ }
1063
+
1064
+ .input-divider::before,
1065
+ .input-divider::after {
1066
+ content: '';
1067
+ flex: 1;
1068
+ height: 1px;
1069
+ background: #ddd;
1070
+ }
1071
+
1072
+ .input-divider span {
1073
+ padding: 0 16px;
1074
+ }
1075
+
1076
+ .initials-instructions {
1077
+ margin-bottom: 24px;
1078
+ color: #000000;
1079
+ font-size: 15px;
1080
+ line-height: 1.7;
1081
+ font-weight: 600;
1082
+ }
1083
+
1084
+ .initials-instructions p {
1085
+ margin: 6px 0;
1086
+ }
1087
+
1088
+ .initials-submit {
1089
+ width: 100%;
1090
+ max-width: 200px;
1091
+ min-height: 48px;
1092
+ font-size: 17px;
1093
+ font-weight: 700;
1094
+ color: #000000 !important;
1095
+ }
1096
+
1097
+ /* Success Message */
1098
+ .success-message {
1099
+ max-width: 400px;
1100
+ padding: 32px;
1101
+ text-align: center;
1102
+ }
1103
+
1104
+ .success-content h2 {
1105
+ font-size: 28px;
1106
+ color: #2d5016;
1107
+ margin-bottom: 12px;
1108
+ font-family: 'Special Elite', 'Courier New', monospace;
1109
+ letter-spacing: 1px;
1110
+ }
1111
+
1112
+ .success-content p {
1113
+ font-size: 16px;
1114
+ color: #666;
1115
+ font-family: 'Courier New', monospace;
1116
+ }
1117
+
1118
+ /* Milestone Toast */
1119
+ .milestone-toast {
1120
+ position: fixed;
1121
+ top: 20px;
1122
+ left: 50%;
1123
+ transform: translateX(-50%) translateY(-100px);
1124
+ background: var(--aged-paper-dark);
1125
+ color: var(--typewriter-ink);
1126
+ padding: 16px 24px;
1127
+ border-radius: 8px;
1128
+ border: 3px solid rgba(0, 0, 0, 0.4);
1129
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 4px 0 rgba(0, 0, 0, 0.2);
1130
+ z-index: 3000;
1131
+ transition: transform 0.3s ease;
1132
+ font-weight: 600;
1133
+ font-size: 18px;
1134
+ font-family: 'Special Elite', 'Courier New', monospace;
1135
+ letter-spacing: 0.5px;
1136
+ }
1137
+
1138
+ .milestone-toast.visible {
1139
+ transform: translateX(-50%) translateY(0);
1140
+ }
1141
+
1142
+ /* Mobile Responsive */
1143
+ @media (max-width: 640px) {
1144
+ .leaderboard-modal {
1145
+ width: 95%;
1146
+ max-height: 90vh;
1147
+ }
1148
+
1149
+ .leaderboard-title {
1150
+ font-size: 20px;
1151
+ }
1152
+
1153
+ .leaderboard-entry {
1154
+ grid-template-columns: 40px 70px 1fr;
1155
+ gap: 8px;
1156
+ padding: 12px;
1157
+ }
1158
+
1159
+ .entry-rank {
1160
+ font-size: 16px;
1161
+ }
1162
+
1163
+ .entry-initials {
1164
+ font-size: 16px;
1165
+ }
1166
+
1167
+ .entry-score {
1168
+ font-size: 14px;
1169
+ }
1170
+
1171
+ .initials-modal {
1172
+ width: 95%;
1173
+ }
1174
+
1175
+ .slot-letter {
1176
+ width: 56px;
1177
+ height: 70px;
1178
+ font-size: 40px;
1179
+ }
1180
+
1181
+ .initials-slots {
1182
+ gap: 12px;
1183
+ }
1184
+
1185
+ .initials-text-input {
1186
+ width: 100px;
1187
+ height: 56px;
1188
+ font-size: 28px;
1189
+ letter-spacing: 6px;
1190
+ }
1191
+
1192
+ .input-divider {
1193
+ font-size: 12px;
1194
+ }
1195
+
1196
+ .input-help {
1197
+ font-size: 11px;
1198
+ }
1199
+ }
1200
+
1201
+ /* Print styles */
1202
+ @media print {
1203
+ body {
1204
+ background: white;
1205
+ color: black;
1206
+ }
1207
+
1208
+ .paper-sheet::before {
1209
+ display: none;
1210
+ }
1211
+
1212
+ .typewriter-button {
1213
+ display: none;
1214
+ }
1215
+
1216
+ .sticky-controls {
1217
+ display: none;
1218
+ }
1219
+
1220
+ #game-container {
1221
+ padding-bottom: 0;
1222
+ }
1223
+
1224
+ .leaderboard-overlay,
1225
+ .milestone-toast {
1226
+ display: none;
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ /* Custom utility classes */
1232
+ @layer utilities {
1233
+ .ink-ribbon-lines {
1234
+ background: repeating-linear-gradient(
1235
+ to bottom,
1236
+ transparent,
1237
+ transparent 23px,
1238
+ rgba(139, 92, 246, 0.03) 23px,
1239
+ rgba(139, 92, 246, 0.03) 24px
1240
+ );
1241
+ }
1242
+ }
src/welcomeOverlay.js ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Welcome overlay for first-time users
2
+ class WelcomeOverlay {
3
+ constructor() {
4
+ this.isVisible = false;
5
+ this.hasBeenShown = localStorage.getItem('cloze-reader-welcomed') === 'true';
6
+ }
7
+
8
+ show() {
9
+ // Always show overlay regardless of previous views
10
+
11
+ this.isVisible = true;
12
+ const overlay = this.createOverlay();
13
+ document.body.appendChild(overlay);
14
+
15
+ // Animate in
16
+ requestAnimationFrame(() => {
17
+ overlay.style.opacity = '1';
18
+ });
19
+ }
20
+
21
+ createOverlay() {
22
+ const overlay = document.createElement('div');
23
+ overlay.className = 'welcome-overlay';
24
+ overlay.style.opacity = '0';
25
+
26
+ const modal = document.createElement('div');
27
+ modal.className = 'welcome-modal';
28
+ modal.style.cssText = `
29
+ max-width: 500px;
30
+ margin: 20px;
31
+ text-align: center;
32
+ `;
33
+
34
+ modal.innerHTML = `
35
+ <div style="display: flex; justify-content: center; margin-bottom: 12px;">
36
+ <img src="https://media.githubusercontent.com/media/milwrite/cloze-reader/main/icon.png"
37
+ alt=""
38
+ style="width: 48px; height: 48px; border-radius: 6px;"
39
+ onerror="this.style.display='none'">
40
+ </div>
41
+ <h1 class="welcome-title">
42
+ Cloze Reader
43
+ </h1>
44
+
45
+ <div class="welcome-content">
46
+ <p>
47
+ <strong>How to play:</strong> Fill in the blanks to advance through levels with increasing difficulty and vocabulary complexity.
48
+ </p>
49
+
50
+ <p>
51
+ <strong>Data source:</strong> Excerpted historical and literary texts from Project Gutenberg's public domain collection, processed via Hugging Face Datasets.
52
+ </p>
53
+
54
+ <p style="margin-bottom: 0;">
55
+ <strong>AI assistance:</strong> Powered by Google's Gemma-3-27b model via OpenRouter for word selection, hints, and contextualization.
56
+ </p>
57
+ </div>
58
+
59
+ <button id="welcome-start-btn" class="typewriter-button">
60
+ Start Reading
61
+ </button>
62
+ `;
63
+
64
+ overlay.appendChild(modal);
65
+
66
+ // Add click handler
67
+ const startBtn = modal.querySelector('#welcome-start-btn');
68
+ startBtn.addEventListener('click', () => this.hide());
69
+
70
+ // Allow clicking outside to close
71
+ overlay.addEventListener('click', (e) => {
72
+ if (e.target === overlay) this.hide();
73
+ });
74
+
75
+ return overlay;
76
+ }
77
+
78
+ hide() {
79
+ const overlay = document.querySelector('.welcome-overlay');
80
+ if (!overlay) return;
81
+
82
+ overlay.style.opacity = '0';
83
+ setTimeout(() => {
84
+ overlay.remove();
85
+ this.isVisible = false;
86
+
87
+ // Remember that user has seen welcome
88
+ localStorage.setItem('cloze-reader-welcomed', 'true');
89
+ this.hasBeenShown = true;
90
+ }, 300);
91
+ }
92
+
93
+ // Method to reset welcome (for testing or new features)
94
+ reset() {
95
+ localStorage.removeItem('cloze-reader-welcomed');
96
+ this.hasBeenShown = false;
97
+ }
98
+
99
+ // Force show overlay (for testing)
100
+ forceShow() {
101
+ this.hasBeenShown = false;
102
+ this.show();
103
+ }
104
+ }
105
+
106
+ export default WelcomeOverlay;